audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -0,0 +1,84 @@
using System.Collections.Immutable;
using System.Text;
using StellaOps.Attestor.Envelope;
namespace StellaOps.Attestor.StandardPredicates.BinaryDiff;
public interface IBinaryDiffDsseSigner
{
Task<BinaryDiffDsseResult> SignAsync(
BinaryDiffPredicate predicate,
EnvelopeKey signingKey,
CancellationToken cancellationToken = default);
}
public sealed record BinaryDiffDsseResult
{
public required string PayloadType { get; init; }
public required byte[] Payload { get; init; }
public required ImmutableArray<DsseSignature> Signatures { get; init; }
public required string EnvelopeJson { get; init; }
public string? RekorLogIndex { get; init; }
public string? RekorEntryId { get; init; }
}
public sealed class BinaryDiffDsseSigner : IBinaryDiffDsseSigner
{
private readonly EnvelopeSignatureService _signatureService;
private readonly IBinaryDiffPredicateSerializer _serializer;
public BinaryDiffDsseSigner(
EnvelopeSignatureService signatureService,
IBinaryDiffPredicateSerializer serializer)
{
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
}
public Task<BinaryDiffDsseResult> SignAsync(
BinaryDiffPredicate predicate,
EnvelopeKey signingKey,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(predicate);
ArgumentNullException.ThrowIfNull(signingKey);
cancellationToken.ThrowIfCancellationRequested();
var payloadBytes = _serializer.SerializeToBytes(predicate);
var signResult = _signatureService.SignDsse(BinaryDiffPredicate.PredicateType, payloadBytes, signingKey, cancellationToken);
if (!signResult.IsSuccess)
{
throw new InvalidOperationException($"BinaryDiff DSSE signing failed: {signResult.Error?.Message}");
}
var signature = DsseSignature.FromBytes(signResult.Value!.Value.Span, signResult.Value.KeyId);
var envelope = new DsseEnvelope(BinaryDiffPredicate.PredicateType, payloadBytes, [signature]);
var envelopeJson = SerializeEnvelope(envelope);
var result = new BinaryDiffDsseResult
{
PayloadType = envelope.PayloadType,
Payload = payloadBytes,
Signatures = envelope.Signatures.ToImmutableArray(),
EnvelopeJson = envelopeJson
};
return Task.FromResult(result);
}
private static string SerializeEnvelope(DsseEnvelope envelope)
{
var serialization = DsseEnvelopeSerializer.Serialize(envelope);
if (serialization.CompactJson is null)
{
return string.Empty;
}
return Encoding.UTF8.GetString(serialization.CompactJson);
}
}

View File

