361 lines
13 KiB
C#
361 lines
13 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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.Encodings.Web;
|
|
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,
|
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
};
|
|
|
|
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; }
|
|
}
|