new advisories work and features gaps work
This commit is contained in:
401
src/__Libraries/StellaOps.Evidence.Pack/EvidenceCardService.cs
Normal file
401
src/__Libraries/StellaOps.Evidence.Pack/EvidenceCardService.cs
Normal file
@@ -0,0 +1,401 @@
|
||||
// <copyright file="EvidenceCardService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// Sprint: SPRINT_20260112_004_LB_evidence_card_core (EVPCARD-LB-002)
|
||||
// Description: Service implementation for evidence card operations.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Evidence.Pack.Models;
|
||||
|
||||
namespace StellaOps.Evidence.Pack;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IEvidenceCardService"/>.
|
||||
/// </summary>
|
||||
public sealed class EvidenceCardService : IEvidenceCardService
|
||||
{
|
||||
private static readonly JsonSerializerOptions IndentedOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions CompactOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.Default
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<EvidenceCardService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EvidenceCardService"/> class.
|
||||
/// </summary>
|
||||
public EvidenceCardService(
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
ILogger<EvidenceCardService>? logger = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<EvidenceCardService>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<EvidenceCard> CreateCardAsync(
|
||||
EvidenceCardRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var cardId = _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Create subject
|
||||
var subject = new EvidenceCardSubject
|
||||
{
|
||||
FindingId = request.FindingId,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
ComponentPurl = request.ComponentPurl
|
||||
};
|
||||
|
||||
// Create placeholder SBOM excerpt (real implementation would fetch from SBOM service)
|
||||
var sbomExcerpt = CreatePlaceholderSbomExcerpt(request);
|
||||
|
||||
// Create placeholder DSSE envelope (real implementation would sign the payload)
|
||||
var envelope = CreatePlaceholderEnvelope(cardId, subject, now);
|
||||
|
||||
// Create Rekor receipt metadata (optional, placeholder for now)
|
||||
RekorReceiptMetadata? rekorReceipt = null;
|
||||
if (request.IncludeRekorReceipt)
|
||||
{
|
||||
// In real implementation, this would be populated from actual Rekor submission
|
||||
_logger.LogDebug("Rekor receipt requested but not yet implemented; card will have null receipt");
|
||||
}
|
||||
|
||||
var card = new EvidenceCard
|
||||
{
|
||||
CardId = cardId,
|
||||
Subject = subject,
|
||||
SbomExcerpt = sbomExcerpt,
|
||||
Envelope = envelope,
|
||||
RekorReceipt = rekorReceipt,
|
||||
GeneratedAt = now,
|
||||
Tool = new EvidenceCardTool
|
||||
{
|
||||
Name = "StellaOps",
|
||||
Version = "1.0.0",
|
||||
Vendor = "StellaOps Inc"
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogInformation("Created evidence card {CardId} for finding {FindingId}", cardId, request.FindingId);
|
||||
|
||||
return Task.FromResult(card);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<EvidenceCardExport> ExportCardAsync(
|
||||
EvidenceCard card,
|
||||
EvidenceCardExportFormat format,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(card);
|
||||
|
||||
byte[] content;
|
||||
string contentType;
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case EvidenceCardExportFormat.Json:
|
||||
content = JsonSerializer.SerializeToUtf8Bytes(card, IndentedOptions);
|
||||
contentType = "application/json";
|
||||
break;
|
||||
|
||||
case EvidenceCardExportFormat.CompactJson:
|
||||
content = JsonSerializer.SerializeToUtf8Bytes(card, CompactOptions);
|
||||
contentType = "application/json";
|
||||
break;
|
||||
|
||||
case EvidenceCardExportFormat.CanonicalJson:
|
||||
var json = JsonSerializer.Serialize(card, CompactOptions);
|
||||
content = Encoding.UTF8.GetBytes(CanonicalizeJson(json));
|
||||
contentType = "application/json";
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported export format");
|
||||
}
|
||||
|
||||
var digest = ComputeDigest(content);
|
||||
|
||||
var export = new EvidenceCardExport
|
||||
{
|
||||
CardId = card.CardId,
|
||||
Format = format,
|
||||
Content = content,
|
||||
ContentDigest = digest,
|
||||
ContentType = contentType,
|
||||
FileName = $"evidence-card-{card.CardId}.json"
|
||||
};
|
||||
|
||||
_logger.LogDebug("Exported evidence card {CardId} to {Format} ({Size} bytes)",
|
||||
card.CardId, format, content.Length);
|
||||
|
||||
return Task.FromResult(export);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<EvidenceCardVerificationResult> VerifyCardAsync(
|
||||
EvidenceCard card,
|
||||
EvidenceCardVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(card);
|
||||
options ??= new EvidenceCardVerificationOptions();
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
// Verify DSSE envelope (placeholder - real implementation would verify signature)
|
||||
var signatureValid = !string.IsNullOrEmpty(card.Envelope.PayloadDigest);
|
||||
if (!signatureValid)
|
||||
{
|
||||
issues.Add("DSSE envelope signature verification failed");
|
||||
}
|
||||
|
||||
// Verify SBOM digest
|
||||
var sbomDigestValid = !string.IsNullOrEmpty(card.SbomExcerpt.SbomDigest);
|
||||
if (!sbomDigestValid)
|
||||
{
|
||||
issues.Add("SBOM excerpt digest is missing");
|
||||
}
|
||||
|
||||
// Verify Rekor receipt if present
|
||||
bool? rekorReceiptValid = null;
|
||||
if (card.RekorReceipt is not null)
|
||||
{
|
||||
rekorReceiptValid = VerifyRekorReceiptOffline(card.RekorReceipt, options, issues);
|
||||
}
|
||||
else if (!options.AllowMissingReceipt)
|
||||
{
|
||||
issues.Add("Rekor receipt is required but not present");
|
||||
}
|
||||
|
||||
var valid = signatureValid && sbomDigestValid && (rekorReceiptValid ?? true) && issues.Count == 0;
|
||||
|
||||
return Task.FromResult(new EvidenceCardVerificationResult
|
||||
{
|
||||
Valid = valid,
|
||||
SignatureValid = signatureValid,
|
||||
RekorReceiptValid = rekorReceiptValid,
|
||||
SbomDigestValid = sbomDigestValid,
|
||||
Issues = issues
|
||||
});
|
||||
}
|
||||
|
||||
private static SbomExcerpt CreatePlaceholderSbomExcerpt(EvidenceCardRequest request)
|
||||
{
|
||||
var components = ImmutableArray<SbomComponent>.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.ComponentPurl))
|
||||
{
|
||||
components = ImmutableArray.Create(new SbomComponent
|
||||
{
|
||||
Purl = request.ComponentPurl,
|
||||
Name = ExtractNameFromPurl(request.ComponentPurl),
|
||||
Version = ExtractVersionFromPurl(request.ComponentPurl)
|
||||
});
|
||||
}
|
||||
|
||||
return new SbomExcerpt
|
||||
{
|
||||
Format = "cyclonedx",
|
||||
FormatVersion = "1.6",
|
||||
SbomDigest = $"sha256:{ComputeDigestString(request.ArtifactDigest)}",
|
||||
Components = components,
|
||||
MaxSizeBytes = request.MaxSbomExcerptSize
|
||||
};
|
||||
}
|
||||
|
||||
private static DsseEnvelope CreatePlaceholderEnvelope(
|
||||
string cardId,
|
||||
EvidenceCardSubject subject,
|
||||
DateTimeOffset timestamp)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
cardId,
|
||||
subject.FindingId,
|
||||
subject.ArtifactDigest,
|
||||
timestamp = timestamp.ToString("O", CultureInfo.InvariantCulture)
|
||||
}, CompactOptions);
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
var payloadDigest = ComputeDigest(payloadBytes);
|
||||
|
||||
return new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.stellaops.evidence-card+json",
|
||||
Payload = payloadBase64,
|
||||
PayloadDigest = payloadDigest,
|
||||
Signatures = ImmutableArray.Create(new DsseSignature
|
||||
{
|
||||
KeyId = "placeholder-key",
|
||||
Sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("placeholder-signature"))
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static bool VerifyRekorReceiptOffline(
|
||||
RekorReceiptMetadata receipt,
|
||||
EvidenceCardVerificationOptions options,
|
||||
List<string> issues)
|
||||
{
|
||||
// Basic structural validation
|
||||
if (string.IsNullOrEmpty(receipt.Uuid))
|
||||
{
|
||||
issues.Add("Rekor receipt UUID is missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (receipt.LogIndex < 0)
|
||||
{
|
||||
issues.Add("Rekor receipt log index is invalid");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(receipt.RootHash))
|
||||
{
|
||||
issues.Add("Rekor receipt root hash is missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (receipt.InclusionProofHashes.Length == 0)
|
||||
{
|
||||
issues.Add("Rekor receipt inclusion proof is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Full verification would validate:
|
||||
// 1. Checkpoint signature against trusted keys
|
||||
// 2. Inclusion proof verification
|
||||
// 3. Entry body hash against log entry
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string CanonicalizeJson(string json)
|
||||
{
|
||||
// RFC 8785 canonicalization (simplified - real impl would use StellaOps.Canonical.Json)
|
||||
using var document = JsonDocument.Parse(json);
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false });
|
||||
|
||||
WriteCanonical(writer, document.RootElement);
|
||||
writer.Flush();
|
||||
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
var properties = element.EnumerateObject()
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
writer.WritePropertyName(prop.Name);
|
||||
WriteCanonical(writer, prop.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(writer, item);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetInt64(out var longVal))
|
||||
{
|
||||
writer.WriteNumberValue(longVal);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteNumberValue(element.GetDouble());
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string ComputeDigestString(string data)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(data);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static string ExtractNameFromPurl(string purl)
|
||||
{
|
||||
// Simple PURL name extraction
|
||||
var parts = purl.Split('/');
|
||||
if (parts.Length > 1)
|
||||
{
|
||||
var nameVersion = parts[^1];
|
||||
var atIndex = nameVersion.IndexOf('@');
|
||||
return atIndex > 0 ? nameVersion[..atIndex] : nameVersion;
|
||||
}
|
||||
return purl;
|
||||
}
|
||||
|
||||
private static string ExtractVersionFromPurl(string purl)
|
||||
{
|
||||
var atIndex = purl.LastIndexOf('@');
|
||||
return atIndex > 0 ? purl[(atIndex + 1)..] : "unknown";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user