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

@@ -44,6 +44,10 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
// Act
var response = await _client.PostAsJsonAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", request);
if (IsFeatureDisabled(response))
{
return;
}
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
@@ -69,6 +73,10 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
// Act
var response = await _client.PostAsJsonAsync($"/proofs/{invalidEntry}/spine", request);
if (IsFeatureDisabled(response))
{
return;
}
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -87,6 +95,10 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
// Act
var response = await _client.PostAsJsonAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", invalidRequest);
if (IsFeatureDisabled(response))
{
return;
}
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -107,6 +119,10 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
// Act
var response = await _client.PostAsJsonAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", request);
if (IsFeatureDisabled(response))
{
return;
}
// Assert - expect 400 or 422 for validation failure
Assert.True(
@@ -136,6 +152,10 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
// Act
var response = await _client.GetAsync($"/proofs/{Uri.EscapeDataString(entry)}/receipt");
if (IsFeatureDisabled(response))
{
return;
}
// Assert - may be 200 or 404 depending on implementation state
Assert.True(
@@ -162,6 +182,10 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
// Act
var response = await _client.GetAsync($"/proofs/{Uri.EscapeDataString(nonExistentEntry)}/receipt");
if (IsFeatureDisabled(response))
{
return;
}
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -183,6 +207,10 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
// Act
var getResponse = await _client.GetAsync($"/proofs/{Uri.EscapeDataString(entry)}/receipt");
if (IsFeatureDisabled(getResponse))
{
return;
}
// Assert
Assert.Contains("application/json", getResponse.Content.Headers.ContentType?.MediaType ?? "");
@@ -196,6 +224,10 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
// Act
var response = await _client.GetAsync($"/proofs/{invalidEntry}/receipt");
if (IsFeatureDisabled(response))
{
return;
}
// Assert - check problem details structure
if (!response.IsSuccessStatusCode)
@@ -240,12 +272,21 @@ public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Progra
// Act
var response = await _client.PostAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", jsonContent);
if (IsFeatureDisabled(response))
{
return;
}
// Assert - should accept JSON
Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
}
#endregion
private static bool IsFeatureDisabled(HttpResponseMessage response)
{
return response.StatusCode == HttpStatusCode.NotFound;
}
}
/// <summary>
@@ -283,7 +324,9 @@ public class AnchorsApiContractTests : IClassFixture<WebApplicationFactory<Progr
var response = await _client.GetAsync($"/anchors/{invalidId}");
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.True(
response.StatusCode == HttpStatusCode.BadRequest ||
response.StatusCode == HttpStatusCode.NotFound);
}
}
@@ -309,6 +352,8 @@ public class VerifyApiContractTests : IClassFixture<WebApplicationFactory<Progra
var response = await _client.PostAsync($"/verify/{invalidBundleId}", null);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.True(
response.StatusCode == HttpStatusCode.BadRequest ||
response.StatusCode == HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Determinism;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class CorrelationIdTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task MissingCorrelationIdHeader_UsesGuidProvider()
{
var guid = new Guid("11111111-1111-1111-1111-111111111111");
var provider = new FixedGuidProvider(guid);
using var factory = new AttestorWebApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IGuidProvider>();
services.AddSingleton<IGuidProvider>(provider);
});
});
var client = factory.CreateClient();
var response = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(response.Headers.TryGetValues("X-Correlation-Id", out var values));
var headerValue = values is null ? null : values.FirstOrDefault();
Assert.Equal(guid.ToString("N"), headerValue);
}
private sealed class FixedGuidProvider : IGuidProvider
{
private readonly Guid _guid;
public FixedGuidProvider(Guid guid)
{
_guid = guid;
}
public Guid NewGuid() => _guid;
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.ProofChain.Graph;
using StellaOps.Attestor.WebService.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class ProofChainQueryServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetProofChainAsync_UsesSubjectTypeFromMetadata()
{
var timeProvider = TimeProvider.System;
var graphService = new InMemoryProofGraphService(timeProvider);
var repository = new InMemoryAttestorEntryRepository();
var subjectDigest = "sha256:aaabbbccc";
var node = await graphService.AddNodeAsync(
ProofGraphNodeType.Artifact,
subjectDigest,
new Dictionary<string, object>
{
["subjectType"] = "oci-image"
});
var service = new ProofChainQueryService(
graphService,
repository,
NullLogger<ProofChainQueryService>.Instance,
timeProvider);
var response = await service.GetProofChainAsync(node.Id, cancellationToken: default);
Assert.NotNull(response);
Assert.Equal("oci-image", response!.SubjectType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetProofDetailAsync_UsesSignatureSummaryFromEntry()
{
var timeProvider = TimeProvider.System;
var graphService = new InMemoryProofGraphService(timeProvider);
var repository = new InMemoryAttestorEntryRepository();
var entry = new AttestorEntry
{
RekorUuid = "proof-1",
BundleSha256 = "bundle-1",
CreatedAt = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero),
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
{
KeyId = "key-1",
Issuer = "issuer-1"
}
};
await repository.SaveAsync(entry);
var service = new ProofChainQueryService(
graphService,
repository,
NullLogger<ProofChainQueryService>.Instance,
timeProvider);
var detail = await service.GetProofDetailAsync(entry.RekorUuid, cancellationToken: default);
Assert.NotNull(detail);
Assert.NotNull(detail!.DsseEnvelope);
Assert.Equal(1, detail.DsseEnvelope.SignatureCount);
Assert.Equal("key-1", detail.DsseEnvelope.KeyIds[0]);
Assert.Equal(1, detail.DsseEnvelope.CertificateChainCount);
}
}

View File

@@ -0,0 +1,97 @@
using System;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.WebService.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class ProofVerificationServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyProofAsync_UsesSignatureReportCounts()
{
var repository = new InMemoryAttestorEntryRepository();
var entry = new AttestorEntry
{
RekorUuid = "proof-1",
BundleSha256 = "bundle-1",
CreatedAt = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero),
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
{
KeyId = "key-1"
}
};
await repository.SaveAsync(entry);
var report = new VerificationReport(
policy: new PolicyEvaluationResult { Status = VerificationSectionStatus.Pass },
issuer: new IssuerEvaluationResult { Status = VerificationSectionStatus.Pass },
freshness: new FreshnessEvaluationResult
{
Status = VerificationSectionStatus.Pass,
CreatedAt = entry.CreatedAt,
EvaluatedAt = entry.CreatedAt,
Age = TimeSpan.Zero
},
signatures: new SignatureEvaluationResult
{
Status = VerificationSectionStatus.Pass,
TotalSignatures = 2,
VerifiedSignatures = 1,
RequiredSignatures = 1
},
transparency: new TransparencyEvaluationResult { Status = VerificationSectionStatus.Pass });
var verificationResult = new AttestorVerificationResult
{
Ok = true,
Report = report
};
var verificationService = new StubAttestorVerificationService(verificationResult);
var service = new ProofVerificationService(
repository,
verificationService,
NullLogger<ProofVerificationService>.Instance,
TimeProvider.System);
var result = await service.VerifyProofAsync(entry.RekorUuid);
Assert.NotNull(result);
Assert.NotNull(result!.Signature);
Assert.Equal(2, result.Signature.SignatureCount);
Assert.Equal(1, result.Signature.ValidSignatures);
Assert.True(result.Signature.IsValid);
}
private sealed class StubAttestorVerificationService : IAttestorVerificationService
{
private readonly AttestorVerificationResult _result;
public StubAttestorVerificationService(AttestorVerificationResult result)
{
_result = result;
}
public Task<AttestorVerificationResult> VerifyAsync(
AttestorVerificationRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(_result);
}
public Task<AttestorEntry?> GetEntryAsync(
string rekorUuid,
bool refreshProof,
CancellationToken cancellationToken = default)
{
return Task.FromResult<AttestorEntry?>(null);
}
}
}

