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,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,55 @@
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; }
}

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,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>