@@ -0,0 +1,201 @@
using System.Text.Json;
using StellaOps.Attestor.Envelope;
namespace StellaOps.Attestor.StandardPredicates.BinaryDiff;
public interface IBinaryDiffDsseVerifier
{
BinaryDiffVerificationResult Verify(
DsseEnvelope envelope,
EnvelopeKey publicKey,
CancellationToken cancellationToken = default);
}
public sealed record BinaryDiffVerificationResult
{
public required bool IsValid { get; init; }
public string? Error { get; init; }
public BinaryDiffPredicate? Predicate { get; init; }
public string? VerifiedKeyId { get; init; }
public IReadOnlyList<string> SchemaErrors { get; init; } = Array.Empty<string>();
public static BinaryDiffVerificationResult Success(BinaryDiffPredicate predicate, string keyId) => new()
{
IsValid = true,
Predicate = predicate,
VerifiedKeyId = keyId,
SchemaErrors = Array.Empty<string>()
};
public static BinaryDiffVerificationResult Failure(string error, IReadOnlyList<string>? schemaErrors = null) => new()
{
IsValid = false,
Error = error,
SchemaErrors = schemaErrors ?? Array.Empty<string>()
};
}
public sealed class BinaryDiffDsseVerifier : IBinaryDiffDsseVerifier
{
private readonly EnvelopeSignatureService _signatureService;
private readonly IBinaryDiffPredicateSerializer _serializer;
public BinaryDiffDsseVerifier(
EnvelopeSignatureService signatureService,
IBinaryDiffPredicateSerializer serializer)
{
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
}
public BinaryDiffVerificationResult Verify(
DsseEnvelope envelope,
EnvelopeKey publicKey,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(publicKey);
cancellationToken.ThrowIfCancellationRequested();
if (!string.Equals(envelope.PayloadType, BinaryDiffPredicate.PredicateType, StringComparison.Ordinal))
{
return BinaryDiffVerificationResult.Failure(
$"Invalid payload type: expected '{BinaryDiffPredicate.PredicateType}', got '{envelope.PayloadType}'.");
}
if (!TryVerifySignature(envelope, publicKey, cancellationToken, out var keyId))
{
return BinaryDiffVerificationResult.Failure("DSSE signature verification failed.");
}
BinaryDiffPredicate predicate;
try
{
predicate = _serializer.Deserialize(envelope.Payload.Span);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
return BinaryDiffVerificationResult.Failure($"Failed to deserialize predicate: {ex.Message}");
}
if (!string.Equals(predicate.PredicateTypeId, BinaryDiffPredicate.PredicateType, StringComparison.Ordinal))
{
return BinaryDiffVerificationResult.Failure("Predicate type does not match BinaryDiffV1.");
}
using var document = JsonDocument.Parse(envelope.Payload);
var schemaResult = BinaryDiffSchema.Validate(document.RootElement);
if (!schemaResult.IsValid)
{
return BinaryDiffVerificationResult.Failure("Schema validation failed.", schemaResult.Errors);
}
if (!HasDeterministicOrdering(predicate))
{
return BinaryDiffVerificationResult.Failure("Predicate ordering is not deterministic.");
}
return BinaryDiffVerificationResult.Success(predicate, keyId ?? publicKey.KeyId);
}
private bool TryVerifySignature(
DsseEnvelope envelope,
EnvelopeKey publicKey,
CancellationToken cancellationToken,
out string? keyId)
{
foreach (var signature in envelope.Signatures)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(signature.KeyId))
{
continue;
}
if (!string.Equals(signature.KeyId, publicKey.KeyId, StringComparison.Ordinal))
{
continue;
}
if (!TryDecodeSignature(signature.Signature, out var signatureBytes))
{
continue;
}
var envelopeSignature = new EnvelopeSignature(signature.KeyId, publicKey.AlgorithmId, signatureBytes);
var result = _signatureService.VerifyDsse(
envelope.PayloadType,
envelope.Payload.Span,
envelopeSignature,
publicKey,
cancellationToken);
if (result.IsSuccess)
{
keyId = signature.KeyId;
return true;
}
}
keyId = null;
return false;
}
private static bool TryDecodeSignature(string signature, out byte[] signatureBytes)
{
try
{
signatureBytes = Convert.FromBase64String(signature);
return signatureBytes.Length > 0;
}
catch (FormatException)
{
signatureBytes = [];
return false;
}
}
private static bool HasDeterministicOrdering(BinaryDiffPredicate predicate)
{
if (!IsSorted(predicate.Subjects.Select(subject => subject.Name)))
{
return false;
}
if (!IsSorted(predicate.Findings.Select(finding => finding.Path)))
{
return false;
}
foreach (var finding in predicate.Findings)
{
if (!IsSorted(finding.SectionDeltas.Select(delta => delta.Section)))
{
return false;
}
}
return true;
}
private static bool IsSorted(IEnumerable<string> values)
{
string? previous = null;
foreach (var value in values)
{
if (previous is not null && string.Compare(previous, value, StringComparison.Ordinal) > 0)
{
return false;
}
previous = value;
}
return true;
}
}

View File

