This commit is contained in:
StellaOps Bot
2025-12-18 20:37:12 +02:00
278 changed files with 35930 additions and 1134 deletions

View File

@@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.Native.Index;
/// <summary>
/// NDJSON format for Build-ID index entries.
/// Each line is one JSON object in this format.
/// </summary>
public sealed class BuildIdIndexEntry
{
/// <summary>
/// The Build-ID with prefix (e.g., "gnu-build-id:abc123", "pe-cv:guid-age", "macho-uuid:xyz").
/// </summary>
[JsonPropertyName("build_id")]
public required string BuildId { get; init; }
/// <summary>
/// Package URL for the binary.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// Package version (extracted from PURL if not provided).
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; init; }
/// <summary>
/// Source distribution (debian, ubuntu, alpine, fedora, etc.).
/// </summary>
[JsonPropertyName("distro")]
public string? Distro { get; init; }
/// <summary>
/// Confidence level: "exact", "inferred", or "heuristic".
/// </summary>
[JsonPropertyName("confidence")]
public string Confidence { get; init; } = "exact";
/// <summary>
/// When this entry was indexed (ISO-8601).
/// </summary>
[JsonPropertyName("indexed_at")]
public DateTimeOffset? IndexedAt { get; init; }
/// <summary>
/// Convert to lookup result.
/// </summary>
public BuildIdLookupResult ToLookupResult() => new(
BuildId,
Purl,
Version,
Distro,
ParseConfidence(Confidence),
IndexedAt ?? DateTimeOffset.MinValue);
private static BuildIdConfidence ParseConfidence(string? value) => value?.ToLowerInvariant() switch
{
"exact" => BuildIdConfidence.Exact,
"inferred" => BuildIdConfidence.Inferred,
"heuristic" => BuildIdConfidence.Heuristic,
_ => BuildIdConfidence.Heuristic
};
}

View File

@@ -0,0 +1,38 @@
namespace StellaOps.Scanner.Analyzers.Native.Index;
/// <summary>
/// Configuration options for the Build-ID index.
/// </summary>
public sealed class BuildIdIndexOptions
{
/// <summary>
/// Path to the offline NDJSON index file.
/// </summary>
public string? IndexPath { get; set; }
/// <summary>
/// Path to the DSSE signature file for the index.
/// </summary>
public string? SignaturePath { get; set; }
/// <summary>
/// Whether to require DSSE signature verification.
/// Defaults to true in production.
/// </summary>
public bool RequireSignature { get; set; } = true;
/// <summary>
/// Maximum age of the index before warning (for freshness checks).
/// </summary>
public TimeSpan MaxIndexAge { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Whether to enable in-memory caching of index entries.
/// </summary>
public bool EnableCache { get; set; } = true;
/// <summary>
/// Maximum number of entries to cache in memory.
/// </summary>
public int MaxCacheEntries { get; set; } = 100_000;
}

View File

@@ -0,0 +1,39 @@
namespace StellaOps.Scanner.Analyzers.Native.Index;
/// <summary>
/// Confidence level for Build-ID to PURL mappings.
/// </summary>
public enum BuildIdConfidence
{
/// <summary>
/// Exact match from official distro metadata or verified source.
/// </summary>
Exact,
/// <summary>
/// Inferred from package metadata with high confidence.
/// </summary>
Inferred,
/// <summary>
/// Best-guess heuristic (version pattern matching, etc.).
/// </summary>
Heuristic
}
/// <summary>
/// Result of a Build-ID lookup.
/// </summary>
/// <param name="BuildId">The queried Build-ID (ELF build-id, PE GUID+Age, Mach-O UUID).</param>
/// <param name="Purl">Package URL for the binary.</param>
/// <param name="Version">Package version if known.</param>
/// <param name="SourceDistro">Source distribution (debian, alpine, fedora, etc.).</param>
/// <param name="Confidence">Confidence level of the match.</param>
/// <param name="IndexedAt">When this mapping was indexed.</param>
public sealed record BuildIdLookupResult(
string BuildId,
string Purl,
string? Version,
string? SourceDistro,
BuildIdConfidence Confidence,
DateTimeOffset IndexedAt);

View File

@@ -0,0 +1,42 @@
namespace StellaOps.Scanner.Analyzers.Native.Index;
/// <summary>
/// Interface for Build-ID to PURL index lookups.
/// Enables binary identification in distroless/scratch images.
/// </summary>
public interface IBuildIdIndex
{
/// <summary>
/// Look up a single Build-ID.
/// </summary>
/// <param name="buildId">The Build-ID to look up (e.g., "gnu-build-id:abc123", "pe-cv:guid-age", "macho-uuid:xyz").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Lookup result if found; null otherwise.</returns>
Task<BuildIdLookupResult?> LookupAsync(string buildId, CancellationToken cancellationToken = default);
/// <summary>
/// Look up multiple Build-IDs efficiently.
/// </summary>
/// <param name="buildIds">Build-IDs to look up.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Found results (unfound IDs are not included).</returns>
Task<IReadOnlyList<BuildIdLookupResult>> BatchLookupAsync(
IEnumerable<string> buildIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the number of entries in the index.
/// </summary>
int Count { get; }
/// <summary>
/// Gets whether the index has been loaded.
/// </summary>
bool IsLoaded { get; }
/// <summary>
/// Load or reload the index from the configured source.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task LoadAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,207 @@
using System.Collections.Frozen;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Analyzers.Native.Index;
/// <summary>
/// Offline Build-ID index that loads from NDJSON files.
/// Enables binary identification in distroless/scratch images.
/// </summary>
public sealed class OfflineBuildIdIndex : IBuildIdIndex
{
private readonly BuildIdIndexOptions _options;
private readonly ILogger<OfflineBuildIdIndex> _logger;
private FrozenDictionary<string, BuildIdLookupResult> _index = FrozenDictionary<string, BuildIdLookupResult>.Empty;
private bool _isLoaded;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Creates a new offline Build-ID index.
/// </summary>
public OfflineBuildIdIndex(IOptions<BuildIdIndexOptions> options, ILogger<OfflineBuildIdIndex> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public int Count => _index.Count;
/// <inheritdoc />
public bool IsLoaded => _isLoaded;
/// <inheritdoc />
public Task<BuildIdLookupResult?> LookupAsync(string buildId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(buildId))
{
return Task.FromResult<BuildIdLookupResult?>(null);
}
// Normalize Build-ID (lowercase, trim)
var normalized = NormalizeBuildId(buildId);
var result = _index.TryGetValue(normalized, out var entry) ? entry : null;
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<IReadOnlyList<BuildIdLookupResult>> BatchLookupAsync(
IEnumerable<string> buildIds,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(buildIds);
var results = new List<BuildIdLookupResult>();
foreach (var buildId in buildIds)
{
if (string.IsNullOrWhiteSpace(buildId))
{
continue;
}
var normalized = NormalizeBuildId(buildId);
if (_index.TryGetValue(normalized, out var entry))
{
results.Add(entry);
}
}
return Task.FromResult<IReadOnlyList<BuildIdLookupResult>>(results);
}
/// <inheritdoc />
public async Task LoadAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_options.IndexPath))
{
_logger.LogWarning("No Build-ID index path configured; index will be empty");
_index = FrozenDictionary<string, BuildIdLookupResult>.Empty;
_isLoaded = true;
return;
}
if (!File.Exists(_options.IndexPath))
{
_logger.LogWarning("Build-ID index file not found at {IndexPath}; index will be empty", _options.IndexPath);
_index = FrozenDictionary<string, BuildIdLookupResult>.Empty;
_isLoaded = true;
return;
}
// TODO: BID-006 - Verify DSSE signature if RequireSignature is true
var entries = new Dictionary<string, BuildIdLookupResult>(StringComparer.OrdinalIgnoreCase);
var lineNumber = 0;
var errorCount = 0;
await using var stream = File.OpenRead(_options.IndexPath);
using var reader = new StreamReader(stream);
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
{
lineNumber++;
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
// Skip comment lines (for manifest headers)
if (line.StartsWith('#') || line.StartsWith("//", StringComparison.Ordinal))
{
continue;
}
try
{
var entry = JsonSerializer.Deserialize<BuildIdIndexEntry>(line, JsonOptions);
if (entry is null || string.IsNullOrWhiteSpace(entry.BuildId) || string.IsNullOrWhiteSpace(entry.Purl))
{
errorCount++;
continue;
}
var normalized = NormalizeBuildId(entry.BuildId);
entries[normalized] = entry.ToLookupResult();
}
catch (JsonException ex)
{
errorCount++;
if (errorCount <= 10)
{
_logger.LogWarning(ex, "Failed to parse Build-ID index line {LineNumber}", lineNumber);
}
}
}
if (errorCount > 0)
{
_logger.LogWarning("Build-ID index had {ErrorCount} parse errors out of {TotalLines} lines", errorCount, lineNumber);
}
_index = entries.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
_isLoaded = true;
_logger.LogInformation("Loaded Build-ID index with {EntryCount} entries from {IndexPath}", _index.Count, _options.IndexPath);
// Check index freshness
if (_options.MaxIndexAge > TimeSpan.Zero)
{
var oldestAllowed = DateTimeOffset.UtcNow - _options.MaxIndexAge;
var latestEntry = entries.Values.MaxBy(e => e.IndexedAt);
if (latestEntry is not null && latestEntry.IndexedAt < oldestAllowed)
{
_logger.LogWarning(
"Build-ID index may be stale. Latest entry from {LatestDate}, max age is {MaxAge}",
latestEntry.IndexedAt,
_options.MaxIndexAge);
}
}
}
/// <summary>
/// Normalize a Build-ID for consistent lookup.
/// </summary>
private static string NormalizeBuildId(string buildId)
{
// Lowercase the entire string for case-insensitive matching
var normalized = buildId.Trim().ToLowerInvariant();
// Ensure consistent prefix format
// ELF: "gnu-build-id:..." or just the hex
// PE: "pe-cv:..." or "pe:guid-age"
// Mach-O: "macho-uuid:..." or just the hex
// If no prefix, try to detect format from length/pattern
if (!normalized.Contains(':'))
{
// 32 hex chars = Mach-O UUID (128 bits)
// 40 hex chars = ELF SHA-1 build-id
// GUID+Age pattern for PE
if (normalized.Length == 32 && IsHex(normalized))
{
// Could be Mach-O UUID or short ELF build-id
normalized = $"build-id:{normalized}";
}
else if (normalized.Length == 40 && IsHex(normalized))
{
normalized = $"gnu-build-id:{normalized}";
}
}
return normalized;
}
private static bool IsHex(string s) => s.All(c => char.IsAsciiHexDigit(c));
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Code signature information from LC_CODE_SIGNATURE.
/// </summary>
/// <param name="TeamId">Team identifier (10-character Apple team ID).</param>
/// <param name="SigningId">Signing identifier (usually bundle ID).</param>
/// <param name="CdHash">Code Directory hash (SHA-256, lowercase hex).</param>
/// <param name="HasHardenedRuntime">Whether hardened runtime is enabled.</param>
/// <param name="Entitlements">Entitlements keys (not values, for privacy).</param>
public sealed record MachOCodeSignature(
string? TeamId,
string? SigningId,
string? CdHash,
bool HasHardenedRuntime,
IReadOnlyList<string> Entitlements);

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Full identity information extracted from a Mach-O file.
/// </summary>
/// <param name="CpuType">CPU type (x86_64, arm64, etc.).</param>
/// <param name="CpuSubtype">CPU subtype for variant detection.</param>
/// <param name="Uuid">LC_UUID in lowercase hex (no dashes).</param>
/// <param name="IsFatBinary">Whether this is a fat/universal binary.</param>
/// <param name="Platform">Platform from LC_BUILD_VERSION.</param>
/// <param name="MinOsVersion">Minimum OS version from LC_VERSION_MIN_* or LC_BUILD_VERSION.</param>
/// <param name="SdkVersion">SDK version from LC_BUILD_VERSION.</param>
/// <param name="CodeSignature">Code signature information (if signed).</param>
/// <param name="Exports">Exported symbols from LC_DYLD_INFO_ONLY or LC_DYLD_EXPORTS_TRIE.</param>
public sealed record MachOIdentity(
string? CpuType,
uint CpuSubtype,
string? Uuid,
bool IsFatBinary,
MachOPlatform Platform,
string? MinOsVersion,
string? SdkVersion,
MachOCodeSignature? CodeSignature,
IReadOnlyList<string> Exports);

View File

@@ -0,0 +1,46 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Mach-O platform values from LC_BUILD_VERSION.
/// </summary>
public enum MachOPlatform : uint
{
/// <summary>Unknown platform.</summary>
Unknown = 0,
/// <summary>macOS.</summary>
MacOS = 1,
/// <summary>iOS.</summary>
iOS = 2,
/// <summary>tvOS.</summary>
TvOS = 3,
/// <summary>watchOS.</summary>
WatchOS = 4,
/// <summary>BridgeOS.</summary>
BridgeOS = 5,
/// <summary>Mac Catalyst (iPad apps on Mac).</summary>
MacCatalyst = 6,
/// <summary>iOS Simulator.</summary>
iOSSimulator = 7,
/// <summary>tvOS Simulator.</summary>
TvOSSimulator = 8,
/// <summary>watchOS Simulator.</summary>
WatchOSSimulator = 9,
/// <summary>DriverKit.</summary>
DriverKit = 10,
/// <summary>visionOS.</summary>
VisionOS = 11,
/// <summary>visionOS Simulator.</summary>
VisionOSSimulator = 12
}

View File

@@ -0,0 +1,640 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Result from parsing a Mach-O file.
/// </summary>
/// <param name="Path">File path.</param>
/// <param name="LayerDigest">Container layer digest if applicable.</param>
/// <param name="Identities">List of identities (one per slice in fat binary).</param>
public sealed record MachOParseResult(
string Path,
string? LayerDigest,
IReadOnlyList<MachOIdentity> Identities);
/// <summary>
/// Full Mach-O file reader with identity extraction.
/// Handles both single-arch and fat (universal) binaries.
/// </summary>
public static class MachOReader
{
// Mach-O magic numbers
private const uint MH_MAGIC = 0xFEEDFACE; // 32-bit, native endian
private const uint MH_CIGAM = 0xCEFAEDFE; // 32-bit, reversed endian
private const uint MH_MAGIC_64 = 0xFEEDFACF; // 64-bit, native endian
private const uint MH_CIGAM_64 = 0xCFFAEDFE; // 64-bit, reversed endian
// Fat binary magic numbers
private const uint FAT_MAGIC = 0xCAFEBABE; // Big-endian
private const uint FAT_CIGAM = 0xBEBAFECA; // Little-endian
// Load command types
private const uint LC_UUID = 0x1B;
private const uint LC_CODE_SIGNATURE = 0x1D;
private const uint LC_VERSION_MIN_MACOSX = 0x24;
private const uint LC_VERSION_MIN_IPHONEOS = 0x25;
private const uint LC_VERSION_MIN_WATCHOS = 0x30;
private const uint LC_VERSION_MIN_TVOS = 0x2F;
private const uint LC_BUILD_VERSION = 0x32;
private const uint LC_DYLD_INFO = 0x22;
private const uint LC_DYLD_INFO_ONLY = 0x80000022;
private const uint LC_DYLD_EXPORTS_TRIE = 0x80000033;
// Code signature blob types
private const uint CSMAGIC_CODEDIRECTORY = 0xFADE0C02;
private const uint CSMAGIC_EMBEDDED_SIGNATURE = 0xFADE0CC0;
private const uint CSMAGIC_EMBEDDED_ENTITLEMENTS = 0xFADE7171;
// CPU types
private const int CPU_TYPE_X86 = 7;
private const int CPU_TYPE_X86_64 = CPU_TYPE_X86 | 0x01000000;
private const int CPU_TYPE_ARM = 12;
private const int CPU_TYPE_ARM64 = CPU_TYPE_ARM | 0x01000000;
/// <summary>
/// Parse a Mach-O file and extract full identity information.
/// For fat binaries, returns identities for all slices.
/// </summary>
public static MachOParseResult? Parse(Stream stream, string path, string? layerDigest = null)
{
if (!TryReadBytes(stream, 4, out var magicBytes))
{
return null;
}
stream.Position = 0;
var magic = BinaryPrimitives.ReadUInt32BigEndian(magicBytes);
// Check for fat binary
if (magic is FAT_MAGIC or FAT_CIGAM)
{
var identities = ParseFatBinary(stream);
return identities.Count > 0
? new MachOParseResult(path, layerDigest, identities)
: null;
}
// Single architecture binary
var identity = ParseSingleMachO(stream);
return identity is not null
? new MachOParseResult(path, layerDigest, [identity])
: null;
}
/// <summary>
/// Try to extract just the identity without full parsing.
/// </summary>
public static bool TryExtractIdentity(Stream stream, out MachOIdentity? identity)
{
identity = null;
if (!TryReadBytes(stream, 4, out var magicBytes))
{
return false;
}
stream.Position = 0;
var magic = BinaryPrimitives.ReadUInt32BigEndian(magicBytes);
// Skip fat binary quick extraction for now
if (magic is FAT_MAGIC or FAT_CIGAM)
{
var identities = ParseFatBinary(stream);
identity = identities.Count > 0 ? identities[0] : null;
return identity is not null;
}
identity = ParseSingleMachO(stream);
return identity is not null;
}
/// <summary>
/// Parse a fat binary and return all slice identities.
/// </summary>
public static IReadOnlyList<MachOIdentity> ParseFatBinary(Stream stream)
{
var identities = new List<MachOIdentity>();
if (!TryReadBytes(stream, 8, out var headerBytes))
{
return identities;
}
var magic = BinaryPrimitives.ReadUInt32BigEndian(headerBytes);
var swapBytes = magic == FAT_CIGAM;
var nfatArch = swapBytes
? BinaryPrimitives.ReadUInt32LittleEndian(headerBytes.AsSpan(4))
: BinaryPrimitives.ReadUInt32BigEndian(headerBytes.AsSpan(4));
if (nfatArch > 100)
{
// Sanity check
return identities;
}
for (var i = 0; i < nfatArch; i++)
{
if (!TryReadBytes(stream, 20, out var archBytes))
{
break;
}
// Fat arch structure is always big-endian (unless FAT_CIGAM)
uint offset, size;
if (swapBytes)
{
// cputype(4), cpusubtype(4), offset(4), size(4), align(4)
offset = BinaryPrimitives.ReadUInt32LittleEndian(archBytes.AsSpan(8));
size = BinaryPrimitives.ReadUInt32LittleEndian(archBytes.AsSpan(12));
}
else
{
offset = BinaryPrimitives.ReadUInt32BigEndian(archBytes.AsSpan(8));
size = BinaryPrimitives.ReadUInt32BigEndian(archBytes.AsSpan(12));
}
// Save position and parse the embedded Mach-O
var currentPos = stream.Position;
stream.Position = offset;
var sliceIdentity = ParseSingleMachO(stream, isFatSlice: true);
if (sliceIdentity is not null)
{
identities.Add(sliceIdentity);
}
stream.Position = currentPos;
}
return identities;
}
/// <summary>
/// Parse a single Mach-O binary (not fat).
/// </summary>
private static MachOIdentity? ParseSingleMachO(Stream stream, bool isFatSlice = false)
{
var startOffset = stream.Position;
if (!TryReadBytes(stream, 4, out var magicBytes))
{
return null;
}
var magic = BinaryPrimitives.ReadUInt32LittleEndian(magicBytes);
bool is64Bit;
bool swapBytes;
switch (magic)
{
case MH_MAGIC:
is64Bit = false;
swapBytes = false;
break;
case MH_CIGAM:
is64Bit = false;
swapBytes = true;
break;
case MH_MAGIC_64:
is64Bit = true;
swapBytes = false;
break;
case MH_CIGAM_64:
is64Bit = true;
swapBytes = true;
break;
default:
return null;
}
// Read rest of Mach header
var headerSize = is64Bit ? 32 : 28;
stream.Position = startOffset;
if (!TryReadBytes(stream, headerSize, out var headerBytes))
{
return null;
}
// Parse header
var cpuType = ReadInt32(headerBytes, 4, swapBytes);
var cpuSubtype = ReadUInt32(headerBytes, 8, swapBytes);
var ncmds = ReadUInt32(headerBytes, 16, swapBytes);
var sizeofcmds = ReadUInt32(headerBytes, 20, swapBytes);
var cpuTypeName = GetCpuTypeName(cpuType);
// Initialize identity fields
string? uuid = null;
var platform = MachOPlatform.Unknown;
string? minOsVersion = null;
string? sdkVersion = null;
MachOCodeSignature? codeSignature = null;
var exports = new List<string>();
// Read load commands
var loadCommandsStart = stream.Position;
var loadCommandsEnd = loadCommandsStart + sizeofcmds;
for (uint cmd = 0; cmd < ncmds && stream.Position < loadCommandsEnd; cmd++)
{
if (!TryReadBytes(stream, 8, out var cmdHeader))
{
break;
}
var cmdType = ReadUInt32(cmdHeader, 0, swapBytes);
var cmdSize = ReadUInt32(cmdHeader, 4, swapBytes);
if (cmdSize < 8)
{
break;
}
var cmdDataSize = (int)cmdSize - 8;
switch (cmdType)
{
case LC_UUID when cmdDataSize >= 16:
if (TryReadBytes(stream, 16, out var uuidBytes))
{
uuid = Convert.ToHexStringLower(uuidBytes);
}
stream.Position = loadCommandsStart + GetNextCmdOffset(cmd, ncmds, stream.Position - loadCommandsStart, cmdSize);
continue;
case LC_BUILD_VERSION when cmdDataSize >= 16:
if (TryReadBytes(stream, cmdDataSize, out var buildVersionBytes))
{
var platformValue = ReadUInt32(buildVersionBytes, 0, swapBytes);
platform = (MachOPlatform)platformValue;
var minos = ReadUInt32(buildVersionBytes, 4, swapBytes);
minOsVersion = FormatVersion(minos);
var sdk = ReadUInt32(buildVersionBytes, 8, swapBytes);
sdkVersion = FormatVersion(sdk);
}
continue;
case LC_VERSION_MIN_MACOSX:
case LC_VERSION_MIN_IPHONEOS:
case LC_VERSION_MIN_WATCHOS:
case LC_VERSION_MIN_TVOS:
if (TryReadBytes(stream, cmdDataSize, out var versionMinBytes))
{
if (platform == MachOPlatform.Unknown)
{
platform = cmdType switch
{
LC_VERSION_MIN_MACOSX => MachOPlatform.MacOS,
LC_VERSION_MIN_IPHONEOS => MachOPlatform.iOS,
LC_VERSION_MIN_WATCHOS => MachOPlatform.WatchOS,
LC_VERSION_MIN_TVOS => MachOPlatform.TvOS,
_ => MachOPlatform.Unknown
};
}
if (versionMinBytes.Length >= 8)
{
var version = ReadUInt32(versionMinBytes, 0, swapBytes);
if (minOsVersion is null)
{
minOsVersion = FormatVersion(version);
}
var sdk = ReadUInt32(versionMinBytes, 4, swapBytes);
if (sdkVersion is null)
{
sdkVersion = FormatVersion(sdk);
}
}
}
continue;
case LC_CODE_SIGNATURE:
if (TryReadBytes(stream, cmdDataSize, out var codeSignBytes) && codeSignBytes.Length >= 8)
{
var dataOff = ReadUInt32(codeSignBytes, 0, swapBytes);
var dataSize = ReadUInt32(codeSignBytes, 4, swapBytes);
// Parse code signature at offset
var currentPos = stream.Position;
stream.Position = startOffset + dataOff;
codeSignature = ParseCodeSignature(stream, (int)dataSize);
stream.Position = currentPos;
}
continue;
}
// Skip remaining bytes of command
var remaining = cmdDataSize - (stream.Position - loadCommandsStart - 8);
if (remaining > 0)
{
stream.Position += remaining;
}
}
return new MachOIdentity(
cpuTypeName,
cpuSubtype,
uuid,
isFatSlice,
platform,
minOsVersion,
sdkVersion,
codeSignature,
exports);
}
/// <summary>
/// Parse the code signature blob.
/// </summary>
private static MachOCodeSignature? ParseCodeSignature(Stream stream, int size)
{
if (!TryReadBytes(stream, 8, out var superBlobHeader))
{
return null;
}
var magic = BinaryPrimitives.ReadUInt32BigEndian(superBlobHeader);
if (magic != CSMAGIC_EMBEDDED_SIGNATURE)
{
return null;
}
var length = BinaryPrimitives.ReadUInt32BigEndian(superBlobHeader.AsSpan(4));
if (length > size || length < 12)
{
return null;
}
if (!TryReadBytes(stream, 4, out var countBytes))
{
return null;
}
var count = BinaryPrimitives.ReadUInt32BigEndian(countBytes);
if (count > 100)
{
return null;
}
var blobStart = stream.Position - 12;
// Read blob index entries
var blobs = new List<(uint type, uint offset)>();
for (uint i = 0; i < count; i++)
{
if (!TryReadBytes(stream, 8, out var indexEntry))
{
break;
}
var blobType = BinaryPrimitives.ReadUInt32BigEndian(indexEntry);
var blobOffset = BinaryPrimitives.ReadUInt32BigEndian(indexEntry.AsSpan(4));
blobs.Add((blobType, blobOffset));
}
string? teamId = null;
string? signingId = null;
string? cdHash = null;
var hasHardenedRuntime = false;
var entitlements = new List<string>();
foreach (var (blobType, blobOffset) in blobs)
{
stream.Position = blobStart + blobOffset;
if (!TryReadBytes(stream, 8, out var blobHeader))
{
continue;
}
var blobMagic = BinaryPrimitives.ReadUInt32BigEndian(blobHeader);
var blobLength = BinaryPrimitives.ReadUInt32BigEndian(blobHeader.AsSpan(4));
switch (blobMagic)
{
case CSMAGIC_CODEDIRECTORY:
(teamId, signingId, cdHash, hasHardenedRuntime) = ParseCodeDirectory(stream, blobStart + blobOffset, (int)blobLength);
break;
case CSMAGIC_EMBEDDED_ENTITLEMENTS:
entitlements = ParseEntitlements(stream, (int)blobLength - 8);
break;
}
}
if (teamId is null && signingId is null && cdHash is null)
{
return null;
}
return new MachOCodeSignature(teamId, signingId, cdHash, hasHardenedRuntime, entitlements);
}
/// <summary>
/// Parse CodeDirectory blob.
/// </summary>
private static (string? TeamId, string? SigningId, string? CdHash, bool HasHardenedRuntime) ParseCodeDirectory(
Stream stream, long blobStart, int length)
{
// CodeDirectory has a complex structure, we'll extract key fields
stream.Position = blobStart;
if (!TryReadBytes(stream, Math.Min(length, 52), out var cdBytes))
{
return (null, null, null, false);
}
// Offsets in CodeDirectory (all big-endian)
// +8: version
// +12: flags
// +16: hashOffset
// +20: identOffset
// +28: nCodeSlots
// +32: codeLimit
// +36: hashSize
// +37: hashType
// +38: platform
// +39: pageSize
// +44: spare2
// +48: scatterOffset (v2+)
// +52: teamOffset (v2+)
var version = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(8));
var flags = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(12));
var identOffset = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(20));
// Check for hardened runtime (flag 0x10000)
var hasHardenedRuntime = (flags & 0x10000) != 0;
// Read signing identifier
string? signingId = null;
if (identOffset > 0 && identOffset < length)
{
stream.Position = blobStart + identOffset;
signingId = ReadNullTerminatedString(stream, 256);
}
// Read team ID (version 0x20200 and later)
string? teamId = null;
if (version >= 0x20200 && cdBytes.Length >= 56)
{
var teamOffset = BinaryPrimitives.ReadUInt32BigEndian(cdBytes.AsSpan(52));
if (teamOffset > 0 && teamOffset < length)
{
stream.Position = blobStart + teamOffset;
teamId = ReadNullTerminatedString(stream, 20);
}
}
// Compute CDHash (SHA-256 of the entire CodeDirectory blob)
stream.Position = blobStart;
if (TryReadBytes(stream, length, out var fullCdBytes))
{
var hash = SHA256.HashData(fullCdBytes);
var cdHash = Convert.ToHexStringLower(hash);
return (teamId, signingId, cdHash, hasHardenedRuntime);
}
return (teamId, signingId, null, hasHardenedRuntime);
}
/// <summary>
/// Parse entitlements plist and extract keys.
/// </summary>
private static List<string> ParseEntitlements(Stream stream, int length)
{
var keys = new List<string>();
if (!TryReadBytes(stream, length, out var plistBytes))
{
return keys;
}
// Simple plist key extraction (looks for <key>...</key> patterns)
var plist = Encoding.UTF8.GetString(plistBytes);
var keyStart = 0;
while ((keyStart = plist.IndexOf("<key>", keyStart, StringComparison.Ordinal)) >= 0)
{
keyStart += 5;
var keyEnd = plist.IndexOf("</key>", keyStart, StringComparison.Ordinal);
if (keyEnd > keyStart)
{
var key = plist[keyStart..keyEnd];
if (!string.IsNullOrWhiteSpace(key))
{
keys.Add(key);
}
keyStart = keyEnd + 6;
}
else
{
break;
}
}
return keys;
}
/// <summary>
/// Get CPU type name from CPU type value.
/// </summary>
private static string? GetCpuTypeName(int cpuType) => cpuType switch
{
CPU_TYPE_X86 => "i386",
CPU_TYPE_X86_64 => "x86_64",
CPU_TYPE_ARM => "arm",
CPU_TYPE_ARM64 => "arm64",
_ => $"cpu_{cpuType}"
};
/// <summary>
/// Format version number (major.minor.patch from packed uint32).
/// </summary>
private static string FormatVersion(uint version)
{
var major = (version >> 16) & 0xFFFF;
var minor = (version >> 8) & 0xFF;
var patch = version & 0xFF;
return patch == 0 ? $"{major}.{minor}" : $"{major}.{minor}.{patch}";
}
/// <summary>
/// Read a null-terminated string from stream.
/// </summary>
private static string? ReadNullTerminatedString(Stream stream, int maxLength)
{
var bytes = new byte[maxLength];
var count = 0;
while (count < maxLength)
{
var b = stream.ReadByte();
if (b <= 0)
{
break;
}
bytes[count++] = (byte)b;
}
return count > 0 ? Encoding.UTF8.GetString(bytes, 0, count) : null;
}
/// <summary>
/// Try to read exactly the specified number of bytes.
/// </summary>
private static bool TryReadBytes(Stream stream, int count, out byte[] bytes)
{
bytes = new byte[count];
var totalRead = 0;
while (totalRead < count)
{
var read = stream.Read(bytes, totalRead, count - totalRead);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
/// <summary>
/// Read int32 with optional byte swapping.
/// </summary>
private static int ReadInt32(byte[] data, int offset, bool swap) =>
swap
? BinaryPrimitives.ReadInt32BigEndian(data.AsSpan(offset))
: BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset));
/// <summary>
/// Read uint32 with optional byte swapping.
/// </summary>
private static uint ReadUInt32(byte[] data, int offset, bool swap) =>
swap
? BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset))
: BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(offset));
/// <summary>
/// Calculate the offset for the next load command.
/// </summary>
private static long GetNextCmdOffset(uint currentCmd, uint totalCmds, long currentOffset, uint cmdSize) =>
currentOffset + cmdSize - 8;
}

View File

@@ -1,5 +1,23 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Identity information extracted from a native binary (ELF, PE, Mach-O).
/// </summary>
/// <param name="Format">Binary format (ELF, PE, Mach-O).</param>
/// <param name="CpuArchitecture">CPU architecture (x86, x86_64, arm64, etc.).</param>
/// <param name="OperatingSystem">Target OS (linux, windows, darwin, etc.).</param>
/// <param name="Endianness">Byte order (le, be).</param>
/// <param name="BuildId">ELF GNU Build-ID (hex string).</param>
/// <param name="Uuid">Mach-O LC_UUID (hex string).</param>
/// <param name="InterpreterPath">ELF interpreter path (e.g., /lib64/ld-linux-x86-64.so.2).</param>
/// <param name="CodeViewGuid">PE CodeView GUID (lowercase hex, no dashes).</param>
/// <param name="CodeViewAge">PE CodeView Age (increments on rebuild).</param>
/// <param name="ProductVersion">PE version resource ProductVersion.</param>
/// <param name="MachOPlatform">Mach-O platform (macOS, iOS, etc.).</param>
/// <param name="MachOMinOsVersion">Mach-O minimum OS version.</param>
/// <param name="MachOSdkVersion">Mach-O SDK version.</param>
/// <param name="MachOCdHash">Mach-O CodeDirectory hash (SHA-256).</param>
/// <param name="MachOTeamId">Mach-O code signing Team ID.</param>
public sealed record NativeBinaryIdentity(
NativeFormat Format,
string? CpuArchitecture,
@@ -7,4 +25,13 @@ public sealed record NativeBinaryIdentity(
string? Endianness,
string? BuildId,
string? Uuid,
string? InterpreterPath);
string? InterpreterPath,
string? CodeViewGuid = null,
int? CodeViewAge = null,
string? ProductVersion = null,
MachOPlatform? MachOPlatform = null,
string? MachOMinOsVersion = null,
string? MachOSdkVersion = null,
string? MachOCdHash = null,
string? MachOTeamId = null);

View File

@@ -180,6 +180,24 @@ public static class NativeFormatDetector
return false;
}
// Try full PE parsing for CodeView GUID and other identity info
if (PeReader.TryExtractIdentity(span, out var peIdentity) && peIdentity is not null)
{
identity = new NativeBinaryIdentity(
NativeFormat.Pe,
peIdentity.Machine,
"windows",
Endianness: "le",
BuildId: null,
Uuid: null,
InterpreterPath: null,
CodeViewGuid: peIdentity.CodeViewGuid,
CodeViewAge: peIdentity.CodeViewAge,
ProductVersion: peIdentity.ProductVersion);
return true;
}
// Fallback to basic parsing
var machine = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(peHeaderOffset + 4, 2));
var arch = MapPeMachine(machine);
@@ -205,6 +223,30 @@ public static class NativeFormatDetector
return false;
}
// Try full parsing with MachOReader
using var stream = new MemoryStream(span.ToArray());
if (MachOReader.TryExtractIdentity(stream, out var machOIdentity) && machOIdentity is not null)
{
var endianness = magic is 0xCAFEBABE or 0xFEEDFACE or 0xFEEDFACF ? "be" : "le";
var prefixedUuid = machOIdentity.Uuid is not null ? $"macho-uuid:{machOIdentity.Uuid}" : null;
identity = new NativeBinaryIdentity(
NativeFormat.MachO,
machOIdentity.CpuType,
"darwin",
Endianness: endianness,
BuildId: prefixedUuid,
Uuid: prefixedUuid,
InterpreterPath: null,
MachOPlatform: machOIdentity.Platform,
MachOMinOsVersion: machOIdentity.MinOsVersion,
MachOSdkVersion: machOIdentity.SdkVersion,
MachOCdHash: machOIdentity.CodeSignature?.CdHash,
MachOTeamId: machOIdentity.CodeSignature?.TeamId);
return true;
}
// Fallback to basic parsing
bool bigEndian = magic is 0xCAFEBABE or 0xFEEDFACE or 0xFEEDFACF;
uint cputype;
@@ -229,7 +271,7 @@ public static class NativeFormatDetector
}
var arch = MapMachCpuType(cputype);
var endianness = bigEndian ? "be" : "le";
var fallbackEndianness = bigEndian ? "be" : "le";
string? uuid = null;
if (!isFat)
@@ -269,7 +311,7 @@ public static class NativeFormatDetector
}
// Store Mach-O UUID in BuildId field (prefixed) and also in Uuid for backwards compatibility
identity = new NativeBinaryIdentity(NativeFormat.MachO, arch, "darwin", Endianness: endianness, BuildId: uuid, Uuid: uuid, InterpreterPath: null);
identity = new NativeBinaryIdentity(NativeFormat.MachO, arch, "darwin", Endianness: fallbackEndianness, BuildId: uuid, Uuid: uuid, InterpreterPath: null);
return true;
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Compiler/linker hint extracted from PE Rich Header.
/// </summary>
/// <param name="ToolId">Tool ID (@comp.id) - identifies the compiler/linker.</param>
/// <param name="ToolVersion">Tool version (@prod.id) - identifies the version.</param>
/// <param name="UseCount">Number of times this tool was used.</param>
public sealed record PeCompilerHint(
ushort ToolId,
ushort ToolVersion,
int UseCount);

View File

@@ -0,0 +1,34 @@
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Full identity information extracted from a PE (Portable Executable) file.
/// </summary>
/// <param name="Machine">Machine type (x86, x86_64, ARM64, etc.).</param>
/// <param name="Is64Bit">Whether this is a 64-bit PE (PE32+).</param>
/// <param name="Subsystem">PE subsystem (Console, GUI, Native, etc.).</param>
/// <param name="CodeViewGuid">CodeView PDB70 GUID in lowercase hex (no dashes).</param>
/// <param name="CodeViewAge">CodeView Age field (increments on rebuild).</param>
/// <param name="PdbPath">Original PDB path from debug directory.</param>
/// <param name="ProductVersion">Product version from version resource.</param>
/// <param name="FileVersion">File version from version resource.</param>
/// <param name="CompanyName">Company name from version resource.</param>
/// <param name="ProductName">Product name from version resource.</param>
/// <param name="OriginalFilename">Original filename from version resource.</param>
/// <param name="RichHeaderHash">Rich header hash (XOR of all entries).</param>
/// <param name="CompilerHints">Compiler hints from rich header.</param>
/// <param name="Exports">Exported symbols from export directory.</param>
public sealed record PeIdentity(
string? Machine,
bool Is64Bit,
PeSubsystem Subsystem,
string? CodeViewGuid,
int? CodeViewAge,
string? PdbPath,
string? ProductVersion,
string? FileVersion,
string? CompanyName,
string? ProductName,
string? OriginalFilename,
uint? RichHeaderHash,
IReadOnlyList<PeCompilerHint> CompilerHints,
IReadOnlyList<string> Exports);

View File

@@ -0,0 +1,757 @@
using System.Buffers.Binary;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Full PE file reader with identity extraction including CodeView GUID, Rich header, and version resources.
/// </summary>
public static class PeReader
{
// PE Data Directory Indices
private const int IMAGE_DIRECTORY_ENTRY_EXPORT = 0;
private const int IMAGE_DIRECTORY_ENTRY_DEBUG = 6;
private const int IMAGE_DIRECTORY_ENTRY_RESOURCE = 2;
// Debug Types
private const uint IMAGE_DEBUG_TYPE_CODEVIEW = 2;
// CodeView Signatures
private const uint RSDS_SIGNATURE = 0x53445352; // "RSDS" in little-endian
// Rich Header Markers
private const uint RICH_MARKER = 0x68636952; // "Rich" in little-endian
private const uint DANS_MARKER = 0x536E6144; // "DanS" in little-endian
/// <summary>
/// Parse result containing identity and any parsing metadata.
/// </summary>
public sealed record PeParseResult(
PeIdentity Identity,
string? ParseWarning);
/// <summary>
/// Parse a PE file and extract full identity information.
/// </summary>
/// <param name="stream">Stream containing PE file data.</param>
/// <param name="path">File path for context (not accessed).</param>
/// <param name="layerDigest">Optional container layer digest.</param>
/// <returns>Parse result, or null if not a valid PE file.</returns>
public static PeParseResult? Parse(Stream stream, string path, string? layerDigest = null)
{
ArgumentNullException.ThrowIfNull(stream);
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
var data = buffer.ToArray();
if (!TryExtractIdentity(data, out var identity) || identity is null)
{
return null;
}
return new PeParseResult(identity, null);
}
/// <summary>
/// Try to extract identity from PE file data.
/// </summary>
/// <param name="data">PE file bytes.</param>
/// <param name="identity">Extracted identity if successful.</param>
/// <returns>True if valid PE file, false otherwise.</returns>
public static bool TryExtractIdentity(ReadOnlySpan<byte> data, out PeIdentity? identity)
{
identity = null;
// Validate DOS header
if (!ValidateDosHeader(data, out var peHeaderOffset))
{
return false;
}
// Validate PE signature
if (!ValidatePeSignature(data, peHeaderOffset))
{
return false;
}
// Parse COFF header
if (!ParseCoffHeader(data, peHeaderOffset, out var machine, out var numberOfSections, out var sizeOfOptionalHeader))
{
return false;
}
// Parse Optional header
if (!ParseOptionalHeader(data, peHeaderOffset, sizeOfOptionalHeader,
out var is64Bit, out var subsystem, out var numberOfRvaAndSizes, out var dataDirectoryOffset))
{
return false;
}
var machineStr = MapPeMachine(machine);
// Parse section headers for RVA-to-file-offset translation
var sectionHeadersOffset = peHeaderOffset + 24 + sizeOfOptionalHeader;
var sections = ParseSectionHeaders(data, sectionHeadersOffset, numberOfSections);
// Extract Rich header (before PE header in DOS stub)
uint? richHeaderHash = null;
var compilerHints = new List<PeCompilerHint>();
ParseRichHeader(data, peHeaderOffset, out richHeaderHash, compilerHints);
// Extract CodeView debug info
string? codeViewGuid = null;
int? codeViewAge = null;
string? pdbPath = null;
if (numberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_DEBUG)
{
ParseDebugDirectory(data, dataDirectoryOffset, numberOfRvaAndSizes, sections,
out codeViewGuid, out codeViewAge, out pdbPath);
}
// Extract version resources
string? productVersion = null;
string? fileVersion = null;
string? companyName = null;
string? productName = null;
string? originalFilename = null;
if (numberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_RESOURCE)
{
ParseVersionResource(data, dataDirectoryOffset, sections, is64Bit,
out productVersion, out fileVersion, out companyName, out productName, out originalFilename);
}
// Extract exports
var exports = new List<string>();
if (numberOfRvaAndSizes > IMAGE_DIRECTORY_ENTRY_EXPORT)
{
ParseExportDirectory(data, dataDirectoryOffset, sections, exports);
}
identity = new PeIdentity(
Machine: machineStr,
Is64Bit: is64Bit,
Subsystem: subsystem,
CodeViewGuid: codeViewGuid,
CodeViewAge: codeViewAge,
PdbPath: pdbPath,
ProductVersion: productVersion,
FileVersion: fileVersion,
CompanyName: companyName,
ProductName: productName,
OriginalFilename: originalFilename,
RichHeaderHash: richHeaderHash,
CompilerHints: compilerHints,
Exports: exports
);
return true;
}
/// <summary>
/// Validate DOS header and extract PE header offset.
/// </summary>
private static bool ValidateDosHeader(ReadOnlySpan<byte> data, out int peHeaderOffset)
{
peHeaderOffset = 0;
if (data.Length < 0x40)
{
return false;
}
// Check MZ signature
if (data[0] != 'M' || data[1] != 'Z')
{
return false;
}
// Read e_lfanew (offset to PE header) at offset 0x3C
peHeaderOffset = BinaryPrimitives.ReadInt32LittleEndian(data.Slice(0x3C, 4));
if (peHeaderOffset < 0 || peHeaderOffset + 24 > data.Length)
{
return false;
}
return true;
}
/// <summary>
/// Validate PE signature at the given offset.
/// </summary>
private static bool ValidatePeSignature(ReadOnlySpan<byte> data, int peHeaderOffset)
{
if (peHeaderOffset + 4 > data.Length)
{
return false;
}
// Check "PE\0\0" signature
return data[peHeaderOffset] == 'P'
&& data[peHeaderOffset + 1] == 'E'
&& data[peHeaderOffset + 2] == 0
&& data[peHeaderOffset + 3] == 0;
}
/// <summary>
/// Parse COFF header.
/// </summary>
private static bool ParseCoffHeader(ReadOnlySpan<byte> data, int peHeaderOffset,
out ushort machine, out ushort numberOfSections, out ushort sizeOfOptionalHeader)
{
machine = 0;
numberOfSections = 0;
sizeOfOptionalHeader = 0;
var coffOffset = peHeaderOffset + 4;
if (coffOffset + 20 > data.Length)
{
return false;
}
machine = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(coffOffset, 2));
numberOfSections = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(coffOffset + 2, 2));
sizeOfOptionalHeader = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(coffOffset + 16, 2));
return sizeOfOptionalHeader > 0;
}
/// <summary>
/// Parse Optional header.
/// </summary>
private static bool ParseOptionalHeader(ReadOnlySpan<byte> data, int peHeaderOffset, ushort sizeOfOptionalHeader,
out bool is64Bit, out PeSubsystem subsystem, out uint numberOfRvaAndSizes, out int dataDirectoryOffset)
{
is64Bit = false;
subsystem = PeSubsystem.Unknown;
numberOfRvaAndSizes = 0;
dataDirectoryOffset = 0;
var optionalHeaderOffset = peHeaderOffset + 24;
if (optionalHeaderOffset + sizeOfOptionalHeader > data.Length)
{
return false;
}
var magic = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(optionalHeaderOffset, 2));
is64Bit = magic == 0x20b; // PE32+
if (magic != 0x10b && magic != 0x20b) // PE32 or PE32+
{
return false;
}
// Subsystem offset: 68 for both PE32 and PE32+
var subsystemOffset = optionalHeaderOffset + 68;
if (subsystemOffset + 2 <= data.Length)
{
subsystem = (PeSubsystem)BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(subsystemOffset, 2));
}
// NumberOfRvaAndSizes
var rvaAndSizesOffset = optionalHeaderOffset + (is64Bit ? 108 : 92);
if (rvaAndSizesOffset + 4 <= data.Length)
{
numberOfRvaAndSizes = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(rvaAndSizesOffset, 4));
}
// Data directories start after the numberOfRvaAndSizes field
dataDirectoryOffset = optionalHeaderOffset + (is64Bit ? 112 : 96);
return true;
}
/// <summary>
/// Parse section headers for RVA-to-file-offset translation.
/// </summary>
private static List<SectionHeader> ParseSectionHeaders(ReadOnlySpan<byte> data, int offset, ushort numberOfSections)
{
const int SECTION_HEADER_SIZE = 40;
var sections = new List<SectionHeader>();
for (var i = 0; i < numberOfSections; i++)
{
var entryOffset = offset + i * SECTION_HEADER_SIZE;
if (entryOffset + SECTION_HEADER_SIZE > data.Length)
{
break;
}
var virtualSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 8, 4));
var virtualAddress = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 12, 4));
var rawDataSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 16, 4));
var rawDataPointer = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 20, 4));
sections.Add(new SectionHeader(virtualAddress, virtualSize, rawDataPointer, rawDataSize));
}
return sections;
}
/// <summary>
/// Convert RVA to file offset using section headers.
/// </summary>
private static bool TryRvaToFileOffset(uint rva, List<SectionHeader> sections, out uint fileOffset)
{
fileOffset = 0;
foreach (var section in sections)
{
if (rva >= section.VirtualAddress && rva < section.VirtualAddress + section.VirtualSize)
{
fileOffset = rva - section.VirtualAddress + section.RawDataPointer;
return true;
}
}
return false;
}
/// <summary>
/// Parse Rich header from DOS stub.
/// </summary>
private static void ParseRichHeader(ReadOnlySpan<byte> data, int peHeaderOffset,
out uint? richHeaderHash, List<PeCompilerHint> compilerHints)
{
richHeaderHash = null;
// Search for "Rich" marker backwards from PE header
var searchEnd = Math.Min(peHeaderOffset, data.Length);
var richOffset = -1;
for (var i = searchEnd - 4; i >= 0x40; i--)
{
var marker = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i, 4));
if (marker == RICH_MARKER)
{
richOffset = i;
break;
}
}
if (richOffset < 0 || richOffset + 8 > data.Length)
{
return;
}
// XOR key follows "Rich" marker
var xorKey = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(richOffset + 4, 4));
richHeaderHash = xorKey;
// Search backwards for "DanS" marker (XOR'd)
var dansOffset = -1;
for (var i = richOffset - 4; i >= 0x40; i -= 4)
{
if (i + 4 > data.Length)
{
continue;
}
var value = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i, 4));
if ((value ^ xorKey) == DANS_MARKER)
{
dansOffset = i;
break;
}
}
if (dansOffset < 0)
{
return;
}
// Parse entries between DanS and Rich (skip first 16 bytes after DanS which are padding)
var entriesStart = dansOffset + 16;
for (var i = entriesStart; i < richOffset; i += 8)
{
if (i + 8 > data.Length)
{
break;
}
var compId = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i, 4)) ^ xorKey;
var useCount = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i + 4, 4)) ^ xorKey;
if (compId == 0 && useCount == 0)
{
continue;
}
var toolId = (ushort)(compId & 0xFFFF);
var toolVersion = (ushort)((compId >> 16) & 0xFFFF);
compilerHints.Add(new PeCompilerHint(toolId, toolVersion, (int)useCount));
}
}
/// <summary>
/// Parse debug directory for CodeView GUID.
/// </summary>
private static void ParseDebugDirectory(ReadOnlySpan<byte> data, int dataDirectoryOffset, uint numberOfRvaAndSizes,
List<SectionHeader> sections, out string? codeViewGuid, out int? codeViewAge, out string? pdbPath)
{
codeViewGuid = null;
codeViewAge = null;
pdbPath = null;
if (numberOfRvaAndSizes <= IMAGE_DIRECTORY_ENTRY_DEBUG)
{
return;
}
var debugDirOffset = dataDirectoryOffset + IMAGE_DIRECTORY_ENTRY_DEBUG * 8;
if (debugDirOffset + 8 > data.Length)
{
return;
}
var debugRva = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(debugDirOffset, 4));
var debugSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(debugDirOffset + 4, 4));
if (debugRva == 0 || debugSize == 0)
{
return;
}
if (!TryRvaToFileOffset(debugRva, sections, out var debugFileOffset))
{
return;
}
// Each debug directory entry is 28 bytes
const int DEBUG_ENTRY_SIZE = 28;
var numEntries = debugSize / DEBUG_ENTRY_SIZE;
for (var i = 0; i < numEntries; i++)
{
var entryOffset = (int)debugFileOffset + i * DEBUG_ENTRY_SIZE;
if (entryOffset + DEBUG_ENTRY_SIZE > data.Length)
{
break;
}
var debugType = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 12, 4));
if (debugType != IMAGE_DEBUG_TYPE_CODEVIEW)
{
continue;
}
var sizeOfData = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 16, 4));
var pointerToRawData = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(entryOffset + 24, 4));
if (pointerToRawData == 0 || sizeOfData < 24)
{
continue;
}
if (pointerToRawData + sizeOfData > data.Length)
{
continue;
}
var cvSpan = data.Slice((int)pointerToRawData, (int)sizeOfData);
// Check for RSDS signature (PDB70)
var signature = BinaryPrimitives.ReadUInt32LittleEndian(cvSpan);
if (signature != RSDS_SIGNATURE)
{
continue;
}
// GUID is 16 bytes at offset 4
var guidBytes = cvSpan.Slice(4, 16);
codeViewGuid = FormatGuidAsLowercaseHex(guidBytes);
// Age is 4 bytes at offset 20
codeViewAge = (int)BinaryPrimitives.ReadUInt32LittleEndian(cvSpan.Slice(20, 4));
// PDB path is null-terminated string starting at offset 24
var pdbPathSpan = cvSpan[24..];
var nullTerminator = pdbPathSpan.IndexOf((byte)0);
var pathLength = nullTerminator >= 0 ? nullTerminator : pdbPathSpan.Length;
if (pathLength > 0)
{
pdbPath = Encoding.UTF8.GetString(pdbPathSpan[..pathLength]);
}
break; // Found CodeView, done
}
}
/// <summary>
/// Format GUID bytes as lowercase hex without dashes.
/// </summary>
private static string FormatGuidAsLowercaseHex(ReadOnlySpan<byte> guidBytes)
{
// GUID structure: Data1 (LE 4 bytes), Data2 (LE 2 bytes), Data3 (LE 2 bytes), Data4 (8 bytes BE)
var sb = new StringBuilder(32);
// Data1 - 4 bytes, little endian
sb.Append(BinaryPrimitives.ReadUInt32LittleEndian(guidBytes).ToString("x8"));
// Data2 - 2 bytes, little endian
sb.Append(BinaryPrimitives.ReadUInt16LittleEndian(guidBytes.Slice(4, 2)).ToString("x4"));
// Data3 - 2 bytes, little endian
sb.Append(BinaryPrimitives.ReadUInt16LittleEndian(guidBytes.Slice(6, 2)).ToString("x4"));
// Data4 - 8 bytes, big endian (stored as-is)
for (var i = 8; i < 16; i++)
{
sb.Append(guidBytes[i].ToString("x2"));
}
return sb.ToString();
}
/// <summary>
/// Parse version resource for product/file information.
/// </summary>
private static void ParseVersionResource(ReadOnlySpan<byte> data, int dataDirectoryOffset,
List<SectionHeader> sections, bool is64Bit,
out string? productVersion, out string? fileVersion,
out string? companyName, out string? productName, out string? originalFilename)
{
productVersion = null;
fileVersion = null;
companyName = null;
productName = null;
originalFilename = null;
var resourceDirOffset = dataDirectoryOffset + IMAGE_DIRECTORY_ENTRY_RESOURCE * 8;
if (resourceDirOffset + 8 > data.Length)
{
return;
}
var resourceRva = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(resourceDirOffset, 4));
var resourceSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(resourceDirOffset + 4, 4));
if (resourceRva == 0 || resourceSize == 0)
{
return;
}
if (!TryRvaToFileOffset(resourceRva, sections, out var resourceFileOffset))
{
return;
}
// Search for VS_VERSION_INFO signature in resources
// This is a simplified approach - searching for the signature in the resource section
var searchSpan = data.Slice((int)resourceFileOffset, (int)Math.Min(resourceSize, data.Length - resourceFileOffset));
// Look for "VS_VERSION_INFO" signature (wide string)
var vsVersionInfo = Encoding.Unicode.GetBytes("VS_VERSION_INFO");
var vsInfoOffset = IndexOf(searchSpan, vsVersionInfo);
if (vsInfoOffset < 0)
{
return;
}
// Parse StringFileInfo to extract version strings
var versionInfoStart = (int)resourceFileOffset + vsInfoOffset;
ParseVersionStrings(data, versionInfoStart, searchSpan.Length - vsInfoOffset,
ref productVersion, ref fileVersion, ref companyName, ref productName, ref originalFilename);
}
/// <summary>
/// Parse version strings from VS_VERSION_INFO structure.
/// </summary>
private static void ParseVersionStrings(ReadOnlySpan<byte> data, int offset, int maxLength,
ref string? productVersion, ref string? fileVersion,
ref string? companyName, ref string? productName, ref string? originalFilename)
{
// Search for common version string keys
var keys = new[] { "ProductVersion", "FileVersion", "CompanyName", "ProductName", "OriginalFilename" };
var searchSpan = data.Slice(offset, Math.Min(maxLength, data.Length - offset));
foreach (var key in keys)
{
var keyBytes = Encoding.Unicode.GetBytes(key);
var keyOffset = IndexOf(searchSpan, keyBytes);
if (keyOffset < 0)
{
continue;
}
// Value follows the key, aligned to 4-byte boundary
var valueStart = keyOffset + keyBytes.Length + 2; // +2 for null terminator
// Align to 4-byte boundary
valueStart = (valueStart + 3) & ~3;
if (offset + valueStart >= data.Length)
{
continue;
}
// Read null-terminated wide string value
var valueSpan = searchSpan[valueStart..];
var nullTerm = -1;
for (var i = 0; i < valueSpan.Length - 1; i += 2)
{
if (valueSpan[i] == 0 && valueSpan[i + 1] == 0)
{
nullTerm = i;
break;
}
}
if (nullTerm > 0)
{
var value = Encoding.Unicode.GetString(valueSpan[..nullTerm]);
if (!string.IsNullOrWhiteSpace(value))
{
switch (key)
{
case "ProductVersion":
productVersion = value;
break;
case "FileVersion":
fileVersion = value;
break;
case "CompanyName":
companyName = value;
break;
case "ProductName":
productName = value;
break;
case "OriginalFilename":
originalFilename = value;
break;
}
}
}
}
}
/// <summary>
/// Parse export directory for exported symbols.
/// </summary>
private static void ParseExportDirectory(ReadOnlySpan<byte> data, int dataDirectoryOffset,
List<SectionHeader> sections, List<string> exports)
{
const int MAX_EXPORTS = 10000;
var exportDirOffset = dataDirectoryOffset + IMAGE_DIRECTORY_ENTRY_EXPORT * 8;
if (exportDirOffset + 8 > data.Length)
{
return;
}
var exportRva = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(exportDirOffset, 4));
var exportSize = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(exportDirOffset + 4, 4));
if (exportRva == 0 || exportSize == 0)
{
return;
}
if (!TryRvaToFileOffset(exportRva, sections, out var exportFileOffset))
{
return;
}
if (exportFileOffset + 40 > data.Length)
{
return;
}
var exportSpan = data.Slice((int)exportFileOffset, 40);
var numberOfNames = BinaryPrimitives.ReadUInt32LittleEndian(exportSpan.Slice(24, 4));
var addressOfNames = BinaryPrimitives.ReadUInt32LittleEndian(exportSpan.Slice(32, 4));
if (numberOfNames == 0 || addressOfNames == 0)
{
return;
}
if (!TryRvaToFileOffset(addressOfNames, sections, out var namesFileOffset))
{
return;
}
var count = Math.Min((int)numberOfNames, MAX_EXPORTS);
for (var i = 0; i < count; i++)
{
var nameRvaOffset = (int)namesFileOffset + i * 4;
if (nameRvaOffset + 4 > data.Length)
{
break;
}
var nameRva = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(nameRvaOffset, 4));
if (!TryRvaToFileOffset(nameRva, sections, out var nameFileOffset))
{
continue;
}
if (nameFileOffset >= data.Length)
{
continue;
}
var nameSpan = data[(int)nameFileOffset..];
var nullTerm = nameSpan.IndexOf((byte)0);
var nameLength = nullTerm >= 0 ? nullTerm : Math.Min(256, nameSpan.Length);
if (nameLength > 0)
{
var name = Encoding.ASCII.GetString(nameSpan[..nameLength]);
if (!string.IsNullOrWhiteSpace(name))
{
exports.Add(name);
}
}
}
}
/// <summary>
/// Simple byte sequence search.
/// </summary>
private static int IndexOf(ReadOnlySpan<byte> haystack, ReadOnlySpan<byte> needle)
{
for (var i = 0; i <= haystack.Length - needle.Length; i++)
{
if (haystack.Slice(i, needle.Length).SequenceEqual(needle))
{
return i;
}
}
return -1;
}
/// <summary>
/// Map PE machine type to architecture string.
/// </summary>
private static string? MapPeMachine(ushort machine)
{
return machine switch
{
0x014c => "x86",
0x0200 => "ia64",
0x8664 => "x86_64",
0x01c0 => "arm",
0x01c2 => "thumb",
0x01c4 => "armnt",
0xaa64 => "arm64",
0x5032 => "riscv32",
0x5064 => "riscv64",
0x5128 => "riscv128",
_ => null
};
}
/// <summary>
/// Section header for RVA translation.
/// </summary>
private sealed record SectionHeader(
uint VirtualAddress,
uint VirtualSize,
uint RawDataPointer,
uint RawDataSize);
}

View File

@@ -0,0 +1,451 @@
// -----------------------------------------------------------------------------
// FindingEvidenceContracts.cs
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
// Description: Unified evidence API response contracts for findings.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
/// <summary>
/// Unified evidence response for a finding, combining reachability, boundary,
/// VEX evidence, and score explanation.
/// </summary>
public sealed record FindingEvidenceResponse
{
/// <summary>
/// Unique identifier for the finding.
/// </summary>
[JsonPropertyName("finding_id")]
public string FindingId { get; init; } = string.Empty;
/// <summary>
/// CVE identifier (e.g., "CVE-2021-44228").
/// </summary>
[JsonPropertyName("cve")]
public string Cve { get; init; } = string.Empty;
/// <summary>
/// Component where the vulnerability was found.
/// </summary>
[JsonPropertyName("component")]
public ComponentRef? Component { get; init; }
/// <summary>
/// Reachable call path from entrypoint to vulnerable sink.
/// Each element is a fully-qualified name (FQN).
/// </summary>
[JsonPropertyName("reachable_path")]
public IReadOnlyList<string>? ReachablePath { get; init; }
/// <summary>
/// Entrypoint proof (how the code is exposed).
/// </summary>
[JsonPropertyName("entrypoint")]
public EntrypointProof? Entrypoint { get; init; }
/// <summary>
/// Boundary proof (surface exposure and controls).
/// </summary>
[JsonPropertyName("boundary")]
public BoundaryProofDto? Boundary { get; init; }
/// <summary>
/// VEX (Vulnerability Exploitability eXchange) evidence.
/// </summary>
[JsonPropertyName("vex")]
public VexEvidenceDto? Vex { get; init; }
/// <summary>
/// Score explanation with additive risk breakdown.
/// </summary>
[JsonPropertyName("score_explain")]
public ScoreExplanationDto? ScoreExplain { get; init; }
/// <summary>
/// When the finding was last observed.
/// </summary>
[JsonPropertyName("last_seen")]
public DateTimeOffset LastSeen { get; init; }
/// <summary>
/// When the evidence expires (for VEX/attestation freshness).
/// </summary>
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// References to DSSE/in-toto attestations backing this evidence.
/// </summary>
[JsonPropertyName("attestation_refs")]
public IReadOnlyList<string>? AttestationRefs { get; init; }
}
/// <summary>
/// Reference to a component (package) by PURL and version.
/// </summary>
public sealed record ComponentRef
{
/// <summary>
/// Package URL (PURL) identifier.
/// </summary>
[JsonPropertyName("purl")]
public string Purl { get; init; } = string.Empty;
/// <summary>
/// Package name.
/// </summary>
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
/// <summary>
/// Package version.
/// </summary>
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
/// <summary>
/// Package type/ecosystem (npm, maven, nuget, etc.).
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
}
/// <summary>
/// Proof of how code is exposed as an entrypoint.
/// </summary>
public sealed record EntrypointProof
{
/// <summary>
/// Type of entrypoint (http_handler, grpc_method, cli_command, etc.).
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
/// <summary>
/// Route or path (e.g., "/api/v1/users", "grpc.UserService.GetUser").
/// </summary>
[JsonPropertyName("route")]
public string? Route { get; init; }
/// <summary>
/// HTTP method if applicable (GET, POST, etc.).
/// </summary>
[JsonPropertyName("method")]
public string? Method { get; init; }
/// <summary>
/// Authentication requirement (none, optional, required).
/// </summary>
[JsonPropertyName("auth")]
public string? Auth { get; init; }
/// <summary>
/// Execution phase (startup, runtime, shutdown).
/// </summary>
[JsonPropertyName("phase")]
public string? Phase { get; init; }
/// <summary>
/// Fully qualified name of the entrypoint symbol.
/// </summary>
[JsonPropertyName("fqn")]
public string Fqn { get; init; } = string.Empty;
/// <summary>
/// Source file location.
/// </summary>
[JsonPropertyName("location")]
public SourceLocation? Location { get; init; }
}
/// <summary>
/// Source file location reference.
/// </summary>
public sealed record SourceLocation
{
/// <summary>
/// File path relative to repository root.
/// </summary>
[JsonPropertyName("file")]
public string File { get; init; } = string.Empty;
/// <summary>
/// Line number (1-indexed).
/// </summary>
[JsonPropertyName("line")]
public int? Line { get; init; }
/// <summary>
/// Column number (1-indexed).
/// </summary>
[JsonPropertyName("column")]
public int? Column { get; init; }
}
/// <summary>
/// Boundary proof describing surface exposure and controls.
/// </summary>
public sealed record BoundaryProofDto
{
/// <summary>
/// Kind of boundary (network, file, ipc, etc.).
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = string.Empty;
/// <summary>
/// Surface descriptor (what is exposed).
/// </summary>
[JsonPropertyName("surface")]
public SurfaceDescriptor? Surface { get; init; }
/// <summary>
/// Exposure descriptor (how it's exposed).
/// </summary>
[JsonPropertyName("exposure")]
public ExposureDescriptor? Exposure { get; init; }
/// <summary>
/// Authentication descriptor.
/// </summary>
[JsonPropertyName("auth")]
public AuthDescriptor? Auth { get; init; }
/// <summary>
/// Security controls in place.
/// </summary>
[JsonPropertyName("controls")]
public IReadOnlyList<ControlDescriptor>? Controls { get; init; }
/// <summary>
/// When the boundary was last verified.
/// </summary>
[JsonPropertyName("last_seen")]
public DateTimeOffset LastSeen { get; init; }
/// <summary>
/// Confidence score (0.0 to 1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; }
}
/// <summary>
/// Describes what attack surface is exposed.
/// </summary>
public sealed record SurfaceDescriptor
{
/// <summary>
/// Type of surface (api, web, cli, library).
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
/// <summary>
/// Protocol (http, https, grpc, tcp).
/// </summary>
[JsonPropertyName("protocol")]
public string? Protocol { get; init; }
/// <summary>
/// Port number if network-exposed.
/// </summary>
[JsonPropertyName("port")]
public int? Port { get; init; }
}
/// <summary>
/// Describes how the surface is exposed.
/// </summary>
public sealed record ExposureDescriptor
{
/// <summary>
/// Exposure level (public, internal, private).
/// </summary>
[JsonPropertyName("level")]
public string Level { get; init; } = string.Empty;
/// <summary>
/// Whether the exposure is internet-facing.
/// </summary>
[JsonPropertyName("internet_facing")]
public bool InternetFacing { get; init; }
/// <summary>
/// Network zone (dmz, internal, trusted).
/// </summary>
[JsonPropertyName("zone")]
public string? Zone { get; init; }
}
/// <summary>
/// Describes authentication requirements.
/// </summary>
public sealed record AuthDescriptor
{
/// <summary>
/// Whether authentication is required.
/// </summary>
[JsonPropertyName("required")]
public bool Required { get; init; }
/// <summary>
/// Authentication type (jwt, oauth2, basic, api_key).
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; init; }
/// <summary>
/// Required roles/scopes.
/// </summary>
[JsonPropertyName("roles")]
public IReadOnlyList<string>? Roles { get; init; }
}
/// <summary>
/// Describes a security control.
/// </summary>
public sealed record ControlDescriptor
{
/// <summary>
/// Type of control (rate_limit, waf, input_validation, etc.).
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
/// <summary>
/// Whether the control is active.
/// </summary>
[JsonPropertyName("active")]
public bool Active { get; init; }
/// <summary>
/// Control configuration details.
/// </summary>
[JsonPropertyName("config")]
public string? Config { get; init; }
}
/// <summary>
/// VEX (Vulnerability Exploitability eXchange) evidence.
/// </summary>
public sealed record VexEvidenceDto
{
/// <summary>
/// VEX status (not_affected, affected, fixed, under_investigation).
/// </summary>
[JsonPropertyName("status")]
public string Status { get; init; } = string.Empty;
/// <summary>
/// Justification for the status.
/// </summary>
[JsonPropertyName("justification")]
public string? Justification { get; init; }
/// <summary>
/// Impact statement explaining why not affected.
/// </summary>
[JsonPropertyName("impact")]
public string? Impact { get; init; }
/// <summary>
/// Action statement (remediation steps).
/// </summary>
[JsonPropertyName("action")]
public string? Action { get; init; }
/// <summary>
/// Reference to the VEX document/attestation.
/// </summary>
[JsonPropertyName("attestation_ref")]
public string? AttestationRef { get; init; }
/// <summary>
/// When the VEX statement was issued.
/// </summary>
[JsonPropertyName("issued_at")]
public DateTimeOffset? IssuedAt { get; init; }
/// <summary>
/// When the VEX statement expires.
/// </summary>
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Source of the VEX statement (vendor, first-party, third-party).
/// </summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
}
/// <summary>
/// Score explanation with additive breakdown of risk factors.
/// </summary>
public sealed record ScoreExplanationDto
{
/// <summary>
/// Kind of scoring algorithm (stellaops_risk_v1, cvss_v4, etc.).
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = string.Empty;
/// <summary>
/// Final computed risk score.
/// </summary>
[JsonPropertyName("risk_score")]
public double RiskScore { get; init; }
/// <summary>
/// Individual score contributions.
/// </summary>
[JsonPropertyName("contributions")]
public IReadOnlyList<ScoreContributionDto>? Contributions { get; init; }
/// <summary>
/// When the score was computed.
/// </summary>
[JsonPropertyName("last_seen")]
public DateTimeOffset LastSeen { get; init; }
}
/// <summary>
/// Individual contribution to the risk score.
/// </summary>
public sealed record ScoreContributionDto
{
/// <summary>
/// Factor name (cvss_base, epss, reachability, gate_multiplier, etc.).
/// </summary>
[JsonPropertyName("factor")]
public string Factor { get; init; } = string.Empty;
/// <summary>
/// Weight applied to this factor (0.0 to 1.0).
/// </summary>
[JsonPropertyName("weight")]
public double Weight { get; init; }
/// <summary>
/// Raw value before weighting.
/// </summary>
[JsonPropertyName("raw_value")]
public double RawValue { get; init; }
/// <summary>
/// Weighted contribution to final score.
/// </summary>
[JsonPropertyName("contribution")]
public double Contribution { get; init; }
/// <summary>
/// Human-readable explanation of this factor.
/// </summary>
[JsonPropertyName("explanation")]
public string? Explanation { get; init; }
}

View File

@@ -0,0 +1,320 @@
// -----------------------------------------------------------------------------
// EpssEndpoints.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-008, EPSS-SCAN-009
// Description: EPSS lookup API endpoints.
// -----------------------------------------------------------------------------
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// EPSS lookup API endpoints.
/// Provides bulk lookup and history APIs for EPSS scores.
/// </summary>
public static class EpssEndpoints
{
/// <summary>
/// Maps EPSS endpoints to the route builder.
/// </summary>
public static IEndpointRouteBuilder MapEpssEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/epss")
.WithTags("EPSS")
.WithOpenApi();
group.MapPost("/current", GetCurrentBatch)
.WithName("GetCurrentEpss")
.WithSummary("Get current EPSS scores for multiple CVEs")
.WithDescription("Returns the latest EPSS scores and percentiles for the specified CVE IDs. " +
"Maximum batch size is 1000 CVEs per request.")
.Produces<EpssBatchResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable);
group.MapGet("/current/{cveId}", GetCurrent)
.WithName("GetCurrentEpssSingle")
.WithSummary("Get current EPSS score for a single CVE")
.WithDescription("Returns the latest EPSS score and percentile for the specified CVE ID.")
.Produces<EpssEvidence>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
group.MapGet("/history/{cveId}", GetHistory)
.WithName("GetEpssHistory")
.WithSummary("Get EPSS score history for a CVE")
.WithDescription("Returns the EPSS score time series for the specified CVE ID and date range.")
.Produces<EpssHistoryResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
group.MapGet("/status", GetStatus)
.WithName("GetEpssStatus")
.WithSummary("Get EPSS data availability status")
.WithDescription("Returns the current status of the EPSS data provider.")
.Produces<EpssStatusResponse>(StatusCodes.Status200OK);
return endpoints;
}
/// <summary>
/// POST /epss/current - Bulk lookup of current EPSS scores.
/// </summary>
private static async Task<IResult> GetCurrentBatch(
[FromBody] EpssBatchRequest request,
[FromServices] IEpssProvider epssProvider,
CancellationToken cancellationToken)
{
if (request.CveIds is null || request.CveIds.Count == 0)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "At least one CVE ID is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request.CveIds.Count > 1000)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Batch size exceeded",
Detail = "Maximum batch size is 1000 CVE IDs.",
Status = StatusCodes.Status400BadRequest
});
}
var isAvailable = await epssProvider.IsAvailableAsync(cancellationToken);
if (!isAvailable)
{
return Results.Problem(
detail: "EPSS data is not available. Please ensure EPSS data has been ingested.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
var result = await epssProvider.GetCurrentBatchAsync(request.CveIds, cancellationToken);
return Results.Ok(new EpssBatchResponse
{
Found = result.Found,
NotFound = result.NotFound,
ModelDate = result.ModelDate.ToString("yyyy-MM-dd"),
LookupTimeMs = result.LookupTimeMs,
PartiallyFromCache = result.PartiallyFromCache
});
}
/// <summary>
/// GET /epss/current/{cveId} - Get current EPSS score for a single CVE.
/// </summary>
private static async Task<IResult> GetCurrent(
[FromRoute] string cveId,
[FromServices] IEpssProvider epssProvider,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(cveId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid CVE ID",
Detail = "CVE ID is required.",
Status = StatusCodes.Status400BadRequest
});
}
var evidence = await epssProvider.GetCurrentAsync(cveId, cancellationToken);
if (evidence is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "CVE not found",
Detail = $"No EPSS score found for {cveId}.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(evidence);
}
/// <summary>
/// GET /epss/history/{cveId} - Get EPSS score history for a CVE.
/// </summary>
private static async Task<IResult> GetHistory(
[FromRoute] string cveId,
[FromServices] IEpssProvider epssProvider,
[FromQuery] string? startDate = null,
[FromQuery] string? endDate = null,
[FromQuery] int days = 30,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cveId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid CVE ID",
Detail = "CVE ID is required.",
Status = StatusCodes.Status400BadRequest
});
}
DateOnly start, end;
if (!string.IsNullOrEmpty(startDate) && !string.IsNullOrEmpty(endDate))
{
if (!DateOnly.TryParse(startDate, out start) || !DateOnly.TryParse(endDate, out end))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid date format",
Detail = "Dates must be in yyyy-MM-dd format.",
Status = StatusCodes.Status400BadRequest
});
}
}
else
{
// Default to last N days
end = DateOnly.FromDateTime(DateTime.UtcNow);
start = end.AddDays(-days);
}
var history = await epssProvider.GetHistoryAsync(cveId, start, end, cancellationToken);
if (history.Count == 0)
{
return Results.NotFound(new ProblemDetails
{
Title = "No history found",
Detail = $"No EPSS history found for {cveId} in the specified date range.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new EpssHistoryResponse
{
CveId = cveId,
StartDate = start.ToString("yyyy-MM-dd"),
EndDate = end.ToString("yyyy-MM-dd"),
History = history
});
}
/// <summary>
/// GET /epss/status - Get EPSS data availability status.
/// </summary>
private static async Task<IResult> GetStatus(
[FromServices] IEpssProvider epssProvider,
CancellationToken cancellationToken)
{
var isAvailable = await epssProvider.IsAvailableAsync(cancellationToken);
var modelDate = await epssProvider.GetLatestModelDateAsync(cancellationToken);
return Results.Ok(new EpssStatusResponse
{
Available = isAvailable,
LatestModelDate = modelDate?.ToString("yyyy-MM-dd"),
LastCheckedUtc = DateTimeOffset.UtcNow
});
}
}
#region Request/Response Models
/// <summary>
/// Request for bulk EPSS lookup.
/// </summary>
public sealed record EpssBatchRequest
{
/// <summary>
/// List of CVE IDs to look up (max 1000).
/// </summary>
[Required]
public required IReadOnlyList<string> CveIds { get; init; }
}
/// <summary>
/// Response for bulk EPSS lookup.
/// </summary>
public sealed record EpssBatchResponse
{
/// <summary>
/// EPSS evidence for found CVEs.
/// </summary>
public required IReadOnlyList<EpssEvidence> Found { get; init; }
/// <summary>
/// CVE IDs that were not found in the EPSS dataset.
/// </summary>
public required IReadOnlyList<string> NotFound { get; init; }
/// <summary>
/// EPSS model date used for this lookup.
/// </summary>
public required string ModelDate { get; init; }
/// <summary>
/// Total lookup time in milliseconds.
/// </summary>
public long LookupTimeMs { get; init; }
/// <summary>
/// Whether any results came from cache.
/// </summary>
public bool PartiallyFromCache { get; init; }
}
/// <summary>
/// Response for EPSS history lookup.
/// </summary>
public sealed record EpssHistoryResponse
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Start of date range.
/// </summary>
public required string StartDate { get; init; }
/// <summary>
/// End of date range.
/// </summary>
public required string EndDate { get; init; }
/// <summary>
/// Historical EPSS evidence records.
/// </summary>
public required IReadOnlyList<EpssEvidence> History { get; init; }
}
/// <summary>
/// Response for EPSS status check.
/// </summary>
public sealed record EpssStatusResponse
{
/// <summary>
/// Whether EPSS data is available.
/// </summary>
public bool Available { get; init; }
/// <summary>
/// Latest EPSS model date available.
/// </summary>
public string? LatestModelDate { get; init; }
/// <summary>
/// When this status was checked.
/// </summary>
public DateTimeOffset LastCheckedUtc { get; init; }
}
#endregion

View File

@@ -0,0 +1,251 @@
// -----------------------------------------------------------------------------
// WitnessEndpoints.cs
// Sprint: SPRINT_3700_0001_0001_witness_foundation
// Task: WIT-010
// Description: API endpoints for DSSE-signed path witnesses.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class WitnessEndpoints
{
public static void MapWitnessEndpoints(this RouteGroupBuilder apiGroup, string witnessSegment = "witnesses")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var witnesses = apiGroup.MapGroup($"/{witnessSegment.TrimStart('/')}");
witnesses.MapGet("/{witnessId:guid}", HandleGetWitnessByIdAsync)
.WithName("scanner.witnesses.get")
.Produces<WitnessResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
witnesses.MapGet("", HandleListWitnessesAsync)
.WithName("scanner.witnesses.list")
.Produces<WitnessListResponseDto>(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.ScansRead);
witnesses.MapGet("/by-hash/{witnessHash}", HandleGetWitnessByHashAsync)
.WithName("scanner.witnesses.get-by-hash")
.Produces<WitnessResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
witnesses.MapPost("/{witnessId:guid}/verify", HandleVerifyWitnessAsync)
.WithName("scanner.witnesses.verify")
.Produces<WitnessVerificationResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
}
private static async Task<IResult> HandleGetWitnessByIdAsync(
Guid witnessId,
IWitnessRepository repository,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
var witness = await repository.GetByIdAsync(witnessId, cancellationToken).ConfigureAwait(false);
if (witness is null)
{
return Results.NotFound();
}
return Results.Ok(MapToDto(witness));
}
private static async Task<IResult> HandleGetWitnessByHashAsync(
string witnessHash,
IWitnessRepository repository,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
if (string.IsNullOrWhiteSpace(witnessHash))
{
return Results.NotFound();
}
var witness = await repository.GetByHashAsync(witnessHash, cancellationToken).ConfigureAwait(false);
if (witness is null)
{
return Results.NotFound();
}
return Results.Ok(MapToDto(witness));
}
private static async Task<IResult> HandleListWitnessesAsync(
HttpContext context,
IWitnessRepository repository,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
var query = context.Request.Query;
IReadOnlyList<WitnessRecord> witnesses;
if (query.TryGetValue("scanId", out var scanIdValue) && Guid.TryParse(scanIdValue, out var scanId))
{
witnesses = await repository.GetByScanIdAsync(scanId, cancellationToken).ConfigureAwait(false);
}
else if (query.TryGetValue("cve", out var cveValue) && !string.IsNullOrWhiteSpace(cveValue))
{
witnesses = await repository.GetByCveAsync(cveValue!, cancellationToken).ConfigureAwait(false);
}
else if (query.TryGetValue("graphHash", out var graphHashValue) && !string.IsNullOrWhiteSpace(graphHashValue))
{
witnesses = await repository.GetByGraphHashAsync(graphHashValue!, cancellationToken).ConfigureAwait(false);
}
else
{
// No filter provided - return empty list (avoid full table scan)
witnesses = [];
}
return Results.Ok(new WitnessListResponseDto
{
Witnesses = witnesses.Select(MapToDto).ToList(),
TotalCount = witnesses.Count
});
}
private static async Task<IResult> HandleVerifyWitnessAsync(
Guid witnessId,
IWitnessRepository repository,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
var witness = await repository.GetByIdAsync(witnessId, cancellationToken).ConfigureAwait(false);
if (witness is null)
{
return Results.NotFound();
}
// Basic verification: check if DSSE envelope exists and witness hash is valid
var verificationStatus = "valid";
string? verificationError = null;
if (string.IsNullOrEmpty(witness.DsseEnvelope))
{
verificationStatus = "unsigned";
verificationError = "Witness does not have a DSSE envelope";
}
else
{
// TODO: WIT-009 - Add actual DSSE signature verification via Attestor
// For now, just check the envelope structure
try
{
var envelope = JsonDocument.Parse(witness.DsseEnvelope);
if (!envelope.RootElement.TryGetProperty("signatures", out var signatures) ||
signatures.GetArrayLength() == 0)
{
verificationStatus = "invalid";
verificationError = "DSSE envelope has no signatures";
}
}
catch (JsonException ex)
{
verificationStatus = "invalid";
verificationError = $"Invalid DSSE envelope JSON: {ex.Message}";
}
}
// Record verification attempt
await repository.RecordVerificationAsync(new WitnessVerificationRecord
{
WitnessId = witnessId,
VerifiedAt = DateTimeOffset.UtcNow,
VerifiedBy = "api",
VerificationStatus = verificationStatus,
VerificationError = verificationError
}, cancellationToken).ConfigureAwait(false);
return Results.Ok(new WitnessVerificationResponseDto
{
WitnessId = witnessId,
WitnessHash = witness.WitnessHash,
Status = verificationStatus,
Error = verificationError,
VerifiedAt = DateTimeOffset.UtcNow,
IsSigned = !string.IsNullOrEmpty(witness.DsseEnvelope)
});
}
private static WitnessResponseDto MapToDto(WitnessRecord record)
{
return new WitnessResponseDto
{
WitnessId = record.WitnessId,
WitnessHash = record.WitnessHash,
SchemaVersion = record.SchemaVersion,
WitnessType = record.WitnessType,
GraphHash = record.GraphHash,
ScanId = record.ScanId,
RunId = record.RunId,
CreatedAt = record.CreatedAt,
SignedAt = record.SignedAt,
SignerKeyId = record.SignerKeyId,
EntrypointFqn = record.EntrypointFqn,
SinkCve = record.SinkCve,
IsSigned = !string.IsNullOrEmpty(record.DsseEnvelope),
Payload = JsonDocument.Parse(record.PayloadJson).RootElement,
DsseEnvelope = string.IsNullOrEmpty(record.DsseEnvelope)
? null
: JsonDocument.Parse(record.DsseEnvelope).RootElement
};
}
}
/// <summary>
/// Response DTO for a single witness.
/// </summary>
public sealed record WitnessResponseDto
{
public Guid WitnessId { get; init; }
public required string WitnessHash { get; init; }
public required string SchemaVersion { get; init; }
public required string WitnessType { get; init; }
public required string GraphHash { get; init; }
public Guid? ScanId { get; init; }
public Guid? RunId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? SignedAt { get; init; }
public string? SignerKeyId { get; init; }
public string? EntrypointFqn { get; init; }
public string? SinkCve { get; init; }
public bool IsSigned { get; init; }
public JsonElement Payload { get; init; }
public JsonElement? DsseEnvelope { get; init; }
}
/// <summary>
/// Response DTO for witness list.
/// </summary>
public sealed record WitnessListResponseDto
{
public required IReadOnlyList<WitnessResponseDto> Witnesses { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Response DTO for witness verification.
/// </summary>
public sealed record WitnessVerificationResponseDto
{
public Guid WitnessId { get; init; }
public required string WitnessHash { get; init; }
public required string Status { get; init; }
public string? Error { get; init; }
public DateTimeOffset VerifiedAt { get; init; }
public bool IsSigned { get; init; }
}

View File

@@ -470,6 +470,7 @@ apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
apiGroup.MapReachabilityDriftRootEndpoints();
apiGroup.MapProofSpineEndpoints(resolvedOptions.Api.SpinesSegment, resolvedOptions.Api.ScansSegment);
apiGroup.MapReplayEndpoints();
apiGroup.MapWitnessEndpoints(); // Sprint: SPRINT_3700_0001_0001
if (resolvedOptions.Features.EnablePolicyPreview)
{

View File

@@ -334,4 +334,13 @@ public sealed class ScannerWorkerMetrics
return tags.ToArray();
}
/// <summary>
/// Records native binary analysis metrics.
/// </summary>
public void RecordNativeAnalysis(NativeAnalysisResult result)
{
// Native analysis metrics are tracked via counters/histograms
// This is a placeholder for when we add dedicated native analysis metrics
}
}

View File

@@ -0,0 +1,110 @@
// -----------------------------------------------------------------------------
// NativeAnalyzerOptions.cs
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
// Task: NAI-004
// Description: Configuration options for native binary analysis.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Worker.Options;
/// <summary>
/// Configuration options for native binary analysis during container scans.
/// </summary>
public sealed class NativeAnalyzerOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Scanner:Worker:NativeAnalyzers";
/// <summary>
/// Whether native binary analysis is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Directories to search for native analyzer plugins.
/// </summary>
public IList<string> PluginDirectories { get; } = new List<string>();
/// <summary>
/// Paths to exclude from binary discovery.
/// Common system paths that contain kernel interfaces or virtual filesystems.
/// </summary>
public IList<string> ExcludePaths { get; } = new List<string>
{
"/proc",
"/sys",
"/dev",
"/run"
};
/// <summary>
/// Maximum number of binaries to analyze per container layer.
/// Prevents performance issues with containers containing many binaries.
/// </summary>
public int MaxBinariesPerLayer { get; set; } = 1000;
/// <summary>
/// Maximum total binaries to analyze per scan.
/// </summary>
public int MaxBinariesPerScan { get; set; } = 5000;
/// <summary>
/// Whether to enable heuristic detection for binaries without file extensions.
/// </summary>
public bool EnableHeuristics { get; set; } = true;
/// <summary>
/// Whether to extract hardening flags from binaries.
/// </summary>
public bool ExtractHardeningFlags { get; set; } = true;
/// <summary>
/// Whether to look up Build-IDs in the index for package correlation.
/// </summary>
public bool EnableBuildIdLookup { get; set; } = true;
/// <summary>
/// File extensions to consider as potential binaries.
/// </summary>
public IList<string> BinaryExtensions { get; } = new List<string>
{
".so",
".dll",
".exe",
".dylib",
".a",
".o"
};
/// <summary>
/// Timeout for analyzing a single binary.
/// </summary>
public TimeSpan SingleBinaryTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Timeout for the entire native analysis phase.
/// </summary>
public TimeSpan TotalAnalysisTimeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Minimum file size to consider as a binary (bytes).
/// </summary>
public long MinFileSizeBytes { get; set; } = 1024;
/// <summary>
/// Maximum file size to analyze (bytes). Larger files are skipped.
/// </summary>
public long MaxFileSizeBytes { get; set; } = 500 * 1024 * 1024; // 500 MB
/// <summary>
/// Whether to include unresolved binaries (no Build-ID match) in SBOM output.
/// </summary>
public bool IncludeUnresolvedInSbom { get; set; } = true;
/// <summary>
/// Degree of parallelism for binary analysis.
/// </summary>
public int MaxDegreeOfParallelism { get; set; } = 4;
}

View File

@@ -28,6 +28,8 @@ public sealed class ScannerWorkerOptions
public AnalyzerOptions Analyzers { get; } = new();
public NativeAnalyzerOptions NativeAnalyzers { get; } = new();
public StellaOpsCryptoOptions Crypto { get; } = new();
public SigningOptions Signing { get; } = new();

View File

@@ -0,0 +1,384 @@
// -----------------------------------------------------------------------------
// EpssEnrichmentJob.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: Task #1 - Implement EpssEnrichmentJob service
// Description: Background job that enriches vulnerability instances with current EPSS scores.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Options for the EPSS enrichment job.
/// </summary>
public sealed class EpssEnrichmentOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Epss:Enrichment";
/// <summary>
/// Whether the enrichment job is enabled. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Delay after EPSS ingestion before running enrichment. Default: 1 minute.
/// </summary>
public TimeSpan PostIngestDelay { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Batch size for processing vulnerability instances. Default: 1000.
/// </summary>
public int BatchSize { get; set; } = 1000;
/// <summary>
/// High percentile threshold. Scores at or above this trigger CROSSED_HIGH. Default: 0.99.
/// </summary>
public double HighPercentile { get; set; } = 0.99;
/// <summary>
/// High score threshold. Scores at or above this trigger priority elevation. Default: 0.5.
/// </summary>
public double HighScore { get; set; } = 0.5;
/// <summary>
/// Big jump delta threshold. Score changes >= this trigger BIG_JUMP flag. Default: 0.10.
/// </summary>
public double BigJumpDelta { get; set; } = 0.10;
/// <summary>
/// Critical percentile threshold. Default: 0.995 (top 0.5%).
/// </summary>
public double CriticalPercentile { get; set; } = 0.995;
/// <summary>
/// Medium percentile threshold. Default: 0.90 (top 10%).
/// </summary>
public double MediumPercentile { get; set; } = 0.90;
/// <summary>
/// Process only CVEs with specific change flags. Empty = process all.
/// </summary>
public EpssChangeFlags FlagsToProcess { get; set; } =
EpssChangeFlags.NewScored |
EpssChangeFlags.CrossedHigh |
EpssChangeFlags.BigJumpUp |
EpssChangeFlags.BigJumpDown;
/// <summary>
/// Suppress signals on model version change. Default: true.
/// </summary>
public bool SuppressSignalsOnModelChange { get; set; } = true;
}
/// <summary>
/// Background service that enriches vulnerability instances with current EPSS scores.
/// Runs after EPSS ingestion to update existing findings with new priority bands.
/// </summary>
public sealed class EpssEnrichmentJob : BackgroundService
{
private readonly IEpssRepository _epssRepository;
private readonly IEpssProvider _epssProvider;
private readonly IEpssSignalPublisher _signalPublisher;
private readonly IOptions<EpssEnrichmentOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EpssEnrichmentJob> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Scanner.EpssEnrichment");
// Event to trigger enrichment after ingestion
private readonly SemaphoreSlim _enrichmentTrigger = new(0);
public EpssEnrichmentJob(
IEpssRepository epssRepository,
IEpssProvider epssProvider,
IEpssSignalPublisher signalPublisher,
IOptions<EpssEnrichmentOptions> options,
TimeProvider timeProvider,
ILogger<EpssEnrichmentJob> logger)
{
_epssRepository = epssRepository ?? throw new ArgumentNullException(nameof(epssRepository));
_epssProvider = epssProvider ?? throw new ArgumentNullException(nameof(epssProvider));
_signalPublisher = signalPublisher ?? throw new ArgumentNullException(nameof(signalPublisher));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("EPSS enrichment job started");
var opts = _options.Value;
if (!opts.Enabled)
{
_logger.LogInformation("EPSS enrichment job is disabled");
return;
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Wait for enrichment trigger or cancellation
await _enrichmentTrigger.WaitAsync(stoppingToken);
// Add delay after ingestion to ensure data is fully committed
await Task.Delay(opts.PostIngestDelay, stoppingToken);
await EnrichAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "EPSS enrichment job encountered an error");
}
}
_logger.LogInformation("EPSS enrichment job stopped");
}
/// <summary>
/// Triggers the enrichment process. Called after EPSS data is ingested.
/// </summary>
public void TriggerEnrichment()
{
_enrichmentTrigger.Release();
_logger.LogDebug("EPSS enrichment triggered");
}
/// <summary>
/// Runs the enrichment process. Updates vulnerability instances with current EPSS scores.
/// </summary>
public async Task EnrichAsync(CancellationToken cancellationToken = default)
{
using var activity = _activitySource.StartActivity("epss.enrich", ActivityKind.Internal);
var stopwatch = Stopwatch.StartNew();
var opts = _options.Value;
_logger.LogInformation("Starting EPSS enrichment");
try
{
// Get the latest model date
var modelDate = await _epssProvider.GetLatestModelDateAsync(cancellationToken);
if (!modelDate.HasValue)
{
_logger.LogWarning("No EPSS data available for enrichment");
return;
}
activity?.SetTag("epss.model_date", modelDate.Value.ToString("yyyy-MM-dd"));
_logger.LogDebug("Using EPSS model date: {ModelDate}", modelDate.Value);
// Get CVEs with changes that need processing
var changedCves = await GetChangedCvesAsync(modelDate.Value, opts.FlagsToProcess, cancellationToken);
if (changedCves.Count == 0)
{
_logger.LogDebug("No CVE changes to process");
return;
}
_logger.LogInformation("Processing {Count} CVEs with EPSS changes", changedCves.Count);
activity?.SetTag("epss.changed_cve_count", changedCves.Count);
var totalUpdated = 0;
var totalBandChanges = 0;
// Process in batches
foreach (var batch in changedCves.Chunk(opts.BatchSize))
{
var (updated, bandChanges) = await ProcessBatchAsync(
batch,
modelDate.Value,
cancellationToken);
totalUpdated += updated;
totalBandChanges += bandChanges;
}
stopwatch.Stop();
_logger.LogInformation(
"EPSS enrichment completed: updated={Updated}, bandChanges={BandChanges}, duration={Duration}ms",
totalUpdated,
totalBandChanges,
stopwatch.ElapsedMilliseconds);
activity?.SetTag("epss.updated_count", totalUpdated);
activity?.SetTag("epss.band_change_count", totalBandChanges);
activity?.SetTag("epss.duration_ms", stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "EPSS enrichment failed");
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private async Task<IReadOnlyList<EpssChangeRecord>> GetChangedCvesAsync(
DateOnly modelDate,
EpssChangeFlags flags,
CancellationToken cancellationToken)
{
// Query epss_changes table for CVEs with matching flags for the model date (Task #4)
_logger.LogDebug("Querying EPSS changes for model date {ModelDate} with flags {Flags}", modelDate, flags);
var changes = await _epssRepository.GetChangesAsync(modelDate, flags, cancellationToken: cancellationToken);
_logger.LogDebug("Found {Count} EPSS changes matching flags {Flags}", changes.Count, flags);
return changes;
}
private async Task<(int Updated, int BandChanges)> ProcessBatchAsync(
EpssChangeRecord[] batch,
DateOnly modelDate,
CancellationToken cancellationToken)
{
var opts = _options.Value;
var updated = 0;
var bandChanges = 0;
// Get current EPSS scores for all CVEs in batch
var cveIds = batch.Select(c => c.CveId).ToList();
var epssResult = await _epssProvider.GetCurrentBatchAsync(cveIds, cancellationToken);
foreach (var change in batch)
{
var evidence = epssResult.Found.FirstOrDefault(e =>
string.Equals(e.CveId, change.CveId, StringComparison.OrdinalIgnoreCase));
if (evidence is null)
{
continue;
}
var previousBand = change.PreviousBand;
var newBand = ComputePriorityBand(evidence.Percentile, opts);
// Check if band changed
if (previousBand != newBand)
{
bandChanges++;
// Emit vuln.priority.changed event
await EmitPriorityChangedEventAsync(
change.CveId,
previousBand,
newBand,
evidence,
cancellationToken);
}
updated++;
}
return (updated, bandChanges);
}
private static EpssPriorityBand ComputePriorityBand(double percentile, EpssEnrichmentOptions opts)
{
if (percentile >= opts.CriticalPercentile)
{
return EpssPriorityBand.Critical;
}
if (percentile >= opts.HighPercentile)
{
return EpssPriorityBand.High;
}
if (percentile >= opts.MediumPercentile)
{
return EpssPriorityBand.Medium;
}
return EpssPriorityBand.Low;
}
private Task EmitPriorityChangedEventAsync(
string cveId,
EpssPriorityBand previousBand,
EpssPriorityBand newBand,
EpssEvidence evidence,
CancellationToken cancellationToken)
{
// Task #6: Emit `vuln.priority.changed` event via signal publisher
_logger.LogDebug(
"Priority changed: {CveId} {PreviousBand} -> {NewBand} (score={Score:F4}, percentile={Percentile:F4})",
cveId,
previousBand,
newBand,
evidence.Score,
evidence.Percentile);
// Publish priority changed event (Task #6)
var result = await _signalPublisher.PublishPriorityChangedAsync(
Guid.Empty, // Tenant ID would come from context
cveId,
previousBand.ToString(),
newBand.ToString(),
evidence.Score,
evidence.ModelDate,
cancellationToken);
if (!result.Success)
{
_logger.LogWarning(
"Failed to publish priority changed event for {CveId}: {Error}",
cveId,
result.Error);
}
}
}
/// <summary>
/// Record representing an EPSS change that needs processing.
/// </summary>
public sealed record EpssChangeRecord
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Change flags indicating what changed.
/// </summary>
public EpssChangeFlags Flags { get; init; }
/// <summary>
/// Previous EPSS score (if available).
/// </summary>
public double? PreviousScore { get; init; }
/// <summary>
/// New EPSS score.
/// </summary>
public double NewScore { get; init; }
/// <summary>
/// Previous priority band (if available).
/// </summary>
public EpssPriorityBand PreviousBand { get; init; }
/// <summary>
/// Model date for this change.
/// </summary>
public DateOnly ModelDate { get; init; }
}

View File

@@ -0,0 +1,205 @@
// -----------------------------------------------------------------------------
// EpssEnrichmentStageExecutor.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-006
// Description: Scan stage executor that enriches findings with EPSS scores.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Scan stage executor that enriches vulnerability findings with EPSS scores.
/// Attaches immutable EPSS evidence to each CVE at scan time.
/// </summary>
public sealed class EpssEnrichmentStageExecutor : IScanStageExecutor
{
private readonly IEpssProvider _epssProvider;
private readonly ILogger<EpssEnrichmentStageExecutor> _logger;
public EpssEnrichmentStageExecutor(
IEpssProvider epssProvider,
ILogger<EpssEnrichmentStageExecutor> logger)
{
_epssProvider = epssProvider ?? throw new ArgumentNullException(nameof(epssProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.EpssEnrichment;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
// Check if EPSS data is available
var isAvailable = await _epssProvider.IsAvailableAsync(cancellationToken).ConfigureAwait(false);
if (!isAvailable)
{
_logger.LogWarning("EPSS data not available; skipping EPSS enrichment for job {JobId}", context.JobId);
return;
}
// Get CVE IDs from findings
var cveIds = ExtractCveIds(context);
if (cveIds.Count == 0)
{
_logger.LogDebug("No CVE IDs found in findings for job {JobId}; skipping EPSS enrichment", context.JobId);
return;
}
_logger.LogInformation(
"Enriching {CveCount} CVEs with EPSS scores for job {JobId}",
cveIds.Count,
context.JobId);
// Fetch EPSS scores in batch
var epssResult = await _epssProvider.GetCurrentBatchAsync(cveIds, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"EPSS lookup: found={Found}, notFound={NotFound}, timeMs={TimeMs}, fromCache={FromCache}",
epssResult.Found.Count,
epssResult.NotFound.Count,
epssResult.LookupTimeMs,
epssResult.PartiallyFromCache);
// Store EPSS evidence in analysis context
var epssMap = epssResult.Found.ToDictionary(
e => e.CveId,
e => e,
StringComparer.OrdinalIgnoreCase);
context.Analysis.Set(ScanAnalysisKeys.EpssEvidence, epssMap);
context.Analysis.Set(ScanAnalysisKeys.EpssModelDate, epssResult.ModelDate);
context.Analysis.Set(ScanAnalysisKeys.EpssNotFoundCves, epssResult.NotFound.ToList());
_logger.LogInformation(
"EPSS enrichment completed for job {JobId}: {Found}/{Total} CVEs enriched, model date {ModelDate}",
context.JobId,
epssMap.Count,
cveIds.Count,
epssResult.ModelDate);
}
private static HashSet<string> ExtractCveIds(ScanJobContext context)
{
var cveIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Extract from OS package analyzer results
if (context.Analysis.TryGet<Dictionary<string, object>>(ScanAnalysisKeys.OsPackageAnalyzers, out var osResults) && osResults is not null)
{
foreach (var analyzerResult in osResults.Values)
{
ExtractCvesFromAnalyzerResult(analyzerResult, cveIds);
}
}
// Extract from language analyzer results
if (context.Analysis.TryGet<Dictionary<string, object>>(ScanAnalysisKeys.LanguagePackageAnalyzers, out var langResults) && langResults is not null)
{
foreach (var analyzerResult in langResults.Values)
{
ExtractCvesFromAnalyzerResult(analyzerResult, cveIds);
}
}
// Extract from consolidated findings if available
if (context.Analysis.TryGet<IEnumerable<object>>(ScanAnalysisKeys.ConsolidatedFindings, out var findings) && findings is not null)
{
foreach (var finding in findings)
{
ExtractCvesFromFinding(finding, cveIds);
}
}
return cveIds;
}
private static void ExtractCvesFromAnalyzerResult(object analyzerResult, HashSet<string> cveIds)
{
// Use reflection to extract CVE IDs from various analyzer result types
// This handles OSPackageAnalyzerResult, LanguagePackageAnalyzerResult, etc.
var resultType = analyzerResult.GetType();
// Try to get Vulnerabilities property
var vulnsProperty = resultType.GetProperty("Vulnerabilities");
if (vulnsProperty?.GetValue(analyzerResult) is IEnumerable<object> vulns)
{
foreach (var vuln in vulns)
{
ExtractCvesFromFinding(vuln, cveIds);
}
}
// Try to get Findings property
var findingsProperty = resultType.GetProperty("Findings");
if (findingsProperty?.GetValue(analyzerResult) is IEnumerable<object> findingsList)
{
foreach (var finding in findingsList)
{
ExtractCvesFromFinding(finding, cveIds);
}
}
}
private static void ExtractCvesFromFinding(object finding, HashSet<string> cveIds)
{
var findingType = finding.GetType();
// Try CveId property
var cveIdProperty = findingType.GetProperty("CveId");
if (cveIdProperty?.GetValue(finding) is string cveId && !string.IsNullOrWhiteSpace(cveId))
{
cveIds.Add(cveId);
return;
}
// Try VulnerabilityId property (some findings use this)
var vulnIdProperty = findingType.GetProperty("VulnerabilityId");
if (vulnIdProperty?.GetValue(finding) is string vulnId &&
!string.IsNullOrWhiteSpace(vulnId) &&
vulnId.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
{
cveIds.Add(vulnId);
return;
}
// Try Identifiers collection
var identifiersProperty = findingType.GetProperty("Identifiers");
if (identifiersProperty?.GetValue(finding) is IEnumerable<object> identifiers)
{
foreach (var identifier in identifiers)
{
var idValue = identifier.ToString();
if (!string.IsNullOrWhiteSpace(idValue) &&
idValue.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
{
cveIds.Add(idValue);
}
}
}
}
}
/// <summary>
/// Well-known keys for EPSS-related analysis data.
/// </summary>
public static partial class ScanAnalysisKeys
{
/// <summary>
/// Dictionary of CVE ID to EpssEvidence for enriched findings.
/// </summary>
public const string EpssEvidence = "epss.evidence";
/// <summary>
/// The EPSS model date used for enrichment.
/// </summary>
public const string EpssModelDate = "epss.model_date";
/// <summary>
/// List of CVE IDs that were not found in EPSS data.
/// </summary>
public const string EpssNotFoundCves = "epss.not_found";
}

View File

@@ -0,0 +1,362 @@
// -----------------------------------------------------------------------------
// EpssIngestJob.cs
// Sprint: SPRINT_3410_0001_0001_epss_ingestion_storage
// Task: EPSS-3410-009
// Description: Background job that ingests EPSS data from online or bundle sources.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Options for the EPSS ingestion job.
/// </summary>
public sealed class EpssIngestOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Epss:Ingest";
/// <summary>
/// Whether the job is enabled. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Cron schedule for EPSS ingestion. Default: "0 5 0 * * *" (00:05 UTC daily).
/// </summary>
public string Schedule { get; set; } = "0 5 0 * * *";
/// <summary>
/// Source type: "online" or "bundle". Default: "online".
/// </summary>
public string SourceType { get; set; } = "online";
/// <summary>
/// Bundle path for air-gapped ingestion (when SourceType is "bundle").
/// </summary>
public string? BundlePath { get; set; }
/// <summary>
/// Initial delay before first run. Default: 30 seconds.
/// </summary>
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Retry delay on failure. Default: 5 minutes.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum retry attempts. Default: 3.
/// </summary>
public int MaxRetries { get; set; } = 3;
}
/// <summary>
/// Background service that ingests EPSS data on a schedule.
/// Supports online (FIRST.org) and offline (bundle) sources.
/// </summary>
public sealed class EpssIngestJob : BackgroundService
{
private readonly IEpssRepository _repository;
private readonly IEpssRawRepository? _rawRepository;
private readonly EpssOnlineSource _onlineSource;
private readonly EpssBundleSource _bundleSource;
private readonly EpssCsvStreamParser _parser;
private readonly IOptions<EpssIngestOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EpssIngestJob> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Scanner.EpssIngest");
public EpssIngestJob(
IEpssRepository repository,
EpssOnlineSource onlineSource,
EpssBundleSource bundleSource,
EpssCsvStreamParser parser,
IOptions<EpssIngestOptions> options,
TimeProvider timeProvider,
ILogger<EpssIngestJob> logger,
IEpssRawRepository? rawRepository = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_rawRepository = rawRepository; // Optional - raw storage for replay capability
_onlineSource = onlineSource ?? throw new ArgumentNullException(nameof(onlineSource));
_bundleSource = bundleSource ?? throw new ArgumentNullException(nameof(bundleSource));
_parser = parser ?? throw new ArgumentNullException(nameof(parser));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("EPSS ingest job started");
var opts = _options.Value;
if (!opts.Enabled)
{
_logger.LogInformation("EPSS ingest job is disabled");
return;
}
// Initial delay to let the system stabilize
await Task.Delay(opts.InitialDelay, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
var now = _timeProvider.GetUtcNow();
var nextRun = ComputeNextRun(now, opts.Schedule);
var delay = nextRun - now;
if (delay > TimeSpan.Zero)
{
_logger.LogDebug("EPSS ingest job waiting until {NextRun}", nextRun);
await Task.Delay(delay, stoppingToken);
}
if (stoppingToken.IsCancellationRequested)
{
break;
}
await RunIngestionWithRetryAsync(stoppingToken);
}
_logger.LogInformation("EPSS ingest job stopped");
}
/// <summary>
/// Runs ingestion for a specific date. Used by tests and manual triggers.
/// </summary>
public async Task IngestAsync(DateOnly modelDate, CancellationToken cancellationToken = default)
{
using var activity = _activitySource.StartActivity("epss.ingest", ActivityKind.Internal);
activity?.SetTag("epss.model_date", modelDate.ToString("yyyy-MM-dd"));
var opts = _options.Value;
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation("Starting EPSS ingestion for {ModelDate}", modelDate);
try
{
// Get source based on configuration
IEpssSource source = opts.SourceType.Equals("bundle", StringComparison.OrdinalIgnoreCase)
? _bundleSource
: _onlineSource;
// Retrieve the EPSS file
await using var sourceFile = await source.GetAsync(modelDate, cancellationToken).ConfigureAwait(false);
// Read file content and compute hash
var fileContent = await File.ReadAllBytesAsync(sourceFile.LocalPath, cancellationToken).ConfigureAwait(false);
var fileSha256 = ComputeSha256(fileContent);
_logger.LogInformation(
"Retrieved EPSS file from {SourceUri}, size={Size}",
sourceFile.SourceUri,
fileContent.Length);
// Begin import run
var importRun = await _repository.BeginImportAsync(
modelDate,
sourceFile.SourceUri,
_timeProvider.GetUtcNow(),
fileSha256,
cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Created import run {ImportRunId}", importRun.ImportRunId);
try
{
// Parse and write snapshot
await using var stream = new MemoryStream(fileContent);
var session = _parser.ParseGzip(stream);
var writeResult = await _repository.WriteSnapshotAsync(
importRun.ImportRunId,
modelDate,
_timeProvider.GetUtcNow(),
session,
cancellationToken).ConfigureAwait(false);
// Store raw payload for replay capability (Sprint: SPRINT_3413_0001_0001, Task: R2)
if (_rawRepository is not null)
{
await StoreRawPayloadAsync(
importRun.ImportRunId,
sourceFile.SourceUri,
modelDate,
session,
fileContent.Length,
cancellationToken).ConfigureAwait(false);
}
// Mark success
await _repository.MarkImportSucceededAsync(
importRun.ImportRunId,
session.RowCount,
session.DecompressedSha256,
session.ModelVersionTag,
session.PublishedDate,
cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
_logger.LogInformation(
"EPSS ingestion completed: modelDate={ModelDate}, rows={RowCount}, cves={CveCount}, duration={Duration}ms",
modelDate,
writeResult.RowCount,
writeResult.DistinctCveCount,
stopwatch.ElapsedMilliseconds);
activity?.SetTag("epss.row_count", writeResult.RowCount);
activity?.SetTag("epss.cve_count", writeResult.DistinctCveCount);
activity?.SetTag("epss.duration_ms", stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
await _repository.MarkImportFailedAsync(
importRun.ImportRunId,
ex.Message,
cancellationToken).ConfigureAwait(false);
throw;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "EPSS ingestion failed for {ModelDate}", modelDate);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private async Task RunIngestionWithRetryAsync(CancellationToken cancellationToken)
{
var opts = _options.Value;
var modelDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime);
for (var attempt = 1; attempt <= opts.MaxRetries; attempt++)
{
try
{
await IngestAsync(modelDate, cancellationToken);
return;
}
catch (Exception ex) when (attempt < opts.MaxRetries)
{
_logger.LogWarning(
ex,
"EPSS ingestion attempt {Attempt}/{MaxRetries} failed, retrying in {RetryDelay}",
attempt,
opts.MaxRetries,
opts.RetryDelay);
await Task.Delay(opts.RetryDelay, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"EPSS ingestion failed after {MaxRetries} attempts",
opts.MaxRetries);
}
}
}
private static DateTimeOffset ComputeNextRun(DateTimeOffset now, string cronSchedule)
{
// Simple cron parser for "0 5 0 * * *" (seconds minutes hours day month dayOfWeek)
// For MVP, we just schedule for 00:05 UTC the next day
var today = now.UtcDateTime.Date;
var scheduledTime = today.AddMinutes(5);
if (now.UtcDateTime > scheduledTime)
{
scheduledTime = scheduledTime.AddDays(1);
}
return new DateTimeOffset(scheduledTime, TimeSpan.Zero);
}
private static string ComputeSha256(byte[] content)
{
var hash = System.Security.Cryptography.SHA256.HashData(content);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Stores raw EPSS payload for deterministic replay capability.
/// Sprint: SPRINT_3413_0001_0001, Task: R2
/// </summary>
private async Task StoreRawPayloadAsync(
Guid importRunId,
string sourceUri,
DateOnly modelDate,
EpssParsedSession session,
long compressedSize,
CancellationToken cancellationToken)
{
if (_rawRepository is null)
{
return;
}
try
{
// Convert parsed rows to JSON array for raw storage
var payload = System.Text.Json.JsonSerializer.Serialize(
session.Rows.Select(r => new
{
cve = r.CveId,
epss = r.Score,
percentile = r.Percentile
}),
new System.Text.Json.JsonSerializerOptions { WriteIndented = false });
var payloadBytes = System.Text.Encoding.UTF8.GetBytes(payload);
var payloadSha256 = System.Security.Cryptography.SHA256.HashData(payloadBytes);
var raw = new EpssRaw
{
SourceUri = sourceUri,
AsOfDate = modelDate,
Payload = payload,
PayloadSha256 = payloadSha256,
HeaderComment = session.HeaderComment,
ModelVersion = session.ModelVersionTag,
PublishedDate = session.PublishedDate,
RowCount = session.RowCount,
CompressedSize = compressedSize,
DecompressedSize = payloadBytes.LongLength,
ImportRunId = importRunId
};
await _rawRepository.CreateAsync(raw, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Stored raw EPSS payload: modelDate={ModelDate}, rows={RowCount}, size={Size}",
modelDate,
session.RowCount,
payloadBytes.Length);
}
catch (Exception ex)
{
// Log but don't fail ingestion if raw storage fails
_logger.LogWarning(
ex,
"Failed to store raw EPSS payload for {ModelDate}; ingestion will continue",
modelDate);
}
}
}

View File

@@ -0,0 +1,505 @@
// -----------------------------------------------------------------------------
// EpssSignalJob.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Tasks: S5-S10 - Signal generation service
// Description: Background job that generates tenant-scoped EPSS signals.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Options for the EPSS signal generation job.
/// </summary>
public sealed class EpssSignalOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Epss:Signal";
/// <summary>
/// Whether the signal job is enabled. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Delay after enrichment before generating signals. Default: 30 seconds.
/// </summary>
public TimeSpan PostEnrichmentDelay { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Batch size for signal generation. Default: 500.
/// </summary>
public int BatchSize { get; set; } = 500;
/// <summary>
/// Signal retention days. Default: 90.
/// </summary>
public int RetentionDays { get; set; } = 90;
}
/// <summary>
/// EPSS signal event types.
/// </summary>
public static class EpssSignalEventTypes
{
/// <summary>
/// Significant score increase (delta >= threshold).
/// </summary>
public const string RiskSpike = "RISK_SPIKE";
/// <summary>
/// Priority band change (e.g., MEDIUM -> HIGH).
/// </summary>
public const string BandChange = "BAND_CHANGE";
/// <summary>
/// New CVE scored for the first time.
/// </summary>
public const string NewHigh = "NEW_HIGH";
/// <summary>
/// CVE dropped from HIGH/CRITICAL to LOW.
/// </summary>
public const string DroppedLow = "DROPPED_LOW";
/// <summary>
/// EPSS model version changed (summary event).
/// </summary>
public const string ModelUpdated = "MODEL_UPDATED";
}
/// <summary>
/// Background service that generates tenant-scoped EPSS signals.
/// Only generates signals for CVEs that are observed in tenant's inventory.
/// </summary>
public sealed class EpssSignalJob : BackgroundService
{
private readonly IEpssRepository _epssRepository;
private readonly IEpssSignalRepository _signalRepository;
private readonly IObservedCveRepository _observedCveRepository;
private readonly IEpssSignalPublisher _signalPublisher;
private readonly IEpssProvider _epssProvider;
private readonly IOptions<EpssSignalOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EpssSignalJob> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Scanner.EpssSignal");
// Trigger for signal generation
private readonly SemaphoreSlim _signalTrigger = new(0);
// Track last processed model date to detect version changes
private string? _lastModelVersion;
public EpssSignalJob(
IEpssRepository epssRepository,
IEpssSignalRepository signalRepository,
IObservedCveRepository observedCveRepository,
IEpssSignalPublisher signalPublisher,
IEpssProvider epssProvider,
IOptions<EpssSignalOptions> options,
TimeProvider timeProvider,
ILogger<EpssSignalJob> logger)
{
_epssRepository = epssRepository ?? throw new ArgumentNullException(nameof(epssRepository));
_signalRepository = signalRepository ?? throw new ArgumentNullException(nameof(signalRepository));
_observedCveRepository = observedCveRepository ?? throw new ArgumentNullException(nameof(observedCveRepository));
_signalPublisher = signalPublisher ?? throw new ArgumentNullException(nameof(signalPublisher));
_epssProvider = epssProvider ?? throw new ArgumentNullException(nameof(epssProvider));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("EPSS signal job started");
var opts = _options.Value;
if (!opts.Enabled)
{
_logger.LogInformation("EPSS signal job is disabled");
return;
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Wait for signal trigger or cancellation
await _signalTrigger.WaitAsync(stoppingToken);
// Add delay after enrichment to ensure data consistency
await Task.Delay(opts.PostEnrichmentDelay, stoppingToken);
await GenerateSignalsAsync(stoppingToken);
// Periodic pruning of old signals
await _signalRepository.PruneAsync(opts.RetentionDays, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "EPSS signal job encountered an error");
}
}
_logger.LogInformation("EPSS signal job stopped");
}
/// <summary>
/// Triggers signal generation. Called after EPSS enrichment completes.
/// </summary>
public void TriggerSignalGeneration()
{
_signalTrigger.Release();
_logger.LogDebug("EPSS signal generation triggered");
}
/// <summary>
/// Generates signals for all tenants based on EPSS changes.
/// </summary>
public async Task GenerateSignalsAsync(CancellationToken cancellationToken = default)
{
using var activity = _activitySource.StartActivity("epss.signal.generate", ActivityKind.Internal);
var stopwatch = Stopwatch.StartNew();
var opts = _options.Value;
_logger.LogInformation("Starting EPSS signal generation");
try
{
// Get current model date
var modelDate = await _epssProvider.GetLatestModelDateAsync(cancellationToken);
if (!modelDate.HasValue)
{
_logger.LogWarning("No EPSS data available for signal generation");
return;
}
activity?.SetTag("epss.model_date", modelDate.Value.ToString("yyyy-MM-dd"));
// Check for model version change (S7)
var currentModelVersion = await GetCurrentModelVersionAsync(modelDate.Value, cancellationToken);
var isModelChange = _lastModelVersion is not null &&
!string.Equals(_lastModelVersion, currentModelVersion, StringComparison.Ordinal);
if (isModelChange)
{
_logger.LogInformation(
"EPSS model version changed: {OldVersion} -> {NewVersion}",
_lastModelVersion,
currentModelVersion);
}
_lastModelVersion = currentModelVersion;
// Get changes from epss_changes table
var changes = await GetEpssChangesAsync(modelDate.Value, cancellationToken);
if (changes.Count == 0)
{
_logger.LogDebug("No EPSS changes to process for signals");
return;
}
_logger.LogInformation("Processing {Count} EPSS changes for signal generation", changes.Count);
activity?.SetTag("epss.change_count", changes.Count);
var totalSignals = 0;
var filteredCount = 0;
// Get all active tenants (S6)
var activeTenants = await _observedCveRepository.GetActiveTenantsAsync(cancellationToken);
if (activeTenants.Count == 0)
{
_logger.LogDebug("No active tenants found; using default tenant");
activeTenants = new[] { Guid.Empty };
}
// For each tenant, filter changes to only observed CVEs
foreach (var tenantId in activeTenants)
{
// Get CVE IDs from changes
var changeCveIds = changes.Select(c => c.CveId).Distinct().ToList();
// Filter to only observed CVEs for this tenant (S6)
var observedCves = await _observedCveRepository.FilterObservedAsync(
tenantId,
changeCveIds,
cancellationToken);
var tenantChanges = changes
.Where(c => observedCves.Contains(c.CveId))
.ToArray();
if (tenantChanges.Length == 0)
{
continue;
}
filteredCount += changes.Length - tenantChanges.Length;
foreach (var batch in tenantChanges.Chunk(opts.BatchSize))
{
var signals = GenerateSignalsForBatch(
batch,
tenantId,
modelDate.Value,
currentModelVersion,
isModelChange);
if (signals.Count > 0)
{
// Store signals in database
var created = await _signalRepository.CreateBulkAsync(signals, cancellationToken);
totalSignals += created;
// Publish signals to notification system (S9)
var published = await _signalPublisher.PublishBatchAsync(signals, cancellationToken);
_logger.LogDebug(
"Published {Published}/{Total} EPSS signals for tenant {TenantId}",
published,
signals.Count,
tenantId);
}
}
// If model changed, emit summary signal per tenant (S8)
if (isModelChange)
{
await EmitModelUpdatedSignalAsync(
tenantId,
modelDate.Value,
_lastModelVersion!,
currentModelVersion!,
tenantChanges.Length,
cancellationToken);
totalSignals++;
}
}
stopwatch.Stop();
_logger.LogInformation(
"EPSS signal generation completed: signals={SignalCount}, changes={ChangeCount}, filtered={FilteredCount}, tenants={TenantCount}, duration={Duration}ms",
totalSignals,
changes.Count,
filteredCount,
activeTenants.Count,
stopwatch.ElapsedMilliseconds);
activity?.SetTag("epss.signal_count", totalSignals);
activity?.SetTag("epss.filtered_count", filteredCount);
activity?.SetTag("epss.tenant_count", activeTenants.Count);
activity?.SetTag("epss.duration_ms", stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "EPSS signal generation failed");
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private IReadOnlyList<EpssSignal> GenerateSignalsForBatch(
EpssChangeRecord[] changes,
Guid tenantId,
DateOnly modelDate,
string? modelVersion,
bool isModelChange)
{
var signals = new List<EpssSignal>();
foreach (var change in changes)
{
// Skip generating individual signals on model change day if suppression is enabled
// (would check tenant config in production)
if (isModelChange && ShouldSuppressOnModelChange(change))
{
continue;
}
var eventType = DetermineEventType(change);
if (string.IsNullOrEmpty(eventType))
{
continue;
}
var dedupeKey = EpssExplainHashCalculator.ComputeDedupeKey(
modelDate,
change.CveId,
eventType,
change.PreviousBand.ToString(),
ComputeNewBand(change).ToString());
var explainHash = EpssExplainHashCalculator.ComputeExplainHash(
modelDate,
change.CveId,
eventType,
change.PreviousBand.ToString(),
ComputeNewBand(change).ToString(),
change.NewScore,
0, // Percentile would come from EPSS data
modelVersion);
var payload = JsonSerializer.Serialize(new
{
cveId = change.CveId,
oldScore = change.PreviousScore,
newScore = change.NewScore,
oldBand = change.PreviousBand.ToString(),
newBand = ComputeNewBand(change).ToString(),
flags = change.Flags.ToString(),
modelVersion
});
signals.Add(new EpssSignal
{
TenantId = tenantId,
ModelDate = modelDate,
CveId = change.CveId,
EventType = eventType,
RiskBand = ComputeNewBand(change).ToString(),
EpssScore = change.NewScore,
EpssDelta = change.NewScore - (change.PreviousScore ?? 0),
IsModelChange = isModelChange,
ModelVersion = modelVersion,
DedupeKey = dedupeKey,
ExplainHash = explainHash,
Payload = payload
});
}
return signals;
}
private static string? DetermineEventType(EpssChangeRecord change)
{
if (change.Flags.HasFlag(EpssChangeFlags.NewScored))
{
return EpssSignalEventTypes.NewHigh;
}
if (change.Flags.HasFlag(EpssChangeFlags.CrossedHigh))
{
return EpssSignalEventTypes.BandChange;
}
if (change.Flags.HasFlag(EpssChangeFlags.BigJumpUp))
{
return EpssSignalEventTypes.RiskSpike;
}
if (change.Flags.HasFlag(EpssChangeFlags.DroppedLow))
{
return EpssSignalEventTypes.DroppedLow;
}
return null;
}
private static EpssPriorityBand ComputeNewBand(EpssChangeRecord change)
{
// Simplified band calculation - would use EpssPriorityCalculator in production
if (change.NewScore >= 0.5)
{
return EpssPriorityBand.Critical;
}
if (change.NewScore >= 0.2)
{
return EpssPriorityBand.High;
}
if (change.NewScore >= 0.05)
{
return EpssPriorityBand.Medium;
}
return EpssPriorityBand.Low;
}
private static bool ShouldSuppressOnModelChange(EpssChangeRecord change)
{
// Suppress RISK_SPIKE and BAND_CHANGE on model change days to avoid alert storms
return change.Flags.HasFlag(EpssChangeFlags.BigJumpUp) ||
change.Flags.HasFlag(EpssChangeFlags.BigJumpDown) ||
change.Flags.HasFlag(EpssChangeFlags.CrossedHigh);
}
private async Task<string?> GetCurrentModelVersionAsync(DateOnly modelDate, CancellationToken cancellationToken)
{
// Would query from epss_import_run or epss_raw table
// For now, return a placeholder based on date
return $"v{modelDate:yyyy.MM.dd}";
}
private async Task<IReadOnlyList<EpssChangeRecord>> GetEpssChangesAsync(
DateOnly modelDate,
CancellationToken cancellationToken)
{
// TODO: Implement repository method to get changes from epss_changes table
// For now, return empty list
return Array.Empty<EpssChangeRecord>();
}
private async Task EmitModelUpdatedSignalAsync(
Guid tenantId,
DateOnly modelDate,
string oldVersion,
string newVersion,
int affectedCveCount,
CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new
{
oldVersion,
newVersion,
affectedCveCount,
suppressedSignals = true
});
var signal = new EpssSignal
{
TenantId = tenantId,
ModelDate = modelDate,
CveId = "MODEL_UPDATE",
EventType = EpssSignalEventTypes.ModelUpdated,
IsModelChange = true,
ModelVersion = newVersion,
DedupeKey = $"{modelDate:yyyy-MM-dd}:MODEL_UPDATE:{oldVersion}->{newVersion}",
ExplainHash = EpssExplainHashCalculator.ComputeExplainHash(
modelDate,
"MODEL_UPDATE",
EpssSignalEventTypes.ModelUpdated,
oldVersion,
newVersion,
0,
0,
newVersion),
Payload = payload
};
await _signalRepository.CreateAsync(signal, cancellationToken);
_logger.LogInformation(
"Emitted MODEL_UPDATED signal: {OldVersion} -> {NewVersion}, affected {Count} CVEs",
oldVersion,
newVersion,
affectedCveCount);
}
}

View File

@@ -0,0 +1,289 @@
// -----------------------------------------------------------------------------
// NativeAnalyzerExecutor.cs
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
// Task: NAI-001
// Description: Executes native binary analysis during container scans.
// Note: NUC-004 (unknown classification) deferred - requires project reference.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Native;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Executes native binary analysis during container scans.
/// Discovers binaries, extracts metadata, correlates with Build-ID index,
/// and emits SBOM components.
/// </summary>
public sealed class NativeAnalyzerExecutor
{
private readonly NativeBinaryDiscovery _discovery;
private readonly INativeComponentEmitter _emitter;
private readonly NativeAnalyzerOptions _options;
private readonly ILogger<NativeAnalyzerExecutor> _logger;
private readonly ScannerWorkerMetrics _metrics;
public NativeAnalyzerExecutor(
NativeBinaryDiscovery discovery,
INativeComponentEmitter emitter,
IOptions<NativeAnalyzerOptions> options,
ILogger<NativeAnalyzerExecutor> logger,
ScannerWorkerMetrics metrics)
{
_discovery = discovery ?? throw new ArgumentNullException(nameof(discovery));
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
}
/// <summary>
/// Analyzes native binaries in the container filesystem.
/// </summary>
/// <param name="rootPath">Path to the extracted container filesystem.</param>
/// <param name="context">Scan job context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Analysis result with discovered components.</returns>
public async Task<NativeAnalysisResult> ExecuteAsync(
string rootPath,
ScanJobContext context,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
_logger.LogDebug("Native analyzer is disabled");
return NativeAnalysisResult.Empty;
}
var sw = Stopwatch.StartNew();
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.TotalAnalysisTimeout);
// Discover binaries
var discovered = await _discovery.DiscoverAsync(rootPath, cts.Token).ConfigureAwait(false);
if (discovered.Count == 0)
{
_logger.LogDebug("No native binaries discovered in {RootPath}", rootPath);
return NativeAnalysisResult.Empty;
}
_logger.LogInformation(
"Starting native analysis of {Count} binaries for job {JobId}",
discovered.Count,
context.JobId);
// Convert to metadata and emit
var metadataList = new List<NativeBinaryMetadata>(discovered.Count);
foreach (var binary in discovered)
{
var metadata = await ExtractMetadataAsync(binary, cts.Token).ConfigureAwait(false);
if (metadata is not null)
{
metadataList.Add(metadata);
}
}
// Batch emit components
var emitResults = await _emitter.EmitBatchAsync(metadataList, cts.Token).ConfigureAwait(false);
sw.Stop();
var result = new NativeAnalysisResult
{
DiscoveredCount = discovered.Count,
AnalyzedCount = metadataList.Count,
ResolvedCount = emitResults.Count(r => r.IndexMatch),
UnresolvedCount = emitResults.Count(r => !r.IndexMatch),
Components = emitResults,
ElapsedMs = sw.ElapsedMilliseconds
};
_metrics.RecordNativeAnalysis(result);
_logger.LogInformation(
"Native analysis complete for job {JobId}: {Resolved}/{Analyzed} resolved in {ElapsedMs}ms",
context.JobId,
result.ResolvedCount,
result.AnalyzedCount,
result.ElapsedMs);
return result;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
"Native analysis timed out for job {JobId} after {ElapsedMs}ms",
context.JobId,
sw.ElapsedMilliseconds);
return new NativeAnalysisResult
{
TimedOut = true,
ElapsedMs = sw.ElapsedMilliseconds
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Native analysis failed for job {JobId}", context.JobId);
throw;
}
}
private async Task<NativeBinaryMetadata?> ExtractMetadataAsync(
DiscoveredBinary binary,
CancellationToken cancellationToken)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.SingleBinaryTimeout);
return await Task.Run(() =>
{
// Read binary header to extract Build-ID and other metadata
var buildId = ExtractBuildId(binary);
return new NativeBinaryMetadata
{
Format = binary.Format.ToString().ToLowerInvariant(),
FilePath = binary.RelativePath,
BuildId = buildId,
Architecture = DetectArchitecture(binary),
Platform = DetectPlatform(binary)
};
}, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
_logger.LogDebug("Extraction timed out for binary: {Path}", binary.RelativePath);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to extract metadata from: {Path}", binary.RelativePath);
return null;
}
}
private string? ExtractBuildId(DiscoveredBinary binary)
{
if (binary.Format != BinaryFormat.Elf)
{
return null;
}
try
{
// Read ELF to find .note.gnu.build-id section
using var fs = File.OpenRead(binary.AbsolutePath);
using var reader = new BinaryReader(fs);
// Skip to ELF header
var magic = reader.ReadBytes(4);
if (magic.Length < 4 ||
magic[0] != 0x7F || magic[1] != 0x45 || magic[2] != 0x4C || magic[3] != 0x46)
{
return null;
}
var elfClass = reader.ReadByte(); // 1 = 32-bit, 2 = 64-bit
var is64Bit = elfClass == 2;
// Skip to section headers (simplified - real implementation would parse properly)
// For now, return null - full implementation is in the Analyzers.Native project
return null;
}
catch
{
return null;
}
}
private static string? DetectArchitecture(DiscoveredBinary binary)
{
if (binary.Format != BinaryFormat.Elf)
{
return null;
}
try
{
using var fs = File.OpenRead(binary.AbsolutePath);
Span<byte> header = stackalloc byte[20];
if (fs.Read(header) < 20)
{
return null;
}
// e_machine is at offset 18 (2 bytes, little-endian typically)
var machine = BitConverter.ToUInt16(header[18..20]);
return machine switch
{
0x03 => "i386",
0x3E => "x86_64",
0x28 => "arm",
0xB7 => "aarch64",
0xF3 => "riscv",
_ => null
};
}
catch
{
return null;
}
}
private static string? DetectPlatform(DiscoveredBinary binary)
{
return binary.Format switch
{
BinaryFormat.Elf => "linux",
BinaryFormat.Pe => "windows",
BinaryFormat.MachO => "darwin",
_ => null
};
}
}
/// <summary>
/// Result of native binary analysis.
/// </summary>
public sealed record NativeAnalysisResult
{
public static readonly NativeAnalysisResult Empty = new();
/// <summary>Number of binaries discovered in filesystem.</summary>
public int DiscoveredCount { get; init; }
/// <summary>Number of binaries successfully analyzed.</summary>
public int AnalyzedCount { get; init; }
/// <summary>Number of binaries resolved via Build-ID index.</summary>
public int ResolvedCount { get; init; }
/// <summary>Number of binaries not found in Build-ID index.</summary>
public int UnresolvedCount { get; init; }
/// <summary>Whether the analysis timed out.</summary>
public bool TimedOut { get; init; }
/// <summary>Total elapsed time in milliseconds.</summary>
public long ElapsedMs { get; init; }
/// <summary>Emitted component results.</summary>
public IReadOnlyList<NativeComponentEmitResult> Components { get; init; } = Array.Empty<NativeComponentEmitResult>();
/// <summary>Layer component fragments for SBOM merging.</summary>
public IReadOnlyList<LayerComponentFragment> LayerFragments { get; init; } = Array.Empty<LayerComponentFragment>();
}

View File

@@ -0,0 +1,294 @@
// -----------------------------------------------------------------------------
// NativeBinaryDiscovery.cs
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
// Task: NAI-002
// Description: Discovers native binaries in container filesystem layers.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Discovers native binaries in container filesystem layers for analysis.
/// </summary>
public sealed class NativeBinaryDiscovery
{
private readonly NativeAnalyzerOptions _options;
private readonly ILogger<NativeBinaryDiscovery> _logger;
private static readonly byte[] ElfMagic = [0x7F, 0x45, 0x4C, 0x46]; // \x7FELF
private static readonly byte[] PeMagic = [0x4D, 0x5A]; // MZ
private static readonly byte[] MachO32Magic = [0xFE, 0xED, 0xFA, 0xCE];
private static readonly byte[] MachO64Magic = [0xFE, 0xED, 0xFA, 0xCF];
private static readonly byte[] MachO32MagicReverse = [0xCE, 0xFA, 0xED, 0xFE];
private static readonly byte[] MachO64MagicReverse = [0xCF, 0xFA, 0xED, 0xFE];
private static readonly byte[] FatMachOMagic = [0xCA, 0xFE, 0xBA, 0xBE];
public NativeBinaryDiscovery(
IOptions<NativeAnalyzerOptions> options,
ILogger<NativeBinaryDiscovery> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Discovers binaries in the specified root filesystem path.
/// </summary>
public async Task<IReadOnlyList<DiscoveredBinary>> DiscoverAsync(
string rootPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
if (!Directory.Exists(rootPath))
{
_logger.LogWarning("Root path does not exist: {RootPath}", rootPath);
return Array.Empty<DiscoveredBinary>();
}
var discovered = new List<DiscoveredBinary>();
var excludeSet = new HashSet<string>(_options.ExcludePaths, StringComparer.OrdinalIgnoreCase);
var extensionSet = new HashSet<string>(
_options.BinaryExtensions.Select(e => e.StartsWith('.') ? e : "." + e),
StringComparer.OrdinalIgnoreCase);
await Task.Run(() =>
{
DiscoverRecursive(
rootPath,
rootPath,
discovered,
excludeSet,
extensionSet,
cancellationToken);
}, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Discovered {Count} native binaries in {RootPath}",
discovered.Count,
rootPath);
return discovered;
}
private void DiscoverRecursive(
string basePath,
string currentPath,
List<DiscoveredBinary> discovered,
HashSet<string> excludeSet,
HashSet<string> extensionSet,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// Check if we've hit the limit
if (discovered.Count >= _options.MaxBinariesPerScan)
{
_logger.LogDebug("Reached max binaries per scan limit ({Limit})", _options.MaxBinariesPerScan);
return;
}
// Get relative path for exclusion check
var relativePath = GetRelativePath(basePath, currentPath);
if (IsExcluded(relativePath, excludeSet))
{
_logger.LogDebug("Skipping excluded path: {Path}", relativePath);
return;
}
// Enumerate files
IEnumerable<string> files;
try
{
files = Directory.EnumerateFiles(currentPath);
}
catch (UnauthorizedAccessException)
{
_logger.LogDebug("Access denied to directory: {Path}", currentPath);
return;
}
catch (DirectoryNotFoundException)
{
return;
}
foreach (var filePath in files)
{
cancellationToken.ThrowIfCancellationRequested();
if (discovered.Count >= _options.MaxBinariesPerScan)
{
break;
}
try
{
var binary = TryDiscoverBinary(basePath, filePath, extensionSet);
if (binary is not null)
{
discovered.Add(binary);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogDebug(ex, "Could not analyze file: {FilePath}", filePath);
}
}
// Recurse into subdirectories
IEnumerable<string> directories;
try
{
directories = Directory.EnumerateDirectories(currentPath);
}
catch (UnauthorizedAccessException)
{
return;
}
catch (DirectoryNotFoundException)
{
return;
}
foreach (var directory in directories)
{
DiscoverRecursive(basePath, directory, discovered, excludeSet, extensionSet, cancellationToken);
}
}
private DiscoveredBinary? TryDiscoverBinary(
string basePath,
string filePath,
HashSet<string> extensionSet)
{
var fileInfo = new FileInfo(filePath);
// Size checks
if (fileInfo.Length < _options.MinFileSizeBytes)
{
return null;
}
if (fileInfo.Length > _options.MaxFileSizeBytes)
{
_logger.LogDebug("File too large ({Size} bytes): {FilePath}", fileInfo.Length, filePath);
return null;
}
// Extension check (if heuristics disabled)
var extension = Path.GetExtension(filePath);
var hasKnownExtension = !string.IsNullOrEmpty(extension) && extensionSet.Contains(extension);
if (!_options.EnableHeuristics && !hasKnownExtension)
{
return null;
}
// Magic byte check
var format = DetectBinaryFormat(filePath);
if (format == BinaryFormat.Unknown)
{
return null;
}
var relativePath = GetRelativePath(basePath, filePath);
return new DiscoveredBinary(
AbsolutePath: filePath,
RelativePath: relativePath,
Format: format,
SizeBytes: fileInfo.Length,
FileName: fileInfo.Name);
}
private BinaryFormat DetectBinaryFormat(string filePath)
{
try
{
Span<byte> header = stackalloc byte[4];
using var fs = File.OpenRead(filePath);
if (fs.Read(header) < 4)
{
return BinaryFormat.Unknown;
}
if (header.SequenceEqual(ElfMagic))
{
return BinaryFormat.Elf;
}
if (header[..2].SequenceEqual(PeMagic))
{
return BinaryFormat.Pe;
}
if (header.SequenceEqual(MachO32Magic) ||
header.SequenceEqual(MachO64Magic) ||
header.SequenceEqual(MachO32MagicReverse) ||
header.SequenceEqual(MachO64MagicReverse) ||
header.SequenceEqual(FatMachOMagic))
{
return BinaryFormat.MachO;
}
return BinaryFormat.Unknown;
}
catch
{
return BinaryFormat.Unknown;
}
}
private static string GetRelativePath(string basePath, string fullPath)
{
if (fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
{
var relative = fullPath[basePath.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return "/" + relative.Replace('\\', '/');
}
return fullPath;
}
private static bool IsExcluded(string relativePath, HashSet<string> excludeSet)
{
foreach (var exclude in excludeSet)
{
if (relativePath.StartsWith(exclude, StringComparison.OrdinalIgnoreCase) ||
relativePath.StartsWith("/" + exclude.TrimStart('/'), StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
/// <summary>
/// A discovered binary file ready for analysis.
/// </summary>
/// <param name="AbsolutePath">Full path to the binary.</param>
/// <param name="RelativePath">Path relative to the container root.</param>
/// <param name="Format">Detected binary format.</param>
/// <param name="SizeBytes">File size in bytes.</param>
/// <param name="FileName">File name only.</param>
public sealed record DiscoveredBinary(
string AbsolutePath,
string RelativePath,
BinaryFormat Format,
long SizeBytes,
string FileName);
/// <summary>
/// Binary format types.
/// </summary>
public enum BinaryFormat
{
Unknown,
Elf,
Pe,
MachO
}

View File

@@ -9,6 +9,7 @@ public static class ScanStageNames
public const string PullLayers = "pull-layers";
public const string BuildFilesystem = "build-filesystem";
public const string ExecuteAnalyzers = "execute-analyzers";
public const string EpssEnrichment = "epss-enrichment";
public const string ComposeArtifacts = "compose-artifacts";
public const string EmitReports = "emit-reports";
public const string Entropy = "entropy";
@@ -20,8 +21,10 @@ public static class ScanStageNames
PullLayers,
BuildFilesystem,
ExecuteAnalyzers,
EpssEnrichment,
ComposeArtifacts,
Entropy,
EmitReports,
};
}

View File

@@ -113,6 +113,12 @@ if (!string.IsNullOrWhiteSpace(connectionString))
builder.Services.AddSingleton<ISurfaceManifestPublisher, SurfaceManifestPublisher>();
builder.Services.AddSingleton<IScanStageExecutor, SurfaceManifestStageExecutor>();
builder.Services.AddSingleton<IDsseEnvelopeSigner, HmacDsseEnvelopeSigner>();
// EPSS ingestion job (Sprint: SPRINT_3410_0001_0001)
builder.Services.AddOptions<EpssIngestOptions>()
.BindConfiguration(EpssIngestOptions.SectionName)
.ValidateOnStart();
builder.Services.AddHostedService<EpssIngestJob>();
}
else
{
@@ -127,6 +133,7 @@ builder.Services.AddSingleton<ILanguageAnalyzerPluginCatalog, LanguageAnalyzerPl
builder.Services.AddSingleton<IScanAnalyzerDispatcher, CompositeScanAnalyzerDispatcher>();
builder.Services.AddSingleton<IScanStageExecutor, RegistrySecretStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, EpssEnrichmentStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityBuildStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityPublishStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, EntropyStageExecutor>();

View File

@@ -29,5 +29,7 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,143 @@
// -----------------------------------------------------------------------------
// EpssEnrichmentOptions.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: 9
// Description: Configuration options for EPSS live enrichment.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Configuration;
/// <summary>
/// Configuration for EPSS live enrichment jobs.
/// Bound from "Scanner:EpssEnrichment" section.
/// </summary>
public sealed class EpssEnrichmentOptions
{
public const string SectionName = "Scanner:EpssEnrichment";
/// <summary>
/// Enables EPSS enrichment jobs.
/// Default: true
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// EPSS percentile threshold for HIGH priority band.
/// Vulnerabilities at or above this percentile are considered high priority.
/// Range: [0, 1]. Default: 0.95 (top 5%)
/// </summary>
public double HighPercentile { get; set; } = 0.95;
/// <summary>
/// EPSS score threshold for HIGH priority (alternative trigger).
/// If score exceeds this, vulnerability is high priority regardless of percentile.
/// Range: [0, 1]. Default: 0.5
/// </summary>
public double HighScore { get; set; } = 0.5;
/// <summary>
/// EPSS percentile threshold for CRITICAL priority band.
/// Range: [0, 1]. Default: 0.99 (top 1%)
/// </summary>
public double CriticalPercentile { get; set; } = 0.99;
/// <summary>
/// EPSS score threshold for CRITICAL priority (alternative trigger).
/// Range: [0, 1]. Default: 0.8
/// </summary>
public double CriticalScore { get; set; } = 0.8;
/// <summary>
/// EPSS percentile threshold for MEDIUM priority band.
/// Range: [0, 1]. Default: 0.75 (top 25%)
/// </summary>
public double MediumPercentile { get; set; } = 0.75;
/// <summary>
/// Delta threshold for BIG_JUMP flag.
/// Triggers when EPSS score increases by more than this amount.
/// Range: [0, 1]. Default: 0.15
/// </summary>
public double BigJumpDelta { get; set; } = 0.15;
/// <summary>
/// Delta threshold for DROPPED_LOW flag.
/// Triggers when EPSS score decreases by more than this amount.
/// Range: [0, 1]. Default: 0.1
/// </summary>
public double DroppedLowDelta { get; set; } = 0.1;
/// <summary>
/// Batch size for bulk updates.
/// Default: 5000
/// </summary>
public int BatchSize { get; set; } = 5000;
/// <summary>
/// Maximum number of instances to process per job run.
/// 0 = unlimited. Default: 0
/// </summary>
public int MaxInstancesPerRun { get; set; } = 0;
/// <summary>
/// Minimum delay between enrichment jobs (prevents rapid re-runs).
/// Default: 1 hour
/// </summary>
public TimeSpan MinJobInterval { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Whether to emit priority change events.
/// Default: true
/// </summary>
public bool EmitPriorityChangeEvents { get; set; } = true;
/// <summary>
/// Whether to skip enrichment when EPSS model version changes.
/// This prevents false positive delta events from model retraining.
/// Default: true
/// </summary>
public bool SkipOnModelVersionChange { get; set; } = true;
/// <summary>
/// Number of days to retain raw EPSS data.
/// Default: 365
/// </summary>
public int RawDataRetentionDays { get; set; } = 365;
/// <summary>
/// Validates the options.
/// </summary>
public void Validate()
{
EnsurePercentage(nameof(HighPercentile), HighPercentile);
EnsurePercentage(nameof(HighScore), HighScore);
EnsurePercentage(nameof(CriticalPercentile), CriticalPercentile);
EnsurePercentage(nameof(CriticalScore), CriticalScore);
EnsurePercentage(nameof(MediumPercentile), MediumPercentile);
EnsurePercentage(nameof(BigJumpDelta), BigJumpDelta);
EnsurePercentage(nameof(DroppedLowDelta), DroppedLowDelta);
if (BatchSize < 1)
{
throw new ArgumentOutOfRangeException(nameof(BatchSize), BatchSize, "Must be at least 1.");
}
if (MinJobInterval < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(MinJobInterval), MinJobInterval, "Cannot be negative.");
}
if (RawDataRetentionDays < 1)
{
throw new ArgumentOutOfRangeException(nameof(RawDataRetentionDays), RawDataRetentionDays, "Must be at least 1.");
}
}
private static void EnsurePercentage(string name, double value)
{
if (double.IsNaN(value) || value < 0.0 || value > 1.0)
{
throw new ArgumentOutOfRangeException(name, value, "Must be between 0 and 1.");
}
}
}

View File

@@ -53,4 +53,17 @@ public sealed class OfflineKitOptions
/// Contains checkpoint.sig and entries/*.jsonl
/// </summary>
public string? RekorSnapshotDirectory { get; set; }
/// <summary>
/// Path to the Build-ID mapping index file (NDJSON format).
/// Used to correlate native binary Build-IDs (ELF GNU build-id, PE CodeView GUID+Age, Mach-O UUID)
/// to Package URLs (PURLs) for binary identification in distroless/scratch images.
/// </summary>
public string? BuildIdIndexPath { get; set; }
/// <summary>
/// When true, Build-ID index must have valid DSSE signature.
/// Default: true
/// </summary>
public bool RequireBuildIdIndexSignature { get; set; } = true;
}

View File

@@ -0,0 +1,146 @@
// -----------------------------------------------------------------------------
// EpssEvidence.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-002
// Description: Immutable EPSS evidence captured at scan time.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Core.Epss;
/// <summary>
/// Immutable EPSS evidence captured at scan time.
/// This record captures the EPSS score and percentile at the exact moment of scanning,
/// providing immutable evidence for deterministic replay and audit.
/// </summary>
public sealed record EpssEvidence
{
/// <summary>
/// EPSS probability score [0,1] at scan time.
/// Represents the probability of exploitation in the wild in the next 30 days.
/// </summary>
[JsonPropertyName("score")]
public required double Score { get; init; }
/// <summary>
/// EPSS percentile rank [0,1] at scan time.
/// Represents where this CVE ranks compared to all other CVEs.
/// </summary>
[JsonPropertyName("percentile")]
public required double Percentile { get; init; }
/// <summary>
/// EPSS model date used for this score.
/// The EPSS model is updated daily, so this records which model version was used.
/// </summary>
[JsonPropertyName("modelDate")]
public required DateOnly ModelDate { get; init; }
/// <summary>
/// Timestamp when this evidence was captured (UTC).
/// </summary>
[JsonPropertyName("capturedAt")]
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>
/// CVE identifier this evidence applies to.
/// </summary>
[JsonPropertyName("cveId")]
public required string CveId { get; init; }
/// <summary>
/// Source of the EPSS data (e.g., "first.org", "offline-bundle", "cache").
/// </summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
/// <summary>
/// Whether this evidence was captured from a cached value.
/// </summary>
[JsonPropertyName("fromCache")]
public bool FromCache { get; init; }
/// <summary>
/// Creates a new EPSS evidence record with current timestamp.
/// </summary>
public static EpssEvidence Create(
string cveId,
double score,
double percentile,
DateOnly modelDate,
string? source = null,
bool fromCache = false)
{
return new EpssEvidence
{
CveId = cveId,
Score = score,
Percentile = percentile,
ModelDate = modelDate,
CapturedAt = DateTimeOffset.UtcNow,
Source = source,
FromCache = fromCache
};
}
/// <summary>
/// Creates a new EPSS evidence record with explicit timestamp (for replay).
/// </summary>
public static EpssEvidence CreateWithTimestamp(
string cveId,
double score,
double percentile,
DateOnly modelDate,
DateTimeOffset capturedAt,
string? source = null,
bool fromCache = false)
{
return new EpssEvidence
{
CveId = cveId,
Score = score,
Percentile = percentile,
ModelDate = modelDate,
CapturedAt = capturedAt,
Source = source,
FromCache = fromCache
};
}
}
/// <summary>
/// Batch result for EPSS lookup operations.
/// </summary>
public sealed record EpssBatchResult
{
/// <summary>
/// Successfully retrieved EPSS evidence records.
/// </summary>
[JsonPropertyName("found")]
public required IReadOnlyList<EpssEvidence> Found { get; init; }
/// <summary>
/// CVE IDs that were not found in the EPSS dataset.
/// </summary>
[JsonPropertyName("notFound")]
public required IReadOnlyList<string> NotFound { get; init; }
/// <summary>
/// Model date used for this batch lookup.
/// </summary>
[JsonPropertyName("modelDate")]
public required DateOnly ModelDate { get; init; }
/// <summary>
/// Whether any results came from cache.
/// </summary>
[JsonPropertyName("partiallyFromCache")]
public bool PartiallyFromCache { get; init; }
/// <summary>
/// Total lookup time in milliseconds.
/// </summary>
[JsonPropertyName("lookupTimeMs")]
public long LookupTimeMs { get; init; }
}

View File

@@ -0,0 +1,187 @@
// -----------------------------------------------------------------------------
// EpssPriorityBand.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: 5
// Description: EPSS priority band calculation and models.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Configuration;
namespace StellaOps.Scanner.Core.Epss;
/// <summary>
/// Priority bands derived from EPSS scores and percentiles.
/// </summary>
public enum EpssPriorityBand
{
/// <summary>Top 1% by percentile or score > 0.8 - requires immediate action.</summary>
Critical = 0,
/// <summary>Top 5% by percentile or score > 0.5 - high likelihood of exploitation.</summary>
High = 1,
/// <summary>Top 25% by percentile - moderate likelihood.</summary>
Medium = 2,
/// <summary>Below top 25% - lower immediate risk.</summary>
Low = 3,
/// <summary>No EPSS data available.</summary>
Unknown = 4
}
/// <summary>
/// Result of EPSS priority band calculation.
/// </summary>
public sealed record EpssPriorityResult(
/// <summary>Calculated priority band.</summary>
EpssPriorityBand Band,
/// <summary>Whether this priority was elevated due to score threshold.</summary>
bool ElevatedByScore,
/// <summary>The trigger condition that determined the band.</summary>
string Reason);
/// <summary>
/// Service for calculating EPSS priority bands.
/// </summary>
public sealed class EpssPriorityCalculator
{
private readonly EpssEnrichmentOptions _options;
public EpssPriorityCalculator(EpssEnrichmentOptions options)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
}
/// <summary>
/// Calculate priority band from EPSS score and percentile.
/// </summary>
/// <param name="score">EPSS probability score [0, 1].</param>
/// <param name="percentile">EPSS percentile rank [0, 1].</param>
/// <returns>Priority result with band and reasoning.</returns>
public EpssPriorityResult Calculate(double? score, double? percentile)
{
if (!score.HasValue || !percentile.HasValue)
{
return new EpssPriorityResult(EpssPriorityBand.Unknown, false, "No EPSS data available");
}
var s = score.Value;
var p = percentile.Value;
// Critical: top 1% by percentile OR score > critical threshold
if (p >= _options.CriticalPercentile)
{
return new EpssPriorityResult(EpssPriorityBand.Critical, false, $"Percentile {p:P1} >= {_options.CriticalPercentile:P0}");
}
if (s >= _options.CriticalScore)
{
return new EpssPriorityResult(EpssPriorityBand.Critical, true, $"Score {s:F3} >= {_options.CriticalScore:F2}");
}
// High: top 5% by percentile OR score > high threshold
if (p >= _options.HighPercentile)
{
return new EpssPriorityResult(EpssPriorityBand.High, false, $"Percentile {p:P1} >= {_options.HighPercentile:P0}");
}
if (s >= _options.HighScore)
{
return new EpssPriorityResult(EpssPriorityBand.High, true, $"Score {s:F3} >= {_options.HighScore:F2}");
}
// Medium: top 25% by percentile
if (p >= _options.MediumPercentile)
{
return new EpssPriorityResult(EpssPriorityBand.Medium, false, $"Percentile {p:P1} >= {_options.MediumPercentile:P0}");
}
// Low: everything else
return new EpssPriorityResult(EpssPriorityBand.Low, false, $"Percentile {p:P1} < {_options.MediumPercentile:P0}");
}
/// <summary>
/// Check if priority band has changed between two EPSS snapshots.
/// </summary>
public bool HasBandChanged(
double? oldScore, double? oldPercentile,
double? newScore, double? newPercentile)
{
var oldBand = Calculate(oldScore, oldPercentile).Band;
var newBand = Calculate(newScore, newPercentile).Band;
return oldBand != newBand;
}
/// <summary>
/// Determine change flags for an EPSS update.
/// </summary>
public EpssChangeFlags ComputeChangeFlags(
double? oldScore, double? oldPercentile,
double newScore, double newPercentile)
{
var flags = EpssChangeFlags.None;
// NEW_SCORED: first time we have EPSS data
if (!oldScore.HasValue && newScore > 0)
{
flags |= EpssChangeFlags.NewScored;
}
if (oldScore.HasValue)
{
var delta = newScore - oldScore.Value;
// BIG_JUMP: significant score increase
if (delta >= _options.BigJumpDelta)
{
flags |= EpssChangeFlags.BigJump;
}
// DROPPED_LOW: significant score decrease
if (delta <= -_options.DroppedLowDelta)
{
flags |= EpssChangeFlags.DroppedLow;
}
}
// CROSSED_HIGH: moved into or out of high priority
var oldBand = Calculate(oldScore, oldPercentile).Band;
var newBand = Calculate(newScore, newPercentile).Band;
if (oldBand != newBand)
{
// Crossed into critical or high
if ((newBand == EpssPriorityBand.Critical || newBand == EpssPriorityBand.High) &&
oldBand != EpssPriorityBand.Critical && oldBand != EpssPriorityBand.High)
{
flags |= EpssChangeFlags.CrossedHigh;
}
}
return flags;
}
}
/// <summary>
/// Flags indicating what kind of EPSS change occurred.
/// </summary>
[Flags]
public enum EpssChangeFlags
{
/// <summary>No significant change.</summary>
None = 0,
/// <summary>CVE was scored for the first time.</summary>
NewScored = 1 << 0,
/// <summary>Score crossed into high priority band.</summary>
CrossedHigh = 1 << 1,
/// <summary>Score increased significantly (above BigJumpDelta).</summary>
BigJump = 1 << 2,
/// <summary>Score dropped significantly (above DroppedLowDelta).</summary>
DroppedLow = 1 << 3
}

View File

@@ -0,0 +1,119 @@
// -----------------------------------------------------------------------------
// IEpssProvider.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-003
// Description: Interface for EPSS data access in the scanner.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Epss;
/// <summary>
/// Provides access to EPSS (Exploit Prediction Scoring System) data.
/// Implementations may use PostgreSQL, cache layers, or offline bundles.
/// </summary>
public interface IEpssProvider
{
/// <summary>
/// Gets the current EPSS score for a single CVE.
/// </summary>
/// <param name="cveId">CVE identifier (e.g., "CVE-2021-44228").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>EPSS evidence if found; otherwise null.</returns>
Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets EPSS scores for multiple CVEs in a single batch operation.
/// </summary>
/// <param name="cveIds">Collection of CVE identifiers.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Batch result with found evidence and missing CVE IDs.</returns>
Task<EpssBatchResult> GetCurrentBatchAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets EPSS score as of a specific date (for replay scenarios).
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="asOfDate">Date for which to retrieve the score.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>EPSS evidence if found for that date; otherwise null.</returns>
Task<EpssEvidence?> GetAsOfDateAsync(
string cveId,
DateOnly asOfDate,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets EPSS score history for a CVE over a date range.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="startDate">Start of date range (inclusive).</param>
/// <param name="endDate">End of date range (inclusive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of EPSS evidence records ordered by date ascending.</returns>
Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(
string cveId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the most recent model date available in the provider.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Most recent model date, or null if no data is available.</returns>
Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Checks if EPSS data is available and the provider is healthy.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the provider can serve requests.</returns>
Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for EPSS provider configuration.
/// </summary>
public sealed class EpssProviderOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Epss";
/// <summary>
/// Whether to enable Valkey/Redis cache layer.
/// </summary>
public bool EnableCache { get; set; } = true;
/// <summary>
/// Cache TTL for current EPSS scores (default: 1 hour).
/// </summary>
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Maximum batch size for bulk lookups (default: 1000).
/// </summary>
public int MaxBatchSize { get; set; } = 1000;
/// <summary>
/// Timeout for individual lookups (default: 5 seconds).
/// </summary>
public TimeSpan LookupTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Whether to use offline/bundled EPSS data (air-gap mode).
/// </summary>
public bool OfflineMode { get; set; }
/// <summary>
/// Path to offline EPSS bundle (when OfflineMode is true).
/// </summary>
public string? OfflineBundlePath { get; set; }
/// <summary>
/// Source identifier for telemetry.
/// </summary>
public string SourceIdentifier { get; set; } = "postgres";
}

View File

@@ -0,0 +1,44 @@
using StellaOps.Scanner.Analyzers.Native.Index;
namespace StellaOps.Scanner.Emit.Native;
/// <summary>
/// Result of emitting a native component.
/// </summary>
/// <param name="Purl">Package URL for the component.</param>
/// <param name="Name">Component name (usually the filename).</param>
/// <param name="Version">Component version if known.</param>
/// <param name="Metadata">Original binary metadata.</param>
/// <param name="IndexMatch">Whether this was matched from the Build-ID index.</param>
/// <param name="LookupResult">The index lookup result if matched.</param>
public sealed record NativeComponentEmitResult(
string Purl,
string Name,
string? Version,
NativeBinaryMetadata Metadata,
bool IndexMatch,
BuildIdLookupResult? LookupResult);
/// <summary>
/// Interface for emitting native binary components for SBOM generation.
/// </summary>
public interface INativeComponentEmitter
{
/// <summary>
/// Emits a native component from binary metadata.
/// </summary>
/// <param name="metadata">Binary metadata.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Component emission result.</returns>
Task<NativeComponentEmitResult> EmitAsync(NativeBinaryMetadata metadata, CancellationToken cancellationToken = default);
/// <summary>
/// Emits multiple native components.
/// </summary>
/// <param name="metadataList">List of binary metadata.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Component emission results.</returns>
Task<IReadOnlyList<NativeComponentEmitResult>> EmitBatchAsync(
IEnumerable<NativeBinaryMetadata> metadataList,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,61 @@
namespace StellaOps.Scanner.Emit.Native;
/// <summary>
/// Metadata for a native binary component.
/// </summary>
public sealed record NativeBinaryMetadata
{
/// <summary>Binary format (elf, pe, macho)</summary>
public required string Format { get; init; }
/// <summary>Build-ID with prefix (gnu-build-id:..., pe-cv:..., macho-uuid:...)</summary>
public string? BuildId { get; init; }
/// <summary>CPU architecture (x86_64, aarch64, arm, i686, etc.)</summary>
public string? Architecture { get; init; }
/// <summary>Whether this is a 64-bit binary</summary>
public bool Is64Bit { get; init; }
/// <summary>Operating system or platform</summary>
public string? Platform { get; init; }
/// <summary>File path within the container layer</summary>
public required string FilePath { get; init; }
/// <summary>SHA-256 digest of the file</summary>
public string? FileDigest { get; init; }
/// <summary>File size in bytes</summary>
public long FileSize { get; init; }
/// <summary>Container layer digest where this binary was introduced</summary>
public string? LayerDigest { get; init; }
/// <summary>Layer index (0-based)</summary>
public int LayerIndex { get; init; }
/// <summary>Product version from PE version resource</summary>
public string? ProductVersion { get; init; }
/// <summary>File version from PE version resource</summary>
public string? FileVersion { get; init; }
/// <summary>Company name from PE version resource</summary>
public string? CompanyName { get; init; }
/// <summary>Hardening flags (PIE, RELRO, NX, etc.)</summary>
public IReadOnlyDictionary<string, string>? HardeningFlags { get; init; }
/// <summary>Whether the binary is signed</summary>
public bool IsSigned { get; init; }
/// <summary>Signature details (Authenticode, codesign, etc.)</summary>
public string? SignatureDetails { get; init; }
/// <summary>Imported libraries (DLL names for PE, SO names for ELF, dylib names for Mach-O)</summary>
public IReadOnlyList<string>? Imports { get; init; }
/// <summary>Exported symbols (for dependency analysis)</summary>
public IReadOnlyList<string>? Exports { get; init; }
}

View File

@@ -0,0 +1,155 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.Native.Index;
namespace StellaOps.Scanner.Emit.Native;
/// <summary>
/// Emits native binary components for SBOM generation.
/// Uses the Build-ID index to resolve PURLs when possible.
/// </summary>
public sealed class NativeComponentEmitter : INativeComponentEmitter
{
private readonly IBuildIdIndex _buildIdIndex;
private readonly NativePurlBuilder _purlBuilder;
private readonly ILogger<NativeComponentEmitter> _logger;
/// <summary>
/// Creates a new native component emitter.
/// </summary>
public NativeComponentEmitter(
IBuildIdIndex buildIdIndex,
ILogger<NativeComponentEmitter> logger)
{
ArgumentNullException.ThrowIfNull(buildIdIndex);
ArgumentNullException.ThrowIfNull(logger);
_buildIdIndex = buildIdIndex;
_purlBuilder = new NativePurlBuilder();
_logger = logger;
}
/// <inheritdoc />
public async Task<NativeComponentEmitResult> EmitAsync(
NativeBinaryMetadata metadata,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(metadata);
// Try to resolve via Build-ID index
BuildIdLookupResult? lookupResult = null;
if (!string.IsNullOrWhiteSpace(metadata.BuildId))
{
lookupResult = await _buildIdIndex.LookupAsync(metadata.BuildId, cancellationToken).ConfigureAwait(false);
}
string purl;
string? version = null;
bool indexMatch = false;
if (lookupResult is not null)
{
// Index match - use the resolved PURL
purl = _purlBuilder.FromIndexResult(lookupResult);
version = lookupResult.Version;
indexMatch = true;
_logger.LogDebug(
"Resolved binary {FilePath} via Build-ID index: {Purl}",
metadata.FilePath,
purl);
}
else
{
// No match - generate generic PURL
purl = _purlBuilder.FromUnresolvedBinary(metadata);
version = metadata.ProductVersion ?? metadata.FileVersion;
_logger.LogDebug(
"Unresolved binary {FilePath}, generated generic PURL: {Purl}",
metadata.FilePath,
purl);
}
var name = Path.GetFileName(metadata.FilePath);
return new NativeComponentEmitResult(
Purl: purl,
Name: name,
Version: version,
Metadata: metadata,
IndexMatch: indexMatch,
LookupResult: lookupResult);
}
/// <inheritdoc />
public async Task<IReadOnlyList<NativeComponentEmitResult>> EmitBatchAsync(
IEnumerable<NativeBinaryMetadata> metadataList,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(metadataList);
var metadataArray = metadataList.ToArray();
if (metadataArray.Length == 0)
{
return Array.Empty<NativeComponentEmitResult>();
}
// Batch lookup for all Build-IDs
var buildIds = metadataArray
.Where(m => !string.IsNullOrWhiteSpace(m.BuildId))
.Select(m => m.BuildId!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var lookupResults = await _buildIdIndex.BatchLookupAsync(buildIds, cancellationToken).ConfigureAwait(false);
var lookupMap = lookupResults.ToDictionary(
r => r.BuildId,
StringComparer.OrdinalIgnoreCase);
_logger.LogDebug(
"Batch lookup: {Total} binaries, {Resolved} resolved via index",
metadataArray.Length,
lookupMap.Count);
// Emit components
var results = new List<NativeComponentEmitResult>(metadataArray.Length);
foreach (var metadata in metadataArray)
{
BuildIdLookupResult? lookupResult = null;
if (!string.IsNullOrWhiteSpace(metadata.BuildId) &&
lookupMap.TryGetValue(metadata.BuildId, out var result))
{
lookupResult = result;
}
string purl;
string? version = null;
bool indexMatch = false;
if (lookupResult is not null)
{
purl = _purlBuilder.FromIndexResult(lookupResult);
version = lookupResult.Version;
indexMatch = true;
}
else
{
purl = _purlBuilder.FromUnresolvedBinary(metadata);
version = metadata.ProductVersion ?? metadata.FileVersion;
}
results.Add(new NativeComponentEmitResult(
Purl: purl,
Name: Path.GetFileName(metadata.FilePath),
Version: version,
Metadata: metadata,
IndexMatch: indexMatch,
LookupResult: lookupResult));
}
return results;
}
}

View File

@@ -0,0 +1,196 @@
// -----------------------------------------------------------------------------
// NativeComponentMapper.cs
// Sprint: SPRINT_3500_0012_0001_binary_sbom_emission
// Task: BSE-004
// Description: Maps native binaries to container layer fragments for SBOM.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Analyzers.Native.Index;
namespace StellaOps.Scanner.Emit.Native;
/// <summary>
/// Maps native binary components to container layer fragments.
/// Generates dependency relationships and layer ownership metadata.
/// </summary>
public sealed class NativeComponentMapper
{
private readonly INativeComponentEmitter _emitter;
public NativeComponentMapper(INativeComponentEmitter emitter)
{
ArgumentNullException.ThrowIfNull(emitter);
_emitter = emitter;
}
/// <summary>
/// Maps a container layer's native binaries to SBOM components.
/// </summary>
/// <param name="layerDigest">Layer digest (sha256:...)</param>
/// <param name="binaries">Native binaries discovered in the layer</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Layer mapping result</returns>
public async Task<LayerComponentMapping> MapLayerAsync(
string layerDigest,
IReadOnlyList<NativeBinaryMetadata> binaries,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(layerDigest);
ArgumentNullException.ThrowIfNull(binaries);
var components = new List<NativeComponentEmitResult>(binaries.Count);
var unresolvedCount = 0;
foreach (var binary in binaries)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await _emitter.EmitAsync(binary, cancellationToken).ConfigureAwait(false);
components.Add(result);
if (!result.IndexMatch)
{
unresolvedCount++;
}
}
return new LayerComponentMapping(
LayerDigest: layerDigest,
Components: components,
TotalCount: components.Count,
ResolvedCount: components.Count - unresolvedCount,
UnresolvedCount: unresolvedCount);
}
/// <summary>
/// Maps all layers in a container image to SBOM components.
/// Deduplicates components that appear in multiple layers.
/// </summary>
/// <param name="imageLayers">Ordered list of layer digests (base to top)</param>
/// <param name="binariesByLayer">Binaries discovered per layer</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Image mapping result with deduplication</returns>
public async Task<ImageComponentMapping> MapImageAsync(
IReadOnlyList<string> imageLayers,
IReadOnlyDictionary<string, IReadOnlyList<NativeBinaryMetadata>> binariesByLayer,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(imageLayers);
ArgumentNullException.ThrowIfNull(binariesByLayer);
var layerMappings = new List<LayerComponentMapping>(imageLayers.Count);
var seenPurls = new HashSet<string>(StringComparer.Ordinal);
var uniqueComponents = new List<NativeComponentEmitResult>();
var duplicateCount = 0;
foreach (var layerDigest in imageLayers)
{
cancellationToken.ThrowIfCancellationRequested();
if (!binariesByLayer.TryGetValue(layerDigest, out var binaries))
{
// Empty layer, skip
layerMappings.Add(new LayerComponentMapping(
LayerDigest: layerDigest,
Components: Array.Empty<NativeComponentEmitResult>(),
TotalCount: 0,
ResolvedCount: 0,
UnresolvedCount: 0));
continue;
}
var layerMapping = await MapLayerAsync(layerDigest, binaries, cancellationToken).ConfigureAwait(false);
layerMappings.Add(layerMapping);
// Track unique components for the final image SBOM
foreach (var component in layerMapping.Components)
{
if (seenPurls.Add(component.Purl))
{
uniqueComponents.Add(component);
}
else
{
duplicateCount++;
}
}
}
return new ImageComponentMapping(
Layers: layerMappings,
UniqueComponents: uniqueComponents,
TotalBinaryCount: layerMappings.Sum(l => l.TotalCount),
UniqueBinaryCount: uniqueComponents.Count,
DuplicateCount: duplicateCount);
}
/// <summary>
/// Computes dependency relationships between native binaries.
/// Uses import table analysis to determine which binaries depend on which.
/// </summary>
/// <param name="components">Components to analyze</param>
/// <returns>Dependency edges (from PURL to list of dependency PURLs)</returns>
public IReadOnlyDictionary<string, IReadOnlyList<string>> ComputeDependencies(
IReadOnlyList<NativeComponentEmitResult> components)
{
ArgumentNullException.ThrowIfNull(components);
// Build lookup by filename for dependency resolution
var byFilename = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var component in components)
{
var filename = Path.GetFileName(component.Metadata.FilePath);
if (!string.IsNullOrWhiteSpace(filename))
{
byFilename.TryAdd(filename, component.Purl);
}
}
var dependencies = new Dictionary<string, IReadOnlyList<string>>();
foreach (var component in components)
{
var deps = new List<string>();
// Use imports from metadata if available
if (component.Metadata.Imports is { Count: > 0 })
{
foreach (var import in component.Metadata.Imports)
{
var importName = Path.GetFileName(import);
if (byFilename.TryGetValue(importName, out var depPurl))
{
deps.Add(depPurl);
}
}
}
if (deps.Count > 0)
{
dependencies[component.Purl] = deps;
}
}
return dependencies;
}
}
/// <summary>
/// Result of mapping a single container layer to SBOM components.
/// </summary>
public sealed record LayerComponentMapping(
string LayerDigest,
IReadOnlyList<NativeComponentEmitResult> Components,
int TotalCount,
int ResolvedCount,
int UnresolvedCount);
/// <summary>
/// Result of mapping an entire container image to SBOM components.
/// </summary>
public sealed record ImageComponentMapping(
IReadOnlyList<LayerComponentMapping> Layers,
IReadOnlyList<NativeComponentEmitResult> UniqueComponents,
int TotalBinaryCount,
int UniqueBinaryCount,
int DuplicateCount);

View File

@@ -0,0 +1,115 @@
using StellaOps.Scanner.Analyzers.Native.Index;
namespace StellaOps.Scanner.Emit.Native;
/// <summary>
/// Builds PURLs for native binaries.
/// </summary>
public sealed class NativePurlBuilder
{
/// <summary>
/// Builds a PURL from a Build-ID index lookup result.
/// </summary>
/// <param name="lookupResult">The index lookup result.</param>
/// <returns>PURL string.</returns>
public string FromIndexResult(BuildIdLookupResult lookupResult)
{
ArgumentNullException.ThrowIfNull(lookupResult);
return lookupResult.Purl;
}
/// <summary>
/// Builds a PURL for an unresolved native binary.
/// Falls back to pkg:generic with build-id qualifier.
/// </summary>
/// <param name="metadata">Binary metadata.</param>
/// <returns>PURL string.</returns>
public string FromUnresolvedBinary(NativeBinaryMetadata metadata)
{
ArgumentNullException.ThrowIfNull(metadata);
// Extract filename from path
var fileName = Path.GetFileName(metadata.FilePath);
// Build pkg:generic PURL with build-id qualifier
var purl = $"pkg:generic/{EncodeComponent(fileName)}@unknown";
var qualifiers = new List<string>();
if (!string.IsNullOrWhiteSpace(metadata.BuildId))
{
qualifiers.Add($"build-id={EncodeComponent(metadata.BuildId)}");
}
if (!string.IsNullOrWhiteSpace(metadata.Architecture))
{
qualifiers.Add($"arch={EncodeComponent(metadata.Architecture)}");
}
if (!string.IsNullOrWhiteSpace(metadata.Platform))
{
qualifiers.Add($"os={EncodeComponent(metadata.Platform)}");
}
if (!string.IsNullOrWhiteSpace(metadata.FileDigest))
{
qualifiers.Add($"checksum={EncodeComponent(metadata.FileDigest)}");
}
if (qualifiers.Count > 0)
{
purl += "?" + string.Join("&", qualifiers.OrderBy(q => q, StringComparer.Ordinal));
}
return purl;
}
/// <summary>
/// Builds a PURL for a binary with known distro information.
/// </summary>
/// <param name="distro">Distribution type (deb, rpm, apk, etc.)</param>
/// <param name="distroName">Distribution name (debian, fedora, alpine, etc.)</param>
/// <param name="packageName">Package name.</param>
/// <param name="version">Package version.</param>
/// <param name="architecture">CPU architecture.</param>
/// <returns>PURL string.</returns>
public string FromDistroPackage(
string distro,
string distroName,
string packageName,
string version,
string? architecture = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(distro);
ArgumentException.ThrowIfNullOrWhiteSpace(distroName);
ArgumentException.ThrowIfNullOrWhiteSpace(packageName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
// Map distro type to PURL type
var purlType = distro.ToLowerInvariant() switch
{
"deb" or "debian" or "ubuntu" => "deb",
"rpm" or "fedora" or "rhel" or "centos" => "rpm",
"apk" or "alpine" => "apk",
"pacman" or "arch" => "pacman",
_ => "generic"
};
var purl = $"pkg:{purlType}/{EncodeComponent(distroName)}/{EncodeComponent(packageName)}@{EncodeComponent(version)}";
if (!string.IsNullOrWhiteSpace(architecture))
{
purl += $"?arch={EncodeComponent(architecture)}";
}
return purl;
}
private static string EncodeComponent(string value)
{
// PURL percent-encoding: only encode special characters
return Uri.EscapeDataString(value)
.Replace("%2F", "/", StringComparison.Ordinal) // Allow / in names
.Replace("%40", "@", StringComparison.Ordinal); // @ is already version separator
}
}

View File

@@ -10,6 +10,7 @@
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.EntryTrace\StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,146 @@
// -----------------------------------------------------------------------------
// AttestingRichGraphWriter.cs
// Sprint: SPRINT_3620_0001_0001_reachability_witness_dsse
// Description: RichGraphWriter wrapper that produces DSSE attestation alongside graph.
// -----------------------------------------------------------------------------
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Result of writing a rich graph with attestation.
/// </summary>
/// <param name="GraphPath">Path to the richgraph-v1.json file.</param>
/// <param name="MetaPath">Path to the meta.json file.</param>
/// <param name="GraphHash">Content-addressed hash of the graph.</param>
/// <param name="NodeCount">Number of nodes in the graph.</param>
/// <param name="EdgeCount">Number of edges in the graph.</param>
/// <param name="AttestationPath">Path to the attestation DSSE envelope (if produced).</param>
/// <param name="WitnessResult">Detailed witness publication result (if attestation enabled).</param>
public sealed record AttestingRichGraphWriteResult(
string GraphPath,
string MetaPath,
string GraphHash,
int NodeCount,
int EdgeCount,
string? AttestationPath,
ReachabilityWitnessPublishResult? WitnessResult);
/// <summary>
/// Writes richgraph-v1 documents with optional DSSE attestation.
/// Wraps <see cref="RichGraphWriter"/> and integrates with <see cref="IReachabilityWitnessPublisher"/>.
/// </summary>
public sealed class AttestingRichGraphWriter
{
private readonly RichGraphWriter _graphWriter;
private readonly IReachabilityWitnessPublisher _witnessPublisher;
private readonly ReachabilityWitnessOptions _options;
private readonly ILogger<AttestingRichGraphWriter> _logger;
/// <summary>
/// Creates a new attesting rich graph writer.
/// </summary>
public AttestingRichGraphWriter(
RichGraphWriter graphWriter,
IReachabilityWitnessPublisher witnessPublisher,
IOptions<ReachabilityWitnessOptions> options,
ILogger<AttestingRichGraphWriter> logger)
{
_graphWriter = graphWriter ?? throw new ArgumentNullException(nameof(graphWriter));
_witnessPublisher = witnessPublisher ?? throw new ArgumentNullException(nameof(witnessPublisher));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Writes the rich graph and produces attestation if enabled.
/// </summary>
/// <param name="graph">The rich graph to write.</param>
/// <param name="outputRoot">Root output directory.</param>
/// <param name="analysisId">Analysis identifier.</param>
/// <param name="subjectDigest">Subject artifact digest for attestation.</param>
/// <param name="policyHash">Optional policy hash for attestation.</param>
/// <param name="sourceCommit">Optional source commit for attestation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Write result including attestation details.</returns>
public async Task<AttestingRichGraphWriteResult> WriteWithAttestationAsync(
RichGraph graph,
string outputRoot,
string analysisId,
string subjectDigest,
string? policyHash = null,
string? sourceCommit = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot);
ArgumentException.ThrowIfNullOrWhiteSpace(analysisId);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
// Step 1: Write the graph using the standard writer
var writeResult = await _graphWriter.WriteAsync(graph, outputRoot, analysisId, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Wrote rich graph: {GraphPath}, hash={GraphHash}, nodes={NodeCount}, edges={EdgeCount}",
writeResult.GraphPath,
writeResult.GraphHash,
writeResult.NodeCount,
writeResult.EdgeCount);
// Step 2: Produce attestation if enabled
string? attestationPath = null;
ReachabilityWitnessPublishResult? witnessResult = null;
if (_options.Enabled)
{
// Read the graph bytes for attestation
var graphBytes = await File.ReadAllBytesAsync(writeResult.GraphPath, cancellationToken)
.ConfigureAwait(false);
// Publish witness attestation
witnessResult = await _witnessPublisher.PublishAsync(
graph,
graphBytes,
writeResult.GraphHash,
subjectDigest,
policyHash,
sourceCommit,
cancellationToken).ConfigureAwait(false);
// Write DSSE envelope to disk alongside the graph
if (witnessResult.DsseEnvelopeBytes.Length > 0)
{
var graphDir = Path.GetDirectoryName(writeResult.GraphPath)!;
attestationPath = Path.Combine(graphDir, "richgraph-v1.dsse.json");
await File.WriteAllBytesAsync(attestationPath, witnessResult.DsseEnvelopeBytes, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Wrote reachability witness attestation: {AttestationPath}, statementHash={StatementHash}",
attestationPath,
witnessResult.StatementHash);
}
}
else
{
_logger.LogDebug("Reachability witness attestation is disabled");
}
return new AttestingRichGraphWriteResult(
GraphPath: writeResult.GraphPath,
MetaPath: writeResult.MetaPath,
GraphHash: writeResult.GraphHash,
NodeCount: writeResult.NodeCount,
EdgeCount: writeResult.EdgeCount,
AttestationPath: attestationPath,
WitnessResult: witnessResult);
}
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Result of publishing a reachability witness.
/// </summary>
/// <param name="StatementHash">Hash of the in-toto statement.</param>
/// <param name="GraphHash">Hash of the rich graph.</param>
/// <param name="CasUri">CAS URI where graph is stored (if applicable).</param>
/// <param name="RekorLogIndex">Rekor transparency log index (if published).</param>
/// <param name="RekorLogId">Rekor log ID (if published).</param>
/// <param name="DsseEnvelopeBytes">Serialized DSSE envelope.</param>
public sealed record ReachabilityWitnessPublishResult(
string StatementHash,
string GraphHash,
string? CasUri,
long? RekorLogIndex,
string? RekorLogId,
byte[] DsseEnvelopeBytes);
/// <summary>
/// Interface for publishing reachability witness attestations.
/// </summary>
public interface IReachabilityWitnessPublisher
{
/// <summary>
/// Publishes a reachability witness attestation for the given graph.
/// </summary>
/// <param name="graph">The rich graph to attest.</param>
/// <param name="graphBytes">Canonical JSON bytes of the graph.</param>
/// <param name="graphHash">Hash of the graph bytes.</param>
/// <param name="subjectDigest">Subject artifact digest.</param>
/// <param name="policyHash">Optional policy hash.</param>
/// <param name="sourceCommit">Optional source commit.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Publication result with CAS URI and optional Rekor proof.</returns>
Task<ReachabilityWitnessPublishResult> PublishAsync(
RichGraph graph,
byte[] graphBytes,
string graphHash,
string subjectDigest,
string? policyHash = null,
string? sourceCommit = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,52 @@
// -----------------------------------------------------------------------------
// ReachabilityAttestationServiceCollectionExtensions.cs
// Sprint: SPRINT_3620_0001_0001_reachability_witness_dsse
// Description: DI registration for reachability witness attestation services.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Extension methods for registering reachability witness attestation services.
/// </summary>
public static class ReachabilityAttestationServiceCollectionExtensions
{
/// <summary>
/// Adds reachability witness attestation services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddReachabilityWitnessAttestation(this IServiceCollection services)
{
// Register DSSE builder
services.TryAddSingleton<ReachabilityWitnessDsseBuilder>();
// Register publisher
services.TryAddSingleton<IReachabilityWitnessPublisher, ReachabilityWitnessPublisher>();
// Register attesting writer (wraps RichGraphWriter)
services.TryAddSingleton<AttestingRichGraphWriter>();
// Register options
services.AddOptions<ReachabilityWitnessOptions>();
return services;
}
/// <summary>
/// Configures reachability witness options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection ConfigureReachabilityWitnessOptions(
this IServiceCollection services,
Action<ReachabilityWitnessOptions> configure)
{
services.Configure(configure);
return services;
}
}

View File

@@ -0,0 +1,207 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Builds DSSE envelopes for reachability witness attestations.
/// Follows in-toto attestation framework with stellaops.reachabilityWitness predicate.
/// </summary>
public sealed class ReachabilityWitnessDsseBuilder
{
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Creates a new DSSE builder.
/// </summary>
/// <param name="cryptoHash">Crypto hash service for content addressing.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
public ReachabilityWitnessDsseBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Builds an in-toto statement from a RichGraph.
/// </summary>
/// <param name="graph">The rich graph to attest.</param>
/// <param name="graphHash">The computed hash of the canonical graph JSON.</param>
/// <param name="subjectDigest">The subject artifact digest (e.g., image digest).</param>
/// <param name="graphCasUri">Optional CAS URI where graph is stored.</param>
/// <param name="policyHash">Optional policy hash that was applied.</param>
/// <param name="sourceCommit">Optional source commit.</param>
/// <returns>An in-toto statement ready for DSSE signing.</returns>
public InTotoStatement BuildStatement(
RichGraph graph,
string graphHash,
string subjectDigest,
string? graphCasUri = null,
string? policyHash = null,
string? sourceCommit = null)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
var generatedAt = _timeProvider.GetUtcNow();
var predicate = new ReachabilityWitnessStatement
{
GraphHash = graphHash,
GraphCasUri = graphCasUri,
GeneratedAt = generatedAt,
Language = graph.Nodes.FirstOrDefault()?.Lang ?? "unknown",
NodeCount = graph.Nodes.Count,
EdgeCount = graph.Edges.Count,
EntrypointCount = graph.Roots?.Count ?? 0,
SinkCount = CountSinks(graph),
ReachableSinkCount = CountReachableSinks(graph),
PolicyHash = policyHash,
AnalyzerVersion = graph.Analyzer.Version ?? "unknown",
SourceCommit = sourceCommit,
SubjectDigest = subjectDigest
};
return new InTotoStatement
{
Type = "https://in-toto.io/Statement/v1",
Subject = new[]
{
new InTotoSubject
{
Name = ExtractSubjectName(subjectDigest),
Digest = new Dictionary<string, string>
{
[ExtractDigestAlgorithm(subjectDigest)] = ExtractDigestValue(subjectDigest)
}
}
},
PredicateType = "https://stella.ops/reachabilityWitness/v1",
Predicate = predicate
};
}
/// <summary>
/// Serializes an in-toto statement to canonical JSON.
/// </summary>
public byte[] SerializeStatement(InTotoStatement statement)
{
ArgumentNullException.ThrowIfNull(statement);
return JsonSerializer.SerializeToUtf8Bytes(statement, CanonicalJsonOptions);
}
/// <summary>
/// Computes the hash of a serialized statement.
/// </summary>
public string ComputeStatementHash(byte[] statementBytes)
{
ArgumentNullException.ThrowIfNull(statementBytes);
return _cryptoHash.ComputePrefixedHashForPurpose(statementBytes, HashPurpose.Graph);
}
private static int CountSinks(RichGraph graph)
{
// Count nodes with sink-related kinds (sql, crypto, deserialize, etc.)
return graph.Nodes.Count(n => IsSinkKind(n.Kind));
}
private static int CountReachableSinks(RichGraph graph)
{
// A sink is reachable if it has incoming edges
var nodesWithIncoming = new HashSet<string>(StringComparer.Ordinal);
foreach (var edge in graph.Edges)
{
if (!string.IsNullOrEmpty(edge.To))
{
nodesWithIncoming.Add(edge.To);
}
}
return graph.Nodes.Count(n =>
IsSinkKind(n.Kind) &&
nodesWithIncoming.Contains(n.Id));
}
private static bool IsSinkKind(string? kind)
{
// Recognize common sink kinds from the taxonomy
return kind?.ToLowerInvariant() switch
{
"sink" => true,
"sql" => true,
"crypto" => true,
"deserialize" => true,
"file" => true,
"network" => true,
"command" => true,
"reflection" => true,
_ => false
};
}
private static string ExtractSubjectName(string subjectDigest)
{
// For image digests like "sha256:abc123", return the full string
// For other formats, try to extract a meaningful name
return subjectDigest;
}
private static string ExtractDigestAlgorithm(string subjectDigest)
{
var colonIndex = subjectDigest.IndexOf(':');
return colonIndex > 0 ? subjectDigest[..colonIndex] : "sha256";
}
private static string ExtractDigestValue(string subjectDigest)
{
var colonIndex = subjectDigest.IndexOf(':');
return colonIndex > 0 ? subjectDigest[(colonIndex + 1)..] : subjectDigest;
}
}
/// <summary>
/// In-toto Statement structure per https://github.com/in-toto/attestation.
/// </summary>
public sealed record InTotoStatement
{
/// <summary>Statement type (always "https://in-toto.io/Statement/v1")</summary>
[JsonPropertyName("_type")]
public required string Type { get; init; }
/// <summary>Array of subjects this attestation refers to</summary>
[JsonPropertyName("subject")]
public required InTotoSubject[] Subject { get; init; }
/// <summary>URI identifying the predicate type</summary>
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
/// <summary>The predicate object (type varies by predicateType)</summary>
[JsonPropertyName("predicate")]
public required object Predicate { get; init; }
}
/// <summary>
/// In-toto Subject structure.
/// </summary>
public sealed record InTotoSubject
{
/// <summary>Subject name (e.g., artifact path or identifier)</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Map of digest algorithm to digest value</summary>
[JsonPropertyName("digest")]
public required Dictionary<string, string> Digest { get; init; }
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Configuration for reachability witness attestation.
/// </summary>
public sealed class ReachabilityWitnessOptions
{
public const string SectionName = "Scanner:ReachabilityWitness";
/// <summary>Whether to generate DSSE attestations</summary>
public bool Enabled { get; set; } = true;
/// <summary>Attestation tier (standard, regulated, air-gapped, dev)</summary>
public AttestationTier Tier { get; set; } = AttestationTier.Standard;
/// <summary>Whether to publish to Rekor transparency log</summary>
public bool PublishToRekor { get; set; } = true;
/// <summary>Whether to store graph in CAS</summary>
public bool StoreInCas { get; set; } = true;
/// <summary>Maximum number of edge bundles to attest (for tier=standard)</summary>
public int MaxEdgeBundles { get; set; } = 5;
/// <summary>Key ID for signing (uses default if not specified)</summary>
public string? SigningKeyId { get; set; }
}
/// <summary>
/// Attestation tiers per hybrid-attestation.md.
/// </summary>
public enum AttestationTier
{
/// <summary>Standard: Graph DSSE + Rekor, optional edge bundles</summary>
Standard,
/// <summary>Regulated: Full attestation with strict signing</summary>
Regulated,
/// <summary>Air-gapped: Local-only, no Rekor</summary>
AirGapped,
/// <summary>Development: Minimal attestation for testing</summary>
Dev
}

View File

@@ -0,0 +1,147 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Publishes reachability witness attestations to CAS and Rekor.
/// </summary>
public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
{
private readonly ReachabilityWitnessOptions _options;
private readonly ReachabilityWitnessDsseBuilder _dsseBuilder;
private readonly ICryptoHash _cryptoHash;
private readonly ILogger<ReachabilityWitnessPublisher> _logger;
/// <summary>
/// Creates a new reachability witness publisher.
/// </summary>
public ReachabilityWitnessPublisher(
IOptions<ReachabilityWitnessOptions> options,
ICryptoHash cryptoHash,
ILogger<ReachabilityWitnessPublisher> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(cryptoHash);
ArgumentNullException.ThrowIfNull(logger);
_options = options.Value;
_cryptoHash = cryptoHash;
_logger = logger;
_dsseBuilder = new ReachabilityWitnessDsseBuilder(cryptoHash, timeProvider);
}
/// <inheritdoc />
public async Task<ReachabilityWitnessPublishResult> PublishAsync(
RichGraph graph,
byte[] graphBytes,
string graphHash,
string subjectDigest,
string? policyHash = null,
string? sourceCommit = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(graphBytes);
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
if (!_options.Enabled)
{
_logger.LogDebug("Reachability witness attestation is disabled");
return new ReachabilityWitnessPublishResult(
StatementHash: string.Empty,
GraphHash: graphHash,
CasUri: null,
RekorLogIndex: null,
RekorLogId: null,
DsseEnvelopeBytes: Array.Empty<byte>());
}
string? casUri = null;
// Step 1: Store graph in CAS (if enabled)
if (_options.StoreInCas)
{
casUri = await StoreInCasAsync(graphBytes, graphHash, cancellationToken).ConfigureAwait(false);
}
// Step 2: Build in-toto statement
var statement = _dsseBuilder.BuildStatement(
graph,
graphHash,
subjectDigest,
casUri,
policyHash,
sourceCommit);
var statementBytes = _dsseBuilder.SerializeStatement(statement);
var statementHash = _dsseBuilder.ComputeStatementHash(statementBytes);
_logger.LogInformation(
"Built reachability witness statement: hash={StatementHash}, nodes={NodeCount}, edges={EdgeCount}",
statementHash,
graph.Nodes.Count,
graph.Edges.Count);
// Step 3: Create DSSE envelope (placeholder - actual signing via Attestor service)
var dsseEnvelope = CreateDsseEnvelope(statementBytes);
// Step 4: Submit to Rekor (if enabled and not air-gapped)
long? rekorLogIndex = null;
string? rekorLogId = null;
if (_options.PublishToRekor && _options.Tier != AttestationTier.AirGapped)
{
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(dsseEnvelope, cancellationToken).ConfigureAwait(false);
}
else if (_options.Tier == AttestationTier.AirGapped)
{
_logger.LogDebug("Skipping Rekor submission (air-gapped tier)");
}
return new ReachabilityWitnessPublishResult(
StatementHash: statementHash,
GraphHash: graphHash,
CasUri: casUri,
RekorLogIndex: rekorLogIndex,
RekorLogId: rekorLogId,
DsseEnvelopeBytes: dsseEnvelope);
}
private Task<string?> StoreInCasAsync(byte[] graphBytes, string graphHash, CancellationToken cancellationToken)
{
// TODO: Integrate with actual CAS storage (BID-007)
// For now, return a placeholder CAS URI based on hash
var casUri = $"cas://local/{graphHash}";
_logger.LogDebug("Stored graph in CAS: {CasUri}", casUri);
return Task.FromResult<string?>(casUri);
}
private byte[] CreateDsseEnvelope(byte[] statementBytes)
{
// TODO: Integrate with Attestor DSSE signing service (RWD-008)
// For now, return unsigned envelope structure
// In production, this would call the Attestor service to sign the statement
// Minimal DSSE envelope structure (unsigned)
var envelope = new
{
payloadType = "application/vnd.in-toto+json",
payload = Convert.ToBase64String(statementBytes),
signatures = Array.Empty<object>() // Will be populated by Attestor
};
return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(envelope);
}
private Task<(long? logIndex, string? logId)> SubmitToRekorAsync(byte[] dsseEnvelope, CancellationToken cancellationToken)
{
// TODO: Integrate with Rekor backend (RWD-008)
// For now, return placeholder values
_logger.LogDebug("Rekor submission placeholder - actual integration pending");
return Task.FromResult<(long?, string?)>((null, null));
}
}

View File

@@ -0,0 +1,66 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Reachability witness statement for DSSE predicate.
/// Conforms to stella.ops/reachabilityWitness@v1 schema.
/// </summary>
public sealed record ReachabilityWitnessStatement
{
/// <summary>Schema identifier</summary>
[JsonPropertyName("schema")]
public string Schema { get; init; } = "stella.ops/reachabilityWitness@v1";
/// <summary>BLAKE3 hash of the canonical RichGraph JSON</summary>
[JsonPropertyName("graphHash")]
public required string GraphHash { get; init; }
/// <summary>CAS URI where graph is stored</summary>
[JsonPropertyName("graphCasUri")]
public string? GraphCasUri { get; init; }
/// <summary>When the analysis was performed (ISO-8601)</summary>
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>Primary language of the analyzed code</summary>
[JsonPropertyName("language")]
public required string Language { get; init; }
/// <summary>Number of nodes in the graph</summary>
[JsonPropertyName("nodeCount")]
public required int NodeCount { get; init; }
/// <summary>Number of edges in the graph</summary>
[JsonPropertyName("edgeCount")]
public required int EdgeCount { get; init; }
/// <summary>Number of entrypoints identified</summary>
[JsonPropertyName("entrypointCount")]
public required int EntrypointCount { get; init; }
/// <summary>Total number of sinks in taxonomy</summary>
[JsonPropertyName("sinkCount")]
public required int SinkCount { get; init; }
/// <summary>Number of reachable sinks</summary>
[JsonPropertyName("reachableSinkCount")]
public required int ReachableSinkCount { get; init; }
/// <summary>Policy hash that was applied (if any)</summary>
[JsonPropertyName("policyHash")]
public string? PolicyHash { get; init; }
/// <summary>Analyzer version used</summary>
[JsonPropertyName("analyzerVersion")]
public required string AnalyzerVersion { get; init; }
/// <summary>Git commit of the analyzed code</summary>
[JsonPropertyName("sourceCommit")]
public string? SourceCommit { get; init; }
/// <summary>Subject artifact (image digest or file hash)</summary>
[JsonPropertyName("subjectDigest")]
public required string SubjectDigest { get; init; }
}

View File

@@ -0,0 +1,90 @@
// -----------------------------------------------------------------------------
// BoundaryExtractionContext.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Context for boundary extraction with environment hints.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Context for boundary extraction, providing environment hints and detected gates.
/// </summary>
public sealed record BoundaryExtractionContext
{
/// <summary>
/// Empty context for simple extractions.
/// </summary>
public static readonly BoundaryExtractionContext Empty = new();
/// <summary>
/// Environment identifier (e.g., "production", "staging").
/// </summary>
public string? EnvironmentId { get; init; }
/// <summary>
/// Deployment namespace or context (e.g., "default", "kube-system").
/// </summary>
public string? Namespace { get; init; }
/// <summary>
/// Additional annotations from deployment metadata.
/// </summary>
public IReadOnlyDictionary<string, string> Annotations { get; init; } =
new Dictionary<string, string>();
/// <summary>
/// Gates detected by gate detection analysis.
/// </summary>
public IReadOnlyList<DetectedGate> DetectedGates { get; init; } =
Array.Empty<DetectedGate>();
/// <summary>
/// Whether the service is known to be internet-facing.
/// </summary>
public bool? IsInternetFacing { get; init; }
/// <summary>
/// Network zone (e.g., "dmz", "internal", "trusted").
/// </summary>
public string? NetworkZone { get; init; }
/// <summary>
/// Known port bindings (port → protocol).
/// </summary>
public IReadOnlyDictionary<int, string> PortBindings { get; init; } =
new Dictionary<int, string>();
/// <summary>
/// Timestamp for the context (for cache invalidation).
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Source of this context (e.g., "k8s", "iac", "runtime").
/// </summary>
public string? Source { get; init; }
/// <summary>
/// Creates a context from detected gates.
/// </summary>
public static BoundaryExtractionContext FromGates(IReadOnlyList<DetectedGate> gates) =>
new() { DetectedGates = gates };
/// <summary>
/// Creates a context with environment hints.
/// </summary>
public static BoundaryExtractionContext ForEnvironment(
string environmentId,
bool? isInternetFacing = null,
string? networkZone = null) =>
new()
{
EnvironmentId = environmentId,
IsInternetFacing = isInternetFacing,
NetworkZone = networkZone
};
}

View File

@@ -0,0 +1,41 @@
// -----------------------------------------------------------------------------
// BoundaryServiceCollectionExtensions.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: DI registration for boundary proof extractors.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Extension methods for registering boundary proof extractors.
/// </summary>
public static class BoundaryServiceCollectionExtensions
{
/// <summary>
/// Adds boundary proof extraction services.
/// </summary>
public static IServiceCollection AddBoundaryExtractors(this IServiceCollection services)
{
// Register base extractor
services.TryAddSingleton<RichGraphBoundaryExtractor>();
services.TryAddSingleton<IBoundaryProofExtractor, RichGraphBoundaryExtractor>();
// Register composite extractor that uses all available extractors
services.TryAddSingleton<CompositeBoundaryExtractor>();
return services;
}
/// <summary>
/// Adds a custom boundary proof extractor.
/// </summary>
public static IServiceCollection AddBoundaryExtractor<TExtractor>(this IServiceCollection services)
where TExtractor : class, IBoundaryProofExtractor
{
services.AddSingleton<IBoundaryProofExtractor, TExtractor>();
return services;
}
}

View File

@@ -0,0 +1,119 @@
// -----------------------------------------------------------------------------
// CompositeBoundaryExtractor.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Composite extractor that aggregates results from multiple extractors.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Composite boundary extractor that selects the best result from multiple extractors.
/// Extractors are sorted by priority and the first successful extraction is used.
/// </summary>
public sealed class CompositeBoundaryExtractor : IBoundaryProofExtractor
{
private readonly IEnumerable<IBoundaryProofExtractor> _extractors;
private readonly ILogger<CompositeBoundaryExtractor> _logger;
public CompositeBoundaryExtractor(
IEnumerable<IBoundaryProofExtractor> extractors,
ILogger<CompositeBoundaryExtractor> logger)
{
_extractors = extractors ?? throw new ArgumentNullException(nameof(extractors));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public int Priority => int.MaxValue; // Composite has highest priority
/// <inheritdoc />
public bool CanHandle(BoundaryExtractionContext context) => true;
/// <inheritdoc />
public async Task<BoundaryProof?> ExtractAsync(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context,
CancellationToken cancellationToken = default)
{
var sortedExtractors = _extractors
.Where(e => e != this) // Avoid recursion
.Where(e => e.CanHandle(context))
.OrderByDescending(e => e.Priority)
.ToList();
if (sortedExtractors.Count == 0)
{
_logger.LogDebug("No extractors available for context {Source}", context.Source);
return null;
}
foreach (var extractor in sortedExtractors)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var result = await extractor.ExtractAsync(root, rootNode, context, cancellationToken);
if (result is not null)
{
_logger.LogDebug(
"Boundary extracted by {Extractor} with confidence {Confidence:F2}",
extractor.GetType().Name,
result.Confidence);
return result;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Extractor {Extractor} failed", extractor.GetType().Name);
// Continue to next extractor
}
}
return null;
}
/// <inheritdoc />
public BoundaryProof? Extract(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context)
{
var sortedExtractors = _extractors
.Where(e => e != this)
.Where(e => e.CanHandle(context))
.OrderByDescending(e => e.Priority)
.ToList();
foreach (var extractor in sortedExtractors)
{
try
{
var result = extractor.Extract(root, rootNode, context);
if (result is not null)
{
return result;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Extractor {Extractor} failed", extractor.GetType().Name);
}
}
return null;
}
}

View File

@@ -0,0 +1,49 @@
// -----------------------------------------------------------------------------
// IBoundaryProofExtractor.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Interface for extracting boundary proofs from various sources.
// -----------------------------------------------------------------------------
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Extracts boundary proof (exposure, auth, controls) from reachability data.
/// </summary>
public interface IBoundaryProofExtractor
{
/// <summary>
/// Extracts boundary proof for a RichGraph root/entrypoint.
/// </summary>
/// <param name="root">The RichGraph root representing the entrypoint.</param>
/// <param name="rootNode">Optional root node with additional metadata.</param>
/// <param name="context">Extraction context with environment hints.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Boundary proof if extractable; otherwise null.</returns>
Task<BoundaryProof?> ExtractAsync(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context,
CancellationToken cancellationToken = default);
/// <summary>
/// Synchronous extraction for contexts where async is not needed.
/// </summary>
BoundaryProof? Extract(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context);
/// <summary>
/// Gets the priority of this extractor (higher = preferred).
/// </summary>
int Priority { get; }
/// <summary>
/// Checks if this extractor can handle the given context.
/// </summary>
bool CanHandle(BoundaryExtractionContext context);
}

View File

@@ -0,0 +1,384 @@
// -----------------------------------------------------------------------------
// RichGraphBoundaryExtractor.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Extracts boundary proof from RichGraph roots and node annotations.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability.Gates;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Extracts boundary proof from RichGraph roots and node annotations.
/// This is the base extractor that infers exposure from static analysis data.
/// </summary>
public sealed class RichGraphBoundaryExtractor : IBoundaryProofExtractor
{
private readonly ILogger<RichGraphBoundaryExtractor> _logger;
private readonly TimeProvider _timeProvider;
public RichGraphBoundaryExtractor(
ILogger<RichGraphBoundaryExtractor> logger,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public int Priority => 100; // Base extractor, lowest priority
/// <inheritdoc />
public bool CanHandle(BoundaryExtractionContext context) => true; // Always handles as fallback
/// <inheritdoc />
public Task<BoundaryProof?> ExtractAsync(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Extract(root, rootNode, context));
}
/// <inheritdoc />
public BoundaryProof? Extract(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context)
{
ArgumentNullException.ThrowIfNull(root);
try
{
var surface = InferSurface(root, rootNode);
var exposure = InferExposure(root, rootNode, context);
var auth = InferAuth(context.DetectedGates, rootNode);
var controls = InferControls(context.DetectedGates);
var confidence = CalculateConfidence(surface, exposure, context);
return new BoundaryProof
{
Kind = InferBoundaryKind(surface),
Surface = surface,
Exposure = exposure,
Auth = auth,
Controls = controls.Count > 0 ? controls : null,
LastSeen = _timeProvider.GetUtcNow(),
Confidence = confidence,
Source = "static_analysis",
EvidenceRef = root.Id
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to extract boundary proof for root {RootId}", root.Id);
return null;
}
}
private BoundarySurface InferSurface(RichGraphRoot root, RichGraphNode? rootNode)
{
var (surfaceType, protocol) = InferSurfaceTypeAndProtocol(root, rootNode);
var port = InferPort(rootNode, protocol);
var path = InferPath(rootNode);
return new BoundarySurface
{
Type = surfaceType,
Protocol = protocol,
Port = port,
Path = path
};
}
private (string type, string? protocol) InferSurfaceTypeAndProtocol(RichGraphRoot root, RichGraphNode? rootNode)
{
var nodeKind = rootNode?.Kind?.ToLowerInvariant() ?? "";
var display = rootNode?.Display?.ToLowerInvariant() ?? "";
var phase = root.Phase?.ToLowerInvariant() ?? "runtime";
// HTTP/HTTPS detection
if (ContainsAny(nodeKind, display, "http", "rest", "api", "web", "controller", "endpoint"))
{
return ("api", "https");
}
// gRPC detection
if (ContainsAny(nodeKind, display, "grpc", "protobuf", "proto"))
{
return ("api", "grpc");
}
// GraphQL detection
if (ContainsAny(nodeKind, display, "graphql", "gql", "query", "mutation"))
{
return ("api", "https");
}
// WebSocket detection
if (ContainsAny(nodeKind, display, "websocket", "ws", "socket"))
{
return ("socket", "wss");
}
// CLI detection
if (ContainsAny(nodeKind, display, "cli", "command", "console", "main"))
{
return ("cli", null);
}
// Scheduled/background detection
if (ContainsAny(nodeKind, display, "scheduled", "cron", "timer", "background", "worker"))
{
return ("scheduled", null);
}
// Library detection
if (phase == "library" || ContainsAny(nodeKind, display, "library", "lib", "internal"))
{
return ("library", null);
}
// Default to API for runtime phase
return phase == "runtime" ? ("api", "https") : ("library", null);
}
private static int? InferPort(RichGraphNode? rootNode, string? protocol)
{
// Try to get port from node attributes
if (rootNode?.Attributes?.TryGetValue("port", out var portStr) == true &&
int.TryParse(portStr, out var port))
{
return port;
}
// Default ports by protocol
return protocol?.ToLowerInvariant() switch
{
"https" => 443,
"http" => 80,
"grpc" => 443,
"wss" => 443,
"ws" => 80,
_ => null
};
}
private static string? InferPath(RichGraphNode? rootNode)
{
// Try to get route from node attributes
if (rootNode?.Attributes?.TryGetValue("route", out var route) == true)
{
return route;
}
if (rootNode?.Attributes?.TryGetValue("path", out var path) == true)
{
return path;
}
return null;
}
private BoundaryExposure InferExposure(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context)
{
// Use context hints if available
var isInternetFacing = context.IsInternetFacing ?? InferInternetFacing(rootNode);
var level = InferExposureLevel(rootNode, isInternetFacing);
var zone = context.NetworkZone ?? InferNetworkZone(isInternetFacing, level);
return new BoundaryExposure
{
Level = level,
InternetFacing = isInternetFacing,
Zone = zone
};
}
private static bool InferInternetFacing(RichGraphNode? rootNode)
{
if (rootNode?.Attributes?.TryGetValue("internet_facing", out var value) == true)
{
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
// Assume public APIs are internet-facing unless specified otherwise
var kind = rootNode?.Kind?.ToLowerInvariant() ?? "";
return kind.Contains("public") || kind.Contains("external");
}
private static string InferExposureLevel(RichGraphNode? rootNode, bool isInternetFacing)
{
var kind = rootNode?.Kind?.ToLowerInvariant() ?? "";
if (kind.Contains("public") || isInternetFacing)
return "public";
if (kind.Contains("internal"))
return "internal";
if (kind.Contains("private") || kind.Contains("localhost"))
return "private";
// Default to internal for most services
return isInternetFacing ? "public" : "internal";
}
private static string InferNetworkZone(bool isInternetFacing, string level)
{
if (isInternetFacing || level == "public")
return "dmz";
if (level == "internal")
return "internal";
return "trusted";
}
private static BoundaryAuth? InferAuth(IReadOnlyList<DetectedGate>? gates, RichGraphNode? rootNode)
{
var authGates = gates?.Where(g =>
g.Type == GateType.AuthRequired || g.Type == GateType.AdminOnly).ToList();
if (authGates is not { Count: > 0 })
{
// Check node attributes for auth hints
if (rootNode?.Attributes?.TryGetValue("auth", out var authAttr) == true)
{
var required = !string.Equals(authAttr, "none", StringComparison.OrdinalIgnoreCase);
return new BoundaryAuth
{
Required = required,
Type = required ? authAttr : null
};
}
return null;
}
var hasAdminGate = authGates.Any(g => g.Type == GateType.AdminOnly);
var roles = hasAdminGate ? new[] { "admin" } : null;
return new BoundaryAuth
{
Required = true,
Type = InferAuthType(authGates),
Roles = roles
};
}
private static string? InferAuthType(IReadOnlyList<DetectedGate> authGates)
{
var details = authGates
.Select(g => g.Detail.ToLowerInvariant())
.ToList();
if (details.Any(d => d.Contains("jwt")))
return "jwt";
if (details.Any(d => d.Contains("oauth")))
return "oauth2";
if (details.Any(d => d.Contains("api_key") || d.Contains("apikey")))
return "api_key";
if (details.Any(d => d.Contains("basic")))
return "basic";
if (details.Any(d => d.Contains("session")))
return "session";
return "required";
}
private static IReadOnlyList<BoundaryControl> InferControls(IReadOnlyList<DetectedGate>? gates)
{
var controls = new List<BoundaryControl>();
if (gates is null)
return controls;
foreach (var gate in gates)
{
var control = gate.Type switch
{
GateType.FeatureFlag => new BoundaryControl
{
Type = "feature_flag",
Active = true,
Config = gate.Detail,
Effectiveness = "high"
},
GateType.NonDefaultConfig => new BoundaryControl
{
Type = "config_gate",
Active = true,
Config = gate.Detail,
Effectiveness = "medium"
},
_ => null
};
if (control is not null)
{
controls.Add(control);
}
}
return controls;
}
private static string InferBoundaryKind(BoundarySurface surface)
{
return surface.Type switch
{
"api" => "network",
"socket" => "network",
"cli" => "process",
"scheduled" => "process",
"library" => "library",
"file" => "file",
_ => "network"
};
}
private static double CalculateConfidence(
BoundarySurface surface,
BoundaryExposure exposure,
BoundaryExtractionContext context)
{
var baseConfidence = 0.6; // Base confidence for static analysis
// Increase confidence if we have context hints
if (context.IsInternetFacing.HasValue)
baseConfidence += 0.1;
if (!string.IsNullOrEmpty(context.NetworkZone))
baseConfidence += 0.1;
if (context.DetectedGates is { Count: > 0 })
baseConfidence += 0.1;
// Lower confidence for inferred values
if (string.IsNullOrEmpty(surface.Protocol))
baseConfidence -= 0.1;
return Math.Clamp(baseConfidence, 0.1, 0.95);
}
private static bool ContainsAny(string primary, string secondary, params string[] terms)
{
foreach (var term in terms)
{
if (primary.Contains(term, StringComparison.OrdinalIgnoreCase) ||
secondary.Contains(term, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,326 @@
// -----------------------------------------------------------------------------
// PathExplanationModels.cs
// Sprint: SPRINT_3620_0002_0001_path_explanation
// Description: Models for explained reachability paths with gate information.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Explanation;
/// <summary>
/// A fully explained path from entrypoint to vulnerable sink.
/// </summary>
public sealed record ExplainedPath
{
/// <summary>
/// Unique identifier for this path.
/// </summary>
[JsonPropertyName("path_id")]
public required string PathId { get; init; }
/// <summary>
/// Sink node identifier.
/// </summary>
[JsonPropertyName("sink_id")]
public required string SinkId { get; init; }
/// <summary>
/// Sink symbol name.
/// </summary>
[JsonPropertyName("sink_symbol")]
public required string SinkSymbol { get; init; }
/// <summary>
/// Sink category from taxonomy.
/// </summary>
[JsonPropertyName("sink_category")]
public required SinkCategory SinkCategory { get; init; }
/// <summary>
/// Entrypoint node identifier.
/// </summary>
[JsonPropertyName("entrypoint_id")]
public required string EntrypointId { get; init; }
/// <summary>
/// Entrypoint symbol name.
/// </summary>
[JsonPropertyName("entrypoint_symbol")]
public required string EntrypointSymbol { get; init; }
/// <summary>
/// Entrypoint type from root.
/// </summary>
[JsonPropertyName("entrypoint_type")]
public required EntrypointType EntrypointType { get; init; }
/// <summary>
/// Number of hops in the path.
/// </summary>
[JsonPropertyName("path_length")]
public required int PathLength { get; init; }
/// <summary>
/// Ordered list of hops from entrypoint to sink.
/// </summary>
[JsonPropertyName("hops")]
public required IReadOnlyList<ExplainedPathHop> Hops { get; init; }
/// <summary>
/// Gates detected along the path.
/// </summary>
[JsonPropertyName("gates")]
public required IReadOnlyList<DetectedGate> Gates { get; init; }
/// <summary>
/// Combined gate multiplier in basis points (0-10000).
/// </summary>
[JsonPropertyName("gate_multiplier_bps")]
public required int GateMultiplierBps { get; init; }
/// <summary>
/// CVE or vulnerability ID this path leads to.
/// </summary>
[JsonPropertyName("vulnerability_id")]
public string? VulnerabilityId { get; init; }
/// <summary>
/// PURL of the affected component.
/// </summary>
[JsonPropertyName("affected_purl")]
public string? AffectedPurl { get; init; }
}
/// <summary>
/// A single hop in an explained path.
/// </summary>
public sealed record ExplainedPathHop
{
/// <summary>
/// Node identifier.
/// </summary>
[JsonPropertyName("node_id")]
public required string NodeId { get; init; }
/// <summary>
/// Symbol name (method/function).
/// </summary>
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
/// <summary>
/// Source file path (if available).
/// </summary>
[JsonPropertyName("file")]
public string? File { get; init; }
/// <summary>
/// Line number in source file (if available).
/// </summary>
[JsonPropertyName("line")]
public int? Line { get; init; }
/// <summary>
/// Package name.
/// </summary>
[JsonPropertyName("package")]
public required string Package { get; init; }
/// <summary>
/// Programming language.
/// </summary>
[JsonPropertyName("language")]
public string? Language { get; init; }
/// <summary>
/// Call site information (if available).
/// </summary>
[JsonPropertyName("call_site")]
public string? CallSite { get; init; }
/// <summary>
/// Gates at this hop (edge-level).
/// </summary>
[JsonPropertyName("gates")]
public IReadOnlyList<DetectedGate>? Gates { get; init; }
/// <summary>
/// Distance from entrypoint (0 = entrypoint).
/// </summary>
[JsonPropertyName("depth")]
public int Depth { get; init; }
/// <summary>
/// Whether this is the entrypoint.
/// </summary>
[JsonPropertyName("is_entrypoint")]
public bool IsEntrypoint { get; init; }
/// <summary>
/// Whether this is the sink.
/// </summary>
[JsonPropertyName("is_sink")]
public bool IsSink { get; init; }
}
/// <summary>
/// Type of entrypoint.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<EntrypointType>))]
public enum EntrypointType
{
/// <summary>HTTP/REST endpoint.</summary>
HttpEndpoint,
/// <summary>gRPC method.</summary>
GrpcMethod,
/// <summary>GraphQL resolver.</summary>
GraphQlResolver,
/// <summary>CLI command handler.</summary>
CliCommand,
/// <summary>Message queue handler.</summary>
MessageHandler,
/// <summary>Scheduled job/cron handler.</summary>
ScheduledJob,
/// <summary>Event handler.</summary>
EventHandler,
/// <summary>WebSocket handler.</summary>
WebSocketHandler,
/// <summary>Public API method.</summary>
PublicApi,
/// <summary>Unknown entrypoint type.</summary>
Unknown
}
/// <summary>
/// Category of vulnerable sink.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<SinkCategory>))]
public enum SinkCategory
{
/// <summary>SQL query execution.</summary>
SqlRaw,
/// <summary>Command execution.</summary>
CommandExec,
/// <summary>File system access.</summary>
FileAccess,
/// <summary>Network/HTTP client.</summary>
NetworkClient,
/// <summary>Deserialization.</summary>
Deserialization,
/// <summary>Path traversal sensitive.</summary>
PathTraversal,
/// <summary>Cryptography weakness.</summary>
CryptoWeakness,
/// <summary>SSRF sensitive.</summary>
Ssrf,
/// <summary>XXE sensitive.</summary>
Xxe,
/// <summary>LDAP injection.</summary>
LdapInjection,
/// <summary>XPath injection.</summary>
XPathInjection,
/// <summary>Log injection.</summary>
LogInjection,
/// <summary>Template injection.</summary>
TemplateInjection,
/// <summary>Other sink category.</summary>
Other
}
/// <summary>
/// Path explanation query parameters.
/// </summary>
public sealed record PathExplanationQuery
{
/// <summary>
/// Filter by vulnerability ID.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Filter by sink ID.
/// </summary>
public string? SinkId { get; init; }
/// <summary>
/// Filter by entrypoint ID.
/// </summary>
public string? EntrypointId { get; init; }
/// <summary>
/// Maximum path length to return.
/// </summary>
public int? MaxPathLength { get; init; }
/// <summary>
/// Include only paths with gates.
/// </summary>
public bool? HasGates { get; init; }
/// <summary>
/// Maximum number of paths to return.
/// </summary>
public int MaxPaths { get; init; } = 10;
}
/// <summary>
/// Result of path explanation.
/// </summary>
public sealed record PathExplanationResult
{
/// <summary>
/// Explained paths matching the query.
/// </summary>
[JsonPropertyName("paths")]
public required IReadOnlyList<ExplainedPath> Paths { get; init; }
/// <summary>
/// Total count of paths (before limiting).
/// </summary>
[JsonPropertyName("total_count")]
public required int TotalCount { get; init; }
/// <summary>
/// Whether more paths are available.
/// </summary>
[JsonPropertyName("has_more")]
public bool HasMore { get; init; }
/// <summary>
/// Graph hash for provenance.
/// </summary>
[JsonPropertyName("graph_hash")]
public string? GraphHash { get; init; }
/// <summary>
/// When the explanation was generated.
/// </summary>
[JsonPropertyName("generated_at")]
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,429 @@
// -----------------------------------------------------------------------------
// PathExplanationService.cs
// Sprint: SPRINT_3620_0002_0001_path_explanation
// Description: Service for reconstructing and explaining reachability paths.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Explanation;
/// <summary>
/// Interface for path explanation service.
/// </summary>
public interface IPathExplanationService
{
/// <summary>
/// Explains paths from a RichGraph to a specific sink or vulnerability.
/// </summary>
Task<PathExplanationResult> ExplainAsync(
RichGraph graph,
PathExplanationQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Explains a single path by its ID.
/// </summary>
Task<ExplainedPath?> ExplainPathAsync(
RichGraph graph,
string pathId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of <see cref="IPathExplanationService"/>.
/// Reconstructs paths from RichGraph and provides user-friendly explanations.
/// </summary>
public sealed class PathExplanationService : IPathExplanationService
{
private readonly ILogger<PathExplanationService> _logger;
private readonly TimeProvider _timeProvider;
public PathExplanationService(
ILogger<PathExplanationService> logger,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
public Task<PathExplanationResult> ExplainAsync(
RichGraph graph,
PathExplanationQuery query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
query ??= new PathExplanationQuery();
var allPaths = new List<ExplainedPath>();
// Build node lookup
var nodeLookup = graph.Nodes.ToDictionary(n => n.Id);
var edgeLookup = BuildEdgeLookup(graph);
// Find paths from each root to sinks
foreach (var root in graph.Roots)
{
cancellationToken.ThrowIfCancellationRequested();
var rootNode = nodeLookup.GetValueOrDefault(root.Id);
if (rootNode is null) continue;
var sinkNodes = graph.Nodes.Where(n => IsSink(n)).ToList();
foreach (var sink in sinkNodes)
{
// Apply query filters
if (query.SinkId is not null && sink.Id != query.SinkId)
continue;
var paths = FindPaths(
rootNode, sink, nodeLookup, edgeLookup,
query.MaxPathLength ?? 20);
foreach (var path in paths)
{
var explained = BuildExplainedPath(
root, rootNode, sink, path, edgeLookup);
// Apply gate filter
if (query.HasGates == true && explained.Gates.Count == 0)
continue;
allPaths.Add(explained);
}
}
}
// Sort by path length, then by gate multiplier (higher = more protected)
var sortedPaths = allPaths
.OrderBy(p => p.PathLength)
.ThenByDescending(p => p.GateMultiplierBps)
.ToList();
var totalCount = sortedPaths.Count;
var limitedPaths = sortedPaths.Take(query.MaxPaths).ToList();
var result = new PathExplanationResult
{
Paths = limitedPaths,
TotalCount = totalCount,
HasMore = totalCount > query.MaxPaths,
GraphHash = null, // RichGraph does not have a Meta property; hash is computed at serialization
GeneratedAt = _timeProvider.GetUtcNow()
};
return Task.FromResult(result);
}
/// <inheritdoc/>
public Task<ExplainedPath?> ExplainPathAsync(
RichGraph graph,
string pathId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
// Path ID format: {rootId}:{sinkId}:{pathIndex}
var parts = pathId?.Split(':');
if (parts is not { Length: >= 2 })
{
return Task.FromResult<ExplainedPath?>(null);
}
var query = new PathExplanationQuery
{
EntrypointId = parts[0],
SinkId = parts[1],
MaxPaths = 100
};
var resultTask = ExplainAsync(graph, query, cancellationToken);
return resultTask.ContinueWith(t =>
{
if (t.Result.Paths.Count == 0)
return null;
// If path index specified, return that specific one
if (parts.Length >= 3 && int.TryParse(parts[2], out var idx) && idx < t.Result.Paths.Count)
{
return t.Result.Paths[idx];
}
return t.Result.Paths[0];
}, cancellationToken);
}
private static Dictionary<string, List<RichGraphEdge>> BuildEdgeLookup(RichGraph graph)
{
var lookup = new Dictionary<string, List<RichGraphEdge>>();
foreach (var edge in graph.Edges)
{
if (!lookup.TryGetValue(edge.From, out var edges))
{
edges = new List<RichGraphEdge>();
lookup[edge.From] = edges;
}
edges.Add(edge);
}
return lookup;
}
private static bool IsSink(RichGraphNode node)
{
// Check if node has sink-like characteristics
return node.Kind?.Contains("sink", StringComparison.OrdinalIgnoreCase) == true
|| node.Attributes?.ContainsKey("is_sink") == true;
}
private List<List<RichGraphNode>> FindPaths(
RichGraphNode start,
RichGraphNode end,
Dictionary<string, RichGraphNode> nodeLookup,
Dictionary<string, List<RichGraphEdge>> edgeLookup,
int maxLength)
{
var paths = new List<List<RichGraphNode>>();
var currentPath = new List<RichGraphNode> { start };
var visited = new HashSet<string> { start.Id };
FindPathsDfs(start, end, currentPath, visited, paths, nodeLookup, edgeLookup, maxLength);
return paths;
}
private void FindPathsDfs(
RichGraphNode current,
RichGraphNode target,
List<RichGraphNode> currentPath,
HashSet<string> visited,
List<List<RichGraphNode>> foundPaths,
Dictionary<string, RichGraphNode> nodeLookup,
Dictionary<string, List<RichGraphEdge>> edgeLookup,
int maxLength)
{
if (currentPath.Count > maxLength)
return;
if (current.Id == target.Id)
{
foundPaths.Add(new List<RichGraphNode>(currentPath));
return;
}
if (!edgeLookup.TryGetValue(current.Id, out var outEdges))
return;
foreach (var edge in outEdges)
{
if (visited.Contains(edge.To))
continue;
if (!nodeLookup.TryGetValue(edge.To, out var nextNode))
continue;
visited.Add(edge.To);
currentPath.Add(nextNode);
FindPathsDfs(nextNode, target, currentPath, visited, foundPaths,
nodeLookup, edgeLookup, maxLength);
currentPath.RemoveAt(currentPath.Count - 1);
visited.Remove(edge.To);
}
}
private ExplainedPath BuildExplainedPath(
RichGraphRoot root,
RichGraphNode rootNode,
RichGraphNode sinkNode,
List<RichGraphNode> path,
Dictionary<string, List<RichGraphEdge>> edgeLookup)
{
var hops = new List<ExplainedPathHop>();
var allGates = new List<DetectedGate>();
for (var i = 0; i < path.Count; i++)
{
var node = path[i];
var isFirst = i == 0;
var isLast = i == path.Count - 1;
// Get edge gates
IReadOnlyList<DetectedGate>? edgeGates = null;
if (i < path.Count - 1)
{
var edge = GetEdge(path[i].Id, path[i + 1].Id, edgeLookup);
if (edge?.Gates is not null)
{
edgeGates = edge.Gates;
allGates.AddRange(edge.Gates);
}
}
hops.Add(new ExplainedPathHop
{
NodeId = node.Id,
Symbol = node.Display ?? node.SymbolId ?? node.Id,
File = GetNodeFile(node),
Line = GetNodeLine(node),
Package = GetNodePackage(node),
Language = node.Lang,
CallSite = GetCallSite(node),
Gates = edgeGates,
Depth = i,
IsEntrypoint = isFirst,
IsSink = isLast
});
}
// Calculate combined gate multiplier
var multiplierBps = CalculateGateMultiplier(allGates);
return new ExplainedPath
{
PathId = $"{rootNode.Id}:{sinkNode.Id}:{0}",
SinkId = sinkNode.Id,
SinkSymbol = sinkNode.Display ?? sinkNode.SymbolId ?? sinkNode.Id,
SinkCategory = InferSinkCategory(sinkNode),
EntrypointId = rootNode.Id,
EntrypointSymbol = rootNode.Display ?? rootNode.SymbolId ?? rootNode.Id,
EntrypointType = InferEntrypointType(root, rootNode),
PathLength = path.Count,
Hops = hops,
Gates = allGates,
GateMultiplierBps = multiplierBps
};
}
private static RichGraphEdge? GetEdge(string from, string to, Dictionary<string, List<RichGraphEdge>> edgeLookup)
{
if (!edgeLookup.TryGetValue(from, out var edges))
return null;
return edges.FirstOrDefault(e => e.To == to);
}
private static string? GetNodeFile(RichGraphNode node)
{
if (node.Attributes?.TryGetValue("file", out var file) == true)
return file;
if (node.Attributes?.TryGetValue("source_file", out file) == true)
return file;
return null;
}
private static int? GetNodeLine(RichGraphNode node)
{
if (node.Attributes?.TryGetValue("line", out var line) == true &&
int.TryParse(line, out var lineNum))
return lineNum;
return null;
}
private static string GetNodePackage(RichGraphNode node)
{
if (node.Purl is not null)
{
// Extract package name from PURL
var purl = node.Purl;
var nameStart = purl.LastIndexOf('/') + 1;
var nameEnd = purl.IndexOf('@', nameStart);
if (nameEnd < 0) nameEnd = purl.Length;
return purl.Substring(nameStart, nameEnd - nameStart);
}
if (node.Attributes?.TryGetValue("package", out var pkg) == true)
return pkg;
return node.SymbolId?.Split('.').FirstOrDefault() ?? "unknown";
}
private static string? GetCallSite(RichGraphNode node)
{
if (node.Attributes?.TryGetValue("call_site", out var site) == true)
return site;
return null;
}
private static SinkCategory InferSinkCategory(RichGraphNode node)
{
var kind = node.Kind?.ToLowerInvariant() ?? "";
var symbol = (node.SymbolId ?? "").ToLowerInvariant();
if (kind.Contains("sql") || symbol.Contains("query") || symbol.Contains("execute"))
return SinkCategory.SqlRaw;
if (kind.Contains("exec") || symbol.Contains("command") || symbol.Contains("process"))
return SinkCategory.CommandExec;
if (kind.Contains("file") || symbol.Contains("write") || symbol.Contains("read"))
return SinkCategory.FileAccess;
if (kind.Contains("http") || symbol.Contains("request"))
return SinkCategory.NetworkClient;
if (kind.Contains("deserialize") || symbol.Contains("deserialize"))
return SinkCategory.Deserialization;
if (kind.Contains("path"))
return SinkCategory.PathTraversal;
return SinkCategory.Other;
}
private static EntrypointType InferEntrypointType(RichGraphRoot root, RichGraphNode node)
{
var phase = root.Phase?.ToLowerInvariant() ?? "";
var kind = node.Kind?.ToLowerInvariant() ?? "";
var display = (node.Display ?? "").ToLowerInvariant();
if (kind.Contains("http") || display.Contains("get ") || display.Contains("post "))
return EntrypointType.HttpEndpoint;
if (kind.Contains("grpc"))
return EntrypointType.GrpcMethod;
if (kind.Contains("graphql"))
return EntrypointType.GraphQlResolver;
if (kind.Contains("cli") || kind.Contains("command"))
return EntrypointType.CliCommand;
if (kind.Contains("message") || kind.Contains("handler"))
return EntrypointType.MessageHandler;
if (kind.Contains("scheduled") || kind.Contains("cron"))
return EntrypointType.ScheduledJob;
if (kind.Contains("websocket"))
return EntrypointType.WebSocketHandler;
if (phase == "library" || kind.Contains("public"))
return EntrypointType.PublicApi;
return EntrypointType.Unknown;
}
private static int CalculateGateMultiplier(List<DetectedGate> gates)
{
if (gates.Count == 0)
return 10000; // 100% (no reduction)
// Apply gates multiplicatively
var multiplier = 10000.0; // Start at 100% in basis points
foreach (var gate in gates.DistinctBy(g => g.Type))
{
var gateMultiplier = gate.Type switch
{
GateType.AuthRequired => 3000, // 30%
GateType.FeatureFlag => 5000, // 50%
GateType.AdminOnly => 2000, // 20%
GateType.NonDefaultConfig => 7000, // 70%
_ => 10000
};
multiplier = multiplier * gateMultiplier / 10000;
}
return (int)Math.Round(multiplier);
}
}

View File

@@ -0,0 +1,286 @@
// -----------------------------------------------------------------------------
// PathRenderer.cs
// Sprint: SPRINT_3620_0002_0001_path_explanation
// Description: Renders explained paths in various output formats.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Explanation;
/// <summary>
/// Output format for path rendering.
/// </summary>
public enum PathOutputFormat
{
/// <summary>Plain text format.</summary>
Text,
/// <summary>Markdown format.</summary>
Markdown,
/// <summary>JSON format.</summary>
Json
}
/// <summary>
/// Interface for path rendering.
/// </summary>
public interface IPathRenderer
{
/// <summary>
/// Renders an explained path in the specified format.
/// </summary>
string Render(ExplainedPath path, PathOutputFormat format);
/// <summary>
/// Renders multiple explained paths in the specified format.
/// </summary>
string RenderMany(IReadOnlyList<ExplainedPath> paths, PathOutputFormat format);
/// <summary>
/// Renders a path explanation result in the specified format.
/// </summary>
string RenderResult(PathExplanationResult result, PathOutputFormat format);
}
/// <summary>
/// Default implementation of <see cref="IPathRenderer"/>.
/// </summary>
public sealed class PathRenderer : IPathRenderer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
/// <inheritdoc/>
public string Render(ExplainedPath path, PathOutputFormat format)
{
return format switch
{
PathOutputFormat.Text => RenderText(path),
PathOutputFormat.Markdown => RenderMarkdown(path),
PathOutputFormat.Json => RenderJson(path),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
/// <inheritdoc/>
public string RenderMany(IReadOnlyList<ExplainedPath> paths, PathOutputFormat format)
{
return format switch
{
PathOutputFormat.Text => RenderManyText(paths),
PathOutputFormat.Markdown => RenderManyMarkdown(paths),
PathOutputFormat.Json => RenderManyJson(paths),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
/// <inheritdoc/>
public string RenderResult(PathExplanationResult result, PathOutputFormat format)
{
return format switch
{
PathOutputFormat.Text => RenderResultText(result),
PathOutputFormat.Markdown => RenderResultMarkdown(result),
PathOutputFormat.Json => JsonSerializer.Serialize(result, JsonOptions),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
#region Text Rendering
private static string RenderText(ExplainedPath path)
{
var sb = new StringBuilder();
// Header
sb.AppendLine($"{path.EntrypointType}: {path.EntrypointSymbol}");
// Hops
foreach (var hop in path.Hops)
{
var prefix = hop.IsEntrypoint ? " " : " → ";
var location = hop.File is not null && hop.Line.HasValue
? $" ({hop.File}:{hop.Line})"
: "";
var sinkMarker = hop.IsSink ? $" [SINK: {path.SinkCategory}]" : "";
sb.AppendLine($"{prefix}{hop.Symbol}{location}{sinkMarker}");
}
// Gates summary
if (path.Gates.Count > 0)
{
sb.AppendLine();
var gatesSummary = string.Join(", ", path.Gates.Select(FormatGateText));
sb.AppendLine($"Gates: {gatesSummary}");
var percentage = path.GateMultiplierBps / 100.0;
sb.AppendLine($"Final multiplier: {percentage:F0}%");
}
return sb.ToString();
}
private static string RenderManyText(IReadOnlyList<ExplainedPath> paths)
{
var sb = new StringBuilder();
sb.AppendLine($"Found {paths.Count} path(s):");
sb.AppendLine(new string('=', 60));
for (var i = 0; i < paths.Count; i++)
{
if (i > 0) sb.AppendLine(new string('-', 60));
sb.AppendLine($"Path {i + 1}:");
sb.Append(RenderText(paths[i]));
}
return sb.ToString();
}
private static string RenderResultText(PathExplanationResult result)
{
var sb = new StringBuilder();
sb.AppendLine($"Path Explanation Result");
sb.AppendLine($"Total paths: {result.TotalCount}");
sb.AppendLine($"Showing: {result.Paths.Count}");
if (result.GraphHash is not null)
sb.AppendLine($"Graph: {result.GraphHash}");
sb.AppendLine($"Generated: {result.GeneratedAt:u}");
sb.AppendLine();
sb.Append(RenderManyText(result.Paths.ToList()));
return sb.ToString();
}
private static string FormatGateText(DetectedGate gate)
{
var multiplier = gate.Type switch
{
GateType.AuthRequired => "30%",
GateType.FeatureFlag => "50%",
GateType.AdminOnly => "20%",
GateType.NonDefaultConfig => "70%",
_ => "100%"
};
return $"{gate.Detail} ({gate.Type.ToString().ToLowerInvariant()}, {multiplier})";
}
#endregion
#region Markdown Rendering
private static string RenderMarkdown(ExplainedPath path)
{
var sb = new StringBuilder();
// Header
sb.AppendLine($"### {path.EntrypointType}: `{path.EntrypointSymbol}`");
sb.AppendLine();
// Path as a code block
sb.AppendLine("```");
foreach (var hop in path.Hops)
{
var arrow = hop.IsEntrypoint ? "" : "→ ";
var location = hop.File is not null && hop.Line.HasValue
? $" ({hop.File}:{hop.Line})"
: "";
var sinkMarker = hop.IsSink ? $" [SINK: {path.SinkCategory}]" : "";
sb.AppendLine($"{arrow}{hop.Symbol}{location}{sinkMarker}");
}
sb.AppendLine("```");
sb.AppendLine();
// Gates table
if (path.Gates.Count > 0)
{
sb.AppendLine("**Gates:**");
sb.AppendLine();
sb.AppendLine("| Type | Detail | Multiplier |");
sb.AppendLine("|------|--------|------------|");
foreach (var gate in path.Gates)
{
var multiplier = gate.Type switch
{
GateType.AuthRequired => "30%",
GateType.FeatureFlag => "50%",
GateType.AdminOnly => "20%",
GateType.NonDefaultConfig => "70%",
_ => "100%"
};
sb.AppendLine($"| {gate.Type} | {gate.Detail} | {multiplier} |");
}
sb.AppendLine();
var percentage = path.GateMultiplierBps / 100.0;
sb.AppendLine($"**Final multiplier:** {percentage:F0}%");
}
return sb.ToString();
}
private static string RenderManyMarkdown(IReadOnlyList<ExplainedPath> paths)
{
var sb = new StringBuilder();
sb.AppendLine($"## Reachability Paths ({paths.Count} found)");
sb.AppendLine();
for (var i = 0; i < paths.Count; i++)
{
sb.AppendLine($"---");
sb.AppendLine($"#### Path {i + 1}");
sb.AppendLine();
sb.Append(RenderMarkdown(paths[i]));
sb.AppendLine();
}
return sb.ToString();
}
private static string RenderResultMarkdown(PathExplanationResult result)
{
var sb = new StringBuilder();
sb.AppendLine("# Path Explanation Result");
sb.AppendLine();
sb.AppendLine($"- **Total paths:** {result.TotalCount}");
sb.AppendLine($"- **Showing:** {result.Paths.Count}");
if (result.HasMore)
sb.AppendLine($"- **More available:** Yes");
if (result.GraphHash is not null)
sb.AppendLine($"- **Graph hash:** `{result.GraphHash}`");
sb.AppendLine($"- **Generated:** {result.GeneratedAt:u}");
sb.AppendLine();
sb.Append(RenderManyMarkdown(result.Paths.ToList()));
return sb.ToString();
}
#endregion
#region JSON Rendering
private static string RenderJson(ExplainedPath path)
{
return JsonSerializer.Serialize(path, JsonOptions);
}
private static string RenderManyJson(IReadOnlyList<ExplainedPath> paths)
{
return JsonSerializer.Serialize(new { paths }, JsonOptions);
}
#endregion
}

View File

@@ -7,6 +7,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />

View File

@@ -0,0 +1,175 @@
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Builds path witnesses from reachability analysis results.
/// </summary>
public interface IPathWitnessBuilder
{
/// <summary>
/// Creates a path witness for a reachable vulnerability.
/// </summary>
/// <param name="request">The witness creation request containing all necessary context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A signed path witness or null if the path is not reachable.</returns>
Task<PathWitness?> BuildAsync(PathWitnessRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Creates multiple path witnesses for all reachable paths to a vulnerability.
/// </summary>
/// <param name="request">The batch witness request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>All generated witnesses.</returns>
IAsyncEnumerable<PathWitness> BuildAllAsync(BatchWitnessRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to build a single path witness.
/// </summary>
public sealed record PathWitnessRequest
{
/// <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>
/// Entrypoint symbol ID.
/// </summary>
public required string EntrypointSymbolId { get; init; }
/// <summary>
/// Entrypoint kind (http, grpc, cli, etc.).
/// </summary>
public required string EntrypointKind { get; init; }
/// <summary>
/// Human-readable entrypoint name.
/// </summary>
public required string EntrypointName { get; init; }
/// <summary>
/// Sink symbol ID.
/// </summary>
public required string SinkSymbolId { get; init; }
/// <summary>
/// Sink taxonomy type.
/// </summary>
public required string SinkType { get; init; }
/// <summary>
/// The call graph to use for path finding.
/// </summary>
public required RichGraph CallGraph { get; init; }
/// <summary>
/// BLAKE3 digest of the call graph.
/// </summary>
public required string CallgraphDigest { get; init; }
/// <summary>
/// Optional attack surface digest.
/// </summary>
public string? SurfaceDigest { get; init; }
/// <summary>
/// Optional analysis config digest.
/// </summary>
public string? AnalysisConfigDigest { get; init; }
/// <summary>
/// Optional build ID.
/// </summary>
public string? BuildId { get; init; }
}
/// <summary>
/// Request to build witnesses for all paths to a vulnerability.
/// </summary>
public sealed record BatchWitnessRequest
{
/// <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.
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Vulnerability source.
/// </summary>
public required string VulnSource { get; init; }
/// <summary>
/// Affected version range.
/// </summary>
public required string AffectedRange { get; init; }
/// <summary>
/// Sink symbol ID to find paths to.
/// </summary>
public required string SinkSymbolId { get; init; }
/// <summary>
/// Sink taxonomy type.
/// </summary>
public required string SinkType { get; init; }
/// <summary>
/// The call graph to use for path finding.
/// </summary>
public required RichGraph CallGraph { get; init; }
/// <summary>
/// BLAKE3 digest of the call graph.
/// </summary>
public required string CallgraphDigest { get; init; }
/// <summary>
/// Maximum number of witnesses to generate.
/// </summary>
public int MaxWitnesses { get; init; } = 10;
/// <summary>
/// Optional attack surface digest.
/// </summary>
public string? SurfaceDigest { get; init; }
/// <summary>
/// Optional analysis config digest.
/// </summary>
public string? AnalysisConfigDigest { get; init; }
/// <summary>
/// Optional build ID.
/// </summary>
public string? BuildId { get; init; }
}

View File

@@ -0,0 +1,256 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// A DSSE-signable path witness documenting the call path from entrypoint to vulnerable sink.
/// Conforms to stellaops.witness.v1 schema.
/// </summary>
public sealed record PathWitness
{
/// <summary>
/// Schema version identifier.
/// </summary>
[JsonPropertyName("witness_schema")]
public string WitnessSchema { get; init; } = Witnesses.WitnessSchema.Version;
/// <summary>
/// Content-addressed witness ID (e.g., "wit: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 entrypoint from which the path originates.
/// </summary>
[JsonPropertyName("entrypoint")]
public required WitnessEntrypoint Entrypoint { get; init; }
/// <summary>
/// The call path from entrypoint to sink, ordered from caller to callee.
/// </summary>
[JsonPropertyName("path")]
public required IReadOnlyList<PathStep> Path { get; init; }
/// <summary>
/// The vulnerable sink reached at the end of the path.
/// </summary>
[JsonPropertyName("sink")]
public required WitnessSink Sink { get; init; }
/// <summary>
/// Detected gates (guards, authentication, validation) along the path.
/// </summary>
[JsonPropertyName("gates")]
public IReadOnlyList<DetectedGate>? Gates { get; init; }
/// <summary>
/// Evidence digests and build context for reproducibility.
/// </summary>
[JsonPropertyName("evidence")]
public required WitnessEvidence Evidence { get; init; }
/// <summary>
/// When this witness was generated (UTC ISO-8601).
/// </summary>
[JsonPropertyName("observed_at")]
public required DateTimeOffset ObservedAt { get; init; }
}
/// <summary>
/// Artifact context for a witness.
/// </summary>
public sealed record WitnessArtifact
{
/// <summary>
/// SHA-256 digest of the SBOM.
/// </summary>
[JsonPropertyName("sbom_digest")]
public required string SbomDigest { get; init; }
/// <summary>
/// Package URL of the vulnerable component.
/// </summary>
[JsonPropertyName("component_purl")]
public required string ComponentPurl { get; init; }
}
/// <summary>
/// Vulnerability information for a witness.
/// </summary>
public sealed record WitnessVuln
{
/// <summary>
/// Vulnerability identifier (e.g., "CVE-2024-12345").
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Vulnerability source (e.g., "NVD", "OSV", "GHSA").
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// Affected version range expression.
/// </summary>
[JsonPropertyName("affected_range")]
public required string AffectedRange { get; init; }
}
/// <summary>
/// Entrypoint that starts the reachability path.
/// </summary>
public sealed record WitnessEntrypoint
{
/// <summary>
/// Kind of entrypoint (http, grpc, cli, job, event).
/// </summary>
[JsonPropertyName("kind")]
public required string Kind { get; init; }
/// <summary>
/// Human-readable name (e.g., "GET /api/users/{id}").
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Canonical symbol ID for the entrypoint.
/// </summary>
[JsonPropertyName("symbol_id")]
public required string SymbolId { get; init; }
}
/// <summary>
/// A single step in the call path from entrypoint to sink.
/// </summary>
public sealed record PathStep
{
/// <summary>
/// Human-readable symbol name.
/// </summary>
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
/// <summary>
/// Canonical symbol ID.
/// </summary>
[JsonPropertyName("symbol_id")]
public required string SymbolId { get; init; }
/// <summary>
/// Source file path (null for external/binary symbols).
/// </summary>
[JsonPropertyName("file")]
public string? File { get; init; }
/// <summary>
/// Line number in source file (1-based).
/// </summary>
[JsonPropertyName("line")]
public int? Line { get; init; }
/// <summary>
/// Column number in source file (1-based).
/// </summary>
[JsonPropertyName("column")]
public int? Column { get; init; }
}
/// <summary>
/// The vulnerable sink at the end of the reachability path.
/// </summary>
public sealed record WitnessSink
{
/// <summary>
/// Human-readable symbol name.
/// </summary>
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
/// <summary>
/// Canonical symbol ID.
/// </summary>
[JsonPropertyName("symbol_id")]
public required string SymbolId { get; init; }
/// <summary>
/// Sink taxonomy type (e.g., "deserialization", "sql_injection", "path_traversal").
/// </summary>
[JsonPropertyName("sink_type")]
public required string SinkType { get; init; }
}
/// <summary>
/// A detected gate (guard/mitigating control) along the path.
/// </summary>
public sealed record DetectedGate
{
/// <summary>
/// Gate type (authRequired, inputValidation, rateLimited, etc.).
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Symbol that implements the gate.
/// </summary>
[JsonPropertyName("guard_symbol")]
public required string GuardSymbol { get; init; }
/// <summary>
/// Confidence level (0.0 - 1.0).
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
/// <summary>
/// Human-readable detail about the gate.
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; init; }
}
/// <summary>
/// Evidence digests for reproducibility and audit trail.
/// </summary>
public sealed record WitnessEvidence
{
/// <summary>
/// BLAKE3 digest of the call graph used.
/// </summary>
[JsonPropertyName("callgraph_digest")]
public required string CallgraphDigest { get; init; }
/// <summary>
/// SHA-256 digest of the attack surface manifest.
/// </summary>
[JsonPropertyName("surface_digest")]
public string? SurfaceDigest { get; init; }
/// <summary>
/// SHA-256 digest of the analysis configuration.
/// </summary>
[JsonPropertyName("analysis_config_digest")]
public string? AnalysisConfigDigest { get; init; }
/// <summary>
/// Build identifier for the analyzed artifact.
/// </summary>
[JsonPropertyName("build_id")]
public string? BuildId { get; init; }
}

View File

@@ -0,0 +1,378 @@
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Builds path witnesses from reachability analysis results.
/// </summary>
public sealed class PathWitnessBuilder : IPathWitnessBuilder
{
private readonly ICryptoHash _cryptoHash;
private readonly CompositeGateDetector? _gateDetector;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
/// <summary>
/// Creates a new PathWitnessBuilder.
/// </summary>
/// <param name="cryptoHash">Crypto hash service for witness ID generation.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <param name="gateDetector">Optional gate detector for identifying guards along paths.</param>
public PathWitnessBuilder(
ICryptoHash cryptoHash,
TimeProvider timeProvider,
CompositeGateDetector? gateDetector = null)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_gateDetector = gateDetector;
}
/// <inheritdoc />
public async Task<PathWitness?> BuildAsync(PathWitnessRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Find path from entrypoint to sink using BFS
var path = FindPath(request.CallGraph, request.EntrypointSymbolId, request.SinkSymbolId);
if (path is null || path.Count == 0)
{
return null; // No path found
}
// Infer language from the call graph nodes
var language = request.CallGraph.Nodes?.FirstOrDefault()?.Lang ?? "unknown";
// Detect gates along the path
var gates = _gateDetector is not null
? await DetectGatesAsync(request.CallGraph, path, language, cancellationToken).ConfigureAwait(false)
: null;
// Get sink node info
var sinkNode = request.CallGraph.Nodes?.FirstOrDefault(n => n.SymbolId == request.SinkSymbolId);
var sinkSymbol = sinkNode?.Display ?? sinkNode?.Symbol?.Demangled ?? request.SinkSymbolId;
// Build the witness
var witness = new PathWitness
{
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
},
Entrypoint = new WitnessEntrypoint
{
Kind = request.EntrypointKind,
Name = request.EntrypointName,
SymbolId = request.EntrypointSymbolId
},
Path = path,
Sink = new WitnessSink
{
Symbol = sinkSymbol,
SymbolId = request.SinkSymbolId,
SinkType = request.SinkType
},
Gates = gates,
Evidence = new WitnessEvidence
{
CallgraphDigest = request.CallgraphDigest,
SurfaceDigest = request.SurfaceDigest,
AnalysisConfigDigest = request.AnalysisConfigDigest,
BuildId = request.BuildId
},
ObservedAt = _timeProvider.GetUtcNow()
};
// Compute witness ID from canonical content
var witnessId = ComputeWitnessId(witness);
witness = witness with { WitnessId = witnessId };
return witness;
}
/// <inheritdoc />
public async IAsyncEnumerable<PathWitness> BuildAllAsync(
BatchWitnessRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Find all roots (entrypoints) in the graph
var roots = request.CallGraph.Roots;
if (roots is null || roots.Count == 0)
{
yield break;
}
var witnessCount = 0;
foreach (var root in roots)
{
if (witnessCount >= request.MaxWitnesses)
{
yield break;
}
cancellationToken.ThrowIfCancellationRequested();
// Look up the node to get the symbol name
var rootNode = request.CallGraph.Nodes?.FirstOrDefault(n => n.Id == root.Id);
var singleRequest = new PathWitnessRequest
{
SbomDigest = request.SbomDigest,
ComponentPurl = request.ComponentPurl,
VulnId = request.VulnId,
VulnSource = request.VulnSource,
AffectedRange = request.AffectedRange,
EntrypointSymbolId = rootNode?.SymbolId ?? root.Id,
EntrypointKind = root.Phase ?? "unknown",
EntrypointName = rootNode?.Display ?? root.Source ?? root.Id,
SinkSymbolId = request.SinkSymbolId,
SinkType = request.SinkType,
CallGraph = request.CallGraph,
CallgraphDigest = request.CallgraphDigest,
SurfaceDigest = request.SurfaceDigest,
AnalysisConfigDigest = request.AnalysisConfigDigest,
BuildId = request.BuildId
};
var witness = await BuildAsync(singleRequest, cancellationToken).ConfigureAwait(false);
if (witness is not null)
{
witnessCount++;
yield return witness;
}
}
}
/// <summary>
/// Finds the shortest path from source to target using BFS.
/// </summary>
private List<PathStep>? FindPath(RichGraph graph, string sourceSymbolId, string targetSymbolId)
{
if (graph.Nodes is null || graph.Edges is null)
{
return null;
}
// Build node ID to symbol ID mapping
var nodeIdToSymbolId = graph.Nodes.ToDictionary(
n => n.Id,
n => n.SymbolId,
StringComparer.Ordinal);
// Build adjacency list using From/To (node IDs) mapped to symbol IDs
var adjacency = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var edge in graph.Edges)
{
if (string.IsNullOrEmpty(edge.From) || string.IsNullOrEmpty(edge.To))
{
continue;
}
// Map node IDs to symbol IDs
if (!nodeIdToSymbolId.TryGetValue(edge.From, out var fromSymbolId) ||
!nodeIdToSymbolId.TryGetValue(edge.To, out var toSymbolId))
{
continue;
}
if (!adjacency.TryGetValue(fromSymbolId, out var neighbors))
{
neighbors = new List<string>();
adjacency[fromSymbolId] = neighbors;
}
neighbors.Add(toSymbolId);
}
// BFS to find shortest path
var visited = new HashSet<string>(StringComparer.Ordinal);
var parent = new Dictionary<string, string>(StringComparer.Ordinal);
var queue = new Queue<string>();
queue.Enqueue(sourceSymbolId);
visited.Add(sourceSymbolId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (current.Equals(targetSymbolId, StringComparison.Ordinal))
{
// Reconstruct path
return ReconstructPath(graph, parent, sourceSymbolId, targetSymbolId);
}
if (!adjacency.TryGetValue(current, out var neighbors))
{
continue;
}
// Sort neighbors for deterministic ordering
foreach (var neighbor in neighbors.Order(StringComparer.Ordinal))
{
if (visited.Add(neighbor))
{
parent[neighbor] = current;
queue.Enqueue(neighbor);
}
}
}
return null; // No path found
}
/// <summary>
/// Reconstructs the path from parent map.
/// </summary>
private static List<PathStep> ReconstructPath(
RichGraph graph,
Dictionary<string, string> parent,
string source,
string target)
{
var path = new List<PathStep>();
var nodeMap = graph.Nodes?.ToDictionary(n => n.SymbolId ?? string.Empty, n => n, StringComparer.Ordinal)
?? new Dictionary<string, RichGraphNode>(StringComparer.Ordinal);
var current = target;
while (current is not null)
{
nodeMap.TryGetValue(current, out var node);
// Extract source file/line from Attributes if available
string? file = null;
int? line = null;
int? column = null;
if (node?.Attributes is not null)
{
if (node.Attributes.TryGetValue("file", out var fileValue))
{
file = fileValue;
}
if (node.Attributes.TryGetValue("line", out var lineValue) && int.TryParse(lineValue, out var parsedLine))
{
line = parsedLine;
}
if (node.Attributes.TryGetValue("column", out var colValue) && int.TryParse(colValue, out var parsedCol))
{
column = parsedCol;
}
}
path.Add(new PathStep
{
Symbol = node?.Display ?? node?.Symbol?.Demangled ?? current,
SymbolId = current,
File = file,
Line = line,
Column = column
});
if (current.Equals(source, StringComparison.Ordinal))
{
break;
}
parent.TryGetValue(current, out current);
}
path.Reverse(); // Reverse to get source → target order
return path;
}
/// <summary>
/// Detects gates along the path using the composite gate detector.
/// </summary>
private async Task<List<DetectedGate>?> DetectGatesAsync(
RichGraph graph,
List<PathStep> path,
string language,
CancellationToken cancellationToken)
{
if (_gateDetector is null || path.Count == 0)
{
return null;
}
// Build source file map for the path
var sourceFiles = new Dictionary<string, string>(StringComparer.Ordinal);
var nodeMap = graph.Nodes?.ToDictionary(n => n.SymbolId ?? string.Empty, n => n, StringComparer.Ordinal)
?? new Dictionary<string, RichGraphNode>(StringComparer.Ordinal);
foreach (var step in path)
{
if (nodeMap.TryGetValue(step.SymbolId, out var node) &&
node.Attributes is not null &&
node.Attributes.TryGetValue("file", out var file))
{
sourceFiles[step.SymbolId] = file;
}
}
var context = new CallPathContext
{
CallPath = path.Select(s => s.SymbolId).ToList(),
SourceFiles = sourceFiles.Count > 0 ? sourceFiles : null,
Language = language
};
var result = await _gateDetector.DetectAllAsync(context, cancellationToken).ConfigureAwait(false);
if (result.Gates.Count == 0)
{
return null;
}
return result.Gates.Select(g => new DetectedGate
{
Type = g.Type.ToString(),
GuardSymbol = g.GuardSymbol,
Confidence = g.Confidence,
Detail = g.Detail
}).ToList();
}
/// <summary>
/// Computes a content-addressed witness ID.
/// </summary>
private string ComputeWitnessId(PathWitness witness)
{
// Create a canonical representation for hashing (excluding witness_id itself)
var canonical = new
{
witness.WitnessSchema,
witness.Artifact,
witness.Vuln,
witness.Entrypoint,
witness.Path,
witness.Sink,
witness.Evidence
};
var json = JsonSerializer.SerializeToUtf8Bytes(canonical, JsonOptions);
var hash = _cryptoHash.ComputePrefixedHashForPurpose(json, HashPurpose.Content);
return $"{WitnessSchema.WitnessIdPrefix}{hash}";
}
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Constants for the stellaops.witness.v1 schema.
/// </summary>
public static class WitnessSchema
{
/// <summary>
/// Current witness schema version.
/// </summary>
public const string Version = "stellaops.witness.v1";
/// <summary>
/// Prefix for witness IDs.
/// </summary>
public const string WitnessIdPrefix = "wit:";
/// <summary>
/// Default DSSE payload type for witnesses.
/// </summary>
public const string DssePayloadType = "application/vnd.stellaops.witness.v1+json";
}

View File

@@ -0,0 +1,216 @@
// -----------------------------------------------------------------------------
// BoundaryProof.cs
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
// Description: Boundary proof model for surface exposure and security controls.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.SmartDiff.Detection;
/// <summary>
/// Boundary proof describing surface exposure, authentication, and security controls.
/// Used to determine the attack surface and protective measures for a finding.
/// </summary>
public sealed record BoundaryProof
{
/// <summary>
/// Kind of boundary (network, file, ipc, process).
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = string.Empty;
/// <summary>
/// Surface descriptor (what is exposed).
/// </summary>
[JsonPropertyName("surface")]
public BoundarySurface? Surface { get; init; }
/// <summary>
/// Exposure descriptor (how it's exposed).
/// </summary>
[JsonPropertyName("exposure")]
public BoundaryExposure? Exposure { get; init; }
/// <summary>
/// Authentication requirements.
/// </summary>
[JsonPropertyName("auth")]
public BoundaryAuth? Auth { get; init; }
/// <summary>
/// Security controls protecting the boundary.
/// </summary>
[JsonPropertyName("controls")]
public IReadOnlyList<BoundaryControl>? Controls { get; init; }
/// <summary>
/// When the boundary was last verified.
/// </summary>
[JsonPropertyName("last_seen")]
public DateTimeOffset LastSeen { get; init; }
/// <summary>
/// Confidence score for this boundary proof (0.0 to 1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; }
/// <summary>
/// Source of this boundary proof (static_analysis, runtime_observation, config).
/// </summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
/// <summary>
/// Reference to the evidence source (graph hash, scan ID, etc.).
/// </summary>
[JsonPropertyName("evidence_ref")]
public string? EvidenceRef { get; init; }
}
/// <summary>
/// Describes what attack surface is exposed.
/// </summary>
public sealed record BoundarySurface
{
/// <summary>
/// Type of surface (api, web, cli, library, file, socket).
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
/// <summary>
/// Protocol (http, https, grpc, tcp, udp, unix).
/// </summary>
[JsonPropertyName("protocol")]
public string? Protocol { get; init; }
/// <summary>
/// Port number if network-exposed.
/// </summary>
[JsonPropertyName("port")]
public int? Port { get; init; }
/// <summary>
/// Host or interface binding.
/// </summary>
[JsonPropertyName("host")]
public string? Host { get; init; }
/// <summary>
/// Path or route pattern.
/// </summary>
[JsonPropertyName("path")]
public string? Path { get; init; }
}
/// <summary>
/// Describes how the surface is exposed.
/// </summary>
public sealed record BoundaryExposure
{
/// <summary>
/// Exposure level (public, internal, private, localhost).
/// </summary>
[JsonPropertyName("level")]
public string Level { get; init; } = string.Empty;
/// <summary>
/// Whether the exposure is internet-facing.
/// </summary>
[JsonPropertyName("internet_facing")]
public bool InternetFacing { get; init; }
/// <summary>
/// Network zone (dmz, internal, trusted, untrusted).
/// </summary>
[JsonPropertyName("zone")]
public string? Zone { get; init; }
/// <summary>
/// Whether behind a load balancer or proxy.
/// </summary>
[JsonPropertyName("behind_proxy")]
public bool? BehindProxy { get; init; }
/// <summary>
/// Expected client types (browser, api_client, service, any).
/// </summary>
[JsonPropertyName("client_types")]
public IReadOnlyList<string>? ClientTypes { get; init; }
}
/// <summary>
/// Describes authentication requirements at the boundary.
/// </summary>
public sealed record BoundaryAuth
{
/// <summary>
/// Whether authentication is required.
/// </summary>
[JsonPropertyName("required")]
public bool Required { get; init; }
/// <summary>
/// Authentication type (jwt, oauth2, basic, api_key, mtls, session).
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; init; }
/// <summary>
/// Required roles or scopes.
/// </summary>
[JsonPropertyName("roles")]
public IReadOnlyList<string>? Roles { get; init; }
/// <summary>
/// Authentication provider or issuer.
/// </summary>
[JsonPropertyName("provider")]
public string? Provider { get; init; }
/// <summary>
/// Whether MFA is required.
/// </summary>
[JsonPropertyName("mfa_required")]
public bool? MfaRequired { get; init; }
}
/// <summary>
/// Describes a security control at the boundary.
/// </summary>
public sealed record BoundaryControl
{
/// <summary>
/// Type of control (rate_limit, waf, input_validation, output_encoding, etc.).
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
/// <summary>
/// Whether the control is currently active.
/// </summary>
[JsonPropertyName("active")]
public bool Active { get; init; }
/// <summary>
/// Control configuration or policy reference.
/// </summary>
[JsonPropertyName("config")]
public string? Config { get; init; }
/// <summary>
/// Effectiveness rating (high, medium, low).
/// </summary>
[JsonPropertyName("effectiveness")]
public string? Effectiveness { get; init; }
/// <summary>
/// When the control was last verified.
/// </summary>
[JsonPropertyName("verified_at")]
public DateTimeOffset? VerifiedAt { get; init; }
}

View File

@@ -0,0 +1,179 @@
// -----------------------------------------------------------------------------
// VexEvidence.cs
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
// Description: VEX (Vulnerability Exploitability eXchange) evidence model.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.SmartDiff.Detection;
/// <summary>
/// VEX (Vulnerability Exploitability eXchange) evidence for a vulnerability.
/// Captures vendor/first-party statements about whether a vulnerability is exploitable.
/// </summary>
public sealed record VexEvidence
{
/// <summary>
/// VEX status: not_affected, affected, fixed, under_investigation.
/// </summary>
[JsonPropertyName("status")]
public VexStatus Status { get; init; }
/// <summary>
/// Justification for the status (per OpenVEX specification).
/// </summary>
[JsonPropertyName("justification")]
public VexJustification? Justification { get; init; }
/// <summary>
/// Human-readable impact statement explaining why not affected.
/// </summary>
[JsonPropertyName("impact")]
public string? Impact { get; init; }
/// <summary>
/// Human-readable action statement (remediation steps).
/// </summary>
[JsonPropertyName("action")]
public string? Action { get; init; }
/// <summary>
/// Reference to the VEX document or DSSE attestation.
/// </summary>
[JsonPropertyName("attestation_ref")]
public string? AttestationRef { get; init; }
/// <summary>
/// VEX document ID.
/// </summary>
[JsonPropertyName("document_id")]
public string? DocumentId { get; init; }
/// <summary>
/// When the VEX statement was issued.
/// </summary>
[JsonPropertyName("issued_at")]
public DateTimeOffset? IssuedAt { get; init; }
/// <summary>
/// When the VEX statement was last updated.
/// </summary>
[JsonPropertyName("updated_at")]
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>
/// When the VEX statement expires.
/// </summary>
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Source of the VEX statement (vendor, first_party, third_party, coordinator).
/// </summary>
[JsonPropertyName("source")]
public VexSource? Source { get; init; }
/// <summary>
/// Affected product or component reference (PURL).
/// </summary>
[JsonPropertyName("product_ref")]
public string? ProductRef { get; init; }
/// <summary>
/// Vulnerability ID (CVE, GHSA, etc.).
/// </summary>
[JsonPropertyName("vulnerability_id")]
public string? VulnerabilityId { get; init; }
/// <summary>
/// Confidence in the VEX statement (0.0 to 1.0).
/// Higher confidence for vendor statements, lower for third-party.
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Whether the VEX statement is still valid (not expired).
/// </summary>
[JsonIgnore]
public bool IsValid => ExpiresAt is null || ExpiresAt > DateTimeOffset.UtcNow;
/// <summary>
/// Whether this VEX statement indicates the vulnerability is not exploitable.
/// </summary>
[JsonIgnore]
public bool IsNotAffected => Status == VexStatus.NotAffected;
/// <summary>
/// Additional context or notes about the VEX statement.
/// </summary>
[JsonPropertyName("notes")]
public IReadOnlyList<string>? Notes { get; init; }
}
/// <summary>
/// VEX status values per OpenVEX specification.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum VexStatus
{
/// <summary>
/// The vulnerability is not exploitable in this context.
/// </summary>
[JsonPropertyName("not_affected")]
NotAffected,
/// <summary>
/// The vulnerability is exploitable.
/// </summary>
[JsonPropertyName("affected")]
Affected,
/// <summary>
/// The vulnerability has been fixed.
/// </summary>
[JsonPropertyName("fixed")]
Fixed,
/// <summary>
/// The vulnerability is under investigation.
/// </summary>
[JsonPropertyName("under_investigation")]
UnderInvestigation
}
// NOTE: VexJustification is defined in VexCandidateModels.cs to avoid duplication
/// <summary>
/// Source of a VEX statement.
/// </summary>
public sealed record VexSource
{
/// <summary>
/// Source type (vendor, first_party, third_party, coordinator, community).
/// </summary>
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
/// <summary>
/// Name of the source organization.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
/// <summary>
/// URL to the source's VEX feed or website.
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; init; }
/// <summary>
/// Trust level (high, medium, low).
/// Vendor and first-party are typically high; third-party varies.
/// </summary>
[JsonPropertyName("trust_level")]
public string? TrustLevel { get; init; }
}

View File

@@ -0,0 +1,338 @@
// -----------------------------------------------------------------------------
// CachingEpssProvider.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-005
// Description: Valkey/Redis cache layer for EPSS lookups.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging.Abstractions;
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Caching decorator for <see cref="IEpssProvider"/> that uses Valkey/Redis.
/// Provides read-through caching for EPSS score lookups.
/// </summary>
public sealed class CachingEpssProvider : IEpssProvider
{
private const string CacheKeyPrefix = "epss:current:";
private const string ModelDateCacheKey = "epss:model-date";
private readonly IEpssProvider _innerProvider;
private readonly IDistributedCache<EpssCacheEntry>? _cache;
private readonly EpssProviderOptions _options;
private readonly ILogger<CachingEpssProvider> _logger;
private readonly TimeProvider _timeProvider;
public CachingEpssProvider(
IEpssProvider innerProvider,
IDistributedCache<EpssCacheEntry>? cache,
IOptions<EpssProviderOptions> options,
ILogger<CachingEpssProvider> logger,
TimeProvider? timeProvider = null)
{
_innerProvider = innerProvider ?? throw new ArgumentNullException(nameof(innerProvider));
_cache = cache; // Can be null if caching is disabled
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
// If caching is disabled or cache is unavailable, go directly to inner provider
if (!_options.EnableCache || _cache is null)
{
return await _innerProvider.GetCurrentAsync(cveId, cancellationToken).ConfigureAwait(false);
}
var cacheKey = BuildCacheKey(cveId);
try
{
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value is not null)
{
_logger.LogDebug("Cache hit for EPSS score: {CveId}", cveId);
return MapFromCacheEntry(cacheResult.Value, fromCache: true);
}
}
catch (Exception ex)
{
// Cache failures should not block the request
_logger.LogWarning(ex, "Cache lookup failed for {CveId}, falling back to database", cveId);
}
// Cache miss - fetch from database
var evidence = await _innerProvider.GetCurrentAsync(cveId, cancellationToken).ConfigureAwait(false);
if (evidence is not null)
{
await TryCacheAsync(cacheKey, evidence, cancellationToken).ConfigureAwait(false);
}
return evidence;
}
public async Task<EpssBatchResult> GetCurrentBatchAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cveIds);
var cveIdList = cveIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (cveIdList.Count == 0)
{
return new EpssBatchResult
{
Found = Array.Empty<EpssEvidence>(),
NotFound = Array.Empty<string>(),
ModelDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = 0
};
}
// If caching is disabled, go directly to inner provider
if (!_options.EnableCache || _cache is null)
{
return await _innerProvider.GetCurrentBatchAsync(cveIdList, cancellationToken).ConfigureAwait(false);
}
var sw = Stopwatch.StartNew();
var found = new List<EpssEvidence>();
var notInCache = new List<string>();
var cacheHits = 0;
DateOnly? modelDate = null;
// Try cache first for each CVE
foreach (var cveId in cveIdList)
{
try
{
var cacheKey = BuildCacheKey(cveId);
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value is not null)
{
var evidence = MapFromCacheEntry(cacheResult.Value, fromCache: true);
found.Add(evidence);
modelDate ??= evidence.ModelDate;
cacheHits++;
}
else
{
notInCache.Add(cveId);
}
}
catch (Exception ex)
{
// Cache failure - will need to fetch from DB
_logger.LogDebug(ex, "Cache lookup failed for {CveId}", cveId);
notInCache.Add(cveId);
}
}
_logger.LogDebug(
"EPSS cache: {CacheHits}/{Total} hits, {CacheMisses} to fetch from database",
cacheHits,
cveIdList.Count,
notInCache.Count);
// Fetch remaining from database
if (notInCache.Count > 0)
{
var dbResult = await _innerProvider.GetCurrentBatchAsync(notInCache, cancellationToken).ConfigureAwait(false);
foreach (var evidence in dbResult.Found)
{
found.Add(evidence);
modelDate ??= evidence.ModelDate;
// Populate cache
await TryCacheAsync(BuildCacheKey(evidence.CveId), evidence, cancellationToken).ConfigureAwait(false);
}
// Add CVEs not found in database to the not found list
var notFound = dbResult.NotFound.ToList();
sw.Stop();
return new EpssBatchResult
{
Found = found,
NotFound = notFound,
ModelDate = modelDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = sw.ElapsedMilliseconds,
PartiallyFromCache = cacheHits > 0 && notInCache.Count > 0
};
}
sw.Stop();
return new EpssBatchResult
{
Found = found,
NotFound = Array.Empty<string>(),
ModelDate = modelDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = sw.ElapsedMilliseconds,
PartiallyFromCache = cacheHits > 0
};
}
public Task<EpssEvidence?> GetAsOfDateAsync(
string cveId,
DateOnly asOfDate,
CancellationToken cancellationToken = default)
{
// Historical lookups are not cached - they're typically one-off queries
return _innerProvider.GetAsOfDateAsync(cveId, asOfDate, cancellationToken);
}
public Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(
string cveId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
{
// History lookups are not cached
return _innerProvider.GetHistoryAsync(cveId, startDate, endDate, cancellationToken);
}
public async Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default)
{
// Try cache first (short TTL for model date)
if (_options.EnableCache && _cache is not null)
{
try
{
var cacheResult = await _cache.GetAsync(ModelDateCacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value?.ModelDate is not null)
{
return cacheResult.Value.ModelDate;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Cache lookup failed for model date");
}
}
var modelDate = await _innerProvider.GetLatestModelDateAsync(cancellationToken).ConfigureAwait(false);
// Cache model date with shorter TTL (5 minutes)
if (modelDate.HasValue && _options.EnableCache && _cache is not null)
{
try
{
await _cache.SetAsync(
ModelDateCacheKey,
new EpssCacheEntry { ModelDate = modelDate.Value },
new CacheEntryOptions { TimeToLive = TimeSpan.FromMinutes(5) },
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to cache model date");
}
}
return modelDate;
}
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
return _innerProvider.IsAvailableAsync(cancellationToken);
}
/// <summary>
/// Invalidates all cached EPSS scores. Called after new EPSS data is ingested.
/// </summary>
public async Task InvalidateCacheAsync(CancellationToken cancellationToken = default)
{
if (_cache is null)
{
return;
}
try
{
var invalidated = await _cache.InvalidateByPatternAsync($"{CacheKeyPrefix}*", cancellationToken).ConfigureAwait(false);
await _cache.InvalidateAsync(ModelDateCacheKey, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Invalidated {Count} EPSS cache entries", invalidated + 1);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate EPSS cache");
}
}
private static string BuildCacheKey(string cveId)
{
return $"{CacheKeyPrefix}{cveId.ToUpperInvariant()}";
}
private async Task TryCacheAsync(string cacheKey, EpssEvidence evidence, CancellationToken cancellationToken)
{
if (_cache is null)
{
return;
}
try
{
var cacheEntry = new EpssCacheEntry
{
CveId = evidence.CveId,
Score = evidence.Score,
Percentile = evidence.Percentile,
ModelDate = evidence.ModelDate,
CachedAt = _timeProvider.GetUtcNow()
};
await _cache.SetAsync(
cacheKey,
cacheEntry,
new CacheEntryOptions { TimeToLive = _options.CacheTtl },
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to cache EPSS score for {CveId}", evidence.CveId);
}
}
private EpssEvidence MapFromCacheEntry(EpssCacheEntry entry, bool fromCache)
{
return new EpssEvidence
{
CveId = entry.CveId ?? string.Empty,
Score = entry.Score,
Percentile = entry.Percentile,
ModelDate = entry.ModelDate,
CapturedAt = entry.CachedAt,
Source = "cache",
FromCache = fromCache
};
}
}
/// <summary>
/// Cache entry for EPSS scores.
/// </summary>
public sealed class EpssCacheEntry
{
public string? CveId { get; set; }
public double Score { get; set; }
public double Percentile { get; set; }
public DateOnly ModelDate { get; set; }
public DateTimeOffset CachedAt { get; set; }
}

View File

@@ -0,0 +1,51 @@
// -----------------------------------------------------------------------------
// EpssChangeRecord.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: #3 - Implement epss_changes flag logic
// Description: Record representing an EPSS change that needs processing.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Record representing an EPSS change that needs processing.
/// </summary>
public sealed record EpssChangeRecord
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Change flags indicating what changed.
/// </summary>
public EpssChangeFlags Flags { get; init; }
/// <summary>
/// Previous EPSS score (if available).
/// </summary>
public double? PreviousScore { get; init; }
/// <summary>
/// New EPSS score.
/// </summary>
public double NewScore { get; init; }
/// <summary>
/// New EPSS percentile.
/// </summary>
public double NewPercentile { get; init; }
/// <summary>
/// Previous priority band (if available).
/// </summary>
public EpssPriorityBand PreviousBand { get; init; }
/// <summary>
/// Model date for this change.
/// </summary>
public DateOnly ModelDate { get; init; }
}

View File

@@ -0,0 +1,110 @@
// -----------------------------------------------------------------------------
// EpssExplainHashCalculator.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S4 - Implement ComputeExplainHash
// Description: Deterministic SHA-256 hash calculator for EPSS signal explainability.
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Calculator for deterministic explain hashes on EPSS signals.
/// The explain hash provides a unique fingerprint for signal inputs,
/// enabling audit trails and change detection.
/// </summary>
public static class EpssExplainHashCalculator
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Computes a deterministic SHA-256 hash from signal input parameters.
/// </summary>
/// <param name="modelDate">EPSS model date.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="eventType">Event type (RISK_SPIKE, BAND_CHANGE, etc.).</param>
/// <param name="oldBand">Previous risk band (nullable).</param>
/// <param name="newBand">New risk band (nullable).</param>
/// <param name="score">EPSS score.</param>
/// <param name="percentile">EPSS percentile.</param>
/// <param name="modelVersion">EPSS model version.</param>
/// <returns>SHA-256 hash as byte array.</returns>
public static byte[] ComputeExplainHash(
DateOnly modelDate,
string cveId,
string eventType,
string? oldBand,
string? newBand,
double score,
double percentile,
string? modelVersion)
{
// Create deterministic input structure
var input = new ExplainHashInput
{
ModelDate = modelDate.ToString("yyyy-MM-dd"),
CveId = cveId.ToUpperInvariant(), // Normalize CVE ID
EventType = eventType.ToUpperInvariant(),
OldBand = oldBand?.ToUpperInvariant() ?? "NONE",
NewBand = newBand?.ToUpperInvariant() ?? "NONE",
Score = Math.Round(score, 6), // Consistent precision
Percentile = Math.Round(percentile, 6),
ModelVersion = modelVersion ?? string.Empty
};
// Serialize to deterministic JSON
var json = JsonSerializer.Serialize(input, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
return SHA256.HashData(bytes);
}
/// <summary>
/// Computes the dedupe key for an EPSS signal.
/// This key is used to prevent duplicate signals.
/// </summary>
/// <param name="modelDate">EPSS model date.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="eventType">Event type.</param>
/// <param name="oldBand">Previous risk band.</param>
/// <param name="newBand">New risk band.</param>
/// <returns>Deterministic dedupe key string.</returns>
public static string ComputeDedupeKey(
DateOnly modelDate,
string cveId,
string eventType,
string? oldBand,
string? newBand)
{
return $"{modelDate:yyyy-MM-dd}:{cveId.ToUpperInvariant()}:{eventType.ToUpperInvariant()}:{oldBand?.ToUpperInvariant() ?? "NONE"}->{newBand?.ToUpperInvariant() ?? "NONE"}";
}
/// <summary>
/// Converts an explain hash to hex string for display.
/// </summary>
/// <param name="hash">The hash bytes.</param>
/// <returns>Lowercase hex string.</returns>
public static string ToHexString(byte[] hash)
{
return Convert.ToHexString(hash).ToLowerInvariant();
}
private sealed record ExplainHashInput
{
public required string ModelDate { get; init; }
public required string CveId { get; init; }
public required string EventType { get; init; }
public required string OldBand { get; init; }
public required string NewBand { get; init; }
public required double Score { get; init; }
public required double Percentile { get; init; }
public required string ModelVersion { get; init; }
}
}

View File

@@ -0,0 +1,229 @@
// -----------------------------------------------------------------------------
// EpssProvider.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-004
// Description: PostgreSQL-backed EPSS provider implementation.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IEpssProvider"/>.
/// Provides EPSS score lookups with optional caching.
/// </summary>
public sealed class EpssProvider : IEpssProvider
{
private readonly IEpssRepository _repository;
private readonly EpssProviderOptions _options;
private readonly ILogger<EpssProvider> _logger;
private readonly TimeProvider _timeProvider;
public EpssProvider(
IEpssRepository repository,
IOptions<EpssProviderOptions> options,
ILogger<EpssProvider> logger,
TimeProvider? timeProvider = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
var results = await _repository.GetCurrentAsync(new[] { cveId }, cancellationToken).ConfigureAwait(false);
if (!results.TryGetValue(cveId, out var entry))
{
_logger.LogDebug("EPSS score not found for {CveId}", cveId);
return null;
}
return MapToEvidence(cveId, entry, fromCache: false);
}
public async Task<EpssBatchResult> GetCurrentBatchAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cveIds);
var cveIdList = cveIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (cveIdList.Count == 0)
{
return new EpssBatchResult
{
Found = Array.Empty<EpssEvidence>(),
NotFound = Array.Empty<string>(),
ModelDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = 0
};
}
// Enforce max batch size
if (cveIdList.Count > _options.MaxBatchSize)
{
_logger.LogWarning(
"Batch size {BatchSize} exceeds maximum {MaxBatchSize}, truncating",
cveIdList.Count,
_options.MaxBatchSize);
cveIdList = cveIdList.Take(_options.MaxBatchSize).ToList();
}
var sw = Stopwatch.StartNew();
var results = await _repository.GetCurrentAsync(cveIdList, cancellationToken).ConfigureAwait(false);
sw.Stop();
var found = new List<EpssEvidence>(results.Count);
var notFound = new List<string>();
DateOnly? modelDate = null;
foreach (var cveId in cveIdList)
{
if (results.TryGetValue(cveId, out var entry))
{
found.Add(MapToEvidence(cveId, entry, fromCache: false));
modelDate ??= entry.ModelDate;
}
else
{
notFound.Add(cveId);
}
}
_logger.LogDebug(
"EPSS batch lookup: {Found}/{Total} found in {ElapsedMs}ms",
found.Count,
cveIdList.Count,
sw.ElapsedMilliseconds);
return new EpssBatchResult
{
Found = found,
NotFound = notFound,
ModelDate = modelDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = sw.ElapsedMilliseconds,
PartiallyFromCache = false
};
}
public async Task<EpssEvidence?> GetAsOfDateAsync(
string cveId,
DateOnly asOfDate,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
// Get history for just that date
var history = await _repository.GetHistoryAsync(cveId, 1, cancellationToken).ConfigureAwait(false);
// Find the entry closest to (but not after) the requested date
var entry = history
.Where(e => e.ModelDate <= asOfDate)
.OrderByDescending(e => e.ModelDate)
.FirstOrDefault();
if (entry is null)
{
_logger.LogDebug("EPSS score not found for {CveId} as of {AsOfDate}", cveId, asOfDate);
return null;
}
return new EpssEvidence
{
CveId = cveId,
Score = entry.Score,
Percentile = entry.Percentile,
ModelDate = entry.ModelDate,
CapturedAt = _timeProvider.GetUtcNow(),
Source = _options.SourceIdentifier,
FromCache = false
};
}
public async Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(
string cveId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
var days = endDate.DayNumber - startDate.DayNumber + 1;
if (days <= 0)
{
return Array.Empty<EpssEvidence>();
}
var history = await _repository.GetHistoryAsync(cveId, days, cancellationToken).ConfigureAwait(false);
return history
.Where(e => e.ModelDate >= startDate && e.ModelDate <= endDate)
.OrderBy(e => e.ModelDate)
.Select(e => new EpssEvidence
{
CveId = cveId,
Score = e.Score,
Percentile = e.Percentile,
ModelDate = e.ModelDate,
CapturedAt = _timeProvider.GetUtcNow(),
Source = _options.SourceIdentifier,
FromCache = false
})
.ToList();
}
public async Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default)
{
// Get any CVE to determine the latest model date
// This is a heuristic - in production, we'd have a metadata table
var results = await _repository.GetCurrentAsync(
new[] { "CVE-2021-44228" }, // Log4Shell - almost certainly in any EPSS dataset
cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.Values.First().ModelDate;
}
return null;
}
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
try
{
var modelDate = await GetLatestModelDateAsync(cancellationToken).ConfigureAwait(false);
return modelDate.HasValue;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EPSS provider availability check failed");
return false;
}
}
private EpssEvidence MapToEvidence(string cveId, EpssCurrentEntry entry, bool fromCache)
{
return new EpssEvidence
{
CveId = cveId,
Score = entry.Score,
Percentile = entry.Percentile,
ModelDate = entry.ModelDate,
CapturedAt = _timeProvider.GetUtcNow(),
Source = _options.SourceIdentifier,
FromCache = fromCache
};
}
}

View File

@@ -0,0 +1,285 @@
// -----------------------------------------------------------------------------
// EpssReplayService.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: R4 - Implement ReplayFromRawAsync
// Description: Service for replaying EPSS data from stored raw payloads.
// -----------------------------------------------------------------------------
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Result of an EPSS replay operation.
/// </summary>
public sealed record EpssReplayResult
{
/// <summary>
/// The model date that was replayed.
/// </summary>
public required DateOnly ModelDate { get; init; }
/// <summary>
/// Number of rows replayed.
/// </summary>
public required int RowCount { get; init; }
/// <summary>
/// Number of distinct CVEs.
/// </summary>
public required int DistinctCveCount { get; init; }
/// <summary>
/// Whether this was a dry run (no writes).
/// </summary>
public required bool IsDryRun { get; init; }
/// <summary>
/// Duration of the replay in milliseconds.
/// </summary>
public required long DurationMs { get; init; }
/// <summary>
/// Model version from the raw payload.
/// </summary>
public string? ModelVersion { get; init; }
}
/// <summary>
/// Service for replaying EPSS data from stored raw payloads.
/// Enables deterministic re-normalization without re-downloading from FIRST.org.
/// </summary>
public sealed class EpssReplayService
{
private readonly IEpssRawRepository _rawRepository;
private readonly IEpssRepository _epssRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EpssReplayService> _logger;
public EpssReplayService(
IEpssRawRepository rawRepository,
IEpssRepository epssRepository,
TimeProvider timeProvider,
ILogger<EpssReplayService> logger)
{
_rawRepository = rawRepository ?? throw new ArgumentNullException(nameof(rawRepository));
_epssRepository = epssRepository ?? throw new ArgumentNullException(nameof(epssRepository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Replays EPSS data from a stored raw payload for a specific date.
/// Re-normalizes the data into the epss_snapshot table without re-downloading.
/// </summary>
/// <param name="modelDate">The model date to replay.</param>
/// <param name="dryRun">If true, validates but doesn't write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the replay operation.</returns>
public async Task<EpssReplayResult> ReplayFromRawAsync(
DateOnly modelDate,
bool dryRun = false,
CancellationToken cancellationToken = default)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
_logger.LogInformation(
"Starting EPSS replay from raw for {ModelDate} (dryRun={DryRun})",
modelDate,
dryRun);
// Fetch the raw payload
var raw = await _rawRepository.GetByDateAsync(modelDate, cancellationToken).ConfigureAwait(false);
if (raw is null)
{
throw new InvalidOperationException($"No raw EPSS payload found for {modelDate}");
}
_logger.LogDebug(
"Found raw payload: rawId={RawId}, rows={RowCount}, modelVersion={ModelVersion}",
raw.RawId,
raw.RowCount,
raw.ModelVersion);
// Parse the JSON payload
var rows = ParseRawPayload(raw.Payload);
if (dryRun)
{
stopwatch.Stop();
_logger.LogInformation(
"EPSS replay dry run completed: modelDate={ModelDate}, rows={RowCount}, cves={CveCount}, duration={Duration}ms",
modelDate,
rows.Count,
rows.Select(r => r.CveId).Distinct().Count(),
stopwatch.ElapsedMilliseconds);
return new EpssReplayResult
{
ModelDate = modelDate,
RowCount = rows.Count,
DistinctCveCount = rows.Select(r => r.CveId).Distinct().Count(),
IsDryRun = true,
DurationMs = stopwatch.ElapsedMilliseconds,
ModelVersion = raw.ModelVersion
};
}
// Create a new import run for the replay
var importRun = await _epssRepository.BeginImportAsync(
modelDate,
$"replay:{raw.SourceUri}",
_timeProvider.GetUtcNow(),
Convert.ToHexString(raw.PayloadSha256).ToLowerInvariant(),
cancellationToken).ConfigureAwait(false);
try
{
// Write the snapshot using async enumerable
var writeResult = await _epssRepository.WriteSnapshotAsync(
importRun.ImportRunId,
modelDate,
_timeProvider.GetUtcNow(),
ToAsyncEnumerable(rows),
cancellationToken).ConfigureAwait(false);
// Mark success
await _epssRepository.MarkImportSucceededAsync(
importRun.ImportRunId,
rows.Count,
Convert.ToHexString(raw.PayloadSha256).ToLowerInvariant(),
raw.ModelVersion,
raw.PublishedDate,
cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
_logger.LogInformation(
"EPSS replay completed: modelDate={ModelDate}, rows={RowCount}, cves={CveCount}, duration={Duration}ms",
modelDate,
writeResult.RowCount,
writeResult.DistinctCveCount,
stopwatch.ElapsedMilliseconds);
return new EpssReplayResult
{
ModelDate = modelDate,
RowCount = writeResult.RowCount,
DistinctCveCount = writeResult.DistinctCveCount,
IsDryRun = false,
DurationMs = stopwatch.ElapsedMilliseconds,
ModelVersion = raw.ModelVersion
};
}
catch (Exception ex)
{
await _epssRepository.MarkImportFailedAsync(
importRun.ImportRunId,
$"Replay failed: {ex.Message}",
cancellationToken).ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Replays EPSS data for a date range.
/// </summary>
/// <param name="startDate">Start date (inclusive).</param>
/// <param name="endDate">End date (inclusive).</param>
/// <param name="dryRun">If true, validates but doesn't write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Results for each date replayed.</returns>
public async Task<IReadOnlyList<EpssReplayResult>> ReplayRangeAsync(
DateOnly startDate,
DateOnly endDate,
bool dryRun = false,
CancellationToken cancellationToken = default)
{
var results = new List<EpssReplayResult>();
var rawPayloads = await _rawRepository.GetByDateRangeAsync(startDate, endDate, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Replaying {Count} EPSS payloads from {StartDate} to {EndDate}",
rawPayloads.Count,
startDate,
endDate);
foreach (var raw in rawPayloads.OrderBy(r => r.AsOfDate))
{
try
{
var result = await ReplayFromRawAsync(raw.AsOfDate, dryRun, cancellationToken)
.ConfigureAwait(false);
results.Add(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to replay EPSS for {ModelDate}", raw.AsOfDate);
// Continue with next date
}
}
return results;
}
/// <summary>
/// Gets available dates for replay.
/// </summary>
/// <param name="startDate">Optional start date filter.</param>
/// <param name="endDate">Optional end date filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of available model dates.</returns>
public async Task<IReadOnlyList<DateOnly>> GetAvailableDatesAsync(
DateOnly? startDate = null,
DateOnly? endDate = null,
CancellationToken cancellationToken = default)
{
var start = startDate ?? DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-1));
var end = endDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
var rawPayloads = await _rawRepository.GetByDateRangeAsync(start, end, cancellationToken)
.ConfigureAwait(false);
return rawPayloads.Select(r => r.AsOfDate).OrderByDescending(d => d).ToList();
}
private static List<EpssScoreRow> ParseRawPayload(string jsonPayload)
{
var rows = new List<EpssScoreRow>();
using var doc = JsonDocument.Parse(jsonPayload);
foreach (var element in doc.RootElement.EnumerateArray())
{
var cveId = element.GetProperty("cve").GetString();
var score = element.GetProperty("epss").GetDouble();
var percentile = element.GetProperty("percentile").GetDouble();
if (!string.IsNullOrEmpty(cveId))
{
rows.Add(new EpssScoreRow(cveId, score, percentile));
}
}
return rows;
}
private static async IAsyncEnumerable<EpssScoreRow> ToAsyncEnumerable(
IEnumerable<EpssScoreRow> rows)
{
foreach (var row in rows)
{
yield return row;
}
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,195 @@
// -----------------------------------------------------------------------------
// EpssUpdatedEvent.cs
// Sprint: SPRINT_3410_0001_0001_epss_ingestion_storage
// Task: EPSS-3410-011
// Description: Event published when EPSS data is successfully updated.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Storage.Epss.Events;
/// <summary>
/// Event published when EPSS data is successfully ingested.
/// Event type: "epss.updated@1"
/// </summary>
public sealed record EpssUpdatedEvent
{
/// <summary>
/// Event type identifier for routing.
/// </summary>
public const string EventType = "epss.updated@1";
/// <summary>
/// Event version for schema evolution.
/// </summary>
public const int Version = 1;
/// <summary>
/// Unique identifier for this event instance.
/// </summary>
[JsonPropertyName("event_id")]
public required Guid EventId { get; init; }
/// <summary>
/// UTC timestamp when the event occurred.
/// </summary>
[JsonPropertyName("occurred_at_utc")]
public required DateTimeOffset OccurredAtUtc { get; init; }
/// <summary>
/// The import run ID that produced this update.
/// </summary>
[JsonPropertyName("import_run_id")]
public required Guid ImportRunId { get; init; }
/// <summary>
/// The EPSS model date (YYYY-MM-DD) that was imported.
/// </summary>
[JsonPropertyName("model_date")]
public required DateOnly ModelDate { get; init; }
/// <summary>
/// The EPSS model version tag (e.g., "v2025.12.17").
/// </summary>
[JsonPropertyName("model_version_tag")]
public string? ModelVersionTag { get; init; }
/// <summary>
/// The published date from the EPSS data.
/// </summary>
[JsonPropertyName("published_date")]
public DateOnly? PublishedDate { get; init; }
/// <summary>
/// Total number of CVEs in the snapshot.
/// </summary>
[JsonPropertyName("row_count")]
public required int RowCount { get; init; }
/// <summary>
/// Number of distinct CVE IDs in the snapshot.
/// </summary>
[JsonPropertyName("distinct_cve_count")]
public required int DistinctCveCount { get; init; }
/// <summary>
/// SHA256 hash of the decompressed CSV content.
/// </summary>
[JsonPropertyName("content_hash")]
public string? ContentHash { get; init; }
/// <summary>
/// Source URI (online URL or bundle path).
/// </summary>
[JsonPropertyName("source_uri")]
public required string SourceUri { get; init; }
/// <summary>
/// Duration of the ingestion in milliseconds.
/// </summary>
[JsonPropertyName("duration_ms")]
public required long DurationMs { get; init; }
/// <summary>
/// Summary of material changes detected.
/// </summary>
[JsonPropertyName("change_summary")]
public EpssChangeSummary? ChangeSummary { get; init; }
/// <summary>
/// Creates an idempotency key for this event based on model date and import run.
/// </summary>
public string GetIdempotencyKey()
=> $"epss.updated:{ModelDate:yyyy-MM-dd}:{ImportRunId:N}";
}
/// <summary>
/// Summary of material changes in an EPSS update.
/// </summary>
public sealed record EpssChangeSummary
{
/// <summary>
/// Number of CVEs newly scored (first appearance).
/// </summary>
[JsonPropertyName("new_scored")]
public int NewScored { get; init; }
/// <summary>
/// Number of CVEs that crossed the high threshold upward.
/// </summary>
[JsonPropertyName("crossed_high")]
public int CrossedHigh { get; init; }
/// <summary>
/// Number of CVEs that crossed the high threshold downward.
/// </summary>
[JsonPropertyName("crossed_low")]
public int CrossedLow { get; init; }
/// <summary>
/// Number of CVEs with a big jump up in score.
/// </summary>
[JsonPropertyName("big_jump_up")]
public int BigJumpUp { get; init; }
/// <summary>
/// Number of CVEs with a big jump down in score.
/// </summary>
[JsonPropertyName("big_jump_down")]
public int BigJumpDown { get; init; }
/// <summary>
/// Number of CVEs that entered the top percentile.
/// </summary>
[JsonPropertyName("top_percentile")]
public int TopPercentile { get; init; }
/// <summary>
/// Number of CVEs that left the top percentile.
/// </summary>
[JsonPropertyName("left_top_percentile")]
public int LeftTopPercentile { get; init; }
/// <summary>
/// Total number of CVEs with any material change.
/// </summary>
[JsonPropertyName("total_changed")]
public int TotalChanged { get; init; }
}
/// <summary>
/// Builder for creating <see cref="EpssUpdatedEvent"/> instances.
/// </summary>
public static class EpssUpdatedEventBuilder
{
public static EpssUpdatedEvent Create(
Guid importRunId,
DateOnly modelDate,
string sourceUri,
int rowCount,
int distinctCveCount,
long durationMs,
TimeProvider timeProvider,
string? modelVersionTag = null,
DateOnly? publishedDate = null,
string? contentHash = null,
EpssChangeSummary? changeSummary = null)
{
return new EpssUpdatedEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = timeProvider.GetUtcNow(),
ImportRunId = importRunId,
ModelDate = modelDate,
ModelVersionTag = modelVersionTag,
PublishedDate = publishedDate,
RowCount = rowCount,
DistinctCveCount = distinctCveCount,
ContentHash = contentHash,
SourceUri = sourceUri,
DurationMs = durationMs,
ChangeSummary = changeSummary
};
}
}

View File

@@ -0,0 +1,104 @@
// -----------------------------------------------------------------------------
// IEpssSignalPublisher.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S9 - Connect to Notify/Router
// Description: Interface for publishing EPSS signals to the notification system.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Result of publishing an EPSS signal.
/// </summary>
public sealed record EpssSignalPublishResult
{
/// <summary>
/// Whether the publish was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Message ID from the queue (if applicable).
/// </summary>
public string? MessageId { get; init; }
/// <summary>
/// Error message if publish failed.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Publisher for EPSS signals to the notification system.
/// Routes signals to the appropriate topics based on event type.
/// </summary>
public interface IEpssSignalPublisher
{
/// <summary>
/// Topic name for EPSS signals.
/// </summary>
const string TopicName = "signals.epss";
/// <summary>
/// Publishes an EPSS signal to the notification system.
/// </summary>
/// <param name="signal">The signal to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the publish operation.</returns>
Task<EpssSignalPublishResult> PublishAsync(
EpssSignal signal,
CancellationToken cancellationToken = default);
/// <summary>
/// Publishes multiple EPSS signals in a batch.
/// </summary>
/// <param name="signals">The signals to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of successfully published signals.</returns>
Task<int> PublishBatchAsync(
IEnumerable<EpssSignal> signals,
CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a priority change event.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="oldBand">Previous priority band.</param>
/// <param name="newBand">New priority band.</param>
/// <param name="epssScore">Current EPSS score.</param>
/// <param name="modelDate">EPSS model date.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the publish operation.</returns>
Task<EpssSignalPublishResult> PublishPriorityChangedAsync(
Guid tenantId,
string cveId,
string oldBand,
string newBand,
double epssScore,
DateOnly modelDate,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Null implementation of IEpssSignalPublisher for when messaging is disabled.
/// </summary>
public sealed class NullEpssSignalPublisher : IEpssSignalPublisher
{
public static readonly NullEpssSignalPublisher Instance = new();
private NullEpssSignalPublisher() { }
public Task<EpssSignalPublishResult> PublishAsync(EpssSignal signal, CancellationToken cancellationToken = default)
=> Task.FromResult(new EpssSignalPublishResult { Success = true, MessageId = "null" });
public Task<int> PublishBatchAsync(IEnumerable<EpssSignal> signals, CancellationToken cancellationToken = default)
=> Task.FromResult(signals.Count());
public Task<EpssSignalPublishResult> PublishPriorityChangedAsync(
Guid tenantId, string cveId, string oldBand, string newBand, double epssScore, DateOnly modelDate,
CancellationToken cancellationToken = default)
=> Task.FromResult(new EpssSignalPublishResult { Success = true, MessageId = "null" });
}

View File

@@ -0,0 +1,165 @@
// -----------------------------------------------------------------------------
// EpssServiceCollectionExtensions.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-005
// Description: DI registration for EPSS services with optional Valkey cache layer.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging.Abstractions;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Extensions;
/// <summary>
/// Extension methods for registering EPSS services with optional Valkey caching.
/// </summary>
public static class EpssServiceCollectionExtensions
{
/// <summary>
/// Adds EPSS provider services to the service collection.
/// Includes optional Valkey/Redis cache layer based on configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration section for EPSS options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddEpssProvider(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
// Bind EPSS provider options
services.AddOptions<EpssProviderOptions>()
.Bind(configuration.GetSection(EpssProviderOptions.SectionName))
.ValidateOnStart();
// Register the base PostgreSQL-backed provider
services.TryAddScoped<EpssProvider>();
// Register the caching decorator
services.TryAddScoped<IEpssProvider>(sp =>
{
var options = sp.GetRequiredService<IOptions<EpssProviderOptions>>().Value;
var innerProvider = sp.GetRequiredService<EpssProvider>();
var logger = sp.GetRequiredService<ILogger<CachingEpssProvider>>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
// If caching is disabled, return the inner provider directly
if (!options.EnableCache)
{
return innerProvider;
}
// Try to get the cache factory (may be null if Valkey is not configured)
var cacheFactory = sp.GetService<IDistributedCacheFactory>();
IDistributedCache<EpssCacheEntry>? cache = null;
if (cacheFactory is not null)
{
try
{
cache = cacheFactory.Create<EpssCacheEntry>(new CacheOptions
{
KeyPrefix = "epss:",
DefaultTtl = options.CacheTtl,
SlidingExpiration = false
});
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to create EPSS cache, falling back to uncached provider. " +
"Ensure Valkey/Redis is configured if caching is desired.");
}
}
else
{
logger.LogDebug(
"No IDistributedCacheFactory registered. EPSS caching will be disabled. " +
"Register StellaOps.Messaging.Transport.Valkey to enable caching.");
}
return new CachingEpssProvider(
innerProvider,
cache,
sp.GetRequiredService<IOptions<EpssProviderOptions>>(),
logger,
timeProvider);
});
return services;
}
/// <summary>
/// Adds EPSS provider services with explicit options configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">The configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddEpssProvider(
this IServiceCollection services,
Action<EpssProviderOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
services.AddOptions<EpssProviderOptions>()
.Configure(configure)
.ValidateOnStart();
// Register the base PostgreSQL-backed provider
services.TryAddScoped<EpssProvider>();
// Register the caching decorator
services.TryAddScoped<IEpssProvider>(sp =>
{
var options = sp.GetRequiredService<IOptions<EpssProviderOptions>>().Value;
var innerProvider = sp.GetRequiredService<EpssProvider>();
var logger = sp.GetRequiredService<ILogger<CachingEpssProvider>>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
// If caching is disabled, return the inner provider directly
if (!options.EnableCache)
{
return innerProvider;
}
// Try to get the cache factory
var cacheFactory = sp.GetService<IDistributedCacheFactory>();
IDistributedCache<EpssCacheEntry>? cache = null;
if (cacheFactory is not null)
{
try
{
cache = cacheFactory.Create<EpssCacheEntry>(new CacheOptions
{
KeyPrefix = "epss:",
DefaultTtl = options.CacheTtl,
SlidingExpiration = false
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to create EPSS cache");
}
}
return new CachingEpssProvider(
innerProvider,
cache,
sp.GetRequiredService<IOptions<EpssProviderOptions>>(),
logger,
timeProvider);
});
return services;
}
}

View File

@@ -82,8 +82,33 @@ public static class ServiceCollectionExtensions
services.AddScoped<IReachabilityResultRepository, PostgresReachabilityResultRepository>();
services.AddScoped<ICodeChangeRepository, PostgresCodeChangeRepository>();
services.AddScoped<IReachabilityDriftResultRepository, PostgresReachabilityDriftResultRepository>();
// EPSS ingestion services
services.AddSingleton<EpssCsvStreamParser>();
services.AddScoped<IEpssRepository, PostgresEpssRepository>();
services.AddSingleton<EpssOnlineSource>();
services.AddSingleton<EpssBundleSource>();
// Note: EpssChangeDetector is a static class, no DI registration needed
// EPSS provider with optional Valkey cache layer (Sprint: SPRINT_3410_0002_0001, Task: EPSS-SCAN-005)
services.AddEpssProvider(options =>
{
// Default configuration - can be overridden via config binding
options.EnableCache = true;
options.CacheTtl = TimeSpan.FromHours(1);
options.MaxBatchSize = 1000;
});
// EPSS raw and signal repositories (Sprint: SPRINT_3413_0001_0001)
services.AddScoped<IEpssRawRepository, PostgresEpssRawRepository>();
services.AddScoped<IEpssSignalRepository, PostgresEpssSignalRepository>();
services.AddScoped<IObservedCveRepository, PostgresObservedCveRepository>();
services.AddSingleton<EpssReplayService>();
services.TryAddSingleton<IEpssSignalPublisher, NullEpssSignalPublisher>();
// Witness storage (Sprint: SPRINT_3700_0001_0001)
services.AddScoped<IWitnessRepository, PostgresWitnessRepository>();
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();

View File

@@ -0,0 +1,60 @@
-- Migration: 013_witness_storage.sql
-- Sprint: SPRINT_3700_0001_0001_witness_foundation
-- Task: WIT-011
-- Description: Creates tables for DSSE-signed path witnesses and witness storage.
-- Witness storage for reachability path proofs
CREATE TABLE IF NOT EXISTS scanner.witnesses (
witness_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
witness_hash TEXT NOT NULL, -- BLAKE3 hash of witness payload
schema_version TEXT NOT NULL DEFAULT 'stellaops.witness.v1',
witness_type TEXT NOT NULL, -- 'reachability_path', 'gate_proof', etc.
-- Reference to the graph/analysis that produced this witness
graph_hash TEXT NOT NULL, -- BLAKE3 hash of source rich graph
scan_id UUID,
run_id UUID,
-- Witness content
payload_json JSONB NOT NULL, -- PathWitness JSON
dsse_envelope JSONB, -- DSSE signed envelope (nullable until signed)
-- Provenance
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
signed_at TIMESTAMPTZ,
signer_key_id TEXT,
-- Indexing
entrypoint_fqn TEXT, -- For quick lookup by entrypoint
sink_cve TEXT, -- For quick lookup by CVE
CONSTRAINT uk_witness_hash UNIQUE (witness_hash)
);
-- Index for efficient lookups
CREATE INDEX IF NOT EXISTS ix_witnesses_graph_hash ON scanner.witnesses (graph_hash);
CREATE INDEX IF NOT EXISTS ix_witnesses_scan_id ON scanner.witnesses (scan_id) WHERE scan_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_witnesses_sink_cve ON scanner.witnesses (sink_cve) WHERE sink_cve IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_witnesses_entrypoint ON scanner.witnesses (entrypoint_fqn) WHERE entrypoint_fqn IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_witnesses_created_at ON scanner.witnesses (created_at DESC);
-- GIN index for JSONB queries on payload
CREATE INDEX IF NOT EXISTS ix_witnesses_payload_gin ON scanner.witnesses USING gin (payload_json jsonb_path_ops);
-- Witness verification log (for audit trail)
CREATE TABLE IF NOT EXISTS scanner.witness_verifications (
verification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
witness_id UUID NOT NULL REFERENCES scanner.witnesses(witness_id),
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
verified_by TEXT, -- 'system', 'api', 'cli'
verification_status TEXT NOT NULL, -- 'valid', 'invalid', 'expired'
verification_error TEXT,
verifier_key_id TEXT
);
CREATE INDEX IF NOT EXISTS ix_witness_verifications_witness_id ON scanner.witness_verifications (witness_id);
COMMENT ON TABLE scanner.witnesses IS 'DSSE-signed path witnesses for reachability proofs (stellaops.witness.v1)';
COMMENT ON TABLE scanner.witness_verifications IS 'Audit log of witness verification attempts';
COMMENT ON COLUMN scanner.witnesses.witness_hash IS 'BLAKE3 hash of witness payload for deduplication and integrity';
COMMENT ON COLUMN scanner.witnesses.dsse_envelope IS 'Dead Simple Signing Envelope (DSSE) containing the signed witness';

View File

@@ -0,0 +1,150 @@
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Sprint: 3413
-- Task: Task #2 - vuln_instance_triage schema updates
-- Description: Adds EPSS tracking columns to vulnerability instance triage table
-- ============================================================================
-- EPSS Tracking Columns for Vulnerability Instances
-- ============================================================================
-- These columns store the current EPSS state for each vulnerability instance,
-- enabling efficient priority band calculation and change detection.
-- Add EPSS columns to vuln_instance_triage if table exists
DO $$
BEGIN
-- Check if table exists
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vuln_instance_triage') THEN
-- Add current_epss_score column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'current_epss_score') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN current_epss_score DOUBLE PRECISION;
COMMENT ON COLUMN vuln_instance_triage.current_epss_score IS 'Current EPSS probability score [0,1]';
END IF;
-- Add current_epss_percentile column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'current_epss_percentile') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN current_epss_percentile DOUBLE PRECISION;
COMMENT ON COLUMN vuln_instance_triage.current_epss_percentile IS 'Current EPSS percentile rank [0,1]';
END IF;
-- Add current_epss_band column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'current_epss_band') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN current_epss_band TEXT;
COMMENT ON COLUMN vuln_instance_triage.current_epss_band IS 'Current EPSS priority band: CRITICAL, HIGH, MEDIUM, LOW';
END IF;
-- Add epss_model_date column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'epss_model_date') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN epss_model_date DATE;
COMMENT ON COLUMN vuln_instance_triage.epss_model_date IS 'EPSS model date when last updated';
END IF;
-- Add epss_updated_at column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'epss_updated_at') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN epss_updated_at TIMESTAMPTZ;
COMMENT ON COLUMN vuln_instance_triage.epss_updated_at IS 'Timestamp when EPSS data was last updated';
END IF;
-- Add previous_epss_band column (for change tracking)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'previous_epss_band') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN previous_epss_band TEXT;
COMMENT ON COLUMN vuln_instance_triage.previous_epss_band IS 'Previous EPSS priority band before last update';
END IF;
-- Create index for efficient band-based queries
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_vuln_instance_epss_band') THEN
CREATE INDEX idx_vuln_instance_epss_band
ON vuln_instance_triage (current_epss_band)
WHERE current_epss_band IN ('CRITICAL', 'HIGH');
END IF;
-- Create index for stale EPSS data detection
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_vuln_instance_epss_model_date') THEN
CREATE INDEX idx_vuln_instance_epss_model_date
ON vuln_instance_triage (epss_model_date);
END IF;
RAISE NOTICE 'Added EPSS columns to vuln_instance_triage table';
ELSE
RAISE NOTICE 'Table vuln_instance_triage does not exist; skipping EPSS column additions';
END IF;
END $$;
-- ============================================================================
-- Batch Update Function for EPSS Enrichment
-- ============================================================================
-- Efficiently updates EPSS data for multiple vulnerability instances
CREATE OR REPLACE FUNCTION batch_update_epss_triage(
p_updates JSONB,
p_model_date DATE,
p_updated_at TIMESTAMPTZ DEFAULT now()
)
RETURNS TABLE (
updated_count INT,
band_change_count INT
) AS $$
DECLARE
v_updated INT := 0;
v_band_changes INT := 0;
v_row RECORD;
BEGIN
-- p_updates format: [{"instance_id": "...", "score": 0.123, "percentile": 0.456, "band": "HIGH"}, ...]
FOR v_row IN SELECT * FROM jsonb_to_recordset(p_updates) AS x(
instance_id UUID,
score DOUBLE PRECISION,
percentile DOUBLE PRECISION,
band TEXT
)
LOOP
UPDATE vuln_instance_triage SET
previous_epss_band = current_epss_band,
current_epss_score = v_row.score,
current_epss_percentile = v_row.percentile,
current_epss_band = v_row.band,
epss_model_date = p_model_date,
epss_updated_at = p_updated_at
WHERE instance_id = v_row.instance_id
AND (current_epss_band IS DISTINCT FROM v_row.band
OR current_epss_score IS DISTINCT FROM v_row.score);
IF FOUND THEN
v_updated := v_updated + 1;
-- Check if band actually changed
IF (SELECT previous_epss_band FROM vuln_instance_triage WHERE instance_id = v_row.instance_id)
IS DISTINCT FROM v_row.band THEN
v_band_changes := v_band_changes + 1;
END IF;
END IF;
END LOOP;
RETURN QUERY SELECT v_updated, v_band_changes;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION batch_update_epss_triage IS 'Batch updates EPSS data for vulnerability instances, tracking band changes';
-- ============================================================================
-- View for Instances Needing EPSS Update
-- ============================================================================
-- Returns instances with stale or missing EPSS data
CREATE OR REPLACE VIEW v_epss_stale_instances AS
SELECT
vit.instance_id,
vit.cve_id,
vit.tenant_id,
vit.current_epss_band,
vit.epss_model_date,
CURRENT_DATE - COALESCE(vit.epss_model_date, '1970-01-01'::DATE) AS days_stale
FROM vuln_instance_triage vit
WHERE vit.epss_model_date IS NULL
OR vit.epss_model_date < CURRENT_DATE - 1;
COMMENT ON VIEW v_epss_stale_instances IS 'Instances with stale or missing EPSS data, needing enrichment';

View File

@@ -0,0 +1,177 @@
-- =============================================================================
-- Migration: 014_vuln_surfaces.sql
-- Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
-- Task: SURF-014
-- Description: Vulnerability surface storage for trigger method analysis.
-- =============================================================================
BEGIN;
-- Prevent re-running
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'scanner' AND tablename = 'vuln_surfaces') THEN
RAISE EXCEPTION 'Migration 014_vuln_surfaces already applied';
END IF;
END $$;
-- =============================================================================
-- VULN_SURFACES: Computed vulnerability surface for CVE + package + version
-- =============================================================================
CREATE TABLE scanner.vuln_surfaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id),
-- CVE/vulnerability identity
cve_id TEXT NOT NULL,
package_ecosystem TEXT NOT NULL, -- 'nuget', 'npm', 'maven', 'pypi'
package_name TEXT NOT NULL,
vuln_version TEXT NOT NULL, -- Version with vulnerability
fixed_version TEXT, -- First fixed version (null if no fix)
-- Surface computation metadata
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
computation_duration_ms INTEGER,
fingerprint_method TEXT NOT NULL, -- 'cecil-il', 'babel-ast', 'asm-bytecode', 'python-ast'
-- Summary statistics
total_methods_vuln INTEGER NOT NULL DEFAULT 0,
total_methods_fixed INTEGER NOT NULL DEFAULT 0,
changed_method_count INTEGER NOT NULL DEFAULT 0,
-- DSSE attestation (optional)
attestation_digest TEXT,
-- Indexes for lookups
CONSTRAINT uq_vuln_surface_key UNIQUE (tenant_id, cve_id, package_ecosystem, package_name, vuln_version)
);
-- Indexes for common queries
CREATE INDEX idx_vuln_surfaces_cve ON scanner.vuln_surfaces(tenant_id, cve_id);
CREATE INDEX idx_vuln_surfaces_package ON scanner.vuln_surfaces(tenant_id, package_ecosystem, package_name);
CREATE INDEX idx_vuln_surfaces_computed_at ON scanner.vuln_surfaces(computed_at DESC);
COMMENT ON TABLE scanner.vuln_surfaces IS 'Computed vulnerability surfaces identifying which methods changed between vulnerable and fixed versions';
-- =============================================================================
-- VULN_SURFACE_SINKS: Individual trigger methods for a vulnerability surface
-- =============================================================================
CREATE TABLE scanner.vuln_surface_sinks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
surface_id UUID NOT NULL REFERENCES scanner.vuln_surfaces(id) ON DELETE CASCADE,
-- Method identity
method_key TEXT NOT NULL, -- Normalized method signature (FQN)
method_name TEXT NOT NULL, -- Simple method name
declaring_type TEXT NOT NULL, -- Containing class/module
namespace TEXT, -- Namespace/package
-- Change classification
change_type TEXT NOT NULL CHECK (change_type IN ('added', 'removed', 'modified')),
-- Fingerprints for comparison
vuln_fingerprint TEXT, -- Hash in vulnerable version (null if added in fix)
fixed_fingerprint TEXT, -- Hash in fixed version (null if removed in fix)
-- Metadata
is_public BOOLEAN NOT NULL DEFAULT true,
parameter_count INTEGER,
return_type TEXT,
-- Source location (if available from debug symbols)
source_file TEXT,
start_line INTEGER,
end_line INTEGER,
-- Indexes for lookups
CONSTRAINT uq_surface_sink_key UNIQUE (surface_id, method_key)
);
-- Indexes for common queries
CREATE INDEX idx_vuln_surface_sinks_surface ON scanner.vuln_surface_sinks(surface_id);
CREATE INDEX idx_vuln_surface_sinks_method ON scanner.vuln_surface_sinks(method_name);
CREATE INDEX idx_vuln_surface_sinks_type ON scanner.vuln_surface_sinks(declaring_type);
COMMENT ON TABLE scanner.vuln_surface_sinks IS 'Individual methods that changed between vulnerable and fixed package versions';
-- =============================================================================
-- VULN_SURFACE_TRIGGERS: Links sinks to call graph nodes where they are invoked
-- =============================================================================
CREATE TABLE scanner.vuln_surface_triggers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sink_id UUID NOT NULL REFERENCES scanner.vuln_surface_sinks(id) ON DELETE CASCADE,
scan_id UUID NOT NULL, -- References scanner.scans
-- Caller identity
caller_node_id TEXT NOT NULL, -- Call graph node ID
caller_method_key TEXT NOT NULL, -- FQN of calling method
caller_file TEXT, -- Source file of caller
caller_line INTEGER, -- Line number of call
-- Reachability analysis
reachability_bucket TEXT NOT NULL DEFAULT 'unknown', -- 'entrypoint', 'direct', 'runtime', 'unknown', 'unreachable'
path_length INTEGER, -- Shortest path from entrypoint
confidence REAL NOT NULL DEFAULT 0.5,
-- Evidence
call_type TEXT NOT NULL DEFAULT 'direct', -- 'direct', 'virtual', 'interface', 'reflection'
is_conditional BOOLEAN NOT NULL DEFAULT false,
-- Indexes for lookups
CONSTRAINT uq_trigger_key UNIQUE (sink_id, scan_id, caller_node_id)
);
-- Indexes for common queries
CREATE INDEX idx_vuln_surface_triggers_sink ON scanner.vuln_surface_triggers(sink_id);
CREATE INDEX idx_vuln_surface_triggers_scan ON scanner.vuln_surface_triggers(scan_id);
CREATE INDEX idx_vuln_surface_triggers_bucket ON scanner.vuln_surface_triggers(reachability_bucket);
COMMENT ON TABLE scanner.vuln_surface_triggers IS 'Links between vulnerability sink methods and their callers in analyzed code';
-- =============================================================================
-- RLS (Row Level Security)
-- =============================================================================
ALTER TABLE scanner.vuln_surfaces ENABLE ROW LEVEL SECURITY;
-- Tenant isolation policy
CREATE POLICY vuln_surfaces_tenant_isolation ON scanner.vuln_surfaces
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- Note: vuln_surface_sinks and triggers inherit isolation through FK to surfaces
-- =============================================================================
-- FUNCTIONS
-- =============================================================================
-- Get surface statistics for a CVE
CREATE OR REPLACE FUNCTION scanner.get_vuln_surface_stats(
p_tenant_id UUID,
p_cve_id TEXT
)
RETURNS TABLE (
package_ecosystem TEXT,
package_name TEXT,
vuln_version TEXT,
fixed_version TEXT,
changed_method_count INTEGER,
trigger_count BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
vs.package_ecosystem,
vs.package_name,
vs.vuln_version,
vs.fixed_version,
vs.changed_method_count,
COUNT(DISTINCT vst.id)::BIGINT AS trigger_count
FROM scanner.vuln_surfaces vs
LEFT JOIN scanner.vuln_surface_sinks vss ON vss.surface_id = vs.id
LEFT JOIN scanner.vuln_surface_triggers vst ON vst.sink_id = vss.id
WHERE vs.tenant_id = p_tenant_id
AND vs.cve_id = p_cve_id
GROUP BY vs.id, vs.package_ecosystem, vs.package_name, vs.vuln_version, vs.fixed_version, vs.changed_method_count
ORDER BY vs.package_ecosystem, vs.package_name;
END;
$$ LANGUAGE plpgsql STABLE;
COMMIT;

View File

@@ -12,4 +12,9 @@ internal static class MigrationIds
public const string EpssIntegration = "008_epss_integration.sql";
public const string CallGraphTables = "009_call_graph_tables.sql";
public const string ReachabilityDriftTables = "010_reachability_drift_tables.sql";
public const string EpssRawLayer = "011_epss_raw_layer.sql";
public const string EpssSignalLayer = "012_epss_signal_layer.sql";
public const string WitnessStorage = "013_witness_storage.sql";
public const string EpssTriageColumns = "014_epss_triage_columns.sql";
}

View File

@@ -0,0 +1,228 @@
// -----------------------------------------------------------------------------
// PostgresEpssRawRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: R1-R4 - EPSS Raw Feed Layer
// Description: PostgreSQL implementation of IEpssRawRepository.
// -----------------------------------------------------------------------------
using Dapper;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IEpssRawRepository"/>.
/// </summary>
public sealed class PostgresEpssRawRepository : IEpssRawRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string RawTable => $"{SchemaName}.epss_raw";
public PostgresEpssRawRepository(ScannerDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<EpssRaw> CreateAsync(EpssRaw raw, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(raw);
var sql = $"""
INSERT INTO {RawTable} (
source_uri, asof_date, payload, payload_sha256,
header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
)
VALUES (
@SourceUri, @AsOfDate, @Payload::jsonb, @PayloadSha256,
@HeaderComment, @ModelVersion, @PublishedDate,
@RowCount, @CompressedSize, @DecompressedSize, @ImportRunId
)
ON CONFLICT (source_uri, asof_date, payload_sha256) DO NOTHING
RETURNING raw_id, ingestion_ts
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long raw_id, DateTimeOffset ingestion_ts)?>(sql, new
{
raw.SourceUri,
AsOfDate = raw.AsOfDate.ToDateTime(TimeOnly.MinValue),
raw.Payload,
raw.PayloadSha256,
raw.HeaderComment,
raw.ModelVersion,
PublishedDate = raw.PublishedDate?.ToDateTime(TimeOnly.MinValue),
raw.RowCount,
raw.CompressedSize,
raw.DecompressedSize,
raw.ImportRunId
});
if (result.HasValue)
{
return raw with
{
RawId = result.Value.raw_id,
IngestionTs = result.Value.ingestion_ts
};
}
// Record already exists (idempotency), fetch existing
var existing = await GetByDateAsync(raw.AsOfDate, cancellationToken);
return existing ?? raw;
}
public async Task<EpssRaw?> GetByDateAsync(DateOnly asOfDate, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE asof_date = @AsOfDate
ORDER BY ingestion_ts DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue)
});
return row.HasValue ? MapToRaw(row.Value) : null;
}
public async Task<IReadOnlyList<EpssRaw>> GetByDateRangeAsync(
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE asof_date >= @StartDate AND asof_date <= @EndDate
ORDER BY asof_date DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
EndDate = endDate.ToDateTime(TimeOnly.MinValue)
});
return rows.Select(MapToRaw).ToList();
}
public async Task<EpssRaw?> GetLatestAsync(CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
ORDER BY asof_date DESC, ingestion_ts DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql);
return row.HasValue ? MapToRaw(row.Value) : null;
}
public async Task<bool> ExistsAsync(DateOnly asOfDate, byte[] payloadSha256, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT EXISTS (
SELECT 1 FROM {RawTable}
WHERE asof_date = @AsOfDate AND payload_sha256 = @PayloadSha256
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue),
PayloadSha256 = payloadSha256
});
}
public async Task<IReadOnlyList<EpssRaw>> GetByModelVersionAsync(
string modelVersion,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE model_version = @ModelVersion
ORDER BY asof_date DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
ModelVersion = modelVersion,
Limit = limit
});
return rows.Select(MapToRaw).ToList();
}
public async Task<int> PruneAsync(int retentionDays = 365, CancellationToken cancellationToken = default)
{
var sql = $"SELECT {SchemaName}.prune_epss_raw(@RetentionDays)";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
}
private static EpssRaw MapToRaw(RawRow row)
{
return new EpssRaw
{
RawId = row.raw_id,
SourceUri = row.source_uri,
AsOfDate = DateOnly.FromDateTime(row.asof_date),
IngestionTs = row.ingestion_ts,
Payload = row.payload,
PayloadSha256 = row.payload_sha256,
HeaderComment = row.header_comment,
ModelVersion = row.model_version,
PublishedDate = row.published_date.HasValue ? DateOnly.FromDateTime(row.published_date.Value) : null,
RowCount = row.row_count,
CompressedSize = row.compressed_size,
DecompressedSize = row.decompressed_size,
ImportRunId = row.import_run_id
};
}
private readonly record struct RawRow(
long raw_id,
string source_uri,
DateTime asof_date,
DateTimeOffset ingestion_ts,
string payload,
byte[] payload_sha256,
string? header_comment,
string? model_version,
DateTime? published_date,
int row_count,
long? compressed_size,
long? decompressed_size,
Guid? import_run_id);
}

View File

@@ -9,6 +9,7 @@ using System.Data;
using Dapper;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
@@ -481,6 +482,61 @@ public sealed class PostgresEpssRepository : IEpssRepository
cancellationToken: cancellationToken)).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<EpssChangeRecord>> GetChangesAsync(
DateOnly modelDate,
Core.Epss.EpssChangeFlags? flags = null,
int limit = 100000,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
cve_id,
flags,
prev_score,
new_score,
new_percentile,
prev_band,
model_date
FROM {ChangesTable}
WHERE model_date = @ModelDate
{(flags.HasValue ? "AND (flags & @Flags) != 0" : "")}
ORDER BY new_score DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<ChangeRow>(sql, new
{
ModelDate = modelDate,
Flags = flags.HasValue ? (int)flags.Value : 0,
Limit = limit
});
return rows.Select(r => new EpssChangeRecord
{
CveId = r.cve_id,
Flags = (Core.Epss.EpssChangeFlags)r.flags,
PreviousScore = r.prev_score,
NewScore = r.new_score,
NewPercentile = r.new_percentile,
PreviousBand = (Core.Epss.EpssPriorityBand)r.prev_band,
ModelDate = r.model_date
}).ToList();
}
private sealed class ChangeRow
{
public string cve_id { get; set; } = "";
public int flags { get; set; }
public double? prev_score { get; set; }
public double new_score { get; set; }
public double new_percentile { get; set; }
public int prev_band { get; set; }
public DateOnly model_date { get; set; }
}
private sealed class StageCounts
{
public int distinct_count { get; set; }

View File

@@ -0,0 +1,395 @@
// -----------------------------------------------------------------------------
// PostgresEpssSignalRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S3 - Implement PostgresEpssSignalRepository
// Description: PostgreSQL implementation of IEpssSignalRepository.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Dapper;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IEpssSignalRepository"/>.
/// </summary>
public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string SignalTable => $"{SchemaName}.epss_signal";
private string ConfigTable => $"{SchemaName}.epss_signal_config";
public PostgresEpssSignalRepository(ScannerDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<EpssSignal> CreateAsync(EpssSignal signal, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(signal);
var sql = $"""
INSERT INTO {SignalTable} (
tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload
)
VALUES (
@TenantId, @ModelDate, @CveId, @EventType, @RiskBand,
@EpssScore, @EpssDelta, @Percentile, @PercentileDelta,
@IsModelChange, @ModelVersion, @DedupeKey, @ExplainHash, @Payload::jsonb
)
ON CONFLICT (tenant_id, dedupe_key) DO NOTHING
RETURNING signal_id, created_at
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long signal_id, DateTimeOffset created_at)?>(sql, new
{
signal.TenantId,
ModelDate = signal.ModelDate.ToDateTime(TimeOnly.MinValue),
signal.CveId,
signal.EventType,
signal.RiskBand,
signal.EpssScore,
signal.EpssDelta,
signal.Percentile,
signal.PercentileDelta,
signal.IsModelChange,
signal.ModelVersion,
signal.DedupeKey,
signal.ExplainHash,
signal.Payload
});
if (result.HasValue)
{
return signal with
{
SignalId = result.Value.signal_id,
CreatedAt = result.Value.created_at
};
}
// Signal already exists (dedupe), fetch existing
var existing = await GetByDedupeKeyAsync(signal.TenantId, signal.DedupeKey, cancellationToken);
return existing ?? signal;
}
public async Task<int> CreateBulkAsync(IEnumerable<EpssSignal> signals, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(signals);
var signalList = signals.ToList();
if (signalList.Count == 0)
{
return 0;
}
var sql = $"""
INSERT INTO {SignalTable} (
tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload
)
VALUES (
@TenantId, @ModelDate, @CveId, @EventType, @RiskBand,
@EpssScore, @EpssDelta, @Percentile, @PercentileDelta,
@IsModelChange, @ModelVersion, @DedupeKey, @ExplainHash, @Payload::jsonb
)
ON CONFLICT (tenant_id, dedupe_key) DO NOTHING
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var inserted = 0;
foreach (var signal in signalList)
{
var affected = await connection.ExecuteAsync(sql, new
{
signal.TenantId,
ModelDate = signal.ModelDate.ToDateTime(TimeOnly.MinValue),
signal.CveId,
signal.EventType,
signal.RiskBand,
signal.EpssScore,
signal.EpssDelta,
signal.Percentile,
signal.PercentileDelta,
signal.IsModelChange,
signal.ModelVersion,
signal.DedupeKey,
signal.ExplainHash,
signal.Payload
}, transaction);
inserted += affected;
}
await transaction.CommitAsync(cancellationToken);
return inserted;
}
public async Task<IReadOnlyList<EpssSignal>> GetByTenantAsync(
Guid tenantId,
DateOnly startDate,
DateOnly endDate,
IEnumerable<string>? eventTypes = null,
CancellationToken cancellationToken = default)
{
var eventTypeList = eventTypes?.ToList();
var hasEventTypeFilter = eventTypeList?.Count > 0;
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId
AND model_date >= @StartDate
AND model_date <= @EndDate
{(hasEventTypeFilter ? "AND event_type = ANY(@EventTypes)" : "")}
ORDER BY model_date DESC, created_at DESC
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
TenantId = tenantId,
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
EndDate = endDate.ToDateTime(TimeOnly.MinValue),
EventTypes = eventTypeList?.ToArray()
});
return rows.Select(MapToSignal).ToList();
}
public async Task<IReadOnlyList<EpssSignal>> GetByCveAsync(
Guid tenantId,
string cveId,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId
AND cve_id = @CveId
ORDER BY model_date DESC, created_at DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
TenantId = tenantId,
CveId = cveId,
Limit = limit
});
return rows.Select(MapToSignal).ToList();
}
public async Task<IReadOnlyList<EpssSignal>> GetHighPriorityAsync(
Guid tenantId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId
AND model_date >= @StartDate
AND model_date <= @EndDate
AND risk_band IN ('CRITICAL', 'HIGH')
ORDER BY model_date DESC, created_at DESC
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
TenantId = tenantId,
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
EndDate = endDate.ToDateTime(TimeOnly.MinValue)
});
return rows.Select(MapToSignal).ToList();
}
public async Task<EpssSignalConfig?> GetConfigAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
config_id, tenant_id,
critical_percentile, high_percentile, medium_percentile,
big_jump_delta, suppress_on_model_change, enabled_event_types,
created_at, updated_at
FROM {ConfigTable}
WHERE tenant_id = @TenantId
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<ConfigRow?>(sql, new { TenantId = tenantId });
return row.HasValue ? MapToConfig(row.Value) : null;
}
public async Task<EpssSignalConfig> UpsertConfigAsync(EpssSignalConfig config, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(config);
var sql = $"""
INSERT INTO {ConfigTable} (
tenant_id, critical_percentile, high_percentile, medium_percentile,
big_jump_delta, suppress_on_model_change, enabled_event_types
)
VALUES (
@TenantId, @CriticalPercentile, @HighPercentile, @MediumPercentile,
@BigJumpDelta, @SuppressOnModelChange, @EnabledEventTypes
)
ON CONFLICT (tenant_id) DO UPDATE SET
critical_percentile = EXCLUDED.critical_percentile,
high_percentile = EXCLUDED.high_percentile,
medium_percentile = EXCLUDED.medium_percentile,
big_jump_delta = EXCLUDED.big_jump_delta,
suppress_on_model_change = EXCLUDED.suppress_on_model_change,
enabled_event_types = EXCLUDED.enabled_event_types,
updated_at = now()
RETURNING config_id, created_at, updated_at
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var result = await connection.QueryFirstAsync<(Guid config_id, DateTimeOffset created_at, DateTimeOffset updated_at)>(sql, new
{
config.TenantId,
config.CriticalPercentile,
config.HighPercentile,
config.MediumPercentile,
config.BigJumpDelta,
config.SuppressOnModelChange,
EnabledEventTypes = config.EnabledEventTypes.ToArray()
});
return config with
{
ConfigId = result.config_id,
CreatedAt = result.created_at,
UpdatedAt = result.updated_at
};
}
public async Task<int> PruneAsync(int retentionDays = 90, CancellationToken cancellationToken = default)
{
var sql = $"SELECT {SchemaName}.prune_epss_signals(@RetentionDays)";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
}
private async Task<EpssSignal?> GetByDedupeKeyAsync(Guid tenantId, string dedupeKey, CancellationToken cancellationToken)
{
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId AND dedupe_key = @DedupeKey
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<SignalRow?>(sql, new { TenantId = tenantId, DedupeKey = dedupeKey });
return row.HasValue ? MapToSignal(row.Value) : null;
}
private static EpssSignal MapToSignal(SignalRow row)
{
return new EpssSignal
{
SignalId = row.signal_id,
TenantId = row.tenant_id,
ModelDate = DateOnly.FromDateTime(row.model_date),
CveId = row.cve_id,
EventType = row.event_type,
RiskBand = row.risk_band,
EpssScore = row.epss_score,
EpssDelta = row.epss_delta,
Percentile = row.percentile,
PercentileDelta = row.percentile_delta,
IsModelChange = row.is_model_change,
ModelVersion = row.model_version,
DedupeKey = row.dedupe_key,
ExplainHash = row.explain_hash,
Payload = row.payload,
CreatedAt = row.created_at
};
}
private static EpssSignalConfig MapToConfig(ConfigRow row)
{
return new EpssSignalConfig
{
ConfigId = row.config_id,
TenantId = row.tenant_id,
CriticalPercentile = row.critical_percentile,
HighPercentile = row.high_percentile,
MediumPercentile = row.medium_percentile,
BigJumpDelta = row.big_jump_delta,
SuppressOnModelChange = row.suppress_on_model_change,
EnabledEventTypes = row.enabled_event_types ?? Array.Empty<string>(),
CreatedAt = row.created_at,
UpdatedAt = row.updated_at
};
}
private readonly record struct SignalRow(
long signal_id,
Guid tenant_id,
DateTime model_date,
string cve_id,
string event_type,
string? risk_band,
double? epss_score,
double? epss_delta,
double? percentile,
double? percentile_delta,
bool is_model_change,
string? model_version,
string dedupe_key,
byte[] explain_hash,
string payload,
DateTimeOffset created_at);
private readonly record struct ConfigRow(
Guid config_id,
Guid tenant_id,
double critical_percentile,
double high_percentile,
double medium_percentile,
double big_jump_delta,
bool suppress_on_model_change,
string[]? enabled_event_types,
DateTimeOffset created_at,
DateTimeOffset updated_at);
}

View File

@@ -0,0 +1,152 @@
// -----------------------------------------------------------------------------
// PostgresObservedCveRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S6 - Add observed CVEs filter
// Description: PostgreSQL implementation of IObservedCveRepository.
// -----------------------------------------------------------------------------
using Dapper;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IObservedCveRepository"/>.
/// Queries vuln_instance_triage to determine which CVEs are observed per tenant.
/// </summary>
public sealed class PostgresObservedCveRepository : IObservedCveRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string TriageTable => $"{SchemaName}.vuln_instance_triage";
public PostgresObservedCveRepository(ScannerDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<IReadOnlySet<string>> GetObservedCvesAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT DISTINCT cve_id
FROM {TriageTable}
WHERE tenant_id = @TenantId
AND cve_id IS NOT NULL
AND cve_id LIKE 'CVE-%'
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var cves = await connection.QueryAsync<string>(sql, new { TenantId = tenantId });
return new HashSet<string>(cves, StringComparer.OrdinalIgnoreCase);
}
public async Task<bool> IsObservedAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT EXISTS (
SELECT 1 FROM {TriageTable}
WHERE tenant_id = @TenantId
AND cve_id = @CveId
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new { TenantId = tenantId, CveId = cveId });
}
public async Task<IReadOnlySet<string>> FilterObservedAsync(
Guid tenantId,
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
var cveList = cveIds.ToList();
if (cveList.Count == 0)
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
var sql = $"""
SELECT DISTINCT cve_id
FROM {TriageTable}
WHERE tenant_id = @TenantId
AND cve_id = ANY(@CveIds)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var observed = await connection.QueryAsync<string>(sql, new
{
TenantId = tenantId,
CveIds = cveList.ToArray()
});
return new HashSet<string>(observed, StringComparer.OrdinalIgnoreCase);
}
public async Task<IReadOnlyList<Guid>> GetActiveTenantsAsync(
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT DISTINCT tenant_id
FROM {TriageTable}
WHERE cve_id IS NOT NULL
AND cve_id LIKE 'CVE-%'
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var tenants = await connection.QueryAsync<Guid>(sql);
return tenants.ToList();
}
public async Task<IReadOnlyDictionary<string, IReadOnlyList<Guid>>> GetTenantsObservingCvesAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
var cveList = cveIds.ToList();
if (cveList.Count == 0)
{
return new Dictionary<string, IReadOnlyList<Guid>>(StringComparer.OrdinalIgnoreCase);
}
var sql = $"""
SELECT cve_id, tenant_id
FROM {TriageTable}
WHERE cve_id = ANY(@CveIds)
GROUP BY cve_id, tenant_id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<(string cve_id, Guid tenant_id)>(sql, new
{
CveIds = cveList.ToArray()
});
var result = new Dictionary<string, List<Guid>>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
if (!result.TryGetValue(row.cve_id, out var tenants))
{
tenants = new List<Guid>();
result[row.cve_id] = tenants;
}
if (!tenants.Contains(row.tenant_id))
{
tenants.Add(row.tenant_id);
}
}
return result.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<Guid>)kvp.Value,
StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,152 @@
// -----------------------------------------------------------------------------
// IEpssRawRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: R1-R4 - EPSS Raw Feed Layer
// Description: Repository interface for immutable EPSS raw payload storage.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Repository for immutable EPSS raw payload storage.
/// Layer 1 of the 3-layer EPSS architecture: stores full CSV payload as JSONB.
/// </summary>
public interface IEpssRawRepository
{
/// <summary>
/// Stores a raw EPSS payload.
/// </summary>
/// <param name="raw">The raw payload to store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The stored record with generated ID.</returns>
Task<EpssRaw> CreateAsync(EpssRaw raw, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a raw payload by as-of date.
/// </summary>
/// <param name="asOfDate">The date of the EPSS snapshot.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw payload, or null if not found.</returns>
Task<EpssRaw?> GetByDateAsync(DateOnly asOfDate, CancellationToken cancellationToken = default);
/// <summary>
/// Gets raw payloads within a date range.
/// </summary>
/// <param name="startDate">Start date (inclusive).</param>
/// <param name="endDate">End date (inclusive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of raw payloads ordered by date descending.</returns>
Task<IReadOnlyList<EpssRaw>> GetByDateRangeAsync(
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the most recent raw payload.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The most recent raw payload, or null if none exist.</returns>
Task<EpssRaw?> GetLatestAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a raw payload exists for a given date and content hash.
/// Used for idempotency checks.
/// </summary>
/// <param name="asOfDate">The date of the EPSS snapshot.</param>
/// <param name="payloadSha256">SHA-256 hash of decompressed content.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the payload already exists.</returns>
Task<bool> ExistsAsync(DateOnly asOfDate, byte[] payloadSha256, CancellationToken cancellationToken = default);
/// <summary>
/// Gets payloads by model version.
/// Useful for detecting model version changes.
/// </summary>
/// <param name="modelVersion">The model version string.</param>
/// <param name="limit">Maximum number of records to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of raw payloads with the specified model version.</returns>
Task<IReadOnlyList<EpssRaw>> GetByModelVersionAsync(
string modelVersion,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Prunes old raw payloads based on retention policy.
/// </summary>
/// <param name="retentionDays">Number of days to retain. Default: 365.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of records deleted.</returns>
Task<int> PruneAsync(int retentionDays = 365, CancellationToken cancellationToken = default);
}
/// <summary>
/// EPSS raw payload entity.
/// </summary>
public sealed record EpssRaw
{
/// <summary>
/// Raw record ID (auto-generated).
/// </summary>
public long RawId { get; init; }
/// <summary>
/// Source URI where the data was retrieved from.
/// </summary>
public required string SourceUri { get; init; }
/// <summary>
/// Date of the EPSS snapshot.
/// </summary>
public required DateOnly AsOfDate { get; init; }
/// <summary>
/// Timestamp when the data was ingested.
/// </summary>
public DateTimeOffset IngestionTs { get; init; }
/// <summary>
/// Full payload as JSON array: [{cve:"CVE-...", epss:0.123, percentile:0.456}, ...].
/// </summary>
public required string Payload { get; init; }
/// <summary>
/// SHA-256 hash of decompressed content for integrity verification.
/// </summary>
public required byte[] PayloadSha256 { get; init; }
/// <summary>
/// Raw comment line from CSV header (e.g., "# model: v2025.03.14, published: 2025-03-14").
/// </summary>
public string? HeaderComment { get; init; }
/// <summary>
/// Extracted model version from header comment.
/// </summary>
public string? ModelVersion { get; init; }
/// <summary>
/// Extracted publish date from header comment.
/// </summary>
public DateOnly? PublishedDate { get; init; }
/// <summary>
/// Number of rows in the payload.
/// </summary>
public required int RowCount { get; init; }
/// <summary>
/// Original compressed file size (bytes).
/// </summary>
public long? CompressedSize { get; init; }
/// <summary>
/// Decompressed CSV size (bytes).
/// </summary>
public long? DecompressedSize { get; init; }
/// <summary>
/// Reference to the import run that created this record.
/// </summary>
public Guid? ImportRunId { get; init; }
}

View File

@@ -5,6 +5,7 @@
// Description: EPSS persistence contract (import runs, scores/current projection, change log).
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
namespace StellaOps.Scanner.Storage.Repositories;
@@ -54,6 +55,21 @@ public interface IEpssRepository
string cveId,
int days,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets EPSS change records for a model date, optionally filtered by flags.
/// Used by enrichment job to target only CVEs with material changes.
/// </summary>
/// <param name="modelDate">The EPSS model date.</param>
/// <param name="flags">Change flags to filter by. Null returns all changes.</param>
/// <param name="limit">Maximum number of records to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of change records matching the criteria.</returns>
Task<IReadOnlyList<EpssChangeRecord>> GetChangesAsync(
DateOnly modelDate,
EpssChangeFlags? flags = null,
int limit = 100000,
CancellationToken cancellationToken = default);
}
public sealed record EpssImportRun(

View File

@@ -0,0 +1,242 @@
// -----------------------------------------------------------------------------
// IEpssSignalRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S2 - Implement IEpssSignalRepository interface
// Description: Repository interface for EPSS signal-ready events.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Repository for EPSS signal-ready events (tenant-scoped).
/// </summary>
public interface IEpssSignalRepository
{
/// <summary>
/// Creates a new EPSS signal.
/// </summary>
/// <param name="signal">The signal to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created signal with generated ID.</returns>
Task<EpssSignal> CreateAsync(EpssSignal signal, CancellationToken cancellationToken = default);
/// <summary>
/// Creates multiple EPSS signals in bulk.
/// Uses upsert with dedupe_key to prevent duplicates.
/// </summary>
/// <param name="signals">The signals to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of signals created (excluding duplicates).</returns>
Task<int> CreateBulkAsync(IEnumerable<EpssSignal> signals, CancellationToken cancellationToken = default);
/// <summary>
/// Gets signals for a tenant within a date range.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="startDate">Start date (inclusive).</param>
/// <param name="endDate">End date (inclusive).</param>
/// <param name="eventTypes">Optional filter by event types.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of signals ordered by model_date descending.</returns>
Task<IReadOnlyList<EpssSignal>> GetByTenantAsync(
Guid tenantId,
DateOnly startDate,
DateOnly endDate,
IEnumerable<string>? eventTypes = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets signals for a specific CVE within a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="limit">Maximum number of signals to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of signals ordered by model_date descending.</returns>
Task<IReadOnlyList<EpssSignal>> GetByCveAsync(
Guid tenantId,
string cveId,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets high-priority signals (CRITICAL/HIGH band) for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="startDate">Start date (inclusive).</param>
/// <param name="endDate">End date (inclusive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of high-priority signals.</returns>
Task<IReadOnlyList<EpssSignal>> GetHighPriorityAsync(
Guid tenantId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the signal configuration for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The configuration, or null if not configured.</returns>
Task<EpssSignalConfig?> GetConfigAsync(Guid tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Upserts the signal configuration for a tenant.
/// </summary>
/// <param name="config">The configuration to upsert.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The upserted configuration.</returns>
Task<EpssSignalConfig> UpsertConfigAsync(EpssSignalConfig config, CancellationToken cancellationToken = default);
/// <summary>
/// Prunes old signals based on retention policy.
/// </summary>
/// <param name="retentionDays">Number of days to retain. Default: 90.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of signals deleted.</returns>
Task<int> PruneAsync(int retentionDays = 90, CancellationToken cancellationToken = default);
}
/// <summary>
/// EPSS signal entity.
/// </summary>
public sealed record EpssSignal
{
/// <summary>
/// Signal ID (auto-generated).
/// </summary>
public long SignalId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required Guid TenantId { get; init; }
/// <summary>
/// EPSS model date.
/// </summary>
public required DateOnly ModelDate { get; init; }
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Event type: RISK_SPIKE, BAND_CHANGE, NEW_HIGH, DROPPED_LOW, MODEL_UPDATED.
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// Risk band: CRITICAL, HIGH, MEDIUM, LOW.
/// </summary>
public string? RiskBand { get; init; }
/// <summary>
/// EPSS score at signal time.
/// </summary>
public double? EpssScore { get; init; }
/// <summary>
/// EPSS score delta from previous day.
/// </summary>
public double? EpssDelta { get; init; }
/// <summary>
/// EPSS percentile at signal time.
/// </summary>
public double? Percentile { get; init; }
/// <summary>
/// Percentile delta from previous day.
/// </summary>
public double? PercentileDelta { get; init; }
/// <summary>
/// Whether this is a model version change day.
/// </summary>
public bool IsModelChange { get; init; }
/// <summary>
/// EPSS model version.
/// </summary>
public string? ModelVersion { get; init; }
/// <summary>
/// Deterministic deduplication key.
/// </summary>
public required string DedupeKey { get; init; }
/// <summary>
/// SHA-256 of signal inputs for audit trail.
/// </summary>
public required byte[] ExplainHash { get; init; }
/// <summary>
/// Full evidence payload as JSON.
/// </summary>
public required string Payload { get; init; }
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// EPSS signal configuration for a tenant.
/// </summary>
public sealed record EpssSignalConfig
{
/// <summary>
/// Configuration ID.
/// </summary>
public Guid ConfigId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required Guid TenantId { get; init; }
/// <summary>
/// Critical percentile threshold. Default: 0.995.
/// </summary>
public double CriticalPercentile { get; init; } = 0.995;
/// <summary>
/// High percentile threshold. Default: 0.99.
/// </summary>
public double HighPercentile { get; init; } = 0.99;
/// <summary>
/// Medium percentile threshold. Default: 0.90.
/// </summary>
public double MediumPercentile { get; init; } = 0.90;
/// <summary>
/// Big jump delta threshold. Default: 0.10.
/// </summary>
public double BigJumpDelta { get; init; } = 0.10;
/// <summary>
/// Suppress signals on model version change. Default: true.
/// </summary>
public bool SuppressOnModelChange { get; init; } = true;
/// <summary>
/// Enabled event types.
/// </summary>
public IReadOnlyList<string> EnabledEventTypes { get; init; } =
new[] { "RISK_SPIKE", "BAND_CHANGE", "NEW_HIGH" };
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Last update timestamp.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,101 @@
// -----------------------------------------------------------------------------
// IObservedCveRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S6 - Add observed CVEs filter
// Description: Repository interface for tracking observed CVEs per tenant.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Repository for tracking which CVEs are observed (in use) by each tenant.
/// Used to filter EPSS signals to only relevant CVEs.
/// </summary>
public interface IObservedCveRepository
{
/// <summary>
/// Gets the set of CVE IDs that are currently observed by a tenant.
/// Only CVEs that exist in the tenant's vulnerability inventory.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Set of observed CVE IDs.</returns>
Task<IReadOnlySet<string>> GetObservedCvesAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a CVE is observed by a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the CVE is observed.</returns>
Task<bool> IsObservedAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Filters a set of CVE IDs to only those observed by a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cveIds">CVE IDs to filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Filtered set of observed CVE IDs.</returns>
Task<IReadOnlySet<string>> FilterObservedAsync(
Guid tenantId,
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all tenant IDs that have at least one observed CVE.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of tenant IDs.</returns>
Task<IReadOnlyList<Guid>> GetActiveTenantsAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Gets tenant IDs that observe specific CVEs.
/// Used for targeted signal delivery.
/// </summary>
/// <param name="cveIds">CVE IDs to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary mapping CVE ID to list of tenant IDs observing it.</returns>
Task<IReadOnlyDictionary<string, IReadOnlyList<Guid>>> GetTenantsObservingCvesAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Null implementation of IObservedCveRepository for when tenant filtering is disabled.
/// Returns all CVEs as observed.
/// </summary>
public sealed class NullObservedCveRepository : IObservedCveRepository
{
public static readonly NullObservedCveRepository Instance = new();
private NullObservedCveRepository() { }
public Task<IReadOnlySet<string>> GetObservedCvesAsync(Guid tenantId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase));
public Task<bool> IsObservedAsync(Guid tenantId, string cveId, CancellationToken cancellationToken = default)
=> Task.FromResult(true); // All CVEs are observed when filtering is disabled
public Task<IReadOnlySet<string>> FilterObservedAsync(Guid tenantId, IEnumerable<string> cveIds, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(cveIds, StringComparer.OrdinalIgnoreCase));
public Task<IReadOnlyList<Guid>> GetActiveTenantsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Guid>>(new[] { Guid.Empty });
public Task<IReadOnlyDictionary<string, IReadOnlyList<Guid>>> GetTenantsObservingCvesAsync(IEnumerable<string> cveIds, CancellationToken cancellationToken = default)
{
var result = cveIds.ToDictionary(
cve => cve,
_ => (IReadOnlyList<Guid>)new[] { Guid.Empty },
StringComparer.OrdinalIgnoreCase);
return Task.FromResult<IReadOnlyDictionary<string, IReadOnlyList<Guid>>>(result);
}
}

View File

@@ -0,0 +1,89 @@
// -----------------------------------------------------------------------------
// IWitnessRepository.cs
// Sprint: SPRINT_3700_0001_0001_witness_foundation
// Task: WIT-012
// Description: Repository interface for path witness storage and retrieval.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Repository for DSSE-signed path witnesses.
/// </summary>
public interface IWitnessRepository
{
/// <summary>
/// Stores a witness and returns the assigned ID.
/// </summary>
Task<Guid> StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a witness by its ID.
/// </summary>
Task<WitnessRecord?> GetByIdAsync(Guid witnessId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a witness by its hash.
/// </summary>
Task<WitnessRecord?> GetByHashAsync(string witnessHash, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all witnesses for a given graph hash.
/// </summary>
Task<IReadOnlyList<WitnessRecord>> GetByGraphHashAsync(string graphHash, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves witnesses for a given scan.
/// </summary>
Task<IReadOnlyList<WitnessRecord>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves witnesses for a given CVE.
/// </summary>
Task<IReadOnlyList<WitnessRecord>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default);
/// <summary>
/// Updates a witness with a DSSE envelope after signing.
/// </summary>
Task UpdateDsseEnvelopeAsync(Guid witnessId, string dsseEnvelopeJson, string signerKeyId, CancellationToken cancellationToken = default);
/// <summary>
/// Records a verification attempt for a witness.
/// </summary>
Task RecordVerificationAsync(WitnessVerificationRecord verification, CancellationToken cancellationToken = default);
}
/// <summary>
/// Record representing a stored witness.
/// </summary>
public sealed record WitnessRecord
{
public Guid WitnessId { get; init; }
public required string WitnessHash { get; init; }
public string SchemaVersion { get; init; } = "stellaops.witness.v1";
public required string WitnessType { get; init; }
public required string GraphHash { get; init; }
public Guid? ScanId { get; init; }
public Guid? RunId { get; init; }
public required string PayloadJson { get; init; }
public string? DsseEnvelope { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? SignedAt { get; init; }
public string? SignerKeyId { get; init; }
public string? EntrypointFqn { get; init; }
public string? SinkCve { get; init; }
}
/// <summary>
/// Record representing a witness verification attempt.
/// </summary>
public sealed record WitnessVerificationRecord
{
public Guid VerificationId { get; init; }
public required Guid WitnessId { get; init; }
public DateTimeOffset VerifiedAt { get; init; }
public string? VerifiedBy { get; init; }
public required string VerificationStatus { get; init; }
public string? VerificationError { get; init; }
public string? VerifierKeyId { get; init; }
}

View File

@@ -0,0 +1,277 @@
// -----------------------------------------------------------------------------
// PostgresWitnessRepository.cs
// Sprint: SPRINT_3700_0001_0001_witness_foundation
// Task: WIT-012
// Description: Postgres implementation of IWitnessRepository for witness storage.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Scanner.Storage.Postgres;
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Postgres implementation of <see cref="IWitnessRepository"/>.
/// </summary>
public sealed class PostgresWitnessRepository : IWitnessRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresWitnessRepository> _logger;
public PostgresWitnessRepository(ScannerDataSource dataSource, ILogger<PostgresWitnessRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<Guid> StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(witness);
const string sql = """
INSERT INTO scanner.witnesses (
witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
) VALUES (
@witness_hash, @schema_version, @witness_type, @graph_hash,
@scan_id, @run_id, @payload_json::jsonb, @dsse_envelope::jsonb, @created_at,
@signed_at, @signer_key_id, @entrypoint_fqn, @sink_cve
)
ON CONFLICT (witness_hash) DO UPDATE SET
dsse_envelope = COALESCE(EXCLUDED.dsse_envelope, scanner.witnesses.dsse_envelope),
signed_at = COALESCE(EXCLUDED.signed_at, scanner.witnesses.signed_at),
signer_key_id = COALESCE(EXCLUDED.signer_key_id, scanner.witnesses.signer_key_id)
RETURNING witness_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_hash", witness.WitnessHash);
cmd.Parameters.AddWithValue("schema_version", witness.SchemaVersion);
cmd.Parameters.AddWithValue("witness_type", witness.WitnessType);
cmd.Parameters.AddWithValue("graph_hash", witness.GraphHash);
cmd.Parameters.AddWithValue("scan_id", witness.ScanId.HasValue ? witness.ScanId.Value : DBNull.Value);
cmd.Parameters.AddWithValue("run_id", witness.RunId.HasValue ? witness.RunId.Value : DBNull.Value);
cmd.Parameters.AddWithValue("payload_json", witness.PayloadJson);
cmd.Parameters.AddWithValue("dsse_envelope", string.IsNullOrEmpty(witness.DsseEnvelope) ? DBNull.Value : witness.DsseEnvelope);
cmd.Parameters.AddWithValue("created_at", witness.CreatedAt == default ? DateTimeOffset.UtcNow : witness.CreatedAt);
cmd.Parameters.AddWithValue("signed_at", witness.SignedAt.HasValue ? witness.SignedAt.Value : DBNull.Value);
cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(witness.SignerKeyId) ? DBNull.Value : witness.SignerKeyId);
cmd.Parameters.AddWithValue("entrypoint_fqn", string.IsNullOrEmpty(witness.EntrypointFqn) ? DBNull.Value : witness.EntrypointFqn);
cmd.Parameters.AddWithValue("sink_cve", string.IsNullOrEmpty(witness.SinkCve) ? DBNull.Value : witness.SinkCve);
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
var witnessId = (Guid)result!;
_logger.LogDebug("Stored witness {WitnessId} with hash {WitnessHash}", witnessId, witness.WitnessHash);
return witnessId;
}
public async Task<WitnessRecord?> GetByIdAsync(Guid witnessId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE witness_id = @witness_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", witnessId);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapToRecord(reader);
}
return null;
}
public async Task<WitnessRecord?> GetByHashAsync(string witnessHash, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(witnessHash);
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE witness_hash = @witness_hash
""";
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_hash", witnessHash);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapToRecord(reader);
}
return null;
}
public async Task<IReadOnlyList<WitnessRecord>> GetByGraphHashAsync(string graphHash, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE graph_hash = @graph_hash
ORDER BY created_at DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("graph_hash", graphHash);
var results = new List<WitnessRecord>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
public async Task<IReadOnlyList<WitnessRecord>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE scan_id = @scan_id
ORDER BY created_at DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("scan_id", scanId);
var results = new List<WitnessRecord>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
public async Task<IReadOnlyList<WitnessRecord>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE sink_cve = @sink_cve
ORDER BY created_at DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("sink_cve", cveId);
var results = new List<WitnessRecord>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
public async Task UpdateDsseEnvelopeAsync(Guid witnessId, string dsseEnvelopeJson, string signerKeyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dsseEnvelopeJson);
const string sql = """
UPDATE scanner.witnesses
SET dsse_envelope = @dsse_envelope::jsonb,
signed_at = @signed_at,
signer_key_id = @signer_key_id
WHERE witness_id = @witness_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", witnessId);
cmd.Parameters.AddWithValue("dsse_envelope", dsseEnvelopeJson);
cmd.Parameters.AddWithValue("signed_at", DateTimeOffset.UtcNow);
cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(signerKeyId) ? DBNull.Value : signerKeyId);
var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (affected > 0)
{
_logger.LogDebug("Updated DSSE envelope for witness {WitnessId}", witnessId);
}
}
public async Task RecordVerificationAsync(WitnessVerificationRecord verification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(verification);
const string sql = """
INSERT INTO scanner.witness_verifications (
witness_id, verified_at, verified_by, verification_status,
verification_error, verifier_key_id
) VALUES (
@witness_id, @verified_at, @verified_by, @verification_status,
@verification_error, @verifier_key_id
)
""";
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", verification.WitnessId);
cmd.Parameters.AddWithValue("verified_at", verification.VerifiedAt == default ? DateTimeOffset.UtcNow : verification.VerifiedAt);
cmd.Parameters.AddWithValue("verified_by", string.IsNullOrEmpty(verification.VerifiedBy) ? DBNull.Value : verification.VerifiedBy);
cmd.Parameters.AddWithValue("verification_status", verification.VerificationStatus);
cmd.Parameters.AddWithValue("verification_error", string.IsNullOrEmpty(verification.VerificationError) ? DBNull.Value : verification.VerificationError);
cmd.Parameters.AddWithValue("verifier_key_id", string.IsNullOrEmpty(verification.VerifierKeyId) ? DBNull.Value : verification.VerifierKeyId);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Recorded verification for witness {WitnessId}: {Status}", verification.WitnessId, verification.VerificationStatus);
}
private static WitnessRecord MapToRecord(NpgsqlDataReader reader)
{
return new WitnessRecord
{
WitnessId = reader.GetGuid(0),
WitnessHash = reader.GetString(1),
SchemaVersion = reader.GetString(2),
WitnessType = reader.GetString(3),
GraphHash = reader.GetString(4),
ScanId = reader.IsDBNull(5) ? null : reader.GetGuid(5),
RunId = reader.IsDBNull(6) ? null : reader.GetGuid(6),
PayloadJson = reader.GetString(7),
DsseEnvelope = reader.IsDBNull(8) ? null : reader.GetString(8),
CreatedAt = reader.GetDateTime(9),
SignedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10),
SignerKeyId = reader.IsDBNull(11) ? null : reader.GetString(11),
EntrypointFqn = reader.IsDBNull(12) ? null : reader.GetString(12),
SinkCve = reader.IsDBNull(13) ? null : reader.GetString(13)
};
}
}

View File

@@ -27,5 +27,6 @@
<ProjectReference Include="..\\StellaOps.Scanner.ReachabilityDrift\\StellaOps.Scanner.ReachabilityDrift.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.SmartDiff\\StellaOps.Scanner.SmartDiff.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Messaging\\StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,162 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace StellaOps.Scanner.Triage.Entities;
/// <summary>
/// Read-only view representing the current state of a triage case,
/// combining the latest risk, reachability, and VEX data.
/// </summary>
[Keyless]
public sealed class TriageCaseCurrent
{
/// <summary>
/// The case/finding ID.
/// </summary>
[Column("case_id")]
public Guid CaseId { get; init; }
/// <summary>
/// The asset ID.
/// </summary>
[Column("asset_id")]
public Guid AssetId { get; init; }
/// <summary>
/// Optional environment ID.
/// </summary>
[Column("environment_id")]
public Guid? EnvironmentId { get; init; }
/// <summary>
/// Human-readable asset label.
/// </summary>
[Column("asset_label")]
public string AssetLabel { get; init; } = string.Empty;
/// <summary>
/// Package URL of the affected component.
/// </summary>
[Column("purl")]
public string Purl { get; init; } = string.Empty;
/// <summary>
/// CVE identifier (if vulnerability finding).
/// </summary>
[Column("cve_id")]
public string? CveId { get; init; }
/// <summary>
/// Rule identifier (if policy rule finding).
/// </summary>
[Column("rule_id")]
public string? RuleId { get; init; }
/// <summary>
/// When this finding was first seen.
/// </summary>
[Column("first_seen_at")]
public DateTimeOffset FirstSeenAt { get; init; }
/// <summary>
/// When this finding was last seen.
/// </summary>
[Column("last_seen_at")]
public DateTimeOffset LastSeenAt { get; init; }
// Latest risk result fields
/// <summary>
/// Policy ID from latest risk evaluation.
/// </summary>
[Column("policy_id")]
public string? PolicyId { get; init; }
/// <summary>
/// Policy version from latest risk evaluation.
/// </summary>
[Column("policy_version")]
public string? PolicyVersion { get; init; }
/// <summary>
/// Inputs hash from latest risk evaluation.
/// </summary>
[Column("inputs_hash")]
public string? InputsHash { get; init; }
/// <summary>
/// Risk score (0-100).
/// </summary>
[Column("score")]
public int? Score { get; init; }
/// <summary>
/// Final verdict.
/// </summary>
[Column("verdict")]
public TriageVerdict? Verdict { get; init; }
/// <summary>
/// Current triage lane.
/// </summary>
[Column("lane")]
public TriageLane? Lane { get; init; }
/// <summary>
/// Short narrative explaining the current state.
/// </summary>
[Column("why")]
public string? Why { get; init; }
/// <summary>
/// When the risk was last computed.
/// </summary>
[Column("risk_computed_at")]
public DateTimeOffset? RiskComputedAt { get; init; }
// Latest reachability fields
/// <summary>
/// Reachability determination.
/// </summary>
[Column("reachable")]
public TriageReachability Reachable { get; init; }
/// <summary>
/// Reachability confidence (0-100).
/// </summary>
[Column("reach_confidence")]
public short? ReachConfidence { get; init; }
// Latest VEX fields
/// <summary>
/// VEX status.
/// </summary>
[Column("vex_status")]
public TriageVexStatus? VexStatus { get; init; }
/// <summary>
/// VEX issuer.
/// </summary>
[Column("vex_issuer")]
public string? VexIssuer { get; init; }
/// <summary>
/// VEX signature reference.
/// </summary>
[Column("vex_signature_ref")]
public string? VexSignatureRef { get; init; }
/// <summary>
/// VEX source domain.
/// </summary>
[Column("vex_source_domain")]
public string? VexSourceDomain { get; init; }
/// <summary>
/// VEX source reference.
/// </summary>
[Column("vex_source_ref")]
public string? VexSourceRef { get; init; }
}

View File

@@ -0,0 +1,120 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Scanner.Triage.Entities;
/// <summary>
/// Signed triage decision (mute, ack, exception). Decisions are reversible via revocation.
/// </summary>
[Table("triage_decision")]
public sealed class TriageDecision
{
/// <summary>
/// Unique identifier.
/// </summary>
[Key]
[Column("id")]
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// The finding this decision applies to.
/// </summary>
[Column("finding_id")]
public Guid FindingId { get; init; }
/// <summary>
/// Type of decision.
/// </summary>
[Column("kind")]
public TriageDecisionKind Kind { get; init; }
/// <summary>
/// Reason code for the decision (from a controlled vocabulary).
/// </summary>
[Required]
[Column("reason_code")]
public required string ReasonCode { get; init; }
/// <summary>
/// Optional freeform note from the decision maker.
/// </summary>
[Column("note")]
public string? Note { get; init; }
/// <summary>
/// Reference to the policy that allowed this decision.
/// </summary>
[Column("policy_ref")]
public string? PolicyRef { get; init; }
/// <summary>
/// Time-to-live for the decision (null = indefinite).
/// </summary>
[Column("ttl")]
public DateTimeOffset? Ttl { get; init; }
/// <summary>
/// Authority subject (sub) of the actor who made the decision.
/// </summary>
[Required]
[Column("actor_subject")]
public required string ActorSubject { get; init; }
/// <summary>
/// Display name of the actor.
/// </summary>
[Column("actor_display")]
public string? ActorDisplay { get; init; }
/// <summary>
/// Reference to DSSE signature.
/// </summary>
[Column("signature_ref")]
public string? SignatureRef { get; init; }
/// <summary>
/// Hash of the DSSE envelope.
/// </summary>
[Column("dsse_hash")]
public string? DsseHash { get; init; }
/// <summary>
/// When the decision was created.
/// </summary>
[Column("created_at")]
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// When the decision was revoked (null = active).
/// </summary>
[Column("revoked_at")]
public DateTimeOffset? RevokedAt { get; set; }
/// <summary>
/// Reason for revocation.
/// </summary>
[Column("revoke_reason")]
public string? RevokeReason { get; set; }
/// <summary>
/// Signature reference for revocation.
/// </summary>
[Column("revoke_signature_ref")]
public string? RevokeSignatureRef { get; set; }
/// <summary>
/// DSSE hash for revocation.
/// </summary>
[Column("revoke_dsse_hash")]
public string? RevokeDsseHash { get; set; }
/// <summary>
/// Whether this decision is currently active.
/// </summary>
[NotMapped]
public bool IsActive => RevokedAt is null;
// Navigation property
[ForeignKey(nameof(FindingId))]
public TriageFinding? Finding { get; init; }
}

View File

@@ -0,0 +1,91 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Scanner.Triage.Entities;
/// <summary>
/// Effective VEX status for a finding after merging multiple VEX sources.
/// Preserves provenance pointers for auditability.
/// </summary>
[Table("triage_effective_vex")]
public sealed class TriageEffectiveVex
{
/// <summary>
/// Unique identifier.
/// </summary>
[Key]
[Column("id")]
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// The finding this VEX status applies to.
/// </summary>
[Column("finding_id")]
public Guid FindingId { get; init; }
/// <summary>
/// The effective VEX status after merging.
/// </summary>
[Column("status")]
public TriageVexStatus Status { get; init; }
/// <summary>
/// Source domain that provided this VEX (e.g., "excititor").
/// </summary>
[Required]
[Column("source_domain")]
public required string SourceDomain { get; init; }
/// <summary>
/// Stable reference string to the source document.
/// </summary>
[Required]
[Column("source_ref")]
public required string SourceRef { get; init; }
/// <summary>
/// Array of pruned VEX sources with reasons (for merge transparency).
/// </summary>
[Column("pruned_sources", TypeName = "jsonb")]
public string? PrunedSourcesJson { get; init; }
/// <summary>
/// Hash of the DSSE envelope if signed.
/// </summary>
[Column("dsse_envelope_hash")]
public string? DsseEnvelopeHash { get; init; }
/// <summary>
/// Reference to Rekor/ledger entry for signature verification.
/// </summary>
[Column("signature_ref")]
public string? SignatureRef { get; init; }
/// <summary>
/// Issuer of the VEX document.
/// </summary>
[Column("issuer")]
public string? Issuer { get; init; }
/// <summary>
/// When this VEX status became valid.
/// </summary>
[Column("valid_from")]
public DateTimeOffset ValidFrom { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// When this VEX status expires (null = indefinite).
/// </summary>
[Column("valid_to")]
public DateTimeOffset? ValidTo { get; init; }
/// <summary>
/// When this record was collected.
/// </summary>
[Column("collected_at")]
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UtcNow;
// Navigation property
[ForeignKey(nameof(FindingId))]
public TriageFinding? Finding { get; init; }
}

View File

@@ -0,0 +1,151 @@
namespace StellaOps.Scanner.Triage.Entities;
/// <summary>
/// Triage lane indicating the current workflow state of a finding.
/// </summary>
public enum TriageLane
{
/// <summary>Finding is actively being evaluated.</summary>
Active,
/// <summary>Finding is blocking shipment.</summary>
Blocked,
/// <summary>Finding requires a security exception to proceed.</summary>
NeedsException,
/// <summary>Finding is muted due to reachability analysis (not reachable).</summary>
MutedReach,
/// <summary>Finding is muted due to VEX status (not affected).</summary>
MutedVex,
/// <summary>Finding is mitigated by compensating controls.</summary>
Compensated
}
/// <summary>
/// Final verdict for a triage case.
/// </summary>
public enum TriageVerdict
{
/// <summary>Can ship - no blocking issues.</summary>
Ship,
/// <summary>Cannot ship - blocking issues present.</summary>
Block,
/// <summary>Exception granted - can ship with documented exception.</summary>
Exception
}
/// <summary>
/// Reachability determination result.
/// </summary>
public enum TriageReachability
{
/// <summary>Vulnerable code is reachable.</summary>
Yes,
/// <summary>Vulnerable code is not reachable.</summary>
No,
/// <summary>Reachability cannot be determined.</summary>
Unknown
}
/// <summary>
/// VEX status per OpenVEX specification.
/// </summary>
public enum TriageVexStatus
{
/// <summary>Product is affected by the vulnerability.</summary>
Affected,
/// <summary>Product is not affected by the vulnerability.</summary>
NotAffected,
/// <summary>Investigation is ongoing.</summary>
UnderInvestigation,
/// <summary>Status is unknown.</summary>
Unknown
}
/// <summary>
/// Type of triage decision.
/// </summary>
public enum TriageDecisionKind
{
/// <summary>Mute based on reachability analysis.</summary>
MuteReach,
/// <summary>Mute based on VEX status.</summary>
MuteVex,
/// <summary>Acknowledge the finding without action.</summary>
Ack,
/// <summary>Grant a security exception.</summary>
Exception
}
/// <summary>
/// Trigger that caused a triage snapshot to be created.
/// </summary>
public enum TriageSnapshotTrigger
{
/// <summary>Vulnerability feed was updated.</summary>
FeedUpdate,
/// <summary>VEX document was updated.</summary>
VexUpdate,
/// <summary>SBOM was updated.</summary>
SbomUpdate,
/// <summary>Runtime trace was received.</summary>
RuntimeTrace,
/// <summary>Policy was updated.</summary>
PolicyUpdate,
/// <summary>A triage decision was made.</summary>
Decision,
/// <summary>Manual rescan was triggered.</summary>
Rescan
}
/// <summary>
/// Type of evidence artifact attached to a finding.
/// </summary>
public enum TriageEvidenceType
{
/// <summary>Slice of the SBOM relevant to the finding.</summary>
SbomSlice,
/// <summary>VEX document.</summary>
VexDoc,
/// <summary>Build provenance attestation.</summary>
Provenance,
/// <summary>Callstack or callgraph slice.</summary>
CallstackSlice,
/// <summary>Reachability proof document.</summary>
ReachabilityProof,
/// <summary>Replay manifest for deterministic reproduction.</summary>
ReplayManifest,
/// <summary>Policy document that was applied.</summary>
Policy,
/// <summary>Scan log output.</summary>
ScanLog,
/// <summary>Other evidence type.</summary>
Other
}

View File

@@ -0,0 +1,103 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Scanner.Triage.Entities;
/// <summary>
/// Evidence artifact attached to a finding. Hash-addressed and optionally signed.
/// </summary>
[Table("triage_evidence_artifact")]
public sealed class TriageEvidenceArtifact
{
/// <summary>
/// Unique identifier.
/// </summary>
[Key]
[Column("id")]
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// The finding this evidence applies to.
/// </summary>
[Column("finding_id")]
public Guid FindingId { get; init; }
/// <summary>
/// Type of evidence.
/// </summary>
[Column("type")]
public TriageEvidenceType Type { get; init; }
/// <summary>
/// Human-readable title for the evidence.
/// </summary>
[Required]
[Column("title")]
public required string Title { get; init; }
/// <summary>
/// Issuer of the evidence (if applicable).
/// </summary>
[Column("issuer")]
public string? Issuer { get; init; }
/// <summary>
/// Whether the evidence is cryptographically signed.
/// </summary>
[Column("signed")]
public bool Signed { get; init; }
/// <summary>
/// Entity that signed the evidence.
/// </summary>
[Column("signed_by")]
public string? SignedBy { get; init; }
/// <summary>
/// Content-addressable hash of the artifact.
/// </summary>
[Required]
[Column("content_hash")]
public required string ContentHash { get; init; }
/// <summary>
/// Reference to the signature.
/// </summary>
[Column("signature_ref")]
public string? SignatureRef { get; init; }
/// <summary>
/// MIME type of the artifact.
/// </summary>
[Column("media_type")]
public string? MediaType { get; init; }
/// <summary>
/// URI to the artifact (object store, file path, or inline reference).
/// </summary>
[Required]
[Column("uri")]
public required string Uri { get; init; }
/// <summary>
/// Size of the artifact in bytes.
/// </summary>
[Column("size_bytes")]
public long? SizeBytes { get; init; }
/// <summary>
/// Additional metadata (JSON).
/// </summary>
[Column("metadata", TypeName = "jsonb")]
public string? MetadataJson { get; init; }
/// <summary>
/// When this artifact was created.
/// </summary>
[Column("created_at")]
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
// Navigation property
[ForeignKey(nameof(FindingId))]
public TriageFinding? Finding { get; init; }
}

View File

@@ -0,0 +1,78 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Scanner.Triage.Entities;
/// <summary>
/// Represents a triage finding (case). This is the core entity that ties
/// together all triage-related data for a specific vulnerability/rule
/// on a specific asset.
/// </summary>
[Table("triage_finding")]
public sealed class TriageFinding
{
/// <summary>
/// Unique identifier for the finding (also serves as the case ID).
/// </summary>
[Key]
[Column("id")]
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// The asset this finding applies to.
/// </summary>
[Column("asset_id")]
public Guid AssetId { get; init; }
/// <summary>
/// Optional environment identifier (e.g., prod, staging).
/// </summary>
[Column("environment_id")]
public Guid? EnvironmentId { get; init; }
/// <summary>
/// Human-readable asset label (e.g., "prod/api-gateway:1.2.3").
/// </summary>
[Required]
[Column("asset_label")]
public required string AssetLabel { get; init; }
/// <summary>
/// Package URL identifying the affected component.
/// </summary>
[Required]
[Column("purl")]
public required string Purl { get; init; }
/// <summary>
/// CVE identifier if this is a vulnerability finding.
/// </summary>
[Column("cve_id")]
public string? CveId { get; init; }
/// <summary>
/// Rule identifier if this is a policy rule finding.
/// </summary>
[Column("rule_id")]
public string? RuleId { get; init; }
/// <summary>
/// When this finding was first observed.
/// </summary>
[Column("first_seen_at")]
public DateTimeOffset FirstSeenAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// When this finding was last observed.
/// </summary>
[Column("last_seen_at")]
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
// Navigation properties
public ICollection<TriageEffectiveVex> EffectiveVexRecords { get; init; } = new List<TriageEffectiveVex>();
public ICollection<TriageReachabilityResult> ReachabilityResults { get; init; } = new List<TriageReachabilityResult>();
public ICollection<TriageRiskResult> RiskResults { get; init; } = new List<TriageRiskResult>();
public ICollection<TriageDecision> Decisions { get; init; } = new List<TriageDecision>();
public ICollection<TriageEvidenceArtifact> EvidenceArtifacts { get; init; } = new List<TriageEvidenceArtifact>();
public ICollection<TriageSnapshot> Snapshots { get; init; } = new List<TriageSnapshot>();
}

View File

@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Scanner.Triage.Entities;
/// <summary>
/// Reachability analysis result for a finding.
/// </summary>
[Table("triage_reachability_result")]
public sealed class TriageReachabilityResult
{
/// <summary>
/// Unique identifier.
/// </summary>
[Key]
[Column("id")]
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// The finding this reachability result applies to.
/// </summary>
[Column("finding_id")]
public Guid FindingId { get; init; }
/// <summary>
/// Reachability determination (Yes, No, Unknown).
/// </summary>
[Column("reachable")]
public TriageReachability Reachable { get; init; }
/// <summary>
/// Confidence level (0-100).
/// </summary>
[Column("confidence")]
[Range(0, 100)]
public short Confidence { get; init; }
/// <summary>
/// Reference to static analysis proof (callgraph slice, CFG slice).
/// </summary>
[Column("static_proof_ref")]
public string? StaticProofRef { get; init; }
/// <summary>
/// Reference to runtime proof (runtime trace hits).
/// </summary>
[Column("runtime_proof_ref")]
public string? RuntimeProofRef { get; init; }
/// <summary>
/// Hash of the inputs used to compute reachability (for caching/diffing).
/// </summary>
[Required]
[Column("inputs_hash")]
public required string InputsHash { get; init; }
/// <summary>
/// When this result was computed.
/// </summary>
[Column("computed_at")]
public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow;
// Navigation property
[ForeignKey(nameof(FindingId))]
public TriageFinding? Finding { get; init; }
}

View File

@@ -0,0 +1,87 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Scanner.Triage.Entities;
/// <summary>
/// Risk/lattice result from the scanner's policy evaluation.
/// </summary>
[Table("triage_risk_result")]
public sealed class TriageRiskResult
{
/// <summary>
/// Unique identifier.
/// </summary>
[Key]
[Column("id")]
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// The finding this risk result applies to.
/// </summary>
[Column("finding_id")]
public Guid FindingId { get; init; }
/// <summary>
/// The policy that was applied.
/// </summary>
[Required]
[Column("policy_id")]
public required string PolicyId { get; init; }
/// <summary>
/// Version of the policy that was applied.
/// </summary>
[Required]
[Column("policy_version")]
public required string PolicyVersion { get; init; }
/// <summary>
/// Hash of the inputs used for this evaluation.
/// </summary>
[Required]
[Column("inputs_hash")]
public required string InputsHash { get; init; }
/// <summary>
/// Computed risk score (0-100).
/// </summary>
[Column("score")]
[Range(0, 100)]
public int Score { get; init; }
/// <summary>
/// Final verdict (Ship, Block, Exception).
/// </summary>
[Column("verdict")]
public TriageVerdict Verdict { get; init; }
/// <summary>
/// Current lane based on policy evaluation.
/// </summary>
[Column("lane")]
public TriageLane Lane { get; init; }
/// <summary>
/// Short narrative explaining the decision.
/// </summary>
[Required]
[Column("why")]
public required string Why { get; init; }
/// <summary>
/// Structured lattice explanation for UI diffing (JSON).
/// </summary>
[Column("explanation", TypeName = "jsonb")]
public string? ExplanationJson { get; init; }
/// <summary>
/// When this result was computed.
/// </summary>
[Column("computed_at")]
public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow;
// Navigation property
[ForeignKey(nameof(FindingId))]
public TriageFinding? Finding { get; init; }
}

View File

@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Scanner.Triage.Entities;
/// <summary>
/// Immutable snapshot record for Smart-Diff, capturing input/output changes.
/// </summary>
[Table("triage_snapshot")]
public sealed class TriageSnapshot
{
/// <summary>
/// Unique identifier.
/// </summary>
[Key]
[Column("id")]
public Guid Id { get; init; } = Guid.NewGuid();
/// <summary>
/// The finding this snapshot applies to.
/// </summary>
[Column("finding_id")]
public Guid FindingId { get; init; }
/// <summary>
/// What triggered this snapshot.
/// </summary>
[Column("trigger")]
public TriageSnapshotTrigger Trigger { get; init; }
/// <summary>
/// Previous inputs hash (null for first snapshot).
/// </summary>
[Column("from_inputs_hash")]
public string? FromInputsHash { get; init; }
/// <summary>
/// New inputs hash.
/// </summary>
[Required]
[Column("to_inputs_hash")]
public required string ToInputsHash { get; init; }
/// <summary>
/// Human-readable summary of what changed.
/// </summary>
[Required]
[Column("summary")]
public required string Summary { get; init; }
/// <summary>
/// Precomputed diff in JSON format (optional).
/// </summary>
[Column("diff_json", TypeName = "jsonb")]
public string? DiffJson { get; init; }
/// <summary>
/// When this snapshot was created.
/// </summary>
[Column("created_at")]
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
// Navigation property
[ForeignKey(nameof(FindingId))]
public TriageFinding? Finding { get; init; }
}

View File

@@ -0,0 +1,249 @@
-- Stella Ops Triage Schema Migration
-- Generated from docs/db/triage_schema.sql
-- Version: 1.0.0
BEGIN;
-- Extensions
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Enums
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_lane') THEN
CREATE TYPE triage_lane AS ENUM (
'ACTIVE',
'BLOCKED',
'NEEDS_EXCEPTION',
'MUTED_REACH',
'MUTED_VEX',
'COMPENSATED'
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_verdict') THEN
CREATE TYPE triage_verdict AS ENUM ('SHIP', 'BLOCK', 'EXCEPTION');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_reachability') THEN
CREATE TYPE triage_reachability AS ENUM ('YES', 'NO', 'UNKNOWN');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_vex_status') THEN
CREATE TYPE triage_vex_status AS ENUM ('affected', 'not_affected', 'under_investigation', 'unknown');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_decision_kind') THEN
CREATE TYPE triage_decision_kind AS ENUM ('MUTE_REACH', 'MUTE_VEX', 'ACK', 'EXCEPTION');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_snapshot_trigger') THEN
CREATE TYPE triage_snapshot_trigger AS ENUM (
'FEED_UPDATE',
'VEX_UPDATE',
'SBOM_UPDATE',
'RUNTIME_TRACE',
'POLICY_UPDATE',
'DECISION',
'RESCAN'
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_evidence_type') THEN
CREATE TYPE triage_evidence_type AS ENUM (
'SBOM_SLICE',
'VEX_DOC',
'PROVENANCE',
'CALLSTACK_SLICE',
'REACHABILITY_PROOF',
'REPLAY_MANIFEST',
'POLICY',
'SCAN_LOG',
'OTHER'
);
END IF;
END $$;
-- Core: finding (caseId == findingId)
CREATE TABLE IF NOT EXISTS triage_finding (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL,
environment_id uuid NULL,
asset_label text NOT NULL,
purl text NOT NULL,
cve_id text NULL,
rule_id text NULL,
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (asset_id, environment_id, purl, cve_id, rule_id)
);
CREATE INDEX IF NOT EXISTS ix_triage_finding_last_seen ON triage_finding (last_seen_at DESC);
CREATE INDEX IF NOT EXISTS ix_triage_finding_asset_label ON triage_finding (asset_label);
CREATE INDEX IF NOT EXISTS ix_triage_finding_purl ON triage_finding (purl);
CREATE INDEX IF NOT EXISTS ix_triage_finding_cve ON triage_finding (cve_id);
-- Effective VEX (post-merge)
CREATE TABLE IF NOT EXISTS triage_effective_vex (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
status triage_vex_status NOT NULL,
source_domain text NOT NULL,
source_ref text NOT NULL,
pruned_sources jsonb NULL,
dsse_envelope_hash text NULL,
signature_ref text NULL,
issuer text NULL,
valid_from timestamptz NOT NULL DEFAULT now(),
valid_to timestamptz NULL,
collected_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_triage_effective_vex_finding ON triage_effective_vex (finding_id, collected_at DESC);
-- Reachability results
CREATE TABLE IF NOT EXISTS triage_reachability_result (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
reachable triage_reachability NOT NULL,
confidence smallint NOT NULL CHECK (confidence >= 0 AND confidence <= 100),
static_proof_ref text NULL,
runtime_proof_ref text NULL,
inputs_hash text NOT NULL,
computed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_triage_reachability_finding ON triage_reachability_result (finding_id, computed_at DESC);
-- Risk/lattice result
CREATE TABLE IF NOT EXISTS triage_risk_result (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
policy_id text NOT NULL,
policy_version text NOT NULL,
inputs_hash text NOT NULL,
score int NOT NULL CHECK (score >= 0 AND score <= 100),
verdict triage_verdict NOT NULL,
lane triage_lane NOT NULL,
why text NOT NULL,
explanation jsonb NULL,
computed_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (finding_id, policy_id, policy_version, inputs_hash)
);
CREATE INDEX IF NOT EXISTS ix_triage_risk_finding ON triage_risk_result (finding_id, computed_at DESC);
CREATE INDEX IF NOT EXISTS ix_triage_risk_lane ON triage_risk_result (lane, computed_at DESC);
-- Signed Decisions
CREATE TABLE IF NOT EXISTS triage_decision (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
kind triage_decision_kind NOT NULL,
reason_code text NOT NULL,
note text NULL,
policy_ref text NULL,
ttl timestamptz NULL,
actor_subject text NOT NULL,
actor_display text NULL,
signature_ref text NULL,
dsse_hash text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz NULL,
revoke_reason text NULL,
revoke_signature_ref text NULL,
revoke_dsse_hash text NULL
);
CREATE INDEX IF NOT EXISTS ix_triage_decision_finding ON triage_decision (finding_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_triage_decision_kind ON triage_decision (kind, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_triage_decision_active ON triage_decision (finding_id) WHERE revoked_at IS NULL;
-- Evidence artifacts
CREATE TABLE IF NOT EXISTS triage_evidence_artifact (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
type triage_evidence_type NOT NULL,
title text NOT NULL,
issuer text NULL,
signed boolean NOT NULL DEFAULT false,
signed_by text NULL,
content_hash text NOT NULL,
signature_ref text NULL,
media_type text NULL,
uri text NOT NULL,
size_bytes bigint NULL,
metadata jsonb NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (finding_id, type, content_hash)
);
CREATE INDEX IF NOT EXISTS ix_triage_evidence_finding ON triage_evidence_artifact (finding_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_triage_evidence_type ON triage_evidence_artifact (type, created_at DESC);
-- Snapshots for Smart-Diff
CREATE TABLE IF NOT EXISTS triage_snapshot (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
trigger triage_snapshot_trigger NOT NULL,
from_inputs_hash text NULL,
to_inputs_hash text NOT NULL,
summary text NOT NULL,
diff_json jsonb NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (finding_id, to_inputs_hash, created_at)
);
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_finding ON triage_snapshot (finding_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_trigger ON triage_snapshot (trigger, created_at DESC);
-- Current-case view
CREATE OR REPLACE VIEW v_triage_case_current AS
WITH latest_risk AS (
SELECT DISTINCT ON (finding_id)
finding_id, policy_id, policy_version, inputs_hash, score, verdict, lane, why, computed_at
FROM triage_risk_result
ORDER BY finding_id, computed_at DESC
),
latest_reach AS (
SELECT DISTINCT ON (finding_id)
finding_id, reachable, confidence, static_proof_ref, runtime_proof_ref, computed_at
FROM triage_reachability_result
ORDER BY finding_id, computed_at DESC
),
latest_vex AS (
SELECT DISTINCT ON (finding_id)
finding_id, status, issuer, signature_ref, source_domain, source_ref, collected_at
FROM triage_effective_vex
ORDER BY finding_id, collected_at DESC
)
SELECT
f.id AS case_id,
f.asset_id,
f.environment_id,
f.asset_label,
f.purl,
f.cve_id,
f.rule_id,
f.first_seen_at,
f.last_seen_at,
r.policy_id,
r.policy_version,
r.inputs_hash,
r.score,
r.verdict,
r.lane,
r.why,
r.computed_at AS risk_computed_at,
coalesce(re.reachable, 'UNKNOWN'::triage_reachability) AS reachable,
re.confidence AS reach_confidence,
v.status AS vex_status,
v.issuer AS vex_issuer,
v.signature_ref AS vex_signature_ref,
v.source_domain AS vex_source_domain,
v.source_ref AS vex_source_ref
FROM triage_finding f
LEFT JOIN latest_risk r ON r.finding_id = f.id
LEFT JOIN latest_reach re ON re.finding_id = f.id
LEFT JOIN latest_vex v ON v.finding_id = f.id;
COMMIT;

View File

@@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Scanner.Triage</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0-*" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,228 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Triage.Entities;
namespace StellaOps.Scanner.Triage;
/// <summary>
/// Entity Framework Core DbContext for the Triage schema.
/// </summary>
public sealed class TriageDbContext : DbContext
{
/// <summary>
/// Initializes a new instance of the <see cref="TriageDbContext"/> class.
/// </summary>
public TriageDbContext(DbContextOptions<TriageDbContext> options)
: base(options)
{
}
/// <summary>
/// Triage findings (cases).
/// </summary>
public DbSet<TriageFinding> Findings => Set<TriageFinding>();
/// <summary>
/// Effective VEX records.
/// </summary>
public DbSet<TriageEffectiveVex> EffectiveVex => Set<TriageEffectiveVex>();
/// <summary>
/// Reachability analysis results.
/// </summary>
public DbSet<TriageReachabilityResult> ReachabilityResults => Set<TriageReachabilityResult>();
/// <summary>
/// Risk/lattice evaluation results.
/// </summary>
public DbSet<TriageRiskResult> RiskResults => Set<TriageRiskResult>();
/// <summary>
/// Triage decisions.
/// </summary>
public DbSet<TriageDecision> Decisions => Set<TriageDecision>();
/// <summary>
/// Evidence artifacts.
/// </summary>
public DbSet<TriageEvidenceArtifact> EvidenceArtifacts => Set<TriageEvidenceArtifact>();
/// <summary>
/// Snapshots for Smart-Diff.
/// </summary>
public DbSet<TriageSnapshot> Snapshots => Set<TriageSnapshot>();
/// <summary>
/// Current case view (read-only).
/// </summary>
public DbSet<TriageCaseCurrent> CurrentCases => Set<TriageCaseCurrent>();
/// <inheritdoc/>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure PostgreSQL enums
modelBuilder.HasPostgresEnum<TriageLane>("triage_lane");
modelBuilder.HasPostgresEnum<TriageVerdict>("triage_verdict");
modelBuilder.HasPostgresEnum<TriageReachability>("triage_reachability");
modelBuilder.HasPostgresEnum<TriageVexStatus>("triage_vex_status");
modelBuilder.HasPostgresEnum<TriageDecisionKind>("triage_decision_kind");
modelBuilder.HasPostgresEnum<TriageSnapshotTrigger>("triage_snapshot_trigger");
modelBuilder.HasPostgresEnum<TriageEvidenceType>("triage_evidence_type");
// Configure TriageFinding
modelBuilder.Entity<TriageFinding>(entity =>
{
entity.ToTable("triage_finding");
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.LastSeenAt)
.IsDescending()
.HasDatabaseName("ix_triage_finding_last_seen");
entity.HasIndex(e => e.AssetLabel)
.HasDatabaseName("ix_triage_finding_asset_label");
entity.HasIndex(e => e.Purl)
.HasDatabaseName("ix_triage_finding_purl");
entity.HasIndex(e => e.CveId)
.HasDatabaseName("ix_triage_finding_cve");
entity.HasIndex(e => new { e.AssetId, e.EnvironmentId, e.Purl, e.CveId, e.RuleId })
.IsUnique();
});
// Configure TriageEffectiveVex
modelBuilder.Entity<TriageEffectiveVex>(entity =>
{
entity.ToTable("triage_effective_vex");
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.FindingId, e.CollectedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_effective_vex_finding");
entity.HasOne(e => e.Finding)
.WithMany(f => f.EffectiveVexRecords)
.HasForeignKey(e => e.FindingId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure TriageReachabilityResult
modelBuilder.Entity<TriageReachabilityResult>(entity =>
{
entity.ToTable("triage_reachability_result");
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.FindingId, e.ComputedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_reachability_finding");
entity.HasOne(e => e.Finding)
.WithMany(f => f.ReachabilityResults)
.HasForeignKey(e => e.FindingId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure TriageRiskResult
modelBuilder.Entity<TriageRiskResult>(entity =>
{
entity.ToTable("triage_risk_result");
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.FindingId, e.ComputedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_risk_finding");
entity.HasIndex(e => new { e.Lane, e.ComputedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_risk_lane");
entity.HasIndex(e => new { e.FindingId, e.PolicyId, e.PolicyVersion, e.InputsHash })
.IsUnique();
entity.HasOne(e => e.Finding)
.WithMany(f => f.RiskResults)
.HasForeignKey(e => e.FindingId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure TriageDecision
modelBuilder.Entity<TriageDecision>(entity =>
{
entity.ToTable("triage_decision");
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.FindingId, e.CreatedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_decision_finding");
entity.HasIndex(e => new { e.Kind, e.CreatedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_decision_kind");
entity.HasIndex(e => e.FindingId)
.HasFilter("revoked_at IS NULL")
.HasDatabaseName("ix_triage_decision_active");
entity.HasOne(e => e.Finding)
.WithMany(f => f.Decisions)
.HasForeignKey(e => e.FindingId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure TriageEvidenceArtifact
modelBuilder.Entity<TriageEvidenceArtifact>(entity =>
{
entity.ToTable("triage_evidence_artifact");
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.FindingId, e.CreatedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_evidence_finding");
entity.HasIndex(e => new { e.Type, e.CreatedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_evidence_type");
entity.HasIndex(e => new { e.FindingId, e.Type, e.ContentHash })
.IsUnique();
entity.HasOne(e => e.Finding)
.WithMany(f => f.EvidenceArtifacts)
.HasForeignKey(e => e.FindingId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure TriageSnapshot
modelBuilder.Entity<TriageSnapshot>(entity =>
{
entity.ToTable("triage_snapshot");
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.FindingId, e.CreatedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_snapshot_finding");
entity.HasIndex(e => new { e.Trigger, e.CreatedAt })
.IsDescending(false, true)
.HasDatabaseName("ix_triage_snapshot_trigger");
entity.HasIndex(e => new { e.FindingId, e.ToInputsHash, e.CreatedAt })
.IsUnique();
entity.HasOne(e => e.Finding)
.WithMany(f => f.Snapshots)
.HasForeignKey(e => e.FindingId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure the read-only view
modelBuilder.Entity<TriageCaseCurrent>(entity =>
{
entity.ToView("v_triage_case_current");
entity.HasNoKey();
});
}
}

View File

@@ -0,0 +1,197 @@
// -----------------------------------------------------------------------------
// CecilMethodFingerprinterTests.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Unit tests for CecilMethodFingerprinter.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
using Xunit;
namespace StellaOps.Scanner.VulnSurfaces.Tests;
public class CecilMethodFingerprinterTests
{
private readonly CecilMethodFingerprinter _fingerprinter;
public CecilMethodFingerprinterTests()
{
_fingerprinter = new CecilMethodFingerprinter(
NullLogger<CecilMethodFingerprinter>.Instance);
}
[Fact]
public void Ecosystem_ReturnsNuget()
{
Assert.Equal("nuget", _fingerprinter.Ecosystem);
}
[Fact]
public async Task FingerprintAsync_WithNullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => _fingerprinter.FingerprintAsync(null!));
}
[Fact]
public async Task FingerprintAsync_WithNonExistentPath_ReturnsEmptyResult()
{
// Arrange
var request = new FingerprintRequest
{
PackagePath = "/nonexistent/path/to/package",
PackageName = "nonexistent",
Version = "1.0.0"
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert
Assert.NotNull(result);
Assert.True(result.Success);
Assert.Empty(result.Methods);
}
[Fact]
public async Task FingerprintAsync_WithOwnAssembly_FindsMethods()
{
// Arrange - use the test assembly itself
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert
Assert.NotNull(result);
Assert.True(result.Success);
Assert.NotEmpty(result.Methods);
// Should find this test class
Assert.True(result.Methods.Count > 0, "Should find at least some methods");
}
[Fact]
public async Task FingerprintAsync_ComputesDeterministicHashes()
{
// Arrange - fingerprint twice
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result1 = await _fingerprinter.FingerprintAsync(request);
var result2 = await _fingerprinter.FingerprintAsync(request);
// Assert - same methods should produce same hashes
Assert.Equal(result1.Methods.Count, result2.Methods.Count);
foreach (var (key, fp1) in result1.Methods)
{
Assert.True(result2.Methods.TryGetValue(key, out var fp2));
Assert.Equal(fp1.BodyHash, fp2.BodyHash);
}
}
[Fact]
public async Task FingerprintAsync_WithCancellation_RespectsCancellation()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0"
};
// Act - operation may either throw or return early
// since the token is already cancelled
try
{
await _fingerprinter.FingerprintAsync(request, cts.Token);
// If it doesn't throw, that's also acceptable behavior
// The key is that it should respect the cancellation token
Assert.True(true, "Method completed without throwing - acceptable if it checks token");
}
catch (OperationCanceledException)
{
// Expected behavior
Assert.True(true);
}
}
[Fact]
public async Task FingerprintAsync_MethodKeyFormat_IsValid()
{
// Arrange
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert - keys should not be empty
foreach (var key in result.Methods.Keys)
{
Assert.NotEmpty(key);
// Method keys use "::" separator between type and method
// Some may be anonymous types like "<>f__AnonymousType0`2"
// Just verify they're non-empty and have reasonable format
Assert.True(key.Contains("::") || key.Contains("."),
$"Method key should contain :: or . separator: {key}");
}
}
[Fact]
public async Task FingerprintAsync_IncludesSignature()
{
// Arrange
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert - fingerprints should have signatures
var anyWithSignature = result.Methods.Values.Any(fp => !string.IsNullOrEmpty(fp.Signature));
Assert.True(anyWithSignature, "At least some methods should have signatures");
}
}

Some files were not shown because too many files have changed in this diff Show More