new two advisories and sprints work on them
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user