@@ -0,0 +1,155 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.StandardPredicates.BinaryDiff;
public sealed record BinaryDiffPredicate
{
public const string PredicateType = "stellaops.binarydiff.v1";
[JsonPropertyName("predicateType")]
public string PredicateTypeId { get; init; } = PredicateType;
public required ImmutableArray<BinaryDiffSubject> Subjects { get; init; }
public required BinaryDiffInputs Inputs { get; init; }
public required ImmutableArray<BinaryDiffFinding> Findings { get; init; }
public required BinaryDiffMetadata Metadata { get; init; }
}
public sealed record BinaryDiffSubject
{
public required string Name { get; init; }
public required ImmutableDictionary<string, string> Digest { get; init; }
public BinaryDiffPlatform? Platform { get; init; }
}
public sealed record BinaryDiffInputs
{
public required BinaryDiffImageReference Base { get; init; }
public required BinaryDiffImageReference Target { get; init; }
}
public sealed record BinaryDiffImageReference
{
public string? Reference { get; init; }
public required string Digest { get; init; }
public string? ManifestDigest { get; init; }
public BinaryDiffPlatform? Platform { get; init; }
}
public sealed record BinaryDiffPlatform
{
public required string Os { get; init; }
public required string Architecture { get; init; }
public string? Variant { get; init; }
}
public sealed record BinaryDiffFinding
{
public required string Path { get; init; }
public required ChangeType ChangeType { get; init; }
public required BinaryFormat BinaryFormat { get; init; }
public string? LayerDigest { get; init; }
public SectionHashSet? BaseHashes { get; init; }
public SectionHashSet? TargetHashes { get; init; }
public ImmutableArray<SectionDelta> SectionDeltas { get; init; } = ImmutableArray<SectionDelta>.Empty;
public double? Confidence { get; init; }
public Verdict? Verdict { get; init; }
}
public enum ChangeType
{
Added,
Removed,
Modified,
Unchanged
}
public enum BinaryFormat
{
Elf,
Pe,
Macho,
Unknown
}
public enum Verdict
{
Patched,
Vanilla,
Unknown,
Incompatible
}
public sealed record SectionHashSet
{
public string? BuildId { get; init; }
public required string FileHash { get; init; }
public required ImmutableDictionary<string, SectionInfo> Sections { get; init; }
}
public sealed record SectionInfo
{
public required string Sha256 { get; init; }
public string? Blake3 { get; init; }
public required long Size { get; init; }
}
public sealed record SectionDelta
{
public required string Section { get; init; }
public required SectionStatus Status { get; init; }
public string? BaseSha256 { get; init; }
public string? TargetSha256 { get; init; }
public long? SizeDelta { get; init; }
}
public enum SectionStatus
{
Identical,
Modified,
Added,
Removed
}
public sealed record BinaryDiffMetadata
{
public required string ToolVersion { get; init; }
public required DateTimeOffset AnalysisTimestamp { get; init; }
public string? ConfigDigest { get; init; }
public int TotalBinaries { get; init; }
public int ModifiedBinaries { get; init; }
public ImmutableArray<string> AnalyzedSections { get; init; } = ImmutableArray<string>.Empty;
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Attestor.StandardPredicates.BinaryDiff;
public sealed class BinaryDiffOptions
{
public const string SectionName = "Attestor:BinaryDiff";
public string ToolVersion { get; set; } = "1.0.0";
public string? ConfigDigest { get; set; }
public IReadOnlyList<string> AnalyzedSections { get; set; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,303 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Options;
namespace StellaOps.Attestor.StandardPredicates.BinaryDiff;
public interface IBinaryDiffPredicateBuilder
{
IBinaryDiffPredicateBuilder WithSubject(string name, string digest, BinaryDiffPlatform? platform = null);
IBinaryDiffPredicateBuilder WithInputs(BinaryDiffImageReference baseImage, BinaryDiffImageReference targetImage);
IBinaryDiffPredicateBuilder AddFinding(BinaryDiffFinding finding);
IBinaryDiffPredicateBuilder WithMetadata(Action<BinaryDiffMetadataBuilder> configure);
BinaryDiffPredicate Build();
}
public sealed class BinaryDiffPredicateBuilder : IBinaryDiffPredicateBuilder
{
private readonly BinaryDiffOptions _options;
private readonly TimeProvider _timeProvider;
private readonly List<BinaryDiffSubject> _subjects = [];
private readonly List<BinaryDiffFinding> _findings = [];
private BinaryDiffInputs? _inputs;
private readonly BinaryDiffMetadataBuilder _metadataBuilder;
public BinaryDiffPredicateBuilder(
IOptions<BinaryDiffOptions>? options = null,
TimeProvider? timeProvider = null)
{
_options = options?.Value ?? new BinaryDiffOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
_metadataBuilder = new BinaryDiffMetadataBuilder(_timeProvider, _options);
}
public IBinaryDiffPredicateBuilder WithSubject(string name, string digest, BinaryDiffPlatform? platform = null)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Subject name must be provided.", nameof(name));
}
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Subject digest must be provided.", nameof(digest));
}
var digestMap = ParseDigest(digest);
_subjects.Add(new BinaryDiffSubject
{
Name = name,
Digest = digestMap,
Platform = platform
});
return this;
}
public IBinaryDiffPredicateBuilder WithInputs(BinaryDiffImageReference baseImage, BinaryDiffImageReference targetImage)
{
ArgumentNullException.ThrowIfNull(baseImage);
ArgumentNullException.ThrowIfNull(targetImage);
_inputs = new BinaryDiffInputs
{
Base = baseImage,
Target = targetImage
};
return this;
}
public IBinaryDiffPredicateBuilder AddFinding(BinaryDiffFinding finding)
{
ArgumentNullException.ThrowIfNull(finding);
_findings.Add(finding);
return this;
}
public IBinaryDiffPredicateBuilder WithMetadata(Action<BinaryDiffMetadataBuilder> configure)
{
ArgumentNullException.ThrowIfNull(configure);
configure(_metadataBuilder);
return this;
}
public BinaryDiffPredicate Build()
{
if (_subjects.Count == 0)
{
throw new InvalidOperationException("At least one subject is required.");
}
if (_inputs is null)
{
throw new InvalidOperationException("Inputs must be provided.");
}
var metadata = _metadataBuilder.Build();
var normalizedSubjects = _subjects
.Select(NormalizeSubject)
.OrderBy(subject => subject.Name, StringComparer.Ordinal)
.ToImmutableArray();
var normalizedFindings = _findings
.Select(NormalizeFinding)
.OrderBy(finding => finding.Path, StringComparer.Ordinal)
.ToImmutableArray();
return new BinaryDiffPredicate
{
Subjects = normalizedSubjects,
Inputs = _inputs,
Findings = normalizedFindings,
Metadata = metadata
};
}
private static BinaryDiffSubject NormalizeSubject(BinaryDiffSubject subject)
{
var digestBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var (algorithm, value) in subject.Digest)
{
if (string.IsNullOrWhiteSpace(algorithm) || string.IsNullOrWhiteSpace(value))
{
continue;
}
digestBuilder[algorithm.Trim().ToLowerInvariant()] = value.Trim();
}
return subject with { Digest = digestBuilder.ToImmutable() };
}
private static BinaryDiffFinding NormalizeFinding(BinaryDiffFinding finding)
{
var sectionDeltas = finding.SectionDeltas;
if (sectionDeltas.IsDefault)
{
sectionDeltas = ImmutableArray<SectionDelta>.Empty;
}
var normalizedDeltas = sectionDeltas
.OrderBy(delta => delta.Section, StringComparer.Ordinal)
.ToImmutableArray();
return finding with
{
SectionDeltas = normalizedDeltas,
BaseHashes = NormalizeHashSet(finding.BaseHashes),
TargetHashes = NormalizeHashSet(finding.TargetHashes)
};
}
private static SectionHashSet? NormalizeHashSet(SectionHashSet? hashSet)
{
if (hashSet is null)
{
return null;
}
var sectionBuilder = ImmutableDictionary.CreateBuilder<string, SectionInfo>(StringComparer.Ordinal);
foreach (var (name, info) in hashSet.Sections)
{
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
sectionBuilder[name] = info;
}
return hashSet with
{
Sections = sectionBuilder.ToImmutable()
};
}
private static ImmutableDictionary<string, string> ParseDigest(string digest)
{
var trimmed = digest.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex > 0 && colonIndex < trimmed.Length - 1)
{
var algorithm = trimmed[..colonIndex].Trim().ToLowerInvariant();
var value = trimmed[(colonIndex + 1)..].Trim();
return ImmutableDictionary<string, string>.Empty
.Add(algorithm, value);
}
return ImmutableDictionary<string, string>.Empty
.Add("sha256", trimmed);
}
}
public sealed class BinaryDiffMetadataBuilder
{
private readonly TimeProvider _timeProvider;
private readonly BinaryDiffOptions _options;
private string? _toolVersion;
private DateTimeOffset? _analysisTimestamp;
private string? _configDigest;
private int? _totalBinaries;
private int? _modifiedBinaries;
private bool _sectionsConfigured;
private readonly List<string> _analyzedSections = [];
public BinaryDiffMetadataBuilder(TimeProvider timeProvider, BinaryDiffOptions options)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public BinaryDiffMetadataBuilder WithToolVersion(string toolVersion)
{
if (string.IsNullOrWhiteSpace(toolVersion))
{
throw new ArgumentException("ToolVersion must be provided.", nameof(toolVersion));
}
_toolVersion = toolVersion;
return this;
}
public BinaryDiffMetadataBuilder WithAnalysisTimestamp(DateTimeOffset analysisTimestamp)
{
_analysisTimestamp = analysisTimestamp;
return this;
}
public BinaryDiffMetadataBuilder WithConfigDigest(string? configDigest)
{
_configDigest = configDigest;
return this;
}
public BinaryDiffMetadataBuilder WithTotals(int totalBinaries, int modifiedBinaries)
{
if (totalBinaries < 0)
{
throw new ArgumentOutOfRangeException(nameof(totalBinaries), "TotalBinaries must be non-negative.");
}
if (modifiedBinaries < 0)
{
throw new ArgumentOutOfRangeException(nameof(modifiedBinaries), "ModifiedBinaries must be non-negative.");
}
_totalBinaries = totalBinaries;
_modifiedBinaries = modifiedBinaries;
return this;
}
public BinaryDiffMetadataBuilder WithAnalyzedSections(IEnumerable<string> sections)
{
ArgumentNullException.ThrowIfNull(sections);
_sectionsConfigured = true;
_analyzedSections.Clear();
_analyzedSections.AddRange(sections);
return this;
}
internal BinaryDiffMetadata Build()
{
var toolVersion = _toolVersion ?? _options.ToolVersion;
if (string.IsNullOrWhiteSpace(toolVersion))
{
throw new InvalidOperationException("ToolVersion must be configured.");
}
var analysisTimestamp = _analysisTimestamp ?? _timeProvider.GetUtcNow();
var configDigest = _configDigest ?? _options.ConfigDigest;
var totalBinaries = _totalBinaries ?? 0;
var modifiedBinaries = _modifiedBinaries ?? 0;
var analyzedSections = ResolveAnalyzedSections();
return new BinaryDiffMetadata
{
ToolVersion = toolVersion,
AnalysisTimestamp = analysisTimestamp,
ConfigDigest = configDigest,
TotalBinaries = totalBinaries,
ModifiedBinaries = modifiedBinaries,
AnalyzedSections = analyzedSections
};
}
private ImmutableArray<string> ResolveAnalyzedSections()
{
var source = _sectionsConfigured ? _analyzedSections : _options.AnalyzedSections;
if (source is null)
{
return ImmutableArray<string>.Empty;
}
return source
.Where(section => !string.IsNullOrWhiteSpace(section))
.Select(section => section.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(section => section, StringComparer.Ordinal)
.ToImmutableArray();
}
}

View File

@@ -0,0 +1,159 @@
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.StandardPredicates.BinaryDiff;
public interface IBinaryDiffPredicateSerializer
{
string Serialize(BinaryDiffPredicate predicate);
byte[] SerializeToBytes(BinaryDiffPredicate predicate);
BinaryDiffPredicate Deserialize(string json);
BinaryDiffPredicate Deserialize(ReadOnlySpan<byte> json);
}
public sealed class BinaryDiffPredicateSerializer : IBinaryDiffPredicateSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public string Serialize(BinaryDiffPredicate predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
var normalized = Normalize(predicate);
var json = JsonSerializer.Serialize(normalized, SerializerOptions);
return JsonCanonicalizer.Canonicalize(json);
}
public byte[] SerializeToBytes(BinaryDiffPredicate predicate)
{
var json = Serialize(predicate);
return Encoding.UTF8.GetBytes(json);
}
public BinaryDiffPredicate Deserialize(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
throw new ArgumentException("JSON must be provided.", nameof(json));
}
var predicate = JsonSerializer.Deserialize<BinaryDiffPredicate>(json, SerializerOptions);
return predicate ?? throw new InvalidOperationException("Failed to deserialize BinaryDiff predicate.");
}
public BinaryDiffPredicate Deserialize(ReadOnlySpan<byte> json)
{
if (json.IsEmpty)
{
throw new ArgumentException("JSON must be provided.", nameof(json));
}
var predicate = JsonSerializer.Deserialize<BinaryDiffPredicate>(json, SerializerOptions);
return predicate ?? throw new InvalidOperationException("Failed to deserialize BinaryDiff predicate.");
}
private static BinaryDiffPredicate Normalize(BinaryDiffPredicate predicate)
{
var normalizedSubjects = predicate.Subjects
.Select(NormalizeSubject)
.OrderBy(subject => subject.Name, StringComparer.Ordinal)
.ToImmutableArray();
var normalizedFindings = predicate.Findings
.Select(NormalizeFinding)
.OrderBy(finding => finding.Path, StringComparer.Ordinal)
.ToImmutableArray();
return predicate with
{
Subjects = normalizedSubjects,
Findings = normalizedFindings,
Metadata = NormalizeMetadata(predicate.Metadata)
};
}
private static BinaryDiffSubject NormalizeSubject(BinaryDiffSubject subject)
{
var digestBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var (algorithm, value) in subject.Digest)
{
if (string.IsNullOrWhiteSpace(algorithm) || string.IsNullOrWhiteSpace(value))
{
continue;
}
digestBuilder[algorithm.Trim().ToLowerInvariant()] = value.Trim();
}
return subject with { Digest = digestBuilder.ToImmutable() };
}
private static BinaryDiffFinding NormalizeFinding(BinaryDiffFinding finding)
{
var sectionDeltas = finding.SectionDeltas;
if (sectionDeltas.IsDefault)
{
sectionDeltas = ImmutableArray<SectionDelta>.Empty;
}
var normalizedDeltas = sectionDeltas
.OrderBy(delta => delta.Section, StringComparer.Ordinal)
.ToImmutableArray();
return finding with
{
SectionDeltas = normalizedDeltas,
BaseHashes = NormalizeHashSet(finding.BaseHashes),
TargetHashes = NormalizeHashSet(finding.TargetHashes)
};
}
private static SectionHashSet? NormalizeHashSet(SectionHashSet? hashSet)
{
if (hashSet is null)
{
return null;
}
var sectionBuilder = ImmutableDictionary.CreateBuilder<string, SectionInfo>(StringComparer.Ordinal);
foreach (var (name, info) in hashSet.Sections)
{
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
sectionBuilder[name] = info;
}
return hashSet with { Sections = sectionBuilder.ToImmutable() };
}
private static BinaryDiffMetadata NormalizeMetadata(BinaryDiffMetadata metadata)
{
var analyzedSections = metadata.AnalyzedSections;
if (analyzedSections.IsDefault)
{
analyzedSections = ImmutableArray<string>.Empty;
}
var normalizedSections = analyzedSections
.Where(section => !string.IsNullOrWhiteSpace(section))
.Select(section => section.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(section => section, StringComparer.Ordinal)
.ToImmutableArray();
return metadata with { AnalyzedSections = normalizedSections };
}
}

