save work

This commit is contained in:
StellaOps Bot
2025-12-19 09:40:41 +02:00
parent 2eafe98d44
commit 43882078a4
44 changed files with 3044 additions and 492 deletions

View File

@@ -1,4 +1,7 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Scanner.Analyzers.Native.Index;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Native;
@@ -17,7 +20,143 @@ public sealed record NativeComponentEmitResult(
string? Version,
NativeBinaryMetadata Metadata,
bool IndexMatch,
BuildIdLookupResult? LookupResult);
BuildIdLookupResult? LookupResult)
{
public ComponentRecord ToComponentRecord(string layerDigest)
{
ArgumentException.ThrowIfNullOrWhiteSpace(layerDigest);
ArgumentNullException.ThrowIfNull(Metadata);
var fileName = string.IsNullOrWhiteSpace(Name)
? Path.GetFileName(Metadata.FilePath)
: Name.Trim();
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = Purl;
}
var properties = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["stellaops:binary.format"] = Metadata.Format,
["stellaops:binary.indexMatch"] = IndexMatch ? "true" : "false",
};
AddIfNotEmpty(properties, "stellaops:binary.architecture", Metadata.Architecture);
AddIfNotEmpty(properties, "stellaops:binary.platform", Metadata.Platform);
AddIfNotEmpty(properties, "stellaops:binary.filePath", Metadata.FilePath);
AddIfNotEmpty(properties, "stellaops:binary.fileDigest", Metadata.FileDigest);
if (Metadata.FileSize > 0)
{
properties["stellaops:binary.fileSizeBytes"] = Metadata.FileSize.ToString(CultureInfo.InvariantCulture);
}
if (Metadata.LayerIndex >= 0)
{
properties["stellaops:binary.layerIndex"] = Metadata.LayerIndex.ToString(CultureInfo.InvariantCulture);
}
if (Metadata.Is64Bit)
{
properties["stellaops:binary.is64Bit"] = "true";
}
if (Metadata.IsSigned)
{
properties["stellaops:binary.isSigned"] = "true";
}
AddIfNotEmpty(properties, "stellaops:binary.signatureDetails", Metadata.SignatureDetails);
AddIfNotEmpty(properties, "stellaops:binary.productVersion", Metadata.ProductVersion);
AddIfNotEmpty(properties, "stellaops:binary.fileVersion", Metadata.FileVersion);
AddIfNotEmpty(properties, "stellaops:binary.companyName", Metadata.CompanyName);
AddDictionary(properties, "stellaops:binary.hardeningFlags", Metadata.HardeningFlags);
AddList(properties, "stellaops:binary.imports", Metadata.Imports);
AddList(properties, "stellaops:binary.exports", Metadata.Exports);
if (LookupResult is not null)
{
AddIfNotEmpty(properties, "stellaops:binary.index.sourceDistro", LookupResult.SourceDistro);
properties["stellaops:binary.index.confidence"] = LookupResult.Confidence.ToString();
}
var componentMetadata = new ComponentMetadata
{
BuildId = Metadata.BuildId,
Properties = properties.Count == 0 ? null : properties,
};
return new ComponentRecord
{
Identity = ComponentIdentity.Create(
key: Purl,
name: fileName,
version: Version,
purl: Purl,
componentType: "file"),
LayerDigest = layerDigest,
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath(Metadata.FilePath)),
Dependencies = ImmutableArray<string>.Empty,
Metadata = componentMetadata,
Usage = ComponentUsage.Unused,
};
}
private static void AddIfNotEmpty(IDictionary<string, string> properties, string key, string? value)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
return;
}
properties[key] = value.Trim();
}
private static void AddDictionary(IDictionary<string, string> properties, string key, IReadOnlyDictionary<string, string>? dictionary)
{
if (dictionary is null || dictionary.Count == 0)
{
return;
}
var entries = dictionary
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}")
.ToArray();
if (entries.Length == 0)
{
return;
}
properties[key] = string.Join(",", entries);
}
private static void AddList(IDictionary<string, string> properties, string key, IReadOnlyList<string>? items)
{
if (items is null || items.Count == 0)
{
return;
}
var normalized = items
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static item => item, StringComparer.Ordinal)
.ToArray();
if (normalized.Length == 0)
{
return;
}
properties[key] = string.Join(",", normalized);
}
}
/// <summary>
/// Interface for emitting native binary components for SBOM generation.

