up
This commit is contained in:
@@ -40,8 +40,101 @@ Deliver the API, workers, and storage that power signing, verification, and life
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/attestor/architecture.md`
|
||||
- `docs/modules/attestor/rekor-verification-design.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
---
|
||||
|
||||
## Active Sprints — Rekor Verification Enhancement
|
||||
|
||||
### SPRINT_3000_0001_0001: Merkle Proof Verification (P0)
|
||||
|
||||
**Objective**: Implement cryptographic verification of Rekor inclusion proofs for offline/air-gap attestation validation.
|
||||
|
||||
**Key Contracts**:
|
||||
|
||||
```csharp
|
||||
// IRekorClient.cs — New method
|
||||
Task<RekorInclusionVerificationResult> VerifyInclusionAsync(
|
||||
AttestorEntry entry,
|
||||
byte[] payloadDigest,
|
||||
byte[] rekorPublicKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// MerkleProofVerifier.cs — RFC 6962 implementation
|
||||
public static bool VerifyInclusion(
|
||||
byte[] leafHash,
|
||||
long leafIndex,
|
||||
long treeSize,
|
||||
IReadOnlyList<byte[]> proofHashes,
|
||||
byte[] expectedRootHash);
|
||||
```
|
||||
|
||||
**New Files**:
|
||||
- `StellaOps.Attestor.Core/Rekor/RekorInclusionVerificationResult.cs`
|
||||
- `StellaOps.Attestor.Core/Verification/MerkleProofVerifier.cs`
|
||||
- `StellaOps.Attestor.Core/Verification/CheckpointVerifier.cs`
|
||||
|
||||
### SPRINT_3000_0001_0002: Rekor Retry Queue & Metrics (P1)
|
||||
|
||||
**Objective**: Implement durable retry queue for failed Rekor submissions with operational metrics.
|
||||
|
||||
**Key Contracts**:
|
||||
|
||||
```csharp
|
||||
// IRekorSubmissionQueue.cs
|
||||
public interface IRekorSubmissionQueue
|
||||
{
|
||||
Task<Guid> EnqueueAsync(string tenantId, string bundleSha256, byte[] dssePayload, string backend, CancellationToken ct);
|
||||
Task<IReadOnlyList<RekorQueueItem>> DequeueAsync(int batchSize, CancellationToken ct);
|
||||
Task MarkSubmittedAsync(Guid id, string rekorUuid, long? logIndex, CancellationToken ct);
|
||||
Task MarkRetryAsync(Guid id, string error, CancellationToken ct);
|
||||
Task MarkDeadLetterAsync(Guid id, string error, CancellationToken ct);
|
||||
Task<QueueDepthSnapshot> GetQueueDepthAsync(CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
**New Metrics**:
|
||||
- `attestor.rekor_queue_depth` (gauge)
|
||||
- `attestor.rekor_retry_attempts_total` (counter)
|
||||
- `attestor.rekor_submission_status_total` (counter)
|
||||
|
||||
**New Files**:
|
||||
- `StellaOps.Attestor.Core/Queue/IRekorSubmissionQueue.cs`
|
||||
- `StellaOps.Attestor.Infrastructure/Queue/PostgresRekorSubmissionQueue.cs`
|
||||
- `StellaOps.Attestor.Infrastructure/Workers/RekorRetryWorker.cs`
|
||||
- `Migrations/00X_rekor_submission_queue.sql`
|
||||
|
||||
### SPRINT_3000_0001_0003: Time Skew Validation (P2)
|
||||
|
||||
**Objective**: Validate Rekor `integrated_time` to detect backdated or anomalous entries.
|
||||
|
||||
**Key Contracts**:
|
||||
|
||||
```csharp
|
||||
// ITimeSkewValidator.cs
|
||||
public interface ITimeSkewValidator
|
||||
{
|
||||
TimeSkewResult Validate(DateTimeOffset integratedTime, DateTimeOffset localTime);
|
||||
}
|
||||
|
||||
public sealed record TimeSkewResult(
|
||||
TimeSkewSeverity Severity, // Ok, Warning, Rejected
|
||||
TimeSpan Skew,
|
||||
string? Message);
|
||||
```
|
||||
|
||||
**Configuration** (`AttestorOptions.TimeSkewOptions`):
|
||||
- `WarnThresholdSeconds`: 300 (5 min)
|
||||
- `RejectThresholdSeconds`: 3600 (1 hour)
|
||||
- `FutureToleranceSeconds`: 60
|
||||
|
||||
**New Files**:
|
||||
- `StellaOps.Attestor.Core/Validation/ITimeSkewValidator.cs`
|
||||
- `StellaOps.Attestor.Infrastructure/Validation/TimeSkewValidator.cs`
|
||||
|
||||
---
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
|
||||
57
src/Attestor/StellaOps.Attestor/TASKS.md
Normal file
57
src/Attestor/StellaOps.Attestor/TASKS.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Attestor · Sprint 3000-0001-0001 (Rekor Merkle Proof Verification)
|
||||
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
| SPRINT_3000_0001_0001-T1 | DOING | Add `VerifyInclusionAsync` contract + wire initial verifier plumbing. | 2025-12-14 |
|
||||
| SPRINT_3000_0001_0001-T2 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T3 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T4 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T5 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T6 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T7 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T8 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T9 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T10 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T11 | TODO | | |
|
||||
| SPRINT_3000_0001_0001-T12 | TODO | | |
|
||||
|
||||
# Attestor · Sprint 3000-0001-0002 (Rekor Durable Retry Queue & Metrics)
|
||||
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
| SPRINT_3000_0001_0002-T1 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T2 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T3 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T4 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T5 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T6 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T7 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T8 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T9 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T10 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T11 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T12 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T13 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T14 | TODO | | |
|
||||
| SPRINT_3000_0001_0002-T15 | TODO | | |
|
||||
|
||||
# Attestor · Sprint 3000-0001-0003 (Rekor Integrated Time Skew Validation)
|
||||
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
| SPRINT_3000_0001_0003-T1 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T2 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T3 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T4 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T5 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T6 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T7 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T8 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T9 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T10 | TODO | | |
|
||||
| SPRINT_3000_0001_0003-T11 | TODO | | |
|
||||
|
||||
Status changes must be mirrored in:
|
||||
- `docs/implplan/SPRINT_3000_0001_0001_rekor_merkle_proof_verification.md`
|
||||
- `docs/implplan/SPRINT_3000_0001_0002_rekor_retry_queue_metrics.md`
|
||||
- `docs/implplan/SPRINT_3000_0001_0003_rekor_time_skew_validation.md`
|
||||
@@ -0,0 +1,23 @@
|
||||
# StellaOps.Attestor.ProofChain — Local Agent Charter
|
||||
|
||||
## Scope
|
||||
- This charter applies to `src/Attestor/__Libraries/StellaOps.Attestor.ProofChain/**`.
|
||||
|
||||
## Primary roles
|
||||
- Backend engineer (C# / .NET 10).
|
||||
- QA automation engineer (xUnit).
|
||||
|
||||
## Required reading (treat as read before edits)
|
||||
- `docs/modules/attestor/architecture.md`
|
||||
- `docs/product-advisories/14-Dec-2025 - Proof and Evidence Chain Technical Reference.md`
|
||||
- RFC 8785 (JSON Canonicalization Scheme)
|
||||
|
||||
## Working agreements
|
||||
- Determinism is mandatory: stable ordering, stable hashes, UTC timestamps only.
|
||||
- No network dependence in library code paths; keep implementations offline-friendly.
|
||||
- Prefer small, composable services with explicit interfaces (`I*`).
|
||||
|
||||
## Testing expectations
|
||||
- Every behavior change must be covered by tests under `src/Attestor/__Tests/StellaOps.Attestor.ProofChain.Tests`.
|
||||
- Include determinism tests (same inputs -> same IDs/hashes) and negative tests (invalid formats).
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using StellaOps.Attestor.ProofChain.Internal;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
public abstract record ContentAddressedId
|
||||
{
|
||||
protected ContentAddressedId(string algorithm, string digest)
|
||||
{
|
||||
Algorithm = NormalizeAlgorithm(algorithm, nameof(algorithm));
|
||||
Digest = NormalizeDigest(Algorithm, digest);
|
||||
}
|
||||
|
||||
public string Algorithm { get; }
|
||||
|
||||
public string Digest { get; }
|
||||
|
||||
public override string ToString() => $"{Algorithm}:{Digest}";
|
||||
|
||||
public static GenericContentAddressedId Parse(string value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||
|
||||
if (!TrySplit(value, out var algorithm, out var digest))
|
||||
{
|
||||
throw new FormatException($"Invalid content-addressed ID format: '{value}'.");
|
||||
}
|
||||
|
||||
return new GenericContentAddressedId(algorithm, digest);
|
||||
}
|
||||
|
||||
internal static bool TrySplit(string value, out string algorithm, out string digest)
|
||||
{
|
||||
algorithm = string.Empty;
|
||||
digest = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var separator = value.IndexOf(':', StringComparison.Ordinal);
|
||||
if (separator <= 0 || separator == value.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
algorithm = value[..separator];
|
||||
digest = value[(separator + 1)..];
|
||||
if (string.IsNullOrWhiteSpace(algorithm) || string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
algorithm = NormalizeAlgorithm(algorithm, nameof(algorithm));
|
||||
digest = NormalizeDigest(algorithm, digest);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string NormalizeAlgorithm(string algorithm, string parameterName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(algorithm, parameterName);
|
||||
return algorithm.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal static string NormalizeDigest(string algorithm, string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest, nameof(digest));
|
||||
digest = digest.Trim();
|
||||
|
||||
return algorithm switch
|
||||
{
|
||||
"sha256" => Hex.NormalizeLowerHex(digest, expectedLength: 64, nameof(digest)),
|
||||
"sha512" => Hex.NormalizeLowerHex(digest, expectedLength: 128, nameof(digest)),
|
||||
_ => throw new FormatException($"Unsupported digest algorithm '{algorithm}'.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record GenericContentAddressedId(string Algorithm, string Digest) : ContentAddressedId(Algorithm, Digest);
|
||||
|
||||
public sealed record ArtifactId(string Digest) : ContentAddressedId("sha256", Digest)
|
||||
{
|
||||
public new static ArtifactId Parse(string value) => new(ParseSha256(value));
|
||||
public static bool TryParse(string value, out ArtifactId? id) => TryParseSha256(value, out id);
|
||||
|
||||
private static string ParseSha256(string value)
|
||||
{
|
||||
if (!TryParseSha256(value, out var id))
|
||||
{
|
||||
throw new FormatException($"Invalid ArtifactID: '{value}'.");
|
||||
}
|
||||
|
||||
return id!.Digest;
|
||||
}
|
||||
|
||||
private static bool TryParseSha256(string value, out ArtifactId? id)
|
||||
{
|
||||
id = null;
|
||||
|
||||
if (!ContentAddressedId.TrySplit(value, out var algorithm, out var digest))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(algorithm, "sha256", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
id = new ArtifactId(digest);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EvidenceId(string Digest) : ContentAddressedId("sha256", Digest)
|
||||
{
|
||||
public new static EvidenceId Parse(string value) => new(Sha256IdParser.Parse(value, "EvidenceID"));
|
||||
}
|
||||
|
||||
public sealed record ReasoningId(string Digest) : ContentAddressedId("sha256", Digest)
|
||||
{
|
||||
public new static ReasoningId Parse(string value) => new(Sha256IdParser.Parse(value, "ReasoningID"));
|
||||
}
|
||||
|
||||
public sealed record VexVerdictId(string Digest) : ContentAddressedId("sha256", Digest)
|
||||
{
|
||||
public new static VexVerdictId Parse(string value) => new(Sha256IdParser.Parse(value, "VEXVerdictID"));
|
||||
}
|
||||
|
||||
public sealed record ProofBundleId(string Digest) : ContentAddressedId("sha256", Digest)
|
||||
{
|
||||
public new static ProofBundleId Parse(string value) => new(Sha256IdParser.Parse(value, "ProofBundleID"));
|
||||
}
|
||||
|
||||
internal static class Sha256IdParser
|
||||
{
|
||||
public static string Parse(string value, string kind)
|
||||
{
|
||||
if (!ContentAddressedId.TrySplit(value, out var algorithm, out var digest) ||
|
||||
!string.Equals(algorithm, "sha256", StringComparison.Ordinal))
|
||||
{
|
||||
throw new FormatException($"Invalid {kind}: '{value}'.");
|
||||
}
|
||||
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.ProofChain.Json;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
public sealed class ContentAddressedIdGenerator : IContentAddressedIdGenerator
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly IJsonCanonicalizer _canonicalizer;
|
||||
private readonly IMerkleTreeBuilder _merkleTreeBuilder;
|
||||
|
||||
public ContentAddressedIdGenerator(IJsonCanonicalizer canonicalizer, IMerkleTreeBuilder merkleTreeBuilder)
|
||||
{
|
||||
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
|
||||
_merkleTreeBuilder = merkleTreeBuilder ?? throw new ArgumentNullException(nameof(merkleTreeBuilder));
|
||||
}
|
||||
|
||||
public EvidenceId ComputeEvidenceId(EvidencePredicate predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
var canonical = Canonicalize(predicate with { EvidenceId = null });
|
||||
return new EvidenceId(HashSha256Hex(canonical));
|
||||
}
|
||||
|
||||
public ReasoningId ComputeReasoningId(ReasoningPredicate predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
var canonical = Canonicalize(predicate with { ReasoningId = null });
|
||||
return new ReasoningId(HashSha256Hex(canonical));
|
||||
}
|
||||
|
||||
public VexVerdictId ComputeVexVerdictId(VexPredicate predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
var canonical = Canonicalize(predicate with { VexVerdictId = null });
|
||||
return new VexVerdictId(HashSha256Hex(canonical));
|
||||
}
|
||||
|
||||
public ProofBundleId ComputeProofBundleId(
|
||||
SbomEntryId sbomEntryId,
|
||||
IReadOnlyList<EvidenceId> evidenceIds,
|
||||
ReasoningId reasoningId,
|
||||
VexVerdictId vexVerdictId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbomEntryId);
|
||||
ArgumentNullException.ThrowIfNull(evidenceIds);
|
||||
ArgumentNullException.ThrowIfNull(reasoningId);
|
||||
ArgumentNullException.ThrowIfNull(vexVerdictId);
|
||||
|
||||
if (evidenceIds.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one EvidenceID is required.", nameof(evidenceIds));
|
||||
}
|
||||
|
||||
var sortedEvidence = new List<string>(evidenceIds.Count);
|
||||
for (var i = 0; i < evidenceIds.Count; i++)
|
||||
{
|
||||
sortedEvidence.Add(evidenceIds[i].ToString());
|
||||
}
|
||||
sortedEvidence.Sort(StringComparer.Ordinal);
|
||||
|
||||
var leaves = new List<ReadOnlyMemory<byte>>(sortedEvidence.Count + 3)
|
||||
{
|
||||
Encoding.UTF8.GetBytes(sbomEntryId.ToString()),
|
||||
};
|
||||
|
||||
foreach (var evidence in sortedEvidence)
|
||||
{
|
||||
leaves.Add(Encoding.UTF8.GetBytes(evidence));
|
||||
}
|
||||
|
||||
leaves.Add(Encoding.UTF8.GetBytes(reasoningId.ToString()));
|
||||
leaves.Add(Encoding.UTF8.GetBytes(vexVerdictId.ToString()));
|
||||
|
||||
var root = _merkleTreeBuilder.ComputeMerkleRoot(leaves);
|
||||
return new ProofBundleId(Convert.ToHexStringLower(root));
|
||||
}
|
||||
|
||||
public GraphRevisionId ComputeGraphRevisionId(
|
||||
IReadOnlyList<string> nodeIds,
|
||||
IReadOnlyList<string> edgeIds,
|
||||
string policyDigest,
|
||||
string feedsDigest,
|
||||
string toolchainDigest,
|
||||
string paramsDigest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodeIds);
|
||||
ArgumentNullException.ThrowIfNull(edgeIds);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(feedsDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(toolchainDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(paramsDigest);
|
||||
|
||||
var nodes = new List<string>(nodeIds);
|
||||
nodes.Sort(StringComparer.Ordinal);
|
||||
|
||||
var edges = new List<string>(edgeIds);
|
||||
edges.Sort(StringComparer.Ordinal);
|
||||
|
||||
var leaves = new List<ReadOnlyMemory<byte>>(nodes.Count + edges.Count + 4);
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
leaves.Add(Encoding.UTF8.GetBytes(node));
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
leaves.Add(Encoding.UTF8.GetBytes(edge));
|
||||
}
|
||||
|
||||
leaves.Add(Encoding.UTF8.GetBytes(policyDigest.Trim()));
|
||||
leaves.Add(Encoding.UTF8.GetBytes(feedsDigest.Trim()));
|
||||
leaves.Add(Encoding.UTF8.GetBytes(toolchainDigest.Trim()));
|
||||
leaves.Add(Encoding.UTF8.GetBytes(paramsDigest.Trim()));
|
||||
|
||||
var root = _merkleTreeBuilder.ComputeMerkleRoot(leaves);
|
||||
return new GraphRevisionId(Convert.ToHexStringLower(root));
|
||||
}
|
||||
|
||||
public string ComputeSbomDigest(ReadOnlySpan<byte> sbomJson)
|
||||
{
|
||||
var canonical = _canonicalizer.Canonicalize(sbomJson);
|
||||
return $"sha256:{HashSha256Hex(canonical)}";
|
||||
}
|
||||
|
||||
public SbomEntryId ComputeSbomEntryId(ReadOnlySpan<byte> sbomJson, string purl, string? version = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
|
||||
var sbomDigest = ComputeSbomDigest(sbomJson);
|
||||
return new SbomEntryId(sbomDigest, purl, version);
|
||||
}
|
||||
|
||||
private byte[] Canonicalize<T>(T value)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(value, SerializerOptions);
|
||||
return _canonicalizer.Canonicalize(json);
|
||||
}
|
||||
|
||||
private static string HashSha256Hex(ReadOnlySpan<byte> bytes)
|
||||
=> Convert.ToHexStringLower(SHA256.HashData(bytes));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using StellaOps.Attestor.ProofChain.Internal;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
public readonly record struct GraphRevisionId
|
||||
{
|
||||
private const string Prefix = "grv_sha256:";
|
||||
|
||||
public GraphRevisionId(string digest)
|
||||
{
|
||||
Digest = Hex.NormalizeLowerHex(digest, expectedLength: 64, nameof(digest));
|
||||
}
|
||||
|
||||
public string Digest { get; }
|
||||
|
||||
public override string ToString() => $"{Prefix}{Digest}";
|
||||
|
||||
public static GraphRevisionId Parse(string value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||
|
||||
if (!value.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new FormatException($"Invalid GraphRevisionID: '{value}'.");
|
||||
}
|
||||
|
||||
return new GraphRevisionId(value[Prefix.Length..]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
public interface IContentAddressedIdGenerator
|
||||
{
|
||||
EvidenceId ComputeEvidenceId(EvidencePredicate predicate);
|
||||
ReasoningId ComputeReasoningId(ReasoningPredicate predicate);
|
||||
VexVerdictId ComputeVexVerdictId(VexPredicate predicate);
|
||||
|
||||
ProofBundleId ComputeProofBundleId(
|
||||
SbomEntryId sbomEntryId,
|
||||
IReadOnlyList<EvidenceId> evidenceIds,
|
||||
ReasoningId reasoningId,
|
||||
VexVerdictId vexVerdictId);
|
||||
|
||||
GraphRevisionId ComputeGraphRevisionId(
|
||||
IReadOnlyList<string> nodeIds,
|
||||
IReadOnlyList<string> edgeIds,
|
||||
string policyDigest,
|
||||
string feedsDigest,
|
||||
string toolchainDigest,
|
||||
string paramsDigest);
|
||||
|
||||
string ComputeSbomDigest(ReadOnlySpan<byte> sbomJson);
|
||||
|
||||
SbomEntryId ComputeSbomEntryId(ReadOnlySpan<byte> sbomJson, string purl, string? version = null);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using StellaOps.Attestor.ProofChain.Internal;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
public sealed record SbomEntryId
|
||||
{
|
||||
public SbomEntryId(string sbomDigest, string purl, string? version = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sbomDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
|
||||
|
||||
SbomDigest = NormalizeSbomDigest(sbomDigest);
|
||||
Purl = purl.Trim();
|
||||
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
|
||||
}
|
||||
|
||||
public string SbomDigest { get; }
|
||||
|
||||
public string Purl { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var purl = Version is null ? Purl : $"{Purl}@{Version}";
|
||||
return $"{SbomDigest}:{purl}";
|
||||
}
|
||||
|
||||
public static SbomEntryId Parse(string value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||
|
||||
// <sbomDigest>:<purl>[@<version>]
|
||||
// where <sbomDigest> is expected to be sha256:<64-hex>
|
||||
var firstColon = value.IndexOf(':', StringComparison.Ordinal);
|
||||
if (firstColon <= 0)
|
||||
{
|
||||
throw new FormatException($"Invalid SBOMEntryID: '{value}'.");
|
||||
}
|
||||
|
||||
var secondColon = value.IndexOf(':', firstColon + 1);
|
||||
if (secondColon <= 0 || secondColon >= value.Length - 1)
|
||||
{
|
||||
throw new FormatException($"Invalid SBOMEntryID: '{value}'.");
|
||||
}
|
||||
|
||||
var algorithm = value[..firstColon];
|
||||
if (!string.Equals(algorithm, "sha256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new FormatException($"Invalid SBOMEntryID digest algorithm: '{algorithm}'.");
|
||||
}
|
||||
|
||||
var digest = value[(firstColon + 1)..secondColon];
|
||||
digest = Hex.NormalizeLowerHex(digest, expectedLength: 64, nameof(digest));
|
||||
|
||||
var rest = value[(secondColon + 1)..];
|
||||
if (string.IsNullOrWhiteSpace(rest))
|
||||
{
|
||||
throw new FormatException($"Invalid SBOMEntryID: '{value}'.");
|
||||
}
|
||||
|
||||
// Heuristic: split version from PURL only when the '@' is not followed by "sha256:" (OCI digests).
|
||||
var at = rest.LastIndexOf('@');
|
||||
if (at > 0 && at < rest.Length - 1 && !rest[(at + 1)..].StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new SbomEntryId($"sha256:{digest}", rest[..at], rest[(at + 1)..]);
|
||||
}
|
||||
|
||||
return new SbomEntryId($"sha256:{digest}", rest);
|
||||
}
|
||||
|
||||
private static string NormalizeSbomDigest(string value)
|
||||
{
|
||||
if (!ContentAddressedId.TrySplit(value, out var algorithm, out var digest) ||
|
||||
!string.Equals(algorithm, "sha256", StringComparison.Ordinal))
|
||||
{
|
||||
throw new FormatException($"Invalid SBOM digest: '{value}'.");
|
||||
}
|
||||
|
||||
return $"sha256:{Hex.NormalizeLowerHex(digest, expectedLength: 64, nameof(value))}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
public readonly record struct TrustAnchorId(Guid Value)
|
||||
{
|
||||
public override string ToString() => Value.ToString();
|
||||
|
||||
public static TrustAnchorId Parse(string value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||
return new TrustAnchorId(Guid.Parse(value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Internal;
|
||||
|
||||
internal static class Hex
|
||||
{
|
||||
public static string NormalizeLowerHex(string value, int expectedLength, string parameterName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName);
|
||||
|
||||
value = value.Trim();
|
||||
|
||||
if (value.Length != expectedLength)
|
||||
{
|
||||
throw new FormatException($"Expected {expectedLength} hex characters but got {value.Length}.");
|
||||
}
|
||||
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var c = value[i];
|
||||
var isHex = (c is >= '0' and <= '9') ||
|
||||
(c is >= 'a' and <= 'f') ||
|
||||
(c is >= 'A' and <= 'F');
|
||||
if (!isHex)
|
||||
{
|
||||
throw new FormatException($"Invalid hex character '{c}' at position {i}.");
|
||||
}
|
||||
}
|
||||
|
||||
return value.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Json;
|
||||
|
||||
public interface IJsonCanonicalizer
|
||||
{
|
||||
byte[] Canonicalize(ReadOnlySpan<byte> utf8Json);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Implements RFC 8785 JSON Canonicalization Scheme (JCS) for stable hashing.
|
||||
/// </summary>
|
||||
public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
{
|
||||
private static readonly JsonWriterOptions CanonicalWriterOptions = new()
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
public byte[] Canonicalize(ReadOnlySpan<byte> utf8Json)
|
||||
{
|
||||
var reader = new Utf8JsonReader(utf8Json, isFinalBlock: true, state: default);
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
return Canonicalize(document.RootElement);
|
||||
}
|
||||
|
||||
private static byte[] Canonicalize(JsonElement element)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, CanonicalWriterOptions))
|
||||
{
|
||||
WriteCanonical(writer, element);
|
||||
}
|
||||
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
WriteObject(writer, element);
|
||||
return;
|
||||
case JsonValueKind.Array:
|
||||
WriteArray(writer, element);
|
||||
return;
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
return;
|
||||
case JsonValueKind.Number:
|
||||
WriteNumber(writer, element);
|
||||
return;
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
return;
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
return;
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
default:
|
||||
throw new FormatException($"Unsupported JSON token kind '{element.ValueKind}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteObject(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
var properties = new List<(string Name, JsonElement Value)>();
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
properties.Add((property.Name, property.Value));
|
||||
}
|
||||
|
||||
properties.Sort(static (x, y) => string.CompareOrdinal(x.Name, y.Name));
|
||||
|
||||
writer.WriteStartObject();
|
||||
foreach (var (name, value) in properties)
|
||||
{
|
||||
writer.WritePropertyName(name);
|
||||
WriteCanonical(writer, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteArray(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(writer, item);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteNumber(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
var raw = element.GetRawText();
|
||||
if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ||
|
||||
double.IsNaN(value) ||
|
||||
double.IsInfinity(value))
|
||||
{
|
||||
throw new FormatException($"Invalid JSON number: '{raw}'.");
|
||||
}
|
||||
|
||||
if (value == 0d)
|
||||
{
|
||||
writer.WriteRawValue("0", skipInputValidation: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var formatted = value.ToString("R", CultureInfo.InvariantCulture);
|
||||
writer.WriteRawValue(NormalizeExponent(formatted), skipInputValidation: true);
|
||||
}
|
||||
|
||||
private static string NormalizeExponent(string formatted)
|
||||
{
|
||||
var e = formatted.IndexOfAny(['E', 'e']);
|
||||
if (e < 0)
|
||||
{
|
||||
return formatted;
|
||||
}
|
||||
|
||||
var mantissa = formatted[..e];
|
||||
var exponent = formatted[(e + 1)..];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(exponent))
|
||||
{
|
||||
return mantissa;
|
||||
}
|
||||
|
||||
var sign = string.Empty;
|
||||
if (exponent[0] is '+' or '-')
|
||||
{
|
||||
sign = exponent[0] == '-' ? "-" : string.Empty;
|
||||
exponent = exponent[1..];
|
||||
}
|
||||
|
||||
exponent = exponent.TrimStart('0');
|
||||
if (exponent.Length == 0)
|
||||
{
|
||||
// 1e0 -> 1
|
||||
return mantissa;
|
||||
}
|
||||
|
||||
return $"{mantissa}e{sign}{exponent}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
|
||||
{
|
||||
public byte[] ComputeMerkleRoot(IReadOnlyList<ReadOnlyMemory<byte>> leafValues)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(leafValues);
|
||||
|
||||
if (leafValues.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one leaf is required.", nameof(leafValues));
|
||||
}
|
||||
|
||||
var hashes = new List<byte[]>(PadToPowerOfTwo(leafValues.Count));
|
||||
for (var i = 0; i < leafValues.Count; i++)
|
||||
{
|
||||
hashes.Add(SHA256.HashData(leafValues[i].Span));
|
||||
}
|
||||
|
||||
// Pad with duplicate of last leaf hash (deterministic).
|
||||
var target = hashes.Capacity;
|
||||
while (hashes.Count < target)
|
||||
{
|
||||
hashes.Add(hashes[^1]);
|
||||
}
|
||||
|
||||
return ComputeRootFromLeafHashes(hashes);
|
||||
}
|
||||
|
||||
private static byte[] ComputeRootFromLeafHashes(List<byte[]> hashes)
|
||||
{
|
||||
while (hashes.Count > 1)
|
||||
{
|
||||
var next = new List<byte[]>(hashes.Count / 2);
|
||||
for (var i = 0; i < hashes.Count; i += 2)
|
||||
{
|
||||
next.Add(HashInternal(hashes[i], hashes[i + 1]));
|
||||
}
|
||||
|
||||
hashes = next;
|
||||
}
|
||||
|
||||
return hashes[0];
|
||||
}
|
||||
|
||||
private static int PadToPowerOfTwo(int count)
|
||||
{
|
||||
var power = 1;
|
||||
while (power < count)
|
||||
{
|
||||
power <<= 1;
|
||||
}
|
||||
return power;
|
||||
}
|
||||
|
||||
private static byte[] HashInternal(byte[] left, byte[] right)
|
||||
{
|
||||
var buffer = new byte[left.Length + right.Length];
|
||||
Buffer.BlockCopy(left, 0, buffer, 0, left.Length);
|
||||
Buffer.BlockCopy(right, 0, buffer, left.Length, right.Length);
|
||||
return SHA256.HashData(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Merkle;
|
||||
|
||||
public interface IMerkleTreeBuilder
|
||||
{
|
||||
byte[] ComputeMerkleRoot(IReadOnlyList<ReadOnlyMemory<byte>> leafValues);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
public sealed record EvidencePredicate
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceVersion")]
|
||||
public required string SourceVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("collectionTime")]
|
||||
public required DateTimeOffset CollectionTime { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("rawFinding")]
|
||||
public required object RawFinding { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceId")]
|
||||
public string? EvidenceId { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
public sealed record ProofSpinePredicate
|
||||
{
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceIds")]
|
||||
public required IReadOnlyList<string> EvidenceIds { get; init; }
|
||||
|
||||
[JsonPropertyName("reasoningId")]
|
||||
public required string ReasoningId { get; init; }
|
||||
|
||||
[JsonPropertyName("vexVerdictId")]
|
||||
public required string VexVerdictId { get; init; }
|
||||
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("proofBundleId")]
|
||||
public required string ProofBundleId { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
public sealed record ReasoningPredicate
|
||||
{
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceIds")]
|
||||
public required IReadOnlyList<string> EvidenceIds { get; init; }
|
||||
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public required IReadOnlyDictionary<string, object> Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("intermediateFindings")]
|
||||
public IReadOnlyDictionary<string, object>? IntermediateFindings { get; init; }
|
||||
|
||||
[JsonPropertyName("reasoningId")]
|
||||
public string? ReasoningId { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
public sealed record VexPredicate
|
||||
{
|
||||
[JsonPropertyName("sbomEntryId")]
|
||||
public required string SbomEntryId { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("reasoningId")]
|
||||
public required string ReasoningId { get; init; }
|
||||
|
||||
[JsonPropertyName("vexVerdictId")]
|
||||
public string? VexVerdictId { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Sbom;
|
||||
|
||||
public sealed class CycloneDxSubjectExtractor : ISbomSubjectExtractor
|
||||
{
|
||||
private readonly IContentAddressedIdGenerator _idGenerator;
|
||||
|
||||
public CycloneDxSubjectExtractor(IContentAddressedIdGenerator idGenerator)
|
||||
{
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public SbomSubjectExtractionResult ExtractCycloneDx(ReadOnlySpan<byte> sbomJson)
|
||||
{
|
||||
var sbomDigest = _idGenerator.ComputeSbomDigest(sbomJson);
|
||||
var entryIds = ExtractEntryIds(sbomDigest, sbomJson);
|
||||
return new SbomSubjectExtractionResult(sbomDigest, entryIds);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SbomEntryId> ExtractEntryIds(string sbomDigest, ReadOnlySpan<byte> sbomJson)
|
||||
{
|
||||
var reader = new Utf8JsonReader(sbomJson, isFinalBlock: true, state: default);
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return Array.Empty<SbomEntryId>();
|
||||
}
|
||||
|
||||
if (!document.RootElement.TryGetProperty("components", out var components) ||
|
||||
components.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<SbomEntryId>();
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(StringComparer.Ordinal);
|
||||
var ids = new List<SbomEntryId>();
|
||||
|
||||
foreach (var component in components.EnumerateArray())
|
||||
{
|
||||
if (component.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.TryGetProperty("purl", out var purlElement) ||
|
||||
purlElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var purl = purlElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? version = null;
|
||||
if (!purl.Contains('@', StringComparison.Ordinal) &&
|
||||
component.TryGetProperty("version", out var versionElement) &&
|
||||
versionElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
version = versionElement.GetString();
|
||||
}
|
||||
|
||||
var id = new SbomEntryId(sbomDigest, purl, version);
|
||||
if (unique.Add(id.ToString()))
|
||||
{
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
ids.Sort(static (x, y) => string.CompareOrdinal(x.ToString(), y.ToString()));
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Sbom;
|
||||
|
||||
public interface ISbomSubjectExtractor
|
||||
{
|
||||
SbomSubjectExtractionResult ExtractCycloneDx(ReadOnlySpan<byte> sbomJson);
|
||||
}
|
||||
|
||||
public sealed record SbomSubjectExtractionResult(string SbomDigest, IReadOnlyList<SbomEntryId> EntryIds);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include=\"..\\..\\__Libraries\\StellaOps.Attestor.ProofChain\\StellaOps.Attestor.ProofChain.csproj\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Attestor.ProofChain.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user