Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Attestation/DriftAttestationService.cs
2026-01-09 18:27:46 +02:00

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