Add Canonical JSON serialization library with tests and documentation
- Implemented CanonJson class for deterministic JSON serialization and hashing. - Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters. - Created project files for the Canonical JSON library and its tests, including necessary package references. - Added README.md for library usage and API reference. - Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftAttestationService.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Task: UI-016
|
||||
// Description: Service for creating signed reachability drift attestations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.Signer.Core;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDriftAttestationService"/>.
|
||||
/// Creates stellaops.dev/predicates/reachability-drift@v1 attestations wrapped in DSSE envelopes.
|
||||
/// </summary>
|
||||
public sealed class DriftAttestationService : IDriftAttestationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IDriftSignerClient? _signerClient;
|
||||
private readonly IOptionsMonitor<DriftAttestationOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DriftAttestationService> _logger;
|
||||
|
||||
public DriftAttestationService(
|
||||
IDriftSignerClient? signerClient,
|
||||
IOptionsMonitor<DriftAttestationOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DriftAttestationService> logger)
|
||||
{
|
||||
_signerClient = signerClient;
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DriftAttestationResult> CreateAttestationAsync(
|
||||
DriftAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
using var activity = Activity.Current?.Source.StartActivity(
|
||||
"reachability_drift.attest",
|
||||
ActivityKind.Internal);
|
||||
activity?.SetTag("tenant", request.TenantId);
|
||||
activity?.SetTag("base_scan", request.DriftResult.BaseScanId);
|
||||
activity?.SetTag("head_scan", request.DriftResult.HeadScanId);
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Drift attestation is disabled");
|
||||
return new DriftAttestationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Attestation creation is disabled"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Build the predicate
|
||||
var predicate = BuildPredicate(request);
|
||||
|
||||
// Build the in-toto statement
|
||||
var statement = BuildStatement(request, predicate);
|
||||
var statementJson = SerializeCanonical(statement);
|
||||
var payloadBase64 = Convert.ToBase64String(statementJson);
|
||||
|
||||
// Sign the payload
|
||||
DriftDsseSignature signature;
|
||||
string? keyId;
|
||||
|
||||
if (_signerClient is not null && options.UseSignerService)
|
||||
{
|
||||
var signResult = await _signerClient.SignAsync(
|
||||
new DriftSignerRequest
|
||||
{
|
||||
PayloadType = ReachabilityDriftPredicate.PredicateType,
|
||||
PayloadBase64 = payloadBase64,
|
||||
KeyId = request.KeyId ?? options.DefaultKeyId,
|
||||
TenantId = request.TenantId
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!signResult.Success)
|
||||
{
|
||||
_logger.LogWarning("Failed to sign drift attestation: {Error}", signResult.Error);
|
||||
return new DriftAttestationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = signResult.Error ?? "Signing failed"
|
||||
};
|
||||
}
|
||||
|
||||
keyId = signResult.KeyId;
|
||||
signature = new DriftDsseSignature
|
||||
{
|
||||
KeyId = signResult.KeyId ?? "unknown",
|
||||
Sig = signResult.Signature!
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create locally-signed envelope (dev/test mode)
|
||||
keyId = "local-dev-key";
|
||||
signature = SignLocally(statementJson);
|
||||
_logger.LogDebug("Created locally-signed attestation (signer service not available)");
|
||||
}
|
||||
|
||||
var envelope = new DriftDsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payloadBase64,
|
||||
Signatures = [signature]
|
||||
};
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, CanonicalJsonOptions);
|
||||
var envelopeDigestHex = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(envelopeJson))).ToLowerInvariant();
|
||||
var attestationDigest = $"sha256:{envelopeDigestHex}";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created drift attestation for scans {BaseScan} → {HeadScan}. " +
|
||||
"Newly reachable: {NewlyReachable}, Newly unreachable: {NewlyUnreachable}. Digest: {Digest}",
|
||||
request.DriftResult.BaseScanId,
|
||||
request.DriftResult.HeadScanId,
|
||||
request.DriftResult.NewlyReachable.Length,
|
||||
request.DriftResult.NewlyUnreachable.Length,
|
||||
attestationDigest);
|
||||
|
||||
return new DriftAttestationResult
|
||||
{
|
||||
Success = true,
|
||||
AttestationDigest = attestationDigest,
|
||||
EnvelopeJson = envelopeJson,
|
||||
KeyId = keyId,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create drift attestation");
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
|
||||
return new DriftAttestationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private ReachabilityDriftPredicate BuildPredicate(DriftAttestationRequest request)
|
||||
{
|
||||
var drift = request.DriftResult;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
return new ReachabilityDriftPredicate
|
||||
{
|
||||
BaseImage = new DriftImageReference
|
||||
{
|
||||
Name = request.BaseImage.Name,
|
||||
Digest = request.BaseImage.Digest,
|
||||
Tag = request.BaseImage.Tag
|
||||
},
|
||||
TargetImage = new DriftImageReference
|
||||
{
|
||||
Name = request.TargetImage.Name,
|
||||
Digest = request.TargetImage.Digest,
|
||||
Tag = request.TargetImage.Tag
|
||||
},
|
||||
BaseScanId = drift.BaseScanId,
|
||||
HeadScanId = drift.HeadScanId,
|
||||
Drift = new DriftPredicateSummary
|
||||
{
|
||||
NewlyReachableCount = drift.NewlyReachable.Length,
|
||||
NewlyUnreachableCount = drift.NewlyUnreachable.Length,
|
||||
NewlyReachable = drift.NewlyReachable
|
||||
.Select(s => MapSinkToSummary(s))
|
||||
.ToImmutableArray(),
|
||||
NewlyUnreachable = drift.NewlyUnreachable
|
||||
.Select(s => MapSinkToSummary(s))
|
||||
.ToImmutableArray()
|
||||
},
|
||||
Analysis = new DriftAnalysisMetadata
|
||||
{
|
||||
AnalyzedAt = now,
|
||||
Scanner = new DriftScannerInfo
|
||||
{
|
||||
Name = "StellaOps.Scanner",
|
||||
Version = GetScannerVersion(),
|
||||
Ruleset = _options.CurrentValue.SinkRuleset
|
||||
},
|
||||
BaseGraphDigest = request.BaseGraphDigest,
|
||||
HeadGraphDigest = request.HeadGraphDigest,
|
||||
CodeChangesDigest = request.CodeChangesDigest
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static DriftedSinkPredicateSummary MapSinkToSummary(DriftedSink sink)
|
||||
{
|
||||
return new DriftedSinkPredicateSummary
|
||||
{
|
||||
SinkNodeId = sink.SinkNodeId,
|
||||
Symbol = sink.Symbol,
|
||||
SinkCategory = sink.SinkCategory.ToString(),
|
||||
CauseKind = sink.Cause.Kind.ToString(),
|
||||
CauseDescription = sink.Cause.Description,
|
||||
AssociatedCves = sink.AssociatedVulns
|
||||
.Select(v => v.CveId)
|
||||
.Where(cve => !string.IsNullOrEmpty(cve))
|
||||
.ToImmutableArray()!,
|
||||
PathHash = ComputePathHash(sink.Path)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputePathHash(CompressedPath path)
|
||||
{
|
||||
// Create a deterministic representation of the path
|
||||
var pathData = new StringBuilder();
|
||||
pathData.Append(path.Entrypoint.NodeId);
|
||||
pathData.Append(':');
|
||||
foreach (var node in path.KeyNodes)
|
||||
{
|
||||
pathData.Append(node.NodeId);
|
||||
pathData.Append(':');
|
||||
}
|
||||
pathData.Append(path.Sink.NodeId);
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(pathData.ToString()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..16]; // First 64 bits
|
||||
}
|
||||
|
||||
private DriftInTotoStatement BuildStatement(
|
||||
DriftAttestationRequest request,
|
||||
ReachabilityDriftPredicate predicate)
|
||||
{
|
||||
return new DriftInTotoStatement
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
Subject =
|
||||
[
|
||||
new DriftSubject
|
||||
{
|
||||
Name = request.TargetImage.Name,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = request.TargetImage.Digest.Replace("sha256:", "")
|
||||
}
|
||||
}
|
||||
],
|
||||
PredicateType = ReachabilityDriftPredicate.PredicateType,
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] SerializeCanonical<T>(T value)
|
||||
{
|
||||
return JsonSerializer.SerializeToUtf8Bytes(value, CanonicalJsonOptions);
|
||||
}
|
||||
|
||||
private static DriftDsseSignature SignLocally(byte[] payload)
|
||||
{
|
||||
// Local/dev signing: create a placeholder signature
|
||||
// In production, this would use a real key
|
||||
var paeString = $"DSSEv1 {payload.Length} application/vnd.in-toto+json {payload.Length} ";
|
||||
var paeBytes = Encoding.UTF8.GetBytes(paeString).Concat(payload).ToArray();
|
||||
var hash = SHA256.HashData(paeBytes);
|
||||
|
||||
return new DriftDsseSignature
|
||||
{
|
||||
KeyId = "local-dev-key",
|
||||
Sig = Convert.ToBase64String(hash)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetScannerVersion()
|
||||
{
|
||||
var assembly = typeof(DriftAttestationService).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for drift attestation.
|
||||
/// </summary>
|
||||
internal sealed record DriftInTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<DriftSubject> Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required ReachabilityDriftPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in an in-toto statement.
|
||||
/// </summary>
|
||||
internal sealed record DriftSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for drift attestation.
|
||||
/// </summary>
|
||||
internal sealed record DriftDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<DriftDsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature in a DSSE envelope.
|
||||
/// </summary>
|
||||
internal sealed record DriftDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user