View File

@@ -6,6 +6,7 @@
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Analyzers.Native.Index;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Native;
@@ -183,7 +184,15 @@ public sealed record LayerComponentMapping(
IReadOnlyList<NativeComponentEmitResult> Components,
int TotalCount,
int ResolvedCount,
int UnresolvedCount);
int UnresolvedCount)
{
public LayerComponentFragment ToFragment()
{
return LayerComponentFragment.Create(
LayerDigest,
Components.Select(component => component.ToComponentRecord(LayerDigest)));
}
}
/// <summary>
/// Result of mapping an entire container image to SBOM components.

View File

@@ -0,0 +1,5 @@
# Scanner Emit Local Tasks
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `BSE-009` | `docs/implplan/SPRINT_3500_0012_0001_binary_sbom_emission.md` | DONE | Added end-to-end integration test coverage for native binary SBOM emission (emit → fragments → CycloneDX). |

View File

@@ -6,6 +6,12 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Cryptography;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.ProofSpine;
namespace StellaOps.Scanner.Reachability.Attestation;
@@ -25,7 +31,16 @@ public static class ReachabilityAttestationServiceCollectionExtensions
services.TryAddSingleton<ReachabilityWitnessDsseBuilder>();
// Register publisher
services.TryAddSingleton<IReachabilityWitnessPublisher, ReachabilityWitnessPublisher>();
services.TryAddSingleton<IReachabilityWitnessPublisher>(sp =>
new ReachabilityWitnessPublisher(
sp.GetRequiredService<IOptions<ReachabilityWitnessOptions>>(),
sp.GetRequiredService<ICryptoHash>(),
sp.GetRequiredService<ILogger<ReachabilityWitnessPublisher>>(),
timeProvider: sp.GetService<TimeProvider>(),
cas: sp.GetService<IFileContentAddressableStore>(),
dsseSigningService: sp.GetService<IDsseSigningService>(),
cryptoProfile: sp.GetService<ICryptoProfile>(),
rekorClient: sp.GetService<IRekorClient>()));
// Register attesting writer (wraps RichGraphWriter)
services.TryAddSingleton<AttestingRichGraphWriter>();

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
namespace StellaOps.Scanner.Reachability.Attestation;
@@ -13,14 +13,6 @@ 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>
@@ -98,7 +90,7 @@ public sealed class ReachabilityWitnessDsseBuilder
public byte[] SerializeStatement(InTotoStatement statement)
{
ArgumentNullException.ThrowIfNull(statement);
return JsonSerializer.SerializeToUtf8Bytes(statement, CanonicalJsonOptions);
return CanonicalJson.SerializeToUtf8Bytes(statement);
}
/// <summary>

View File

@@ -16,6 +16,16 @@ public sealed class ReachabilityWitnessOptions
/// <summary>Whether to publish to Rekor transparency log</summary>
public bool PublishToRekor { get; set; } = true;
/// <summary>
/// Rekor backend base URL (required when <see cref="PublishToRekor"/> is enabled and tier is not air-gapped).
/// </summary>
public Uri? RekorUrl { get; set; }
/// <summary>
/// Rekor backend name used for labeling/logging.
/// </summary>
public string RekorBackendName { get; set; } = "primary";
/// <summary>Whether to store graph in CAS</summary>
public bool StoreInCas { get; set; } = true;

View File

