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:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user