View File

@@ -0,0 +1,247 @@
using System.Text.Json;
using Json.Schema;
namespace StellaOps.Attestor.StandardPredicates.BinaryDiff;
public sealed record BinaryDiffSchemaValidationResult
{
public required bool IsValid { get; init; }
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
public static BinaryDiffSchemaValidationResult Valid() => new()
{
IsValid = true,
Errors = Array.Empty<string>()
};
public static BinaryDiffSchemaValidationResult Invalid(IReadOnlyList<string> errors) => new()
{
IsValid = false,
Errors = errors
};
}
public static class BinaryDiffSchema
{
public const string SchemaId = "https://stellaops.io/schemas/binarydiff-v1.schema.json";
private static readonly Lazy<JsonSchema> CachedSchema = new(() =>
JsonSchema.FromText(SchemaJson, new BuildOptions
{
SchemaRegistry = new SchemaRegistry()
}));
public static BinaryDiffSchemaValidationResult Validate(JsonElement element)
{
var schema = CachedSchema.Value;
var result = schema.Evaluate(element, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true
});
if (result.IsValid)
{
return BinaryDiffSchemaValidationResult.Valid();
}
var errors = CollectErrors(result);
return BinaryDiffSchemaValidationResult.Invalid(errors);
}
private static IReadOnlyList<string> CollectErrors(EvaluationResults results)
{
var errors = new List<string>();
if (results.Details is null)
{
return errors;
}
foreach (var detail in results.Details)
{
if (detail.IsValid || detail.Errors is null)
{
continue;
}
foreach (var error in detail.Errors)
{
var message = error.Value ?? "Schema validation error";
errors.Add($"{detail.InstanceLocation}: {message}");
}
}
return errors;
}
private const string SchemaJson = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://stellaops.io/schemas/binarydiff-v1.schema.json",
"title": "BinaryDiffV1",
"description": "In-toto predicate for binary-level diff attestations",
"type": "object",
"required": ["predicateType", "subjects", "inputs", "findings", "metadata"],
"properties": {
"predicateType": {
"const": "stellaops.binarydiff.v1"
},
"subjects": {
"type": "array",
"items": { "$ref": "#/$defs/BinaryDiffSubject" },
"minItems": 1
},
"inputs": {
"$ref": "#/$defs/BinaryDiffInputs"
},
"findings": {
"type": "array",
"items": { "$ref": "#/$defs/BinaryDiffFinding" }
},
"metadata": {
"$ref": "#/$defs/BinaryDiffMetadata"
}
},
"$defs": {
"BinaryDiffSubject": {
"type": "object",
"required": ["name", "digest"],
"properties": {
"name": {
"type": "string",
"description": "Image reference (e.g., docker://repo/app@sha256:...)"
},
"digest": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"platform": {
"$ref": "#/$defs/Platform"
}
}
},
"BinaryDiffInputs": {
"type": "object",
"required": ["base", "target"],
"properties": {
"base": { "$ref": "#/$defs/ImageReference" },
"target": { "$ref": "#/$defs/ImageReference" }
}
},
"ImageReference": {
"type": "object",
"required": ["digest"],
"properties": {
"reference": { "type": "string" },
"digest": { "type": "string" },
"manifestDigest": { "type": "string" },
"platform": { "$ref": "#/$defs/Platform" }
}
},
"Platform": {
"type": "object",
"properties": {
"os": { "type": "string" },
"architecture": { "type": "string" },
"variant": { "type": "string" }
}
},
"BinaryDiffFinding": {
"type": "object",
"required": ["path", "changeType", "binaryFormat"],
"properties": {
"path": {
"type": "string",
"description": "File path within the image filesystem"
},
"changeType": {
"enum": ["added", "removed", "modified", "unchanged"]
},
"binaryFormat": {
"enum": ["elf", "pe", "macho", "unknown"]
},
"layerDigest": {
"type": "string",
"description": "Layer that introduced this change"
},
"baseHashes": {
"$ref": "#/$defs/SectionHashSet"
},
"targetHashes": {
"$ref": "#/$defs/SectionHashSet"
},
"sectionDeltas": {
"type": "array",
"items": { "$ref": "#/$defs/SectionDelta" }
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"verdict": {
"enum": ["patched", "vanilla", "unknown", "incompatible"]
}
}
},
"SectionHashSet": {
"type": "object",
"properties": {
"buildId": { "type": "string" },
"fileHash": { "type": "string" },
"sections": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/SectionInfo"
}
}
}
},
"SectionInfo": {
"type": "object",
"required": ["sha256", "size"],
"properties": {
"sha256": { "type": "string" },
"blake3": { "type": "string" },
"size": { "type": "integer" }
}
},
"SectionDelta": {
"type": "object",
"required": ["section", "status"],
"properties": {
"section": {
"type": "string",
"description": "Section name (e.g., .text, .rodata)"
},
"status": {
"enum": ["identical", "modified", "added", "removed"]
},
"baseSha256": { "type": "string" },
"targetSha256": { "type": "string" },
"sizeDelta": { "type": "integer" }
}
},
"BinaryDiffMetadata": {
"type": "object",
"required": ["toolVersion", "analysisTimestamp"],
"properties": {
"toolVersion": { "type": "string" },
"analysisTimestamp": {
"type": "string",
"format": "date-time"
},
"configDigest": { "type": "string" },
"totalBinaries": { "type": "integer" },
"modifiedBinaries": { "type": "integer" },
"analyzedSections": {
"type": "array",
"items": { "type": "string" }
}
}
}
}
}
""";
}

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Attestor.StandardPredicates.BinaryDiff;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddBinaryDiffPredicates(
this IServiceCollection services,
Action<BinaryDiffOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
if (configure is not null)
{
services.Configure(configure);
}
else
{
services.AddOptions<BinaryDiffOptions>();
}
services.TryAddSingleton<IBinaryDiffPredicateSerializer, BinaryDiffPredicateSerializer>();
services.TryAddSingleton<IBinaryDiffPredicateBuilder, BinaryDiffPredicateBuilder>();
services.TryAddSingleton<IBinaryDiffDsseSigner, BinaryDiffDsseSigner>();
services.TryAddSingleton<IBinaryDiffDsseVerifier, BinaryDiffDsseVerifier>();
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<StellaOps.Attestor.Envelope.EnvelopeSignatureService>();
return services;
}
}

View File

@@ -11,10 +11,13 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="JsonSchema.Net" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
</ItemGroup>

View File

@@ -1,10 +1,17 @@
# Attestor StandardPredicates Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260113_001_002_ATTESTOR_binary_diff_predicate.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0064-M | DONE | Revalidated 2026-01-06. |
| AUDIT-0064-T | DONE | Revalidated 2026-01-06. |
| AUDIT-0064-A | TODO | Reopened after revalidation 2026-01-06. |
| BINARYDIFF-SCHEMA-0001 | DONE | Define schema and C# models for BinaryDiffV1. |
| BINARYDIFF-MODELS-0001 | DONE | Implement predicate models and enums. |
| BINARYDIFF-BUILDER-0001 | DONE | Implement BinaryDiff predicate builder. |
| BINARYDIFF-SERIALIZER-0001 | DONE | Implement RFC 8785 serializer and registry registration. |
| BINARYDIFF-SIGNER-0001 | DONE | Implement DSSE signer for binary diff predicates. |
| BINARYDIFF-VERIFIER-0001 | DONE | Implement DSSE verifier for binary diff predicates. |
| BINARYDIFF-DI-0001 | DONE | Register BinaryDiff services and options in DI. |

View File

@@ -127,7 +127,7 @@ public sealed class BuildProfileValidatorTests
SpdxId = "https://stellaops.io/spdx/test/build/123",
BuildType = "https://slsa.dev/provenance/v1",
BuildId = "build-123",
ConfigSourceDigest = ImmutableArray.Create(Spdx3Hash.Sha256("abc123"))
ConfigSourceDigest = ImmutableArray.Create(Spdx3BuildHash.Sha256("abc123"))
// Note: ConfigSourceUri is empty
};
@@ -149,7 +149,7 @@ public sealed class BuildProfileValidatorTests
BuildType = "https://slsa.dev/provenance/v1",
BuildId = "build-123",
ConfigSourceUri = ImmutableArray.Create("https://github.com/test/repo"),
ConfigSourceDigest = ImmutableArray.Create(new Spdx3Hash
ConfigSourceDigest = ImmutableArray.Create(new Spdx3BuildHash
{
Algorithm = "unknown-algo",
HashValue = "abc123"
@@ -183,3 +183,4 @@ public sealed class BuildProfileValidatorTests
result.ErrorsOnly.Should().Contain(e => e.Field == "spdxId");
}
}

View File

@@ -32,22 +32,33 @@ public sealed class BuildProfileIntegrationTests
// Arrange: Create a realistic build attestation payload
var attestation = new BuildAttestationPayload
{
Type = "https://in-toto.io/Statement/v1",
PredicateType = "https://slsa.dev/provenance/v1",
Subject = ImmutableArray.Create(new AttestationSubject
BuildType = "https://slsa.dev/provenance/v1",
Builder = new BuilderInfo
{
Name = "pkg:oci/myapp@sha256:abc123",
Id = "https://github.com/stellaops/ci-builder@v1"
},
Invocation = new BuildInvocation
{
ConfigSource = new BuildConfigSource
{
Uri = "https://github.com/stellaops/repo",
Digest = new Dictionary<string, string>
{
["sha256"] = "abc123def456"
}.ToImmutableDictionary()
}
},
Materials = ImmutableArray.Create(new BuildMaterial
{
Uri = "pkg:oci/base-image@sha256:base123",
Digest = new Dictionary<string, string>
{
["sha256"] = "abc123def456"
["sha256"] = "base123abc"
}.ToImmutableDictionary()
}),
Predicate = new BuildPredicate
{
BuildDefinition = new BuildDefinitionInfo
{
BuildType = "https://stellaops.org/build/container-scan/v1",
ExternalParameters = new Dictionary<string, object>
})
};
// Remove the Subject and PredicateType as they don't exist in BuildAttestationPayload
{
["imageReference"] = "registry.io/myapp:latest"
}.ToImmutableDictionary(),
@@ -349,13 +360,13 @@ public sealed class BuildProfileIntegrationTests
}
public Task<bool> VerifyAsync(
byte[] payload,
byte[] data,
byte[] signature,
string keyId,
DsseVerificationKey key,
CancellationToken cancellationToken)
{
using var hmac = new System.Security.Cryptography.HMACSHA256(TestKey);
var expectedSignature = hmac.ComputeHash(payload);
var expectedSignature = hmac.ComputeHash(data);
return Task.FromResult(signature.SequenceEqual(expectedSignature));
}
@@ -380,7 +391,7 @@ file sealed class Spdx3JsonSerializer : ISpdx3Serializer
return JsonSerializer.SerializeToUtf8Bytes(document, Options);
}
public Spdx3Document? DeserializeFromBytes(byte[] bytes)
public Spdx3Document? Deserialize(byte[] bytes)
{
return JsonSerializer.Deserialize<Spdx3Document>(bytes, Options);
}