feat: add Attestation Chain and Triage Evidence API clients and models

- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains.
- Created models for Attestation Chain, including DSSE envelope structures and verification results.
- Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component.
- Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence.
- Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
master
2025-12-18 13:15:13 +02:00
parent 7d5250238c
commit 00d2c99af9
118 changed files with 13463 additions and 151 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));
}