@@ -1,6 +1,14 @@
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.ProofSpine;
namespace StellaOps.Scanner.Reachability.Attestation;
@@ -13,6 +21,14 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
private readonly ReachabilityWitnessDsseBuilder _dsseBuilder;
private readonly ICryptoHash _cryptoHash;
private readonly ILogger<ReachabilityWitnessPublisher> _logger;
private readonly IFileContentAddressableStore? _cas;
private readonly IDsseSigningService? _dsseSigningService;
private readonly ICryptoProfile? _cryptoProfile;
private readonly IRekorClient? _rekorClient;
private static readonly JsonSerializerOptions DsseJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
/// <summary>
/// Creates a new reachability witness publisher.
@@ -21,7 +37,11 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
IOptions<ReachabilityWitnessOptions> options,
ICryptoHash cryptoHash,
ILogger<ReachabilityWitnessPublisher> logger,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IFileContentAddressableStore? cas = null,
IDsseSigningService? dsseSigningService = null,
ICryptoProfile? cryptoProfile = null,
IRekorClient? rekorClient = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(cryptoHash);
@@ -31,6 +51,10 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
_cryptoHash = cryptoHash;
_logger = logger;
_dsseBuilder = new ReachabilityWitnessDsseBuilder(cryptoHash, timeProvider);
_cas = cas;
_dsseSigningService = dsseSigningService;
_cryptoProfile = cryptoProfile;
_rekorClient = rekorClient;
}
/// <inheritdoc />
@@ -61,11 +85,13 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
}
string? casUri = null;
string? casKey = null;
// Step 1: Store graph in CAS (if enabled)
if (_options.StoreInCas)
{
casUri = await StoreInCasAsync(graphBytes, graphHash, cancellationToken).ConfigureAwait(false);
casKey = ExtractHashDigest(graphHash);
casUri = await StoreInCasAsync(graphBytes, casKey, cancellationToken).ConfigureAwait(false);
}
// Step 2: Build in-toto statement
@@ -86,8 +112,14 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
graph.Nodes.Count,
graph.Edges.Count);
// Step 3: Create DSSE envelope (placeholder - actual signing via Attestor service)
var dsseEnvelope = CreateDsseEnvelope(statementBytes);
// Step 3: Create DSSE envelope (signed where configured; deterministic fallback otherwise).
var (envelope, dsseEnvelopeBytes) = await CreateDsseEnvelopeAsync(statement, statementBytes, cancellationToken)
.ConfigureAwait(false);
if (_options.StoreInCas && casKey is not null)
{
await StoreDsseInCasAsync(dsseEnvelopeBytes, casKey, cancellationToken).ConfigureAwait(false);
}
// Step 4: Submit to Rekor (if enabled and not air-gapped)
long? rekorLogIndex = null;
@@ -95,7 +127,7 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
if (_options.PublishToRekor && _options.Tier != AttestationTier.AirGapped)
{
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(dsseEnvelope, cancellationToken).ConfigureAwait(false);
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(envelope, dsseEnvelopeBytes, cancellationToken).ConfigureAwait(false);
}
else if (_options.Tier == AttestationTier.AirGapped)
{
@@ -108,40 +140,157 @@ public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
CasUri: casUri,
RekorLogIndex: rekorLogIndex,
RekorLogId: rekorLogId,
DsseEnvelopeBytes: dsseEnvelope);
DsseEnvelopeBytes: dsseEnvelopeBytes);
}
private Task<string?> StoreInCasAsync(byte[] graphBytes, string graphHash, CancellationToken cancellationToken)
private async Task<string?> StoreInCasAsync(byte[] graphBytes, string casKey, 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
if (_cas is null)
{
payloadType = "application/vnd.in-toto+json",
payload = Convert.ToBase64String(statementBytes),
signatures = Array.Empty<object>() // Will be populated by Attestor
_logger.LogWarning("CAS storage requested but no CAS store is configured; skipping graph CAS publication.");
return null;
}
var existing = await _cas.TryGetAsync(casKey, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await using var stream = new MemoryStream(graphBytes, writable: false);
await _cas.PutAsync(new FileCasPutRequest(casKey, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
}
var casUri = $"cas://reachability/graphs/{casKey}";
_logger.LogDebug("Stored graph in CAS: {CasUri}", casUri);
return casUri;
}
private async Task StoreDsseInCasAsync(byte[] dsseBytes, string casKey, CancellationToken cancellationToken)
{
if (_cas is null)
{
return;
}
var key = $"{casKey}.dsse";
var existing = await _cas.TryGetAsync(key, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return;
}
await using var stream = new MemoryStream(dsseBytes, writable: false);
await _cas.PutAsync(new FileCasPutRequest(key, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
}
private async Task<(DsseEnvelope Envelope, byte[] EnvelopeBytes)> CreateDsseEnvelopeAsync(
InTotoStatement statement,
byte[] statementBytes,
CancellationToken cancellationToken)
{
const string payloadType = "application/vnd.in-toto+json";
if (_dsseSigningService is not null)
{
var profile = _cryptoProfile ?? new InlineCryptoProfile(_options.SigningKeyId ?? "scanner-deterministic", "hs256");
var signed = await _dsseSigningService.SignAsync(statement, payloadType, profile, cancellationToken).ConfigureAwait(false);
return (signed, SerializeDsseEnvelope(signed));
}
// Deterministic fallback signature: SHA-256 over the canonical statement bytes (no external key material).
var signature = SHA256.HashData(statementBytes);
var envelope = new DsseEnvelope(
payloadType,
Convert.ToBase64String(statementBytes),
new[] { new DsseSignature(_options.SigningKeyId ?? "scanner-deterministic", Convert.ToBase64String(signature)) });
return (envelope, SerializeDsseEnvelope(envelope));
}
private async Task<(long? logIndex, string? logId)> SubmitToRekorAsync(
DsseEnvelope envelope,
byte[] envelopeBytes,
CancellationToken cancellationToken)
{
if (_rekorClient is null)
{
_logger.LogWarning("Rekor submission requested but no Rekor client is configured; skipping.");
return (null, null);
}
if (_options.RekorUrl is null)
{
_logger.LogWarning("Rekor submission requested but no RekorUrl is configured; skipping.");
return (null, null);
}
var request = new AttestorSubmissionRequest();
request.Bundle.Dsse.PayloadType = envelope.PayloadType;
request.Bundle.Dsse.PayloadBase64 = envelope.Payload;
request.Bundle.Dsse.Signatures.Clear();
foreach (var signature in envelope.Signatures)
{
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
{
KeyId = signature.KeyId,
Signature = signature.Sig
});
}
request.Meta.BundleSha256 = ComputeSha256Hex(envelopeBytes);
request.Meta.LogPreference = _options.RekorBackendName;
var backend = new RekorBackend
{
Name = _options.RekorBackendName,
Url = _options.RekorUrl
};
return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(envelope);
try
{
var response = await _rekorClient.SubmitAsync(request, backend, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(response.Uuid))
{
_logger.LogInformation("Submitted reachability witness envelope to Rekor backend {Backend} as {Uuid}", backend.Name, response.Uuid);
}
return (response.Index, response.Uuid);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to submit reachability witness envelope to Rekor backend {Backend}", backend.Name);
return (null, null);
}
}
private Task<(long? logIndex, string? logId)> SubmitToRekorAsync(byte[] dsseEnvelope, CancellationToken cancellationToken)
private static string ExtractHashDigest(string prefixedHash)
{
// 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));
var colonIndex = prefixedHash.IndexOf(':');
return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash;
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> data)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(data, hash);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static byte[] SerializeDsseEnvelope(DsseEnvelope envelope)
{
var signatures = envelope.Signatures
.OrderBy(static s => s.KeyId, StringComparer.Ordinal)
.ThenBy(static s => s.Sig, StringComparer.Ordinal)
.Select(static s => new { keyid = s.KeyId, sig = s.Sig })
.ToArray();
var dto = new
{
payloadType = envelope.PayloadType,
payload = envelope.Payload,
signatures
};
return JsonSerializer.SerializeToUtf8Bytes(dto, DsseJsonOptions);
}
private sealed record InlineCryptoProfile(string KeyId, string Algorithm) : ICryptoProfile;
}

View File

@@ -6,9 +6,11 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.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="..\..\..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>