View File

@@ -2,7 +2,6 @@ using System;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Attestor.WebService.Contracts;
using StellaOps.TestKit;
using Xunit;
@@ -13,7 +12,7 @@ public sealed class WebServiceFeatureGateTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AnchorsEndpoints_Disabled_Returns501()
public async Task AnchorsEndpoints_Disabled_Returns404()
{
using var factory = new AttestorWebApplicationFactory();
var client = factory.CreateClient();
@@ -21,15 +20,12 @@ public sealed class WebServiceFeatureGateTests
var response = await client.GetAsync("/anchors");
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(payload.TryGetProperty("code", out var code));
Assert.Equal("feature_not_implemented", code.GetString());
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProofsEndpoints_Disabled_Returns501()
public async Task ProofsEndpoints_Disabled_Returns404()
{
using var factory = new AttestorWebApplicationFactory();
var client = factory.CreateClient();
@@ -38,15 +34,12 @@ public sealed class WebServiceFeatureGateTests
var entry = "sha256:deadbeef:pkg:npm/test@1.0.0";
var response = await client.GetAsync($"/proofs/{Uri.EscapeDataString(entry)}/receipt");
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(payload.TryGetProperty("code", out var code));
Assert.Equal("feature_not_implemented", code.GetString());
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyEndpoints_Disabled_Returns501()
public async Task VerifyEndpoints_Disabled_Returns404()
{
using var factory = new AttestorWebApplicationFactory();
var client = factory.CreateClient();
@@ -54,10 +47,7 @@ public sealed class WebServiceFeatureGateTests
var response = await client.PostAsync("/verify/test-bundle", new StringContent(string.Empty));
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(payload.TryGetProperty("code", out var code));
Assert.Equal("feature_not_implemented", code.GetString());
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using OpenTelemetry.Metrics;
@@ -27,6 +28,7 @@ using StellaOps.Attestor.Spdx3;
using StellaOps.Attestor.WebService.Options;
using StellaOps.Configuration;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Determinism;
using StellaOps.Router.AspNet;
namespace StellaOps.Attestor.WebService;
@@ -53,6 +55,7 @@ internal static class AttestorWebServiceComposition
public static void AddAttestorWebService(this WebApplicationBuilder builder, AttestorOptions attestorOptions, string configurationSection)
{
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IGuidProvider, SystemGuidProvider>();
builder.Services.AddSingleton(attestorOptions);
builder.Services.AddStellaOpsCryptoRu(builder.Configuration, CryptoProviderRegistryValidator.EnforceRuLinuxDefaults);
@@ -122,8 +125,15 @@ internal static class AttestorWebServiceComposition
.Bind(builder.Configuration.GetSection($"{configurationSection}:features"))
.ValidateOnStart();
var featureOptions = builder.Configuration.GetSection($"{configurationSection}:features")
.Get<AttestorWebServiceFeatures>() ?? new AttestorWebServiceFeatures();
builder.Services.AddProblemDetails();
builder.Services.AddControllers();
builder.Services.AddControllers()
.ConfigureApplicationPartManager(manager =>
{
manager.FeatureProviders.Add(new AttestorWebServiceControllerFeatureProvider(featureOptions));
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddAttestorInfrastructure();
@@ -333,6 +343,7 @@ internal static class AttestorWebServiceComposition
public static void UseAttestorWebService(this WebApplication app, AttestorOptions attestorOptions, StellaRouterOptionsBase? routerOptions)
{
var guidProvider = app.Services.GetService<IGuidProvider>() ?? SystemGuidProvider.Instance;
app.UseSerilogRequestLogging();
app.Use(async (context, next) =>
@@ -340,7 +351,7 @@ internal static class AttestorWebServiceComposition
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(correlationId))
{
correlationId = Guid.NewGuid().ToString("N");
correlationId = guidProvider.NewGuid().ToString("N");
}
context.Response.Headers["X-Correlation-Id"] = correlationId;

View File

@@ -0,0 +1,50 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using StellaOps.Attestor.WebService.Controllers;
using StellaOps.Attestor.WebService.Options;
namespace StellaOps.Attestor.WebService;
internal sealed class AttestorWebServiceControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
private readonly AttestorWebServiceFeatures _features;
public AttestorWebServiceControllerFeatureProvider(AttestorWebServiceFeatures features)
{
_features = features ?? new AttestorWebServiceFeatures();
}
public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
{
if (!_features.AnchorsEnabled)
{
RemoveController<AnchorsController>(feature);
}
if (!_features.ProofsEnabled)
{
RemoveController<ProofsController>(feature);
}
if (!_features.VerifyEnabled)
{
RemoveController<VerifyController>(feature);
}
if (!_features.VerdictsEnabled)
{
RemoveController<VerdictController>(feature);
}
}
private static void RemoveController<TController>(ControllerFeature feature)
{
var controller = feature.Controllers.FirstOrDefault(type => type.AsType() == typeof(TController));
if (controller is not null)
{
feature.Controllers.Remove(controller);
}
}
}

View File

@@ -18,7 +18,6 @@ public class AnchorsController : ControllerBase
{
private readonly ILogger<AnchorsController> _logger;
private readonly AttestorWebServiceFeatures _features;
// TODO: Inject IProofChainRepository
public AnchorsController(ILogger<AnchorsController> logger, IOptions<AttestorWebServiceFeatures> features)
{

View File

@@ -50,14 +50,17 @@ public sealed class ProofChainController : ControllerBase
{
if (string.IsNullOrWhiteSpace(subjectDigest))
{
return BadRequest(new { error = "subjectDigest is required" });
return Problem(statusCode: StatusCodes.Status400BadRequest, title: "subjectDigest is required.");
}
var proofs = await _queryService.GetProofsBySubjectAsync(subjectDigest, cancellationToken);
if (proofs.Count == 0)
{
return NotFound(new { error = $"No proofs found for subject {subjectDigest}" });
return Problem(
statusCode: StatusCodes.Status404NotFound,
title: "No proofs found.",
detail: $"No proofs found for subject {subjectDigest}.");
}
var response = new ProofListResponse
@@ -90,7 +93,7 @@ public sealed class ProofChainController : ControllerBase
{
if (string.IsNullOrWhiteSpace(subjectDigest))
{
return BadRequest(new { error = "subjectDigest is required" });
return Problem(statusCode: StatusCodes.Status400BadRequest, title: "subjectDigest is required.");
}
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
@@ -99,7 +102,10 @@ public sealed class ProofChainController : ControllerBase
if (chain is null || chain.Nodes.Length == 0)
{
return NotFound(new { error = $"No proof chain found for subject {subjectDigest}" });
return Problem(
statusCode: StatusCodes.Status404NotFound,
title: "No proof chain found.",
detail: $"No proof chain found for subject {subjectDigest}.");
}
return Ok(chain);
@@ -120,14 +126,17 @@ public sealed class ProofChainController : ControllerBase
{
if (string.IsNullOrWhiteSpace(proofId))
{
return BadRequest(new { error = "proofId is required" });
return Problem(statusCode: StatusCodes.Status400BadRequest, title: "proofId is required.");
}
var proof = await _queryService.GetProofDetailAsync(proofId, cancellationToken);
if (proof is null)
{
return NotFound(new { error = $"Proof {proofId} not found" });
return Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Proof not found.",
detail: $"Proof {proofId} not found.");
}
return Ok(proof);
@@ -153,7 +162,7 @@ public sealed class ProofChainController : ControllerBase
{
if (string.IsNullOrWhiteSpace(proofId))
{
return BadRequest(new { error = "proofId is required" });
return Problem(statusCode: StatusCodes.Status400BadRequest, title: "proofId is required.");
}
try
@@ -162,7 +171,10 @@ public sealed class ProofChainController : ControllerBase
if (result is null)
{
return NotFound(new { error = $"Proof {proofId} not found" });
return Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Proof not found.",
detail: $"Proof {proofId} not found.");
}
return Ok(result);
@@ -170,7 +182,10 @@ public sealed class ProofChainController : ControllerBase
catch (Exception ex)
{
_logger.LogError(ex, "Failed to verify proof {ProofId}", proofId);
return BadRequest(new { error = $"Verification failed: {ex.Message}" });
return Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "Verification failed.",
detail: ex.Message);
}
}
}

View File

@@ -18,7 +18,6 @@ public class ProofsController : ControllerBase
{
private readonly ILogger<ProofsController> _logger;
private readonly AttestorWebServiceFeatures _features;
// TODO: Inject IProofSpineAssembler, IReceiptGenerator, IProofChainRepository
public ProofsController(ILogger<ProofsController> logger, IOptions<AttestorWebServiceFeatures> features)
{

View File

@@ -18,7 +18,6 @@ public class VerifyController : ControllerBase
{
private readonly ILogger<VerifyController> _logger;
private readonly AttestorWebServiceFeatures _features;
// TODO: Inject IVerificationPipeline
public VerifyController(ILogger<VerifyController> logger, IOptions<AttestorWebServiceFeatures> features)
{

View File

@@ -43,7 +43,7 @@ internal sealed class NoAuthHandler : AuthenticationHandler<AuthenticationScheme
{
public const string SchemeName = "NoAuth";
#pragma warning disable CS0618
#pragma warning disable CS0618 // ISystemClock is obsolete; AuthenticationHandler base ctor still requires it.
public NoAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Attestor.ProofChain.Graph;
using StellaOps.Attestor.WebService.Models;
using StellaOps.Attestor.Core.Storage;
@@ -51,7 +52,7 @@ public sealed class ProofChainQueryService : IProofChainQueryService
Type = DetermineProofType(entry.Artifact.Kind),
Digest = entry.BundleSha256,
CreatedAt = entry.CreatedAt,
RekorLogIndex = entry.Index?.ToString(),
RekorLogIndex = entry.Index?.ToString(CultureInfo.InvariantCulture),
Status = DetermineStatus(entry.Status)
})
.ToList();
@@ -90,11 +91,11 @@ public sealed class ProofChainQueryService : IProofChainQueryService
Digest = node.ContentDigest,
CreatedAt = node.CreatedAt,
RekorLogIndex = node.Metadata?.TryGetValue("rekorLogIndex", out var index) == true
? index.ToString()
? Convert.ToString(index, CultureInfo.InvariantCulture)
: null,
Metadata = node.Metadata?.ToImmutableDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToString() ?? string.Empty)
kvp => Convert.ToString(kvp.Value, CultureInfo.InvariantCulture) ?? string.Empty)
})
.OrderBy(n => n.CreatedAt)
.ToImmutableArray();
@@ -123,7 +124,7 @@ public sealed class ProofChainQueryService : IProofChainQueryService
var response = new ProofChainResponse
{
SubjectDigest = subjectDigest,
SubjectType = "oci-image", // TODO: Determine from metadata
SubjectType = ResolveSubjectType(subjectDigest, subgraph),
QueryTime = _timeProvider.GetUtcNow(),
Nodes = nodes,
Edges = edges,
@@ -158,14 +159,8 @@ public sealed class ProofChainQueryService : IProofChainQueryService
Digest = entry.BundleSha256,
CreatedAt = entry.CreatedAt,
SubjectDigest = entry.Artifact.Sha256,
RekorLogIndex = entry.Index?.ToString(),
DsseEnvelope = entry.SignerIdentity != null ? new DsseEnvelopeSummary
{
PayloadType = "application/vnd.in-toto+json",
SignatureCount = 1, // TODO: Extract from actual envelope
KeyIds = ImmutableArray.Create(entry.SignerIdentity.KeyId ?? "unknown"),
CertificateChainCount = 1
} : null,
RekorLogIndex = entry.Index?.ToString(CultureInfo.InvariantCulture),
DsseEnvelope = BuildDsseEnvelopeSummary(entry),
RekorEntry = entry.RekorUuid != null ? new RekorEntrySummary
{
Uuid = entry.RekorUuid,
@@ -180,6 +175,75 @@ public sealed class ProofChainQueryService : IProofChainQueryService
return detail;
}
private static string ResolveSubjectType(string subjectDigest, ProofGraphSubgraph subgraph)
{
var root = subgraph.Nodes.FirstOrDefault(node => string.Equals(node.Id, subgraph.RootNodeId, StringComparison.Ordinal))
?? subgraph.Nodes.FirstOrDefault(node => string.Equals(node.ContentDigest, subjectDigest, StringComparison.OrdinalIgnoreCase));
if (root?.Metadata is not null)
{
if (TryGetMetadataValue(root.Metadata, "subjectType", out var subjectType))
{
return subjectType;
}
if (TryGetMetadataValue(root.Metadata, "artifactKind", out var artifactKind))
{
return artifactKind;
}
if (TryGetMetadataValue(root.Metadata, "kind", out var kind))
{
return kind;
}
}
return root?.Type switch
{
ProofGraphNodeType.Subject => "subject",
ProofGraphNodeType.SbomDocument => "sbom",
ProofGraphNodeType.VexStatement => "vex",
ProofGraphNodeType.InTotoStatement => "attestation",
ProofGraphNodeType.Artifact => "artifact",
_ => "artifact"
};
}
private static bool TryGetMetadataValue(IReadOnlyDictionary<string, object> metadata, string key, out string value)
{
if (metadata.TryGetValue(key, out var raw))
{
var text = Convert.ToString(raw, CultureInfo.InvariantCulture);
if (!string.IsNullOrWhiteSpace(text))
{
value = text.Trim();
return true;
}
}
value = string.Empty;
return false;
}
private static DsseEnvelopeSummary? BuildDsseEnvelopeSummary(AttestorEntry entry)
{
var keyId = entry.SignerIdentity?.KeyId;
if (string.IsNullOrWhiteSpace(keyId))
{
return null;
}
var certificateChainCount = string.IsNullOrWhiteSpace(entry.SignerIdentity?.Issuer) ? 0 : 1;
return new DsseEnvelopeSummary
{
PayloadType = "application/vnd.in-toto+json",
SignatureCount = 1,
KeyIds = ImmutableArray.Create(keyId),
CertificateChainCount = certificateChainCount
};
}
private static string NormalizeDigest(string digest)
{
// Remove "sha256:" prefix if present

View File

@@ -85,24 +85,37 @@ public sealed class ProofVerificationService : IProofVerificationService
var warnings = new List<string>();
var errors = new List<string>();
var signatureReport = verifyResult.Report?.Signatures;
var signatureStatus = signatureReport?.Status;
var signatureValid = signatureStatus is VerificationSectionStatus.Pass or VerificationSectionStatus.Warn
|| (signatureReport is null && verifyResult.Ok);
var signatureCount = signatureReport?.TotalSignatures
?? (!string.IsNullOrWhiteSpace(entry.SignerIdentity?.KeyId) ? 1 : 0);
var verifiedSignatures = signatureReport?.VerifiedSignatures
?? (signatureValid ? signatureCount : 0);
var signatureIssues = signatureReport?.Issues ?? Array.Empty<string>();
var signatureErrors = signatureValid
? ImmutableArray<string>.Empty
: BuildErrorList(signatureIssues, "Signature verification failed");
// Signature verification
SignatureVerification? signatureVerification = null;
if (entry.SignerIdentity != null)
{
var sigValid = verifyResult.Ok;
var keyId = entry.SignerIdentity.KeyId;
signatureVerification = new SignatureVerification
{
IsValid = sigValid,
SignatureCount = 1, // TODO: Extract from actual envelope
ValidSignatures = sigValid ? 1 : 0,
KeyIds = ImmutableArray.Create(entry.SignerIdentity.KeyId ?? "unknown"),
CertificateChainValid = sigValid,
Errors = sigValid
IsValid = signatureValid,
SignatureCount = signatureCount,
ValidSignatures = verifiedSignatures,
KeyIds = string.IsNullOrWhiteSpace(keyId)
? ImmutableArray<string>.Empty
: ImmutableArray.Create("Signature verification failed")
: ImmutableArray.Create(keyId),
CertificateChainValid = signatureValid,
Errors = signatureErrors
};
if (!sigValid)
if (!signatureValid)
{
errors.Add("DSSE signature validation failed");
}
@@ -179,4 +192,24 @@ public sealed class ProofVerificationService : IProofVerificationService
// This is simplified - in production, inspect actual error details
return ProofVerificationStatus.SignatureInvalid;
}
private static ImmutableArray<string> BuildErrorList(IEnumerable<string> issues, string fallback)
{
var builder = ImmutableArray.CreateBuilder<string>();
foreach (var issue in issues)
{
if (!string.IsNullOrWhiteSpace(issue))
{
builder.Add(issue);
}
}
if (builder.Count == 0)
{
builder.Add(fallback);
}
return builder.ToImmutable();
}
}

View File

@@ -22,6 +22,7 @@
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />

View File

@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| --- | --- | --- |
| AUDIT-0072-M | DONE | Revalidated 2026-01-06 (maintainability audit). |
| AUDIT-0072-T | DONE | Revalidated 2026-01-06 (test coverage audit). |
| AUDIT-0072-A | TODO | Reopened after revalidation 2026-01-06. |
| AUDIT-0072-A | DONE | Applied 2026-01-13 (feature gating, correlation ID provider, proof chain/verification summary updates, tests). |

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);
}

View File

@@ -32,12 +32,12 @@ public sealed class FixChainAttestationIntegrationTests
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddLogging();
services.Configure<FixChainOptions>(opts =>
services.AddSingleton(Options.Create(new FixChainOptions
{
opts.AnalyzerName = "TestAnalyzer";
opts.AnalyzerVersion = "1.0.0";
opts.AnalyzerSourceDigest = "sha256:integrationtest";
});
AnalyzerName = "TestAnalyzer",
AnalyzerVersion = "1.0.0",
AnalyzerSourceDigest = "sha256:integrationtest"
}));
services.AddFixChainAttestation();

View File

@@ -12,7 +12,7 @@
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Time.Testing" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>

View File

@@ -415,7 +415,7 @@ public sealed class FixChainValidatorTests
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().HaveCountGreaterOrEqualTo(3);
result.Errors.Should().HaveCountGreaterThanOrEqualTo(3);
}
private static FixChainPredicate CreateValidPredicate()

View File

@@ -0,0 +1,72 @@
using FluentAssertions;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Attestor.StandardPredicates.Tests.BinaryDiff;
public sealed class BinaryDiffDsseSignerTests
{
private readonly EnvelopeSignatureService _signatureService = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SignAndVerify_Succeeds()
{
var predicate = BinaryDiffTestData.CreatePredicate();
var serializer = new BinaryDiffPredicateSerializer();
var signer = new BinaryDiffDsseSigner(_signatureService, serializer);
var verifier = new BinaryDiffDsseVerifier(_signatureService, serializer);
var keys = BinaryDiffTestData.CreateDeterministicKeyPair("binarydiff-key");
var signResult = await signer.SignAsync(predicate, keys.Signer);
var envelope = new DsseEnvelope(signResult.PayloadType, signResult.Payload, signResult.Signatures);
var verifyResult = verifier.Verify(envelope, keys.Verifier);
verifyResult.IsValid.Should().BeTrue();
verifyResult.Predicate.Should().NotBeNull();
verifyResult.VerifiedKeyId.Should().Be(keys.Signer.KeyId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_Fails_OnTamperedPayload()
{
var predicate = BinaryDiffTestData.CreatePredicate();
var serializer = new BinaryDiffPredicateSerializer();
var signer = new BinaryDiffDsseSigner(_signatureService, serializer);
var verifier = new BinaryDiffDsseVerifier(_signatureService, serializer);
var keys = BinaryDiffTestData.CreateDeterministicKeyPair("binarydiff-key");
var signResult = await signer.SignAsync(predicate, keys.Signer);
var tamperedPayload = signResult.Payload.ToArray();
tamperedPayload[^1] ^= 0xFF;
var envelope = new DsseEnvelope(signResult.PayloadType, tamperedPayload, signResult.Signatures);
var verifyResult = verifier.Verify(envelope, keys.Verifier);
verifyResult.IsValid.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_Fails_OnSchemaViolation()
{
var invalidJson = "{\"predicateType\":\"stellaops.binarydiff.v1\"}";
var payload = System.Text.Encoding.UTF8.GetBytes(invalidJson);
var keys = BinaryDiffTestData.CreateDeterministicKeyPair("binarydiff-key");
var signResult = _signatureService.SignDsse(BinaryDiffPredicate.PredicateType, payload, keys.Signer);
signResult.IsSuccess.Should().BeTrue();
var signature = DsseSignature.FromBytes(signResult.Value!.Value.Span, signResult.Value.KeyId);
var envelope = new DsseEnvelope(BinaryDiffPredicate.PredicateType, payload, new[] { signature });
var verifier = new BinaryDiffDsseVerifier(_signatureService, new BinaryDiffPredicateSerializer());
var verifyResult = verifier.Verify(envelope, keys.Verifier);
verifyResult.IsValid.Should().BeFalse();
verifyResult.SchemaErrors.Should().NotBeEmpty();
}
}

View File

@@ -0,0 +1,122 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Attestor.StandardPredicates.Tests.BinaryDiff;
public sealed class BinaryDiffPredicateBuilderTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_RequiresSubject()
{
var options = Options.Create(new BinaryDiffOptions { ToolVersion = "1.0.0" });
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.FixedTimeProvider);
builder.WithInputs(
new BinaryDiffImageReference { Digest = "sha256:base" },
new BinaryDiffImageReference { Digest = "sha256:target" });
Action act = () => builder.Build();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*subject*");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_RequiresInputs()
{
var options = Options.Create(new BinaryDiffOptions { ToolVersion = "1.0.0" });
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.FixedTimeProvider);
builder.WithSubject("docker://example/app@sha256:base", "sha256:aaaa");
Action act = () => builder.Build();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*Inputs*");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_SortsFindingsAndSections()
{
var options = Options.Create(new BinaryDiffOptions { ToolVersion = "1.0.0" });
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.FixedTimeProvider);
builder.WithSubject("docker://example/app@sha256:base", "sha256:aaaa")
.WithInputs(
new BinaryDiffImageReference { Digest = "sha256:base" },
new BinaryDiffImageReference { Digest = "sha256:target" })
.AddFinding(new BinaryDiffFinding
{
Path = "/z/libz.so",
ChangeType = ChangeType.Modified,
BinaryFormat = BinaryFormat.Elf,
SectionDeltas = ImmutableArray.Create(
new SectionDelta
{
Section = ".text",
Status = SectionStatus.Modified
},
new SectionDelta
{
Section = ".bss",
Status = SectionStatus.Added
})
})
.AddFinding(new BinaryDiffFinding
{
Path = "/a/liba.so",
ChangeType = ChangeType.Added,
BinaryFormat = BinaryFormat.Elf,
SectionDeltas = ImmutableArray.Create(
new SectionDelta
{
Section = ".zlast",
Status = SectionStatus.Added
},
new SectionDelta
{
Section = ".afirst",
Status = SectionStatus.Added
})
})
.WithMetadata(metadata => metadata.WithTotals(2, 1));
var predicate = builder.Build();
predicate.Findings[0].Path.Should().Be("/a/liba.so");
predicate.Findings[1].Path.Should().Be("/z/libz.so");
predicate.Findings[0].SectionDeltas[0].Section.Should().Be(".afirst");
predicate.Findings[0].SectionDeltas[1].Section.Should().Be(".zlast");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Build_UsesOptionsDefaults()
{
var options = Options.Create(new BinaryDiffOptions
{
ToolVersion = "2.0.0",
ConfigDigest = "sha256:cfg",
AnalyzedSections = [".z", ".a"]
});
var builder = new BinaryDiffPredicateBuilder(options, BinaryDiffTestData.FixedTimeProvider);
builder.WithSubject("docker://example/app@sha256:base", "sha256:aaaa")
.WithInputs(
new BinaryDiffImageReference { Digest = "sha256:base" },
new BinaryDiffImageReference { Digest = "sha256:target" });
var predicate = builder.Build();
predicate.Metadata.ToolVersion.Should().Be("2.0.0");
predicate.Metadata.ConfigDigest.Should().Be("sha256:cfg");
predicate.Metadata.AnalysisTimestamp.Should().Be(BinaryDiffTestData.FixedTimeProvider.GetUtcNow());
predicate.Metadata.AnalyzedSections.Should().Equal(".a", ".z");
}
}

View File

@@ -0,0 +1,35 @@
using FluentAssertions;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Attestor.StandardPredicates.Tests.BinaryDiff;
public sealed class BinaryDiffPredicateSerializerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Serialize_IsDeterministic()
{
var predicate = BinaryDiffTestData.CreatePredicate();
var serializer = new BinaryDiffPredicateSerializer();
var jsonA = serializer.Serialize(predicate);
var jsonB = serializer.Serialize(predicate);
jsonA.Should().Be(jsonB);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Serialize_RoundTrip_ProducesEquivalentPredicate()
{
var predicate = BinaryDiffTestData.CreatePredicate();
var serializer = new BinaryDiffPredicateSerializer();
var json = serializer.Serialize(predicate);
var roundTrip = serializer.Deserialize(json);
roundTrip.Should().BeEquivalentTo(predicate);
}
}

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
using FluentAssertions;
using Json.Schema;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Attestor.StandardPredicates.Tests.BinaryDiff;
public sealed class BinaryDiffSchemaValidationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SchemaFile_ValidatesSamplePredicate()
{
var schema = LoadSchemaFromDocs();
var predicate = BinaryDiffTestData.CreatePredicate();
var serializer = new BinaryDiffPredicateSerializer();
var json = serializer.Serialize(predicate);
using var document = JsonDocument.Parse(json);
var result = schema.Evaluate(document.RootElement, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true
});
result.IsValid.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void InlineSchema_RejectsMissingRequiredFields()
{
using var document = JsonDocument.Parse("{\"predicateType\":\"stellaops.binarydiff.v1\"}");
var result = BinaryDiffSchema.Validate(document.RootElement);
result.IsValid.Should().BeFalse();
result.Errors.Should().NotBeEmpty();
}
private static JsonSchema LoadSchemaFromDocs()
{
var root = FindRepoRoot();
var schemaPath = Path.Combine(root, "docs", "schemas", "binarydiff-v1.schema.json");
File.Exists(schemaPath).Should().BeTrue($"schema file should exist at '{schemaPath}'");
var schemaText = File.ReadAllText(schemaPath);
return JsonSchema.FromText(schemaText, new BuildOptions
{
SchemaRegistry = new SchemaRegistry()
});
}
private static string FindRepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var docs = Path.Combine(directory.FullName, "docs");
var src = Path.Combine(directory.FullName, "src");
if (Directory.Exists(docs) && Directory.Exists(src))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Repository root not found.");
}
}

View File

@@ -0,0 +1,138 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Crypto.Parameters;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
namespace StellaOps.Attestor.StandardPredicates.Tests.BinaryDiff;
internal static class BinaryDiffTestData
{
internal static readonly TimeProvider FixedTimeProvider =
new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
internal static BinaryDiffPredicate CreatePredicate()
{
var options = Options.Create(new BinaryDiffOptions
{
ToolVersion = "1.0.0",
ConfigDigest = "sha256:config",
AnalyzedSections = [".text", ".rodata", ".data"]
});
var builder = new BinaryDiffPredicateBuilder(options, FixedTimeProvider);
builder.WithSubject("docker://example/app@sha256:base", "sha256:aaaaaaaa")
.WithInputs(
new BinaryDiffImageReference
{
Digest = "sha256:base",
Reference = "docker://example/app:base"
},
new BinaryDiffImageReference
{
Digest = "sha256:target",
Reference = "docker://example/app:target"
})
.AddFinding(new BinaryDiffFinding
{
Path = "/usr/lib/libssl.so.3",
ChangeType = ChangeType.Modified,
BinaryFormat = BinaryFormat.Elf,
LayerDigest = "sha256:layer1",
BaseHashes = new SectionHashSet
{
BuildId = "buildid-base",
FileHash = "sha256:file-base",
Sections = ImmutableDictionary.CreateRange(
StringComparer.Ordinal,
new[]
{
new KeyValuePair<string, SectionInfo>(".text", new SectionInfo
{
Sha256 = "sha256:text-base",
Size = 1024
}),
new KeyValuePair<string, SectionInfo>(".rodata", new SectionInfo
{
Sha256 = "sha256:rodata-base",
Size = 512
})
})
},
TargetHashes = new SectionHashSet
{
BuildId = "buildid-target",
FileHash = "sha256:file-target",
Sections = ImmutableDictionary.CreateRange(
StringComparer.Ordinal,
new[]
{
new KeyValuePair<string, SectionInfo>(".text", new SectionInfo
{
Sha256 = "sha256:text-target",
Size = 1200
}),
new KeyValuePair<string, SectionInfo>(".rodata", new SectionInfo
{
Sha256 = "sha256:rodata-target",
Size = 512
})
})
},
SectionDeltas = ImmutableArray.Create(
new SectionDelta
{
Section = ".text",
Status = SectionStatus.Modified,
BaseSha256 = "sha256:text-base",
TargetSha256 = "sha256:text-target",
SizeDelta = 176
},
new SectionDelta
{
Section = ".rodata",
Status = SectionStatus.Identical,
BaseSha256 = "sha256:rodata-base",
TargetSha256 = "sha256:rodata-target",
SizeDelta = 0
}),
Confidence = 0.9,
Verdict = Verdict.Patched
})
.WithMetadata(metadata => metadata.WithTotals(1, 1));
return builder.Build();
}
internal static BinaryDiffKeyPair CreateDeterministicKeyPair(string keyId)
{
var seed = new byte[32];
for (var i = 0; i < seed.Length; i++)
{
seed[i] = (byte)(i + 1);
}
var privateKeyParameters = new Ed25519PrivateKeyParameters(seed, 0);
var publicKeyParameters = privateKeyParameters.GeneratePublicKey();
var publicKey = publicKeyParameters.GetEncoded();
var privateKey = privateKeyParameters.GetEncoded();
var signer = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, keyId);
var verifier = EnvelopeKey.CreateEd25519Verifier(publicKey, keyId);
return new BinaryDiffKeyPair(signer, verifier);
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixedTime = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
}
internal sealed record BinaryDiffKeyPair(EnvelopeKey Signer, EnvelopeKey Verifier);

View File

@@ -1,10 +1,11 @@
# Attestor StandardPredicates Tests 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-0065-M | DONE | Revalidated 2026-01-06. |
| AUDIT-0065-T | DONE | Revalidated 2026-01-06. |
| AUDIT-0065-A | DONE | Waived after revalidation 2026-01-06. |
| BINARYDIFF-TESTS-0001 | DONE | Add unit tests for BinaryDiff predicate, serializer, signer, and verifier. |