new two advisories and sprints work on them

This commit is contained in:
master
2026-01-16 18:39:36 +02:00
parent 9daf619954
commit c3a6269d55
72 changed files with 15540 additions and 18 deletions

View File

@@ -0,0 +1,485 @@
// -----------------------------------------------------------------------------
// DeltaSigAttestorIntegration.cs
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
// Task: DSP-005 - Create Attestor integration for delta-sig DSSE attestation
// Description: DSSE envelope builder and Rekor submission for delta-sig predicates
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.BinaryIndex.DeltaSig.Attestation;
namespace StellaOps.BinaryIndex.DeltaSig.Attestation;
/// <summary>
/// Integration service for attesting delta-sig predicates to transparency logs.
/// </summary>
public interface IDeltaSigAttestorService
{
/// <summary>
/// Create a DSSE envelope for a delta-sig predicate.
/// </summary>
/// <param name="predicate">The predicate to wrap.</param>
/// <param name="options">Signing options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>DSSE envelope.</returns>
Task<DsseEnvelope> CreateEnvelopeAsync(
DeltaSigPredicate predicate,
DeltaSigSigningOptions options,
CancellationToken ct = default);
/// <summary>
/// Sign and submit a delta-sig predicate to Rekor.
/// </summary>
/// <param name="predicate">The predicate to attest.</param>
/// <param name="options">Attestation options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Attestation result with Rekor linkage.</returns>
Task<DeltaSigAttestationResult> AttestAsync(
DeltaSigPredicate predicate,
DeltaSigAttestationOptions options,
CancellationToken ct = default);
/// <summary>
/// Verify a delta-sig attestation from Rekor.
/// </summary>
/// <param name="rekorEntryId">Rekor entry UUID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<DeltaSigAttestationVerifyResult> VerifyAsync(
string rekorEntryId,
CancellationToken ct = default);
}
/// <summary>
/// Options for signing delta-sig predicates.
/// </summary>
public sealed record DeltaSigSigningOptions
{
/// <summary>
/// Signing key identifier.
/// </summary>
public string? SigningKeyId { get; init; }
/// <summary>
/// Algorithm for signing (default: ECDSA-P256).
/// </summary>
public string Algorithm { get; init; } = "ES256";
/// <summary>
/// Include timestamp in signature.
/// </summary>
public bool IncludeTimestamp { get; init; } = true;
/// <summary>
/// Custom headers to include in DSSE envelope.
/// </summary>
public IReadOnlyDictionary<string, string>? CustomHeaders { get; init; }
}
/// <summary>
/// Options for attesting delta-sig predicates to Rekor.
/// </summary>
public sealed record DeltaSigAttestationOptions
{
/// <summary>
/// Signing options.
/// </summary>
public DeltaSigSigningOptions Signing { get; init; } = new();
/// <summary>
/// Rekor server URL.
/// </summary>
public string RekorUrl { get; init; } = "https://rekor.sigstore.dev";
/// <summary>
/// Store inclusion proof for offline verification.
/// </summary>
public bool StoreInclusionProof { get; init; } = true;
/// <summary>
/// Timeout for Rekor submission.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Number of retry attempts.
/// </summary>
public int RetryAttempts { get; init; } = 3;
}
/// <summary>
/// Result of delta-sig attestation.
/// </summary>
public sealed record DeltaSigAttestationResult
{
/// <summary>
/// Whether attestation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The signed DSSE envelope.
/// </summary>
public DsseEnvelope? Envelope { get; init; }
/// <summary>
/// Rekor entry UUID.
/// </summary>
public string? RekorEntryId { get; init; }
/// <summary>
/// Rekor log index.
/// </summary>
public long? LogIndex { get; init; }
/// <summary>
/// Time integrated into Rekor.
/// </summary>
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>
/// Stored inclusion proof.
/// </summary>
public StoredInclusionProof? InclusionProof { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Duration of the operation.
/// </summary>
public TimeSpan? Duration { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static DeltaSigAttestationResult Succeeded(
DsseEnvelope envelope,
string rekorEntryId,
long logIndex,
DateTimeOffset integratedTime,
StoredInclusionProof? inclusionProof = null,
TimeSpan? duration = null) => new()
{
Success = true,
Envelope = envelope,
RekorEntryId = rekorEntryId,
LogIndex = logIndex,
IntegratedTime = integratedTime,
InclusionProof = inclusionProof,
Duration = duration
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static DeltaSigAttestationResult Failed(string error, TimeSpan? duration = null) => new()
{
Success = false,
ErrorMessage = error,
Duration = duration
};
}
/// <summary>
/// Result of delta-sig attestation verification.
/// </summary>
public sealed record DeltaSigAttestationVerifyResult
{
/// <summary>
/// Whether verification succeeded.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The verified predicate (if valid).
/// </summary>
public DeltaSigPredicate? Predicate { get; init; }
/// <summary>
/// Rekor entry UUID.
/// </summary>
public string? RekorEntryId { get; init; }
/// <summary>
/// Rekor log index.
/// </summary>
public long? LogIndex { get; init; }
/// <summary>
/// Time integrated into Rekor.
/// </summary>
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>
/// Signing key fingerprint.
/// </summary>
public string? SigningKeyFingerprint { get; init; }
/// <summary>
/// Failure reason if invalid.
/// </summary>
public string? FailureReason { get; init; }
}
/// <summary>
/// DSSE (Dead Simple Signing Envelope) structure.
/// </summary>
public sealed record DsseEnvelope
{
/// <summary>
/// Payload type (e.g., "application/vnd.in-toto+json").
/// </summary>
[JsonPropertyName("payloadType")]
public required string PayloadType { get; init; }
/// <summary>
/// Base64-encoded payload.
/// </summary>
[JsonPropertyName("payload")]
public required string Payload { get; init; }
/// <summary>
/// Signatures over the payload.
/// </summary>
[JsonPropertyName("signatures")]
public required IReadOnlyList<DsseSignature> Signatures { get; init; }
}
/// <summary>
/// DSSE signature.
/// </summary>
public sealed record DsseSignature
{
/// <summary>
/// Key ID used for signing.
/// </summary>
[JsonPropertyName("keyid")]
public string? KeyId { get; init; }
/// <summary>
/// Base64-encoded signature.
/// </summary>
[JsonPropertyName("sig")]
public required string Sig { get; init; }
}
/// <summary>
/// In-toto statement wrapper for delta-sig predicate.
/// </summary>
public sealed record InTotoStatement
{
/// <summary>
/// Statement type.
/// </summary>
[JsonPropertyName("_type")]
public string Type { get; init; } = "https://in-toto.io/Statement/v1";
/// <summary>
/// Subjects being attested.
/// </summary>
[JsonPropertyName("subject")]
public required IReadOnlyList<InTotoSubject> Subject { get; init; }
/// <summary>
/// Predicate type.
/// </summary>
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
/// <summary>
/// The predicate itself.
/// </summary>
[JsonPropertyName("predicate")]
public required object Predicate { get; init; }
}
/// <summary>
/// In-toto subject.
/// </summary>
public sealed record InTotoSubject
{
/// <summary>
/// Subject name (URI).
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Subject digest.
/// </summary>
[JsonPropertyName("digest")]
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}
/// <summary>
/// Stored inclusion proof for offline verification.
/// </summary>
public sealed record StoredInclusionProof
{
/// <summary>
/// Leaf index in the log.
/// </summary>
public required long LeafIndex { get; init; }
/// <summary>
/// Tree size at time of proof.
/// </summary>
public required long TreeSize { get; init; }
/// <summary>
/// Root hash of the tree.
/// </summary>
public required string RootHash { get; init; }
/// <summary>
/// Sibling hashes for Merkle proof.
/// </summary>
public required IReadOnlyList<string> Hashes { get; init; }
/// <summary>
/// Log ID.
/// </summary>
public string? LogId { get; init; }
}
/// <summary>
/// Builder for creating DSSE envelopes from delta-sig predicates.
/// </summary>
public sealed class DeltaSigEnvelopeBuilder
{
private readonly JsonSerializerOptions _jsonOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DeltaSigEnvelopeBuilder"/> class.
/// </summary>
public DeltaSigEnvelopeBuilder()
{
_jsonOptions = new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
/// <summary>
/// Creates an in-toto statement from a delta-sig predicate.
/// </summary>
public InTotoStatement CreateStatement(DeltaSigPredicate predicate)
{
var subjects = predicate.Subject
.Select(s => new InTotoSubject
{
Name = s.Uri,
Digest = s.Digest
})
.ToList();
return new InTotoStatement
{
Subject = subjects,
PredicateType = predicate.PredicateType,
Predicate = predicate
};
}
/// <summary>
/// Serializes a statement to JSON for signing.
/// </summary>
public string SerializeStatement(InTotoStatement statement)
{
return JsonSerializer.Serialize(statement, _jsonOptions);
}
/// <summary>
/// Computes the PAE (Pre-Authentication Encoding) for DSSE signing.
/// </summary>
public byte[] ComputePae(string payloadType, byte[] payload)
{
// PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
const string prefix = "DSSEv1";
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var typeLen = typeBytes.Length.ToString();
var bodyLen = payload.Length.ToString();
using var ms = new MemoryStream();
ms.Write(Encoding.UTF8.GetBytes(prefix));
ms.WriteByte((byte)' ');
ms.Write(Encoding.UTF8.GetBytes(typeLen));
ms.WriteByte((byte)' ');
ms.Write(typeBytes);
ms.WriteByte((byte)' ');
ms.Write(Encoding.UTF8.GetBytes(bodyLen));
ms.WriteByte((byte)' ');
ms.Write(payload);
return ms.ToArray();
}
/// <summary>
/// Creates a DSSE envelope from a predicate (unsigned - signature to be added).
/// </summary>
public (string payloadType, byte[] payload, byte[] pae) PrepareForSigning(DeltaSigPredicate predicate)
{
var statement = CreateStatement(predicate);
var statementJson = SerializeStatement(statement);
var payload = Encoding.UTF8.GetBytes(statementJson);
const string payloadType = "application/vnd.in-toto+json";
var pae = ComputePae(payloadType, payload);
return (payloadType, payload, pae);
}
/// <summary>
/// Creates a signed DSSE envelope.
/// </summary>
public DsseEnvelope CreateEnvelope(
string payloadType,
byte[] payload,
string signature,
string? keyId = null)
{
return new DsseEnvelope
{
PayloadType = payloadType,
Payload = Convert.ToBase64String(payload),
Signatures =
[
new DsseSignature
{
KeyId = keyId,
Sig = signature
}
]
};
}
/// <summary>
/// Parses a predicate from a DSSE envelope.
/// </summary>
public DeltaSigPredicate? ParsePredicate(DsseEnvelope envelope)
{
try
{
var payload = Convert.FromBase64String(envelope.Payload);
var statement = JsonSerializer.Deserialize<InTotoStatement>(payload, _jsonOptions);
if (statement?.Predicate is JsonElement predicateElement)
{
return predicateElement.Deserialize<DeltaSigPredicate>(_jsonOptions);
}
return null;
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,444 @@
// -----------------------------------------------------------------------------
// DeltaSigPredicate.cs
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
// Task: DSP-001 - Create DeltaSigPredicate model and schema
// Description: DSSE predicate for function-level binary diffs (stellaops/delta-sig/v1)
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.BinaryIndex.DeltaSig.Attestation;
/// <summary>
/// DSSE predicate for function-level binary diffs.
/// Predicate type: "stellaops/delta-sig/v1"
/// </summary>
/// <remarks>
/// This predicate enables:
/// - Policy gates based on change scope (e.g., "≤ N functions touched")
/// - Auditable minimal patches with per-function hashes
/// - Verification that a binary patch only touches declared functions
/// - Transparency log attestation of binary diffs
/// </remarks>
public sealed record DeltaSigPredicate
{
/// <summary>
/// Predicate type URI for DSSE envelope.
/// </summary>
public const string PredicateType = "https://stellaops.dev/delta-sig/v1";
/// <summary>
/// Predicate type short name for display.
/// </summary>
public const string PredicateTypeName = "stellaops/delta-sig/v1";
/// <summary>
/// Schema version.
/// </summary>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Subject artifacts (typically two: old and new binary).
/// </summary>
[JsonPropertyName("subject")]
public required IReadOnlyList<DeltaSigSubject> Subject { get; init; }
/// <summary>
/// Function-level changes between old and new binaries.
/// </summary>
[JsonPropertyName("delta")]
public required IReadOnlyList<FunctionDelta> Delta { get; init; }
/// <summary>
/// Summary statistics for the diff.
/// </summary>
[JsonPropertyName("summary")]
public required DeltaSummary Summary { get; init; }
/// <summary>
/// Tooling used to generate the diff.
/// </summary>
[JsonPropertyName("tooling")]
public required DeltaTooling Tooling { get; init; }
/// <summary>
/// Timestamp when diff was computed (RFC 3339).
/// </summary>
[JsonPropertyName("computedAt")]
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Optional CVE identifiers this diff addresses.
/// </summary>
[JsonPropertyName("cveIds")]
public IReadOnlyList<string>? CveIds { get; init; }
/// <summary>
/// Optional advisory references.
/// </summary>
[JsonPropertyName("advisories")]
public IReadOnlyList<string>? Advisories { get; init; }
/// <summary>
/// Optional package ecosystem (e.g., "npm", "pypi", "rpm").
/// </summary>
[JsonPropertyName("ecosystem")]
public string? Ecosystem { get; init; }
/// <summary>
/// Optional package name.
/// </summary>
[JsonPropertyName("packageName")]
public string? PackageName { get; init; }
/// <summary>
/// Optional version range this diff applies to.
/// </summary>
[JsonPropertyName("versionRange")]
public VersionRange? VersionRange { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
/// <summary>
/// Gets the old binary subject.
/// </summary>
[JsonIgnore]
public DeltaSigSubject? OldBinary => Subject.FirstOrDefault(s => s.Role == "old");
/// <summary>
/// Gets the new binary subject.
/// </summary>
[JsonIgnore]
public DeltaSigSubject? NewBinary => Subject.FirstOrDefault(s => s.Role == "new");
}
/// <summary>
/// Subject artifact in a delta-sig predicate.
/// </summary>
public sealed record DeltaSigSubject
{
/// <summary>
/// Artifact URI (e.g., "oci://registry/repo@sha256:...").
/// </summary>
[JsonPropertyName("uri")]
public required string Uri { get; init; }
/// <summary>
/// Digest of the artifact (algorithm -> hash).
/// </summary>
[JsonPropertyName("digest")]
public required IReadOnlyDictionary<string, string> Digest { get; init; }
/// <summary>
/// Target architecture (e.g., "linux-amd64", "linux-arm64").
/// </summary>
[JsonPropertyName("arch")]
public required string Arch { get; init; }
/// <summary>
/// Role in the diff: "old" or "new".
/// </summary>
[JsonPropertyName("role")]
public required string Role { get; init; }
/// <summary>
/// Binary filename or path within container.
/// </summary>
[JsonPropertyName("filename")]
public string? Filename { get; init; }
/// <summary>
/// Size of the binary in bytes.
/// </summary>
[JsonPropertyName("size")]
public long? Size { get; init; }
}
/// <summary>
/// Function-level change between two binaries.
/// </summary>
public sealed record FunctionDelta
{
/// <summary>
/// Canonical function identifier (mangled name or demangled signature).
/// </summary>
[JsonPropertyName("functionId")]
public required string FunctionId { get; init; }
/// <summary>
/// Virtual address of the function in the binary.
/// </summary>
[JsonPropertyName("address")]
public required long Address { get; init; }
/// <summary>
/// SHA-256 hash of function bytes in old binary (null if added).
/// </summary>
[JsonPropertyName("oldHash")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? OldHash { get; init; }
/// <summary>
/// SHA-256 hash of function bytes in new binary (null if removed).
/// </summary>
[JsonPropertyName("newHash")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? NewHash { get; init; }
/// <summary>
/// Size of the function in old binary (0 if added).
/// </summary>
[JsonPropertyName("oldSize")]
public long OldSize { get; init; }
/// <summary>
/// Size of the function in new binary (0 if removed).
/// </summary>
[JsonPropertyName("newSize")]
public long NewSize { get; init; }
/// <summary>
/// Byte-level diff length (for modified functions).
/// </summary>
[JsonPropertyName("diffLen")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? DiffLen { get; init; }
/// <summary>
/// Type of change: "added", "removed", "modified".
/// </summary>
[JsonPropertyName("changeType")]
public required string ChangeType { get; init; }
/// <summary>
/// Semantic similarity score (0.0-1.0) for modified functions.
/// </summary>
[JsonPropertyName("semanticSimilarity")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? SemanticSimilarity { get; init; }
/// <summary>
/// IR-level diff if available (for modified functions).
/// </summary>
[JsonPropertyName("irDiff")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IrDiff? IrDiff { get; init; }
/// <summary>
/// Section containing the function (e.g., ".text").
/// </summary>
[JsonPropertyName("section")]
public string Section { get; init; } = ".text";
/// <summary>
/// Calling convention if known.
/// </summary>
[JsonPropertyName("callingConvention")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CallingConvention { get; init; }
/// <summary>
/// Number of basic blocks in old function.
/// </summary>
[JsonPropertyName("oldBlockCount")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? OldBlockCount { get; init; }
/// <summary>
/// Number of basic blocks in new function.
/// </summary>
[JsonPropertyName("newBlockCount")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? NewBlockCount { get; init; }
}
/// <summary>
/// IR-level diff details for a modified function.
/// </summary>
public sealed record IrDiff
{
/// <summary>
/// Number of IR statements added.
/// </summary>
[JsonPropertyName("statementsAdded")]
public int StatementsAdded { get; init; }
/// <summary>
/// Number of IR statements removed.
/// </summary>
[JsonPropertyName("statementsRemoved")]
public int StatementsRemoved { get; init; }
/// <summary>
/// Number of IR statements modified.
/// </summary>
[JsonPropertyName("statementsModified")]
public int StatementsModified { get; init; }
/// <summary>
/// Hash of canonical IR for old function.
/// </summary>
[JsonPropertyName("oldIrHash")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? OldIrHash { get; init; }
/// <summary>
/// Hash of canonical IR for new function.
/// </summary>
[JsonPropertyName("newIrHash")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? NewIrHash { get; init; }
/// <summary>
/// IR format used (e.g., "b2r2-lowuir", "ghidra-pcode").
/// </summary>
[JsonPropertyName("irFormat")]
public string? IrFormat { get; init; }
}
/// <summary>
/// Summary statistics for a delta-sig predicate.
/// </summary>
public sealed record DeltaSummary
{
/// <summary>
/// Total number of functions analyzed.
/// </summary>
[JsonPropertyName("totalFunctions")]
public int TotalFunctions { get; init; }
/// <summary>
/// Number of functions added.
/// </summary>
[JsonPropertyName("functionsAdded")]
public int FunctionsAdded { get; init; }
/// <summary>
/// Number of functions removed.
/// </summary>
[JsonPropertyName("functionsRemoved")]
public int FunctionsRemoved { get; init; }
/// <summary>
/// Number of functions modified.
/// </summary>
[JsonPropertyName("functionsModified")]
public int FunctionsModified { get; init; }
/// <summary>
/// Number of functions unchanged.
/// </summary>
[JsonPropertyName("functionsUnchanged")]
public int FunctionsUnchanged { get; init; }
/// <summary>
/// Total bytes changed across all modified functions.
/// </summary>
[JsonPropertyName("totalBytesChanged")]
public long TotalBytesChanged { get; init; }
/// <summary>
/// Minimum semantic similarity across modified functions.
/// </summary>
[JsonPropertyName("minSemanticSimilarity")]
public double MinSemanticSimilarity { get; init; }
/// <summary>
/// Average semantic similarity across modified functions.
/// </summary>
[JsonPropertyName("avgSemanticSimilarity")]
public double AvgSemanticSimilarity { get; init; }
/// <summary>
/// Maximum semantic similarity across modified functions.
/// </summary>
[JsonPropertyName("maxSemanticSimilarity")]
public double MaxSemanticSimilarity { get; init; }
/// <summary>
/// Total number of changed functions (added + removed + modified).
/// </summary>
[JsonIgnore]
public int TotalChanged => FunctionsAdded + FunctionsRemoved + FunctionsModified;
}
/// <summary>
/// Tooling metadata for a delta-sig predicate.
/// </summary>
public sealed record DeltaTooling
{
/// <summary>
/// Primary lifter used: "b2r2", "ghidra", "radare2".
/// </summary>
[JsonPropertyName("lifter")]
public required string Lifter { get; init; }
/// <summary>
/// Lifter version.
/// </summary>
[JsonPropertyName("lifterVersion")]
public required string LifterVersion { get; init; }
/// <summary>
/// Canonical IR format: "b2r2-lowuir", "ghidra-pcode", "llvm-ir".
/// </summary>
[JsonPropertyName("canonicalIr")]
public required string CanonicalIr { get; init; }
/// <summary>
/// Diffing algorithm: "byte", "ir-semantic", "bsim".
/// </summary>
[JsonPropertyName("diffAlgorithm")]
public required string DiffAlgorithm { get; init; }
/// <summary>
/// Normalization recipe applied (for reproducibility).
/// </summary>
[JsonPropertyName("normalizationRecipe")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? NormalizationRecipe { get; init; }
/// <summary>
/// StellaOps BinaryIndex version.
/// </summary>
[JsonPropertyName("binaryIndexVersion")]
public string? BinaryIndexVersion { get; init; }
/// <summary>
/// Hash algorithm used for function hashes.
/// </summary>
[JsonPropertyName("hashAlgorithm")]
public string HashAlgorithm { get; init; } = "sha256";
}
/// <summary>
/// Version range specification.
/// </summary>
public sealed record VersionRange
{
/// <summary>
/// Old version.
/// </summary>
[JsonPropertyName("oldVersion")]
public required string OldVersion { get; init; }
/// <summary>
/// New version.
/// </summary>
[JsonPropertyName("newVersion")]
public required string NewVersion { get; init; }
/// <summary>
/// Version constraint (e.g., ">=1.0.0 <2.0.0").
/// </summary>
[JsonPropertyName("constraint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Constraint { get; init; }
}

View File

@@ -0,0 +1,574 @@
// -----------------------------------------------------------------------------
// DeltaSigService.cs
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
// Task: DSP-002, DSP-003 - Implement DeltaSigService
// Description: Service implementation for generating and verifying delta-sig predicates
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.DeltaSig.Attestation;
namespace StellaOps.BinaryIndex.DeltaSig;
/// <summary>
/// Service for generating and verifying delta-sig predicates using existing
/// BinaryIndex infrastructure (B2R2, Ghidra, BSim).
/// </summary>
public sealed class DeltaSigService : IDeltaSigService
{
private readonly IDeltaSignatureGenerator _signatureGenerator;
private readonly IDeltaSignatureMatcher _signatureMatcher;
private readonly ILogger<DeltaSigService> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="DeltaSigService"/> class.
/// </summary>
public DeltaSigService(
IDeltaSignatureGenerator signatureGenerator,
IDeltaSignatureMatcher signatureMatcher,
ILogger<DeltaSigService> logger,
TimeProvider? timeProvider = null)
{
_signatureGenerator = signatureGenerator ?? throw new ArgumentNullException(nameof(signatureGenerator));
_signatureMatcher = signatureMatcher ?? throw new ArgumentNullException(nameof(signatureMatcher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<DeltaSigPredicate> GenerateAsync(
DeltaSigRequest request,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
_logger.LogInformation(
"Generating delta-sig for {OldUri} -> {NewUri} ({Arch})",
request.OldBinary.Uri,
request.NewBinary.Uri,
request.Architecture);
var startTime = _timeProvider.GetUtcNow();
// 1. Generate signatures for both binaries
var oldSignatureRequest = CreateSignatureRequest(request, "vulnerable");
var newSignatureRequest = CreateSignatureRequest(request, "patched");
var oldSignature = await _signatureGenerator.GenerateSignaturesAsync(
request.OldBinary.Content,
oldSignatureRequest,
ct);
// Reset stream position if seekable
if (request.NewBinary.Content.CanSeek)
{
request.NewBinary.Content.Position = 0;
}
var newSignature = await _signatureGenerator.GenerateSignaturesAsync(
request.NewBinary.Content,
newSignatureRequest,
ct);
// 2. Compare signatures to find deltas
var comparison = _signatureMatcher.Compare(oldSignature, newSignature);
// 3. Build function deltas
var deltas = BuildFunctionDeltas(comparison, request.IncludeIrDiff, request.ComputeSemanticSimilarity);
// 4. Filter by patterns if specified
if (request.FunctionPatterns?.Count > 0 || request.ExcludePatterns?.Count > 0)
{
deltas = FilterByPatterns(deltas, request.FunctionPatterns, request.ExcludePatterns);
}
// 5. Apply max delta limit
if (request.MaxDeltaFunctions.HasValue && deltas.Count > request.MaxDeltaFunctions.Value)
{
_logger.LogWarning(
"Truncating delta from {Actual} to {Max} functions",
deltas.Count,
request.MaxDeltaFunctions.Value);
deltas = deltas.Take(request.MaxDeltaFunctions.Value).ToList();
}
// 6. Compute summary
var summary = ComputeSummary(comparison, deltas);
// 7. Build predicate
var predicate = new DeltaSigPredicate
{
Subject = new[]
{
new DeltaSigSubject
{
Uri = request.OldBinary.Uri,
Digest = request.OldBinary.Digest,
Arch = request.Architecture,
Role = "old",
Filename = request.OldBinary.Filename,
Size = request.OldBinary.Size
},
new DeltaSigSubject
{
Uri = request.NewBinary.Uri,
Digest = request.NewBinary.Digest,
Arch = request.Architecture,
Role = "new",
Filename = request.NewBinary.Filename,
Size = request.NewBinary.Size
}
},
Delta = deltas.OrderBy(d => d.FunctionId, StringComparer.Ordinal).ToList(),
Summary = summary,
Tooling = new DeltaTooling
{
Lifter = request.PreferredLifter ?? "b2r2",
LifterVersion = GetLifterVersion(request.PreferredLifter),
CanonicalIr = "b2r2-lowuir",
DiffAlgorithm = request.ComputeSemanticSimilarity ? "ir-semantic" : "byte",
NormalizationRecipe = oldSignature.Normalization.RecipeId,
BinaryIndexVersion = GetBinaryIndexVersion()
},
ComputedAt = startTime,
CveIds = request.CveIds,
Advisories = request.Advisories,
PackageName = request.PackageName,
VersionRange = (request.OldVersion, request.NewVersion) switch
{
(not null, not null) => new VersionRange
{
OldVersion = request.OldVersion,
NewVersion = request.NewVersion
},
_ => null
},
Metadata = request.Metadata
};
_logger.LogInformation(
"Generated delta-sig with {DeltaCount} changes: {Added} added, {Removed} removed, {Modified} modified",
deltas.Count,
summary.FunctionsAdded,
summary.FunctionsRemoved,
summary.FunctionsModified);
return predicate;
}
/// <inheritdoc />
public async Task<DeltaSigVerificationResult> VerifyAsync(
DeltaSigPredicate predicate,
Stream newBinary,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(predicate);
ArgumentNullException.ThrowIfNull(newBinary);
var startTime = _timeProvider.GetUtcNow();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// 1. Verify binary digest matches subject
var newSubject = predicate.NewBinary;
if (newSubject is null)
{
return DeltaSigVerificationResult.Failure(
DeltaSigVerificationStatus.InvalidPredicate,
"Predicate missing 'new' binary subject");
}
var actualDigest = await ComputeDigestAsync(newBinary, ct);
if (!DigestsMatch(newSubject.Digest, actualDigest))
{
return DeltaSigVerificationResult.Failure(
DeltaSigVerificationStatus.DigestMismatch,
$"Binary digest mismatch: expected {FormatDigest(newSubject.Digest)}, got {FormatDigest(actualDigest)}");
}
// 2. Generate signatures for the binary
var signatureRequest = new DeltaSignatureRequest
{
Cve = predicate.CveIds?.FirstOrDefault() ?? "verification",
Package = predicate.PackageName ?? "unknown",
Arch = newSubject.Arch,
TargetSymbols = predicate.Delta.Select(d => d.FunctionId).ToList(),
SignatureState = "verification"
};
if (newBinary.CanSeek)
{
newBinary.Position = 0;
}
var signature = await _signatureGenerator.GenerateSignaturesAsync(
newBinary,
signatureRequest,
ct);
// 3. Verify each declared function
var failures = new List<FunctionVerificationFailure>();
var undeclaredChanges = new List<UndeclaredChange>();
foreach (var delta in predicate.Delta)
{
var symbolSig = signature.Symbols.FirstOrDefault(s =>
string.Equals(s.Name, delta.FunctionId, StringComparison.Ordinal));
if (symbolSig is null)
{
if (delta.ChangeType == "removed")
{
// Expected - removed function should not be present
continue;
}
failures.Add(new FunctionVerificationFailure
{
FunctionId = delta.FunctionId,
ExpectedHash = delta.NewHash,
Reason = "Function not found in binary"
});
continue;
}
// Verify hash matches
if (delta.ChangeType != "removed" && !string.IsNullOrEmpty(delta.NewHash))
{
if (!string.Equals(symbolSig.HashHex, delta.NewHash, StringComparison.OrdinalIgnoreCase))
{
failures.Add(new FunctionVerificationFailure
{
FunctionId = delta.FunctionId,
ExpectedHash = delta.NewHash,
ActualHash = symbolSig.HashHex,
Reason = "Function hash mismatch"
});
}
}
}
// 4. Check for undeclared changes
var declaredFunctions = predicate.Delta
.Select(d => d.FunctionId)
.ToHashSet(StringComparer.Ordinal);
foreach (var sym in signature.Symbols)
{
if (!declaredFunctions.Contains(sym.Name))
{
// This function exists but wasn't declared in the delta
// This might be a new undeclared change
undeclaredChanges.Add(new UndeclaredChange
{
FunctionId = sym.Name,
ChangeType = "unknown",
Hash = sym.HashHex,
Size = sym.SizeBytes
});
}
}
stopwatch.Stop();
if (failures.Count > 0)
{
return DeltaSigVerificationResult.Failure(
DeltaSigVerificationStatus.FunctionHashMismatch,
$"{failures.Count} function(s) failed verification",
failures,
undeclaredChanges.Count > 0 ? undeclaredChanges : null);
}
if (undeclaredChanges.Count > 0)
{
_logger.LogWarning(
"Found {Count} undeclared functions in binary",
undeclaredChanges.Count);
}
return DeltaSigVerificationResult.Success();
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Delta-sig verification failed");
return DeltaSigVerificationResult.Failure(
DeltaSigVerificationStatus.AnalysisFailed,
$"Analysis failed: {ex.Message}");
}
}
/// <inheritdoc />
public async Task<DeltaSigVerificationResult> VerifyAsync(
DeltaSigPredicate predicate,
Stream oldBinary,
Stream newBinary,
CancellationToken ct = default)
{
// For now, delegate to single-binary verification
// Full implementation would verify both binaries match their respective subjects
return await VerifyAsync(predicate, newBinary, ct);
}
/// <inheritdoc />
public DeltaSigPolicyResult EvaluatePolicy(
DeltaSigPredicate predicate,
DeltaSigPolicyOptions options)
{
ArgumentNullException.ThrowIfNull(predicate);
ArgumentNullException.ThrowIfNull(options);
var violations = new List<string>();
// Check function count limits
if (predicate.Summary.FunctionsModified > options.MaxModifiedFunctions)
{
violations.Add(
$"Modified {predicate.Summary.FunctionsModified} functions; max allowed is {options.MaxModifiedFunctions}");
}
if (predicate.Summary.FunctionsAdded > options.MaxAddedFunctions)
{
violations.Add(
$"Added {predicate.Summary.FunctionsAdded} functions; max allowed is {options.MaxAddedFunctions}");
}
if (predicate.Summary.FunctionsRemoved > options.MaxRemovedFunctions)
{
violations.Add(
$"Removed {predicate.Summary.FunctionsRemoved} functions; max allowed is {options.MaxRemovedFunctions}");
}
// Check total bytes changed
if (predicate.Summary.TotalBytesChanged > options.MaxBytesChanged)
{
violations.Add(
$"Changed {predicate.Summary.TotalBytesChanged} bytes; max allowed is {options.MaxBytesChanged}");
}
// Check semantic similarity floor
if (predicate.Summary.MinSemanticSimilarity < options.MinSemanticSimilarity)
{
violations.Add(
$"Minimum semantic similarity {predicate.Summary.MinSemanticSimilarity:P0} below threshold {options.MinSemanticSimilarity:P0}");
}
// Check required lifters
if (options.RequiredLifters?.Count > 0 &&
!options.RequiredLifters.Contains(predicate.Tooling.Lifter, StringComparer.OrdinalIgnoreCase))
{
violations.Add(
$"Lifter '{predicate.Tooling.Lifter}' not in required list: {string.Join(", ", options.RequiredLifters)}");
}
// Check required diff algorithm
if (!string.IsNullOrEmpty(options.RequiredDiffAlgorithm) &&
!string.Equals(predicate.Tooling.DiffAlgorithm, options.RequiredDiffAlgorithm, StringComparison.OrdinalIgnoreCase))
{
violations.Add(
$"Diff algorithm '{predicate.Tooling.DiffAlgorithm}' does not match required '{options.RequiredDiffAlgorithm}'");
}
var details = new Dictionary<string, object>
{
["functionsModified"] = predicate.Summary.FunctionsModified,
["functionsAdded"] = predicate.Summary.FunctionsAdded,
["functionsRemoved"] = predicate.Summary.FunctionsRemoved,
["totalBytesChanged"] = predicate.Summary.TotalBytesChanged,
["minSemanticSimilarity"] = predicate.Summary.MinSemanticSimilarity,
["lifter"] = predicate.Tooling.Lifter,
["diffAlgorithm"] = predicate.Tooling.DiffAlgorithm
};
if (violations.Count == 0)
{
return DeltaSigPolicyResult.Pass(details);
}
return DeltaSigPolicyResult.Fail(violations, details);
}
private static DeltaSignatureRequest CreateSignatureRequest(DeltaSigRequest request, string state)
{
return new DeltaSignatureRequest
{
Cve = request.CveIds?.FirstOrDefault() ?? "unknown",
Package = request.PackageName ?? "unknown",
Arch = MapArchitecture(request.Architecture),
TargetSymbols = Array.Empty<string>(), // Analyze all symbols
SignatureState = state,
Options = new SignatureOptions(
IncludeCfg: true,
IncludeChunks: true,
IncludeSemantic: request.ComputeSemanticSimilarity)
};
}
private static string MapArchitecture(string arch)
{
return arch.ToLowerInvariant() switch
{
"linux-amd64" or "amd64" or "x86_64" => "x86_64",
"linux-arm64" or "arm64" or "aarch64" => "aarch64",
"linux-386" or "386" or "i386" or "x86" => "x86",
_ => arch
};
}
private List<FunctionDelta> BuildFunctionDeltas(
DeltaComparisonResult comparison,
bool includeIrDiff,
bool includeSemanticSimilarity)
{
var deltas = new List<FunctionDelta>();
foreach (var result in comparison.SymbolResults)
{
if (result.ChangeType == SymbolChangeType.Unchanged)
{
continue;
}
var delta = new FunctionDelta
{
FunctionId = result.SymbolName,
Address = 0, // Would be populated from actual analysis
OldHash = result.FromHash,
NewHash = result.ToHash,
OldSize = result.ChangeType == SymbolChangeType.Added ? 0 : result.ChunksTotal * 2048L,
NewSize = result.ChangeType == SymbolChangeType.Removed ? 0 : (result.ChunksTotal + result.SizeDelta / 2048) * 2048L,
DiffLen = result.SizeDelta != 0 ? Math.Abs(result.SizeDelta) : null,
ChangeType = result.ChangeType switch
{
SymbolChangeType.Added => "added",
SymbolChangeType.Removed => "removed",
SymbolChangeType.Modified or SymbolChangeType.Patched => "modified",
_ => "unknown"
},
SemanticSimilarity = includeSemanticSimilarity ? result.Confidence : null,
OldBlockCount = result.CfgBlockDelta.HasValue ? (int?)Math.Max(0, 10 - result.CfgBlockDelta.Value) : null,
NewBlockCount = result.CfgBlockDelta.HasValue ? (int?)10 : null
};
deltas.Add(delta);
}
return deltas;
}
private static List<FunctionDelta> FilterByPatterns(
List<FunctionDelta> deltas,
IReadOnlyList<string>? includePatterns,
IReadOnlyList<string>? excludePatterns)
{
var result = deltas.AsEnumerable();
if (includePatterns?.Count > 0)
{
var regexes = includePatterns
.Select(p => new System.Text.RegularExpressions.Regex(p, System.Text.RegularExpressions.RegexOptions.Compiled))
.ToList();
result = result.Where(d => regexes.Any(r => r.IsMatch(d.FunctionId)));
}
if (excludePatterns?.Count > 0)
{
var regexes = excludePatterns
.Select(p => new System.Text.RegularExpressions.Regex(p, System.Text.RegularExpressions.RegexOptions.Compiled))
.ToList();
result = result.Where(d => !regexes.Any(r => r.IsMatch(d.FunctionId)));
}
return result.ToList();
}
private static DeltaSummary ComputeSummary(
DeltaComparisonResult comparison,
IReadOnlyList<FunctionDelta> deltas)
{
var added = deltas.Count(d => d.ChangeType == "added");
var removed = deltas.Count(d => d.ChangeType == "removed");
var modified = deltas.Count(d => d.ChangeType == "modified");
var unchanged = comparison.Summary.UnchangedSymbols;
var similarities = deltas
.Where(d => d.SemanticSimilarity.HasValue)
.Select(d => d.SemanticSimilarity!.Value)
.ToList();
return new DeltaSummary
{
TotalFunctions = comparison.Summary.TotalSymbols,
FunctionsAdded = added,
FunctionsRemoved = removed,
FunctionsModified = modified,
FunctionsUnchanged = unchanged,
TotalBytesChanged = deltas.Sum(d => d.DiffLen ?? 0),
MinSemanticSimilarity = similarities.Count > 0 ? similarities.Min() : 1.0,
AvgSemanticSimilarity = similarities.Count > 0 ? similarities.Average() : 1.0,
MaxSemanticSimilarity = similarities.Count > 0 ? similarities.Max() : 1.0
};
}
private static async Task<IReadOnlyDictionary<string, string>> ComputeDigestAsync(
Stream stream,
CancellationToken ct)
{
if (stream.CanSeek)
{
stream.Position = 0;
}
using var sha256 = SHA256.Create();
var hash = await sha256.ComputeHashAsync(stream, ct);
return new Dictionary<string, string>
{
["sha256"] = Convert.ToHexString(hash).ToLowerInvariant()
};
}
private static bool DigestsMatch(
IReadOnlyDictionary<string, string> expected,
IReadOnlyDictionary<string, string> actual)
{
foreach (var (algo, hash) in expected)
{
if (actual.TryGetValue(algo, out var actualHash))
{
if (string.Equals(hash, actualHash, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
private static string FormatDigest(IReadOnlyDictionary<string, string> digest)
{
return string.Join(", ", digest.Select(kv => $"{kv.Key}:{kv.Value[..Math.Min(16, kv.Value.Length)]}..."));
}
private static string GetLifterVersion(string? lifter)
{
return lifter?.ToLowerInvariant() switch
{
"ghidra" => "11.0",
"b2r2" => "0.7.0",
"radare2" => "5.8.0",
_ => "1.0.0"
};
}
private static string GetBinaryIndexVersion()
{
var assembly = typeof(DeltaSigService).Assembly;
var version = assembly.GetName().Version;
return version?.ToString() ?? "1.0.0";
}
}

View File

@@ -0,0 +1,431 @@
// -----------------------------------------------------------------------------
// IDeltaSigService.cs
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
// Task: DSP-002 - Implement IDeltaSigService interface
// Description: Service interface for generating and verifying delta-sig predicates
// -----------------------------------------------------------------------------
using StellaOps.BinaryIndex.DeltaSig.Attestation;
namespace StellaOps.BinaryIndex.DeltaSig;
/// <summary>
/// Service for generating and verifying delta-sig predicates.
/// </summary>
/// <remarks>
/// This service leverages existing BinaryIndex infrastructure:
/// - Ghidra integration for function extraction
/// - B2R2 IR lifting for semantic analysis
/// - BSim for similarity scoring
/// - VersionTrackingService for function matching
/// </remarks>
public interface IDeltaSigService
{
/// <summary>
/// Generate a delta-sig predicate by comparing two binaries.
/// </summary>
/// <param name="request">The diff generation request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The generated delta-sig predicate.</returns>
Task<DeltaSigPredicate> GenerateAsync(
DeltaSigRequest request,
CancellationToken ct = default);
/// <summary>
/// Verify that a binary matches the declared delta from a predicate.
/// </summary>
/// <param name="predicate">The delta-sig predicate to verify against.</param>
/// <param name="newBinary">Stream containing the new binary to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<DeltaSigVerificationResult> VerifyAsync(
DeltaSigPredicate predicate,
Stream newBinary,
CancellationToken ct = default);
/// <summary>
/// Verify that a binary matches the declared delta using both old and new binaries.
/// </summary>
/// <param name="predicate">The delta-sig predicate to verify against.</param>
/// <param name="oldBinary">Stream containing the old binary.</param>
/// <param name="newBinary">Stream containing the new binary.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<DeltaSigVerificationResult> VerifyAsync(
DeltaSigPredicate predicate,
Stream oldBinary,
Stream newBinary,
CancellationToken ct = default);
/// <summary>
/// Evaluates whether a delta-sig predicate passes policy constraints.
/// </summary>
/// <param name="predicate">The delta-sig predicate to evaluate.</param>
/// <param name="options">Policy gate options.</param>
/// <returns>Policy evaluation result.</returns>
DeltaSigPolicyResult EvaluatePolicy(
DeltaSigPredicate predicate,
DeltaSigPolicyOptions options);
}
/// <summary>
/// Request for generating a delta-sig predicate.
/// </summary>
public sealed record DeltaSigRequest
{
/// <summary>
/// Old binary to compare from.
/// </summary>
public required BinaryReference OldBinary { get; init; }
/// <summary>
/// New binary to compare to.
/// </summary>
public required BinaryReference NewBinary { get; init; }
/// <summary>
/// Target architecture (e.g., "linux-amd64", "linux-arm64").
/// </summary>
public required string Architecture { get; init; }
/// <summary>
/// Include IR-level diff details.
/// </summary>
public bool IncludeIrDiff { get; init; } = true;
/// <summary>
/// Compute semantic similarity scores.
/// </summary>
public bool ComputeSemanticSimilarity { get; init; } = true;
/// <summary>
/// Preferred lifter (defaults to auto-select based on architecture).
/// </summary>
public string? PreferredLifter { get; init; }
/// <summary>
/// Optional CVE identifiers this diff addresses.
/// </summary>
public IReadOnlyList<string>? CveIds { get; init; }
/// <summary>
/// Optional advisory references.
/// </summary>
public IReadOnlyList<string>? Advisories { get; init; }
/// <summary>
/// Optional package name.
/// </summary>
public string? PackageName { get; init; }
/// <summary>
/// Optional old version string.
/// </summary>
public string? OldVersion { get; init; }
/// <summary>
/// Optional new version string.
/// </summary>
public string? NewVersion { get; init; }
/// <summary>
/// Include only functions matching these patterns (regex).
/// If null, include all functions.
/// </summary>
public IReadOnlyList<string>? FunctionPatterns { get; init; }
/// <summary>
/// Exclude functions matching these patterns (regex).
/// </summary>
public IReadOnlyList<string>? ExcludePatterns { get; init; }
/// <summary>
/// Minimum function size to include (bytes).
/// </summary>
public int MinFunctionSize { get; init; } = 16;
/// <summary>
/// Maximum functions to include in delta (for large binaries).
/// </summary>
public int? MaxDeltaFunctions { get; init; }
/// <summary>
/// Additional metadata to include in predicate.
/// </summary>
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
/// <summary>
/// Reference to a binary for delta-sig generation.
/// </summary>
public sealed record BinaryReference
{
/// <summary>
/// Artifact URI (e.g., "oci://registry/repo@sha256:...").
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Stream containing the binary content.
/// </summary>
public required Stream Content { get; init; }
/// <summary>
/// Digest of the binary (algorithm -> hash).
/// </summary>
public required IReadOnlyDictionary<string, string> Digest { get; init; }
/// <summary>
/// Optional filename hint.
/// </summary>
public string? Filename { get; init; }
/// <summary>
/// Size of the binary in bytes.
/// </summary>
public long? Size { get; init; }
}
/// <summary>
/// Result of verifying a delta-sig predicate.
/// </summary>
public sealed record DeltaSigVerificationResult
{
/// <summary>
/// Whether the verification passed.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Verification status.
/// </summary>
public required DeltaSigVerificationStatus Status { get; init; }
/// <summary>
/// Human-readable message.
/// </summary>
public string? Message { get; init; }
/// <summary>
/// Functions that failed verification.
/// </summary>
public IReadOnlyList<FunctionVerificationFailure>? Failures { get; init; }
/// <summary>
/// Undeclared changes found in the binary.
/// </summary>
public IReadOnlyList<UndeclaredChange>? UndeclaredChanges { get; init; }
/// <summary>
/// Timestamp when verification was performed.
/// </summary>
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Duration of the verification.
/// </summary>
public TimeSpan? Duration { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static DeltaSigVerificationResult Success() => new()
{
IsValid = true,
Status = DeltaSigVerificationStatus.Valid,
Message = "Delta-sig predicate verified successfully"
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static DeltaSigVerificationResult Failure(
DeltaSigVerificationStatus status,
string message,
IReadOnlyList<FunctionVerificationFailure>? failures = null,
IReadOnlyList<UndeclaredChange>? undeclaredChanges = null) => new()
{
IsValid = false,
Status = status,
Message = message,
Failures = failures,
UndeclaredChanges = undeclaredChanges
};
}
/// <summary>
/// Verification status codes.
/// </summary>
public enum DeltaSigVerificationStatus
{
/// <summary>
/// Verification passed.
/// </summary>
Valid,
/// <summary>
/// Subject digest mismatch.
/// </summary>
DigestMismatch,
/// <summary>
/// Function hash mismatch.
/// </summary>
FunctionHashMismatch,
/// <summary>
/// Undeclared changes found.
/// </summary>
UndeclaredChanges,
/// <summary>
/// Function not found in binary.
/// </summary>
FunctionNotFound,
/// <summary>
/// Binary analysis failed.
/// </summary>
AnalysisFailed,
/// <summary>
/// Predicate schema invalid.
/// </summary>
InvalidPredicate
}
/// <summary>
/// Details of a function verification failure.
/// </summary>
public sealed record FunctionVerificationFailure
{
/// <summary>
/// Function identifier.
/// </summary>
public required string FunctionId { get; init; }
/// <summary>
/// Expected hash from predicate.
/// </summary>
public string? ExpectedHash { get; init; }
/// <summary>
/// Actual hash from binary.
/// </summary>
public string? ActualHash { get; init; }
/// <summary>
/// Failure reason.
/// </summary>
public required string Reason { get; init; }
}
/// <summary>
/// Undeclared change found during verification.
/// </summary>
public sealed record UndeclaredChange
{
/// <summary>
/// Function identifier.
/// </summary>
public required string FunctionId { get; init; }
/// <summary>
/// Type of undeclared change.
/// </summary>
public required string ChangeType { get; init; }
/// <summary>
/// Hash of the changed function.
/// </summary>
public string? Hash { get; init; }
/// <summary>
/// Size of the changed function.
/// </summary>
public long? Size { get; init; }
}
/// <summary>
/// Options for delta-sig policy evaluation.
/// </summary>
public sealed record DeltaSigPolicyOptions
{
/// <summary>
/// Maximum allowed modified functions.
/// </summary>
public int MaxModifiedFunctions { get; init; } = 10;
/// <summary>
/// Maximum allowed added functions.
/// </summary>
public int MaxAddedFunctions { get; init; } = 5;
/// <summary>
/// Maximum allowed removed functions.
/// </summary>
public int MaxRemovedFunctions { get; init; } = 2;
/// <summary>
/// Maximum total bytes changed.
/// </summary>
public long MaxBytesChanged { get; init; } = 10_000;
/// <summary>
/// Minimum semantic similarity for modified functions.
/// </summary>
public double MinSemanticSimilarity { get; init; } = 0.8;
/// <summary>
/// Required lifter tools (e.g., must use ghidra for high-assurance).
/// </summary>
public IReadOnlyList<string>? RequiredLifters { get; init; }
/// <summary>
/// Required diffing algorithm.
/// </summary>
public string? RequiredDiffAlgorithm { get; init; }
}
/// <summary>
/// Result of delta-sig policy evaluation.
/// </summary>
public sealed record DeltaSigPolicyResult
{
/// <summary>
/// Whether the policy passed.
/// </summary>
public required bool Passed { get; init; }
/// <summary>
/// Policy violations found.
/// </summary>
public required IReadOnlyList<string> Violations { get; init; }
/// <summary>
/// Summary details for audit.
/// </summary>
public IReadOnlyDictionary<string, object>? Details { get; init; }
/// <summary>
/// Creates a passing result.
/// </summary>
public static DeltaSigPolicyResult Pass(IReadOnlyDictionary<string, object>? details = null) => new()
{
Passed = true,
Violations = Array.Empty<string>(),
Details = details
};
/// <summary>
/// Creates a failing result.
/// </summary>
public static DeltaSigPolicyResult Fail(
IReadOnlyList<string> violations,
IReadOnlyDictionary<string, object>? details = null) => new()
{
Passed = false,
Violations = violations,
Details = details
};
}

View File

@@ -0,0 +1,428 @@
// -----------------------------------------------------------------------------
// DeltaScopePolicyGate.cs
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
// Task: DSP-006 - Implement DeltaScopePolicyGate
// Description: Policy gate that enforces limits on binary patch scope
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.DeltaSig.Attestation;
namespace StellaOps.BinaryIndex.DeltaSig.Policy;
/// <summary>
/// Policy gate that enforces limits on binary patch scope based on delta-sig predicates.
/// </summary>
/// <remarks>
/// This gate can be used to:
/// - Limit hotfix scope (e.g., max 5 functions touched)
/// - Require minimum semantic similarity for changes
/// - Enforce specific tooling requirements
/// - Gate releases based on change magnitude
/// </remarks>
public sealed class DeltaScopePolicyGate : IDeltaScopePolicyGate
{
private readonly ILogger<DeltaScopePolicyGate> _logger;
private readonly IOptions<DeltaScopeGateOptions> _defaultOptions;
/// <summary>
/// Gate name for identification.
/// </summary>
public const string GateName = "DeltaScopeGate";
/// <summary>
/// Initializes a new instance of the <see cref="DeltaScopePolicyGate"/> class.
/// </summary>
public DeltaScopePolicyGate(
ILogger<DeltaScopePolicyGate> logger,
IOptions<DeltaScopeGateOptions>? defaultOptions = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_defaultOptions = defaultOptions ?? Options.Create(new DeltaScopeGateOptions());
}
/// <inheritdoc />
public string Name => GateName;
/// <inheritdoc />
public Task<DeltaScopeGateResult> EvaluateAsync(
DeltaSigPredicate predicate,
DeltaScopeGateOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(predicate);
var opts = options ?? _defaultOptions.Value;
var issues = new List<DeltaScopeViolation>();
_logger.LogDebug(
"Evaluating delta scope gate for predicate with {Total} changes",
predicate.Summary.TotalChanged);
// Check function count limits
if (predicate.Summary.FunctionsModified > opts.MaxModifiedFunctions)
{
issues.Add(new DeltaScopeViolation
{
Rule = DeltaScopeRule.MaxModifiedFunctions,
Message = $"Modified {predicate.Summary.FunctionsModified} functions; max allowed is {opts.MaxModifiedFunctions}",
Severity = DeltaScopeViolationSeverity.Error,
ActualValue = predicate.Summary.FunctionsModified,
ThresholdValue = opts.MaxModifiedFunctions
});
}
if (predicate.Summary.FunctionsAdded > opts.MaxAddedFunctions)
{
issues.Add(new DeltaScopeViolation
{
Rule = DeltaScopeRule.MaxAddedFunctions,
Message = $"Added {predicate.Summary.FunctionsAdded} functions; max allowed is {opts.MaxAddedFunctions}",
Severity = DeltaScopeViolationSeverity.Error,
ActualValue = predicate.Summary.FunctionsAdded,
ThresholdValue = opts.MaxAddedFunctions
});
}
if (predicate.Summary.FunctionsRemoved > opts.MaxRemovedFunctions)
{
issues.Add(new DeltaScopeViolation
{
Rule = DeltaScopeRule.MaxRemovedFunctions,
Message = $"Removed {predicate.Summary.FunctionsRemoved} functions; max allowed is {opts.MaxRemovedFunctions}",
Severity = DeltaScopeViolationSeverity.Error,
ActualValue = predicate.Summary.FunctionsRemoved,
ThresholdValue = opts.MaxRemovedFunctions
});
}
// Check total bytes changed
if (predicate.Summary.TotalBytesChanged > opts.MaxBytesChanged)
{
issues.Add(new DeltaScopeViolation
{
Rule = DeltaScopeRule.MaxBytesChanged,
Message = $"Changed {predicate.Summary.TotalBytesChanged} bytes; max allowed is {opts.MaxBytesChanged}",
Severity = DeltaScopeViolationSeverity.Error,
ActualValue = predicate.Summary.TotalBytesChanged,
ThresholdValue = opts.MaxBytesChanged
});
}
// Check semantic similarity floor
if (predicate.Summary.MinSemanticSimilarity < opts.MinSemanticSimilarity)
{
issues.Add(new DeltaScopeViolation
{
Rule = DeltaScopeRule.MinSemanticSimilarity,
Message = $"Minimum semantic similarity {predicate.Summary.MinSemanticSimilarity:P0} below threshold {opts.MinSemanticSimilarity:P0}",
Severity = DeltaScopeViolationSeverity.Error,
ActualValue = predicate.Summary.MinSemanticSimilarity,
ThresholdValue = opts.MinSemanticSimilarity
});
}
// Check average semantic similarity (warning level)
if (opts.WarnAvgSemanticSimilarity.HasValue &&
predicate.Summary.AvgSemanticSimilarity < opts.WarnAvgSemanticSimilarity.Value)
{
issues.Add(new DeltaScopeViolation
{
Rule = DeltaScopeRule.WarnAvgSemanticSimilarity,
Message = $"Average semantic similarity {predicate.Summary.AvgSemanticSimilarity:P0} below warning threshold {opts.WarnAvgSemanticSimilarity:P0}",
Severity = DeltaScopeViolationSeverity.Warning,
ActualValue = predicate.Summary.AvgSemanticSimilarity,
ThresholdValue = opts.WarnAvgSemanticSimilarity.Value
});
}
// Check required lifters
if (opts.RequiredLifters?.Count > 0 &&
!opts.RequiredLifters.Contains(predicate.Tooling.Lifter, StringComparer.OrdinalIgnoreCase))
{
issues.Add(new DeltaScopeViolation
{
Rule = DeltaScopeRule.RequiredLifter,
Message = $"Lifter '{predicate.Tooling.Lifter}' not in required list: {string.Join(", ", opts.RequiredLifters)}",
Severity = DeltaScopeViolationSeverity.Error
});
}
// Check required diff algorithm
if (!string.IsNullOrEmpty(opts.RequiredDiffAlgorithm) &&
!string.Equals(predicate.Tooling.DiffAlgorithm, opts.RequiredDiffAlgorithm, StringComparison.OrdinalIgnoreCase))
{
issues.Add(new DeltaScopeViolation
{
Rule = DeltaScopeRule.RequiredDiffAlgorithm,
Message = $"Diff algorithm '{predicate.Tooling.DiffAlgorithm}' does not match required '{opts.RequiredDiffAlgorithm}'",
Severity = DeltaScopeViolationSeverity.Error
});
}
// Check forbidden function patterns
if (opts.ForbiddenFunctionPatterns?.Count > 0)
{
var regexes = opts.ForbiddenFunctionPatterns
.Select(p => new System.Text.RegularExpressions.Regex(p, System.Text.RegularExpressions.RegexOptions.Compiled))
.ToList();
foreach (var delta in predicate.Delta)
{
foreach (var regex in regexes)
{
if (regex.IsMatch(delta.FunctionId))
{
issues.Add(new DeltaScopeViolation
{
Rule = DeltaScopeRule.ForbiddenFunctionPattern,
Message = $"Function '{delta.FunctionId}' matches forbidden pattern",
Severity = DeltaScopeViolationSeverity.Error,
FunctionId = delta.FunctionId
});
}
}
}
}
// Build result
var hasErrors = issues.Any(i => i.Severity == DeltaScopeViolationSeverity.Error);
var result = new DeltaScopeGateResult
{
GateName = GateName,
Passed = !hasErrors,
Violations = issues,
Summary = new DeltaScopeSummary
{
FunctionsModified = predicate.Summary.FunctionsModified,
FunctionsAdded = predicate.Summary.FunctionsAdded,
FunctionsRemoved = predicate.Summary.FunctionsRemoved,
TotalBytesChanged = predicate.Summary.TotalBytesChanged,
MinSemanticSimilarity = predicate.Summary.MinSemanticSimilarity,
AvgSemanticSimilarity = predicate.Summary.AvgSemanticSimilarity,
Lifter = predicate.Tooling.Lifter,
DiffAlgorithm = predicate.Tooling.DiffAlgorithm
},
EvaluatedAt = DateTimeOffset.UtcNow
};
if (hasErrors)
{
_logger.LogWarning(
"Delta scope gate FAILED with {ErrorCount} error(s): {Errors}",
issues.Count(i => i.Severity == DeltaScopeViolationSeverity.Error),
string.Join("; ", issues.Where(i => i.Severity == DeltaScopeViolationSeverity.Error).Select(i => i.Message)));
}
else
{
_logger.LogInformation(
"Delta scope gate PASSED (warnings: {WarnCount})",
issues.Count(i => i.Severity == DeltaScopeViolationSeverity.Warning));
}
return Task.FromResult(result);
}
}
/// <summary>
/// Interface for delta scope policy gate.
/// </summary>
public interface IDeltaScopePolicyGate
{
/// <summary>
/// Gate name.
/// </summary>
string Name { get; }
/// <summary>
/// Evaluate a delta-sig predicate against policy constraints.
/// </summary>
Task<DeltaScopeGateResult> EvaluateAsync(
DeltaSigPredicate predicate,
DeltaScopeGateOptions? options = null,
CancellationToken ct = default);
}
/// <summary>
/// Configuration options for delta scope policy gate.
/// </summary>
public sealed class DeltaScopeGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "BinaryIndex:DeltaScopeGate";
/// <summary>
/// Maximum allowed modified functions.
/// </summary>
public int MaxModifiedFunctions { get; set; } = 10;
/// <summary>
/// Maximum allowed added functions.
/// </summary>
public int MaxAddedFunctions { get; set; } = 5;
/// <summary>
/// Maximum allowed removed functions.
/// </summary>
public int MaxRemovedFunctions { get; set; } = 2;
/// <summary>
/// Maximum total bytes changed.
/// </summary>
public long MaxBytesChanged { get; set; } = 10_000;
/// <summary>
/// Minimum semantic similarity for modified functions.
/// </summary>
public double MinSemanticSimilarity { get; set; } = 0.8;
/// <summary>
/// Warning threshold for average semantic similarity.
/// </summary>
public double? WarnAvgSemanticSimilarity { get; set; } = 0.9;
/// <summary>
/// Required lifter tools (e.g., must use ghidra for high-assurance).
/// </summary>
public IReadOnlyList<string>? RequiredLifters { get; set; }
/// <summary>
/// Required diffing algorithm.
/// </summary>
public string? RequiredDiffAlgorithm { get; set; }
/// <summary>
/// Forbidden function name patterns (regex).
/// </summary>
public IReadOnlyList<string>? ForbiddenFunctionPatterns { get; set; }
/// <summary>
/// Allow bypass with explicit approval.
/// </summary>
public bool AllowApprovalBypass { get; set; } = false;
}
/// <summary>
/// Result of delta scope gate evaluation.
/// </summary>
public sealed record DeltaScopeGateResult
{
/// <summary>
/// Gate name.
/// </summary>
public required string GateName { get; init; }
/// <summary>
/// Whether the gate passed.
/// </summary>
public required bool Passed { get; init; }
/// <summary>
/// Violations found.
/// </summary>
public required IReadOnlyList<DeltaScopeViolation> Violations { get; init; }
/// <summary>
/// Summary of the evaluated delta.
/// </summary>
public DeltaScopeSummary? Summary { get; init; }
/// <summary>
/// When the gate was evaluated.
/// </summary>
public DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Human-readable reason for failure.
/// </summary>
public string? Reason => Passed
? null
: string.Join("; ", Violations.Where(v => v.Severity == DeltaScopeViolationSeverity.Error).Select(v => v.Message));
}
/// <summary>
/// A specific violation of delta scope policy.
/// </summary>
public sealed record DeltaScopeViolation
{
/// <summary>
/// Rule that was violated.
/// </summary>
public required DeltaScopeRule Rule { get; init; }
/// <summary>
/// Human-readable message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Severity of the violation.
/// </summary>
public required DeltaScopeViolationSeverity Severity { get; init; }
/// <summary>
/// Actual value that violated the rule.
/// </summary>
public object? ActualValue { get; init; }
/// <summary>
/// Threshold value from the rule.
/// </summary>
public object? ThresholdValue { get; init; }
/// <summary>
/// Function ID if the violation is specific to a function.
/// </summary>
public string? FunctionId { get; init; }
}
/// <summary>
/// Delta scope rules that can be violated.
/// </summary>
public enum DeltaScopeRule
{
MaxModifiedFunctions,
MaxAddedFunctions,
MaxRemovedFunctions,
MaxBytesChanged,
MinSemanticSimilarity,
WarnAvgSemanticSimilarity,
RequiredLifter,
RequiredDiffAlgorithm,
ForbiddenFunctionPattern
}
/// <summary>
/// Severity of a delta scope violation.
/// </summary>
public enum DeltaScopeViolationSeverity
{
/// <summary>
/// Warning - does not fail the gate.
/// </summary>
Warning,
/// <summary>
/// Error - fails the gate.
/// </summary>
Error
}
/// <summary>
/// Summary of delta characteristics for audit.
/// </summary>
public sealed record DeltaScopeSummary
{
public int FunctionsModified { get; init; }
public int FunctionsAdded { get; init; }
public int FunctionsRemoved { get; init; }
public long TotalBytesChanged { get; init; }
public double MinSemanticSimilarity { get; init; }
public double AvgSemanticSimilarity { get; init; }
public string? Lifter { get; init; }
public string? DiffAlgorithm { get; init; }
}

View File

@@ -0,0 +1,372 @@
// -----------------------------------------------------------------------------
// DeltaSigAttestorIntegrationTests.cs
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
// Task: DSP-008 - Unit tests for DeltaSig attestation
// Description: Unit tests for delta-sig attestation integration
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.BinaryIndex.DeltaSig.Attestation;
namespace StellaOps.BinaryIndex.DeltaSig.Tests.Attestation;
/// <summary>
/// Unit tests for delta-sig attestation integration.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DeltaSigAttestorIntegrationTests
{
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _timeProvider;
public DeltaSigAttestorIntegrationTests()
{
_timeProvider = new FakeTimeProvider(FixedTimestamp);
}
[Fact]
public void CreatePredicate_ValidInput_CreatesPredicateWithCorrectType()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest();
// Act
var predicate = service.CreatePredicate(request);
// Assert
predicate.PredicateType.Should().Be("https://stellaops.io/delta-sig/v1");
predicate.Subject.Should().NotBeEmpty();
predicate.DeltaSignatures.Should().NotBeEmpty();
}
[Fact]
public void CreatePredicate_WithSymbols_IncludesAllSymbols()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest(symbolCount: 5);
// Act
var predicate = service.CreatePredicate(request);
// Assert
predicate.DeltaSignatures.Should().HaveCount(5);
predicate.Statistics.TotalSymbols.Should().Be(5);
}
[Fact]
public void CreatePredicate_IncludesTimestamp()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest();
// Act
var predicate = service.CreatePredicate(request);
// Assert
predicate.Timestamp.Should().Be(FixedTimestamp);
}
[Fact]
public void CreatePredicate_ComputesContentDigest()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest();
// Act
var predicate = service.CreatePredicate(request);
// Assert
predicate.Subject.Should().ContainSingle();
predicate.Subject.First().Digest.Should().ContainKey("sha256");
predicate.Subject.First().Digest["sha256"].Should().NotBeNullOrEmpty();
}
[Fact]
public void CreatePredicate_DeterministicOutput()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest();
// Act
var predicate1 = service.CreatePredicate(request);
var predicate2 = service.CreatePredicate(request);
// Assert
predicate1.DeltaSignatures.Should().BeEquivalentTo(predicate2.DeltaSignatures);
predicate1.Subject.First().Digest["sha256"].Should().Be(predicate2.Subject.First().Digest["sha256"]);
}
[Fact]
public void CreateEnvelope_ValidPredicate_CreatesDsseEnvelope()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest();
var predicate = service.CreatePredicate(request);
// Act
var envelope = service.CreateEnvelope(predicate);
// Assert
envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
envelope.Payload.Should().NotBeNullOrEmpty();
}
[Fact]
public void CreateEnvelope_PayloadIsBase64Encoded()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest();
var predicate = service.CreatePredicate(request);
// Act
var envelope = service.CreateEnvelope(predicate);
// Assert
var decoded = Convert.FromBase64String(envelope.Payload);
decoded.Should().NotBeEmpty();
}
[Fact]
public void SerializePredicate_ProducesValidJson()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest();
var predicate = service.CreatePredicate(request);
// Act
var json = service.SerializePredicate(predicate);
// Assert
json.Should().Contain("\"predicateType\"");
json.Should().Contain("\"subject\"");
json.Should().Contain("\"deltaSignatures\"");
json.Should().Contain("delta-sig/v1");
}
[Fact]
public void ValidatePredicate_ValidPredicate_ReturnsTrue()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest();
var predicate = service.CreatePredicate(request);
// Act
var result = service.ValidatePredicate(predicate);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void ValidatePredicate_EmptySubject_ReturnsFalse()
{
// Arrange
var service = CreateService();
var predicate = new DeltaSigPredicate(
PredicateType: "https://stellaops.io/delta-sig/v1",
Subject: Array.Empty<InTotoSubject>(),
DeltaSignatures: new[] { CreateTestDeltaSig() },
Timestamp: FixedTimestamp,
Statistics: new DeltaSigStatistics(1, 0, 0));
// Act
var result = service.ValidatePredicate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("subject", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ValidatePredicate_EmptyDeltaSignatures_ReturnsFalse()
{
// Arrange
var service = CreateService();
var predicate = new DeltaSigPredicate(
PredicateType: "https://stellaops.io/delta-sig/v1",
Subject: new[] { CreateTestSubject() },
DeltaSignatures: Array.Empty<DeltaSignatureEntry>(),
Timestamp: FixedTimestamp,
Statistics: new DeltaSigStatistics(0, 0, 0));
// Act
var result = service.ValidatePredicate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("signature", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ComparePredicate_SameContent_ReturnsNoDifferences()
{
// Arrange
var service = CreateService();
var request = CreateValidPredicateRequest();
var predicate1 = service.CreatePredicate(request);
var predicate2 = service.CreatePredicate(request);
// Act
var diff = service.ComparePredicate(predicate1, predicate2);
// Assert
diff.HasDifferences.Should().BeFalse();
diff.AddedSymbols.Should().BeEmpty();
diff.RemovedSymbols.Should().BeEmpty();
diff.ModifiedSymbols.Should().BeEmpty();
}
[Fact]
public void ComparePredicate_AddedSymbol_DetectsAddition()
{
// Arrange
var service = CreateService();
var request1 = CreateValidPredicateRequest(symbolCount: 3);
var request2 = CreateValidPredicateRequest(symbolCount: 4);
var predicate1 = service.CreatePredicate(request1);
var predicate2 = service.CreatePredicate(request2);
// Act
var diff = service.ComparePredicate(predicate1, predicate2);
// Assert
diff.HasDifferences.Should().BeTrue();
diff.AddedSymbols.Should().HaveCount(1);
}
[Fact]
public void ComparePredicate_RemovedSymbol_DetectsRemoval()
{
// Arrange
var service = CreateService();
var request1 = CreateValidPredicateRequest(symbolCount: 4);
var request2 = CreateValidPredicateRequest(symbolCount: 3);
var predicate1 = service.CreatePredicate(request1);
var predicate2 = service.CreatePredicate(request2);
// Act
var diff = service.ComparePredicate(predicate1, predicate2);
// Assert
diff.HasDifferences.Should().BeTrue();
diff.RemovedSymbols.Should().HaveCount(1);
}
// Helper methods
private IDeltaSigAttestorIntegration CreateService()
{
return new DeltaSigAttestorIntegration(
Options.Create(new DeltaSigAttestorOptions
{
PredicateType = "https://stellaops.io/delta-sig/v1",
IncludeStatistics = true
}),
_timeProvider,
NullLogger<DeltaSigAttestorIntegration>.Instance);
}
private static DeltaSigPredicateRequest CreateValidPredicateRequest(int symbolCount = 3)
{
var signatures = Enumerable.Range(0, symbolCount)
.Select(i => CreateTestDeltaSig(i))
.ToArray();
return new DeltaSigPredicateRequest(
BinaryDigest: $"sha256:abc123def456{symbolCount:D4}",
BinaryName: "libtest.so",
Signatures: signatures);
}
private static DeltaSignatureEntry CreateTestDeltaSig(int index = 0)
{
return new DeltaSignatureEntry(
SymbolName: $"test_function_{index}",
HashAlgorithm: "sha256",
HashHex: $"abcdef{index:D8}0123456789abcdef0123456789abcdef0123456789abcdef01234567",
SizeBytes: 128 + index * 16,
Scope: ".text");
}
private static InTotoSubject CreateTestSubject()
{
return new InTotoSubject(
Name: "libtest.so",
Digest: new Dictionary<string, string>
{
["sha256"] = "abc123def4560000"
});
}
}
// Supporting types for tests (would normally be in main project)
public record DeltaSigPredicate(
string PredicateType,
IReadOnlyList<InTotoSubject> Subject,
IReadOnlyList<DeltaSignatureEntry> DeltaSignatures,
DateTimeOffset Timestamp,
DeltaSigStatistics Statistics);
public record InTotoSubject(
string Name,
IReadOnlyDictionary<string, string> Digest);
public record DeltaSignatureEntry(
string SymbolName,
string HashAlgorithm,
string HashHex,
int SizeBytes,
string Scope);
public record DeltaSigStatistics(
int TotalSymbols,
int AddedSymbols,
int ModifiedSymbols);
public record DeltaSigPredicateRequest(
string BinaryDigest,
string BinaryName,
IReadOnlyList<DeltaSignatureEntry> Signatures);
public record DeltaSigPredicateDiff(
bool HasDifferences,
IReadOnlyList<string> AddedSymbols,
IReadOnlyList<string> RemovedSymbols,
IReadOnlyList<string> ModifiedSymbols);
public record PredicateValidationResult(
bool IsValid,
IReadOnlyList<string> Errors);
public record DsseEnvelope(
string PayloadType,
string Payload);
public record DeltaSigAttestorOptions
{
public string PredicateType { get; init; } = "https://stellaops.io/delta-sig/v1";
public bool IncludeStatistics { get; init; } = true;
}
public interface IDeltaSigAttestorIntegration
{
DeltaSigPredicate CreatePredicate(DeltaSigPredicateRequest request);
DsseEnvelope CreateEnvelope(DeltaSigPredicate predicate);
string SerializePredicate(DeltaSigPredicate predicate);
PredicateValidationResult ValidatePredicate(DeltaSigPredicate predicate);
DeltaSigPredicateDiff ComparePredicate(DeltaSigPredicate before, DeltaSigPredicate after);
}

View File

@@ -0,0 +1,499 @@
// -----------------------------------------------------------------------------
// DeltaSigEndToEndTests.cs
// Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate
// Task: DSP-009 - Integration tests for delta-sig predicate E2E flow
// Description: End-to-end tests for delta-sig generation, signing, submission, and verification
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.BinaryIndex.DeltaSig.Tests.Integration;
[Trait("Category", TestCategories.Integration)]
public sealed class DeltaSigEndToEndTests
{
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _timeProvider;
private readonly MockRekorClient _rekorClient;
private readonly MockSigningService _signingService;
public DeltaSigEndToEndTests()
{
_timeProvider = new FakeTimeProvider(FixedTimestamp);
_rekorClient = new MockRekorClient();
_signingService = new MockSigningService();
}
[Fact]
public async Task FullFlow_GenerateSignSubmitVerify_Succeeds()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 10);
var afterBinary = CreateTestBinary("libtest-1.1.so", 12); // 2 new functions
// Act - Step 1: Generate delta-sig predicate
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Assert - predicate created correctly
predicate.Should().NotBeNull();
predicate.PredicateType.Should().Contain("delta-sig");
predicate.Summary.FunctionsAdded.Should().Be(2);
predicate.Summary.FunctionsModified.Should().Be(0);
// Act - Step 2: Sign the predicate
var envelope = await service.SignAsync(predicate, CancellationToken.None);
// Assert - envelope created
envelope.Should().NotBeNull();
envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
envelope.Signatures.Should().NotBeEmpty();
// Act - Step 3: Submit to Rekor
var submission = await service.SubmitToRekorAsync(envelope, CancellationToken.None);
// Assert - submission successful
submission.Success.Should().BeTrue();
submission.EntryId.Should().NotBeNullOrEmpty();
submission.LogIndex.Should().BeGreaterThan(0);
// Act - Step 4: Verify from Rekor
var verification = await service.VerifyFromRekorAsync(submission.EntryId!, CancellationToken.None);
// Assert - verification successful
verification.IsValid.Should().BeTrue();
verification.PredicateType.Should().Contain("delta-sig");
}
[Fact]
public async Task Generate_IdenticalBinaries_ReturnsEmptyDiff()
{
// Arrange
var service = CreateService();
var binary = CreateTestBinary("libtest.so", 5);
// Act
var predicate = await service.GenerateAsync(binary, binary, CancellationToken.None);
// Assert
predicate.Summary.FunctionsAdded.Should().Be(0);
predicate.Summary.FunctionsModified.Should().Be(0);
predicate.Summary.FunctionsRemoved.Should().Be(0);
predicate.Diff.Should().BeEmpty();
}
[Fact]
public async Task Generate_RemovedFunctions_TracksRemovals()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 10);
var afterBinary = CreateTestBinary("libtest-1.1.so", 7); // 3 removed
// Act
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Assert
predicate.Summary.FunctionsRemoved.Should().Be(3);
}
[Fact]
public async Task Generate_ModifiedFunctions_TracksModifications()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinaryWithModifications("libtest-1.0.so", 5, modifyIndices: new[] { 1, 3 });
var afterBinary = CreateTestBinaryWithModifications("libtest-1.1.so", 5, modifyIndices: new[] { 1, 3 }, modified: true);
// Act
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Assert
predicate.Summary.FunctionsModified.Should().Be(2);
}
[Fact]
public async Task Verify_TamperedPredicate_FailsVerification()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 5);
var afterBinary = CreateTestBinary("libtest-1.1.so", 6);
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
var envelope = await service.SignAsync(predicate, CancellationToken.None);
// Tamper with the envelope
var tamperedEnvelope = envelope with
{
Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("tampered content"))
};
// Act
var verification = await service.VerifyEnvelopeAsync(tamperedEnvelope, CancellationToken.None);
// Assert
verification.IsValid.Should().BeFalse();
verification.FailureReason.Should().Contain("signature");
}
[Fact]
public async Task PolicyGate_WithinLimits_Passes()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 10);
var afterBinary = CreateTestBinary("libtest-1.1.so", 12); // 2 added
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
var policyOptions = new DeltaScopePolicyOptions
{
MaxAddedFunctions = 5,
MaxRemovedFunctions = 5,
MaxModifiedFunctions = 10,
MaxBytesChanged = 10000
};
// Act
var gateResult = await service.EvaluatePolicyAsync(predicate, policyOptions, CancellationToken.None);
// Assert
gateResult.Passed.Should().BeTrue();
gateResult.Violations.Should().BeEmpty();
}
[Fact]
public async Task PolicyGate_ExceedsLimits_FailsWithViolations()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 10);
var afterBinary = CreateTestBinary("libtest-1.1.so", 20); // 10 added
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
var policyOptions = new DeltaScopePolicyOptions
{
MaxAddedFunctions = 5, // Exceeded
MaxRemovedFunctions = 5,
MaxModifiedFunctions = 10,
MaxBytesChanged = 10000
};
// Act
var gateResult = await service.EvaluatePolicyAsync(predicate, policyOptions, CancellationToken.None);
// Assert
gateResult.Passed.Should().BeFalse();
gateResult.Violations.Should().ContainSingle();
gateResult.Violations.First().Should().Contain("added");
}
[Fact]
public async Task SerializeDeserialize_RoundTrip_PreservesData()
{
// Arrange
var service = CreateService();
var beforeBinary = CreateTestBinary("libtest-1.0.so", 5);
var afterBinary = CreateTestBinary("libtest-1.1.so", 7);
var originalPredicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Act
var json = service.SerializePredicate(originalPredicate);
var deserialized = service.DeserializePredicate(json);
// Assert
deserialized.PredicateType.Should().Be(originalPredicate.PredicateType);
deserialized.Summary.FunctionsAdded.Should().Be(originalPredicate.Summary.FunctionsAdded);
deserialized.Subject.Should().HaveCount(originalPredicate.Subject.Count);
}
[Fact]
public async Task Generate_WithSemanticSimilarity_IncludesSimilarityScores()
{
// Arrange
var options = CreateOptions();
options.Value.IncludeSemanticSimilarity = true;
var service = CreateService(options);
var beforeBinary = CreateTestBinaryWithModifications("libtest-1.0.so", 5, modifyIndices: new[] { 2 });
var afterBinary = CreateTestBinaryWithModifications("libtest-1.1.so", 5, modifyIndices: new[] { 2 }, modified: true);
// Act
var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None);
// Assert
var modifiedFunc = predicate.Diff.FirstOrDefault(d => d.ChangeType == "modified");
modifiedFunc.Should().NotBeNull();
modifiedFunc!.SemanticSimilarity.Should().BeGreaterThan(0);
}
[Fact]
public async Task SubmitToRekor_Offline_ReturnsError()
{
// Arrange
_rekorClient.SetOffline(true);
var service = CreateService();
var predicate = CreateMinimalPredicate();
var envelope = await service.SignAsync(predicate, CancellationToken.None);
// Act
var submission = await service.SubmitToRekorAsync(envelope, CancellationToken.None);
// Assert
submission.Success.Should().BeFalse();
submission.Error.Should().Contain("offline");
}
[Fact]
public async Task Verify_StoredOfflineProof_SucceedsWithoutNetwork()
{
// Arrange
var service = CreateService();
var predicate = CreateMinimalPredicate();
var envelope = await service.SignAsync(predicate, CancellationToken.None);
// Submit and get proof
var submission = await service.SubmitToRekorAsync(envelope, CancellationToken.None);
var proof = await service.GetInclusionProofAsync(submission.EntryId!, CancellationToken.None);
// Go offline
_rekorClient.SetOffline(true);
// Act - verify using stored proof
var verification = await service.VerifyWithStoredProofAsync(envelope, proof, CancellationToken.None);
// Assert
verification.IsValid.Should().BeTrue();
verification.VerificationMode.Should().Be("offline");
}
// Helper methods
private IDeltaSigService CreateService(IOptions<DeltaSigServiceOptions>? options = null)
{
return new DeltaSigService(
options ?? CreateOptions(),
_rekorClient,
_signingService,
_timeProvider,
NullLogger<DeltaSigService>.Instance);
}
private static IOptions<DeltaSigServiceOptions> CreateOptions()
{
return Options.Create(new DeltaSigServiceOptions
{
PredicateType = "https://stellaops.io/delta-sig/v1",
IncludeSemanticSimilarity = false,
RekorUrl = "https://rekor.sigstore.dev"
});
}
private static TestBinaryData CreateTestBinary(string name, int functionCount)
{
var functions = Enumerable.Range(0, functionCount)
.Select(i => new TestFunction(
Name: $"func_{i:D3}",
Hash: ComputeHash($"{name}-func-{i}"),
Size: 100 + i * 10))
.ToImmutableArray();
return new TestBinaryData(
Name: name,
Digest: $"sha256:{ComputeHash(name)}",
Functions: functions);
}
private static TestBinaryData CreateTestBinaryWithModifications(
string name, int functionCount, int[] modifyIndices, bool modified = false)
{
var functions = Enumerable.Range(0, functionCount)
.Select(i =>
{
var suffix = modified && modifyIndices.Contains(i) ? "-modified" : "";
return new TestFunction(
Name: $"func_{i:D3}",
Hash: ComputeHash($"{name}-func-{i}{suffix}"),
Size: 100 + i * 10);
})
.ToImmutableArray();
return new TestBinaryData(
Name: name,
Digest: $"sha256:{ComputeHash(name)}",
Functions: functions);
}
private DeltaSigPredicate CreateMinimalPredicate()
{
return new DeltaSigPredicate(
PredicateType: "https://stellaops.io/delta-sig/v1",
Subject: ImmutableArray.Create(new InTotoSubject(
Name: "test.so",
Digest: ImmutableDictionary<string, string>.Empty.Add("sha256", "abc123"))),
Diff: ImmutableArray<DeltaSigDiffEntry>.Empty,
Summary: new DeltaSigSummary(0, 0, 0, 0),
Timestamp: FixedTimestamp,
BeforeDigest: "sha256:before",
AfterDigest: "sha256:after");
}
private static string ComputeHash(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
// Supporting types for tests
public record TestBinaryData(
string Name,
string Digest,
ImmutableArray<TestFunction> Functions);
public record TestFunction(
string Name,
string Hash,
int Size);
public record DeltaSigPredicate(
string PredicateType,
ImmutableArray<InTotoSubject> Subject,
ImmutableArray<DeltaSigDiffEntry> Diff,
DeltaSigSummary Summary,
DateTimeOffset Timestamp,
string BeforeDigest,
string AfterDigest);
public record InTotoSubject(
string Name,
ImmutableDictionary<string, string> Digest);
public record DeltaSigDiffEntry(
string FunctionName,
string ChangeType,
string? BeforeHash,
string? AfterHash,
int BytesDelta,
double? SemanticSimilarity);
public record DeltaSigSummary(
int FunctionsAdded,
int FunctionsRemoved,
int FunctionsModified,
int TotalBytesChanged);
public record DsseEnvelope(
string PayloadType,
string Payload,
ImmutableArray<DsseSignature> Signatures);
public record DsseSignature(
string KeyId,
string Sig);
public record RekorSubmissionResult(
bool Success,
string? EntryId,
long LogIndex,
string? Error);
public record VerificationResult(
bool IsValid,
string? PredicateType,
string? FailureReason,
string? VerificationMode);
public record PolicyGateResult(
bool Passed,
ImmutableArray<string> Violations);
public record InclusionProof(
long TreeSize,
string RootHash,
ImmutableArray<string> Hashes);
public record DeltaScopePolicyOptions
{
public int MaxAddedFunctions { get; init; }
public int MaxRemovedFunctions { get; init; }
public int MaxModifiedFunctions { get; init; }
public int MaxBytesChanged { get; init; }
}
public record DeltaSigServiceOptions
{
public string PredicateType { get; init; } = "https://stellaops.io/delta-sig/v1";
public bool IncludeSemanticSimilarity { get; init; }
public string RekorUrl { get; init; } = "https://rekor.sigstore.dev";
}
public interface IDeltaSigService
{
Task<DeltaSigPredicate> GenerateAsync(TestBinaryData before, TestBinaryData after, CancellationToken ct);
Task<DsseEnvelope> SignAsync(DeltaSigPredicate predicate, CancellationToken ct);
Task<RekorSubmissionResult> SubmitToRekorAsync(DsseEnvelope envelope, CancellationToken ct);
Task<VerificationResult> VerifyFromRekorAsync(string entryId, CancellationToken ct);
Task<VerificationResult> VerifyEnvelopeAsync(DsseEnvelope envelope, CancellationToken ct);
Task<PolicyGateResult> EvaluatePolicyAsync(DeltaSigPredicate predicate, DeltaScopePolicyOptions options, CancellationToken ct);
string SerializePredicate(DeltaSigPredicate predicate);
DeltaSigPredicate DeserializePredicate(string json);
Task<InclusionProof> GetInclusionProofAsync(string entryId, CancellationToken ct);
Task<VerificationResult> VerifyWithStoredProofAsync(DsseEnvelope envelope, InclusionProof proof, CancellationToken ct);
}
public sealed class MockRekorClient
{
private bool _offline;
private long _nextLogIndex = 10000;
private readonly Dictionary<string, InclusionProof> _proofs = new();
public void SetOffline(bool offline) => _offline = offline;
public Task<RekorSubmissionResult> SubmitAsync(byte[] payload, CancellationToken ct)
{
if (_offline)
return Task.FromResult(new RekorSubmissionResult(false, null, 0, "offline"));
var entryId = Guid.NewGuid().ToString("N");
var logIndex = _nextLogIndex++;
_proofs[entryId] = new InclusionProof(logIndex, "root-hash", ImmutableArray.Create("h1", "h2"));
return Task.FromResult(new RekorSubmissionResult(true, entryId, logIndex, null));
}
public Task<InclusionProof?> GetProofAsync(string entryId, CancellationToken ct)
{
if (_offline) return Task.FromResult<InclusionProof?>(null);
_proofs.TryGetValue(entryId, out var proof);
return Task.FromResult(proof);
}
}
public sealed class MockSigningService
{
public Task<DsseEnvelope> SignAsync(string payload, CancellationToken ct)
{
var signature = Convert.ToBase64String(
SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
return Task.FromResult(new DsseEnvelope(
PayloadType: "application/vnd.in-toto+json",
Payload: Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
Signatures: ImmutableArray.Create(new DsseSignature("key-1", signature))));
}
}