up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,49 +1,49 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
/// <summary>
/// Sends provenance placeholders to the Attestor service for asynchronous DSSE signing.
/// </summary>
public sealed class AttestorClient
{
private readonly HttpClient httpClient;
public AttestorClient(HttpClient httpClient)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task SendPlaceholderAsync(Uri attestorUri, DescriptorDocument document, CancellationToken cancellationToken)
{
if (attestorUri is null)
{
throw new ArgumentNullException(nameof(attestorUri));
}
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
var payload = new AttestorProvenanceRequest(
ImageDigest: document.Subject.Digest,
SbomDigest: document.Artifact.Digest,
ExpectedDsseSha256: document.Provenance.ExpectedDsseSha256,
Nonce: document.Provenance.Nonce,
PredicateType: document.Provenance.PredicateType,
Schema: document.Schema);
using var response = await httpClient.PostAsJsonAsync(attestorUri, payload, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new BuildxPluginException($"Attestor rejected provenance placeholder ({(int)response.StatusCode}): {body}");
}
}
}
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
/// <summary>
/// Sends provenance placeholders to the Attestor service for asynchronous DSSE signing.
/// </summary>
public sealed class AttestorClient
{
private readonly HttpClient httpClient;
public AttestorClient(HttpClient httpClient)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task SendPlaceholderAsync(Uri attestorUri, DescriptorDocument document, CancellationToken cancellationToken)
{
if (attestorUri is null)
{
throw new ArgumentNullException(nameof(attestorUri));
}
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
var payload = new AttestorProvenanceRequest(
ImageDigest: document.Subject.Digest,
SbomDigest: document.Artifact.Digest,
ExpectedDsseSha256: document.Provenance.ExpectedDsseSha256,
Nonce: document.Provenance.Nonce,
PredicateType: document.Provenance.PredicateType,
Schema: document.Schema);
using var response = await httpClient.PostAsJsonAsync(attestorUri, payload, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new BuildxPluginException($"Attestor rejected provenance placeholder ({(int)response.StatusCode}): {body}");
}
}
}

View File

@@ -1,11 +1,11 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
public sealed record AttestorProvenanceRequest(
[property: JsonPropertyName("imageDigest")] string ImageDigest,
[property: JsonPropertyName("sbomDigest")] string SbomDigest,
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
[property: JsonPropertyName("nonce")] string Nonce,
[property: JsonPropertyName("predicateType")] string PredicateType,
[property: JsonPropertyName("schema")] string Schema);
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
public sealed record AttestorProvenanceRequest(
[property: JsonPropertyName("imageDigest")] string ImageDigest,
[property: JsonPropertyName("sbomDigest")] string SbomDigest,
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
[property: JsonPropertyName("nonce")] string Nonce,
[property: JsonPropertyName("predicateType")] string PredicateType,
[property: JsonPropertyName("schema")] string Schema);

View File

@@ -1,19 +1,19 @@
using System;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin;
/// <summary>
/// Represents user-facing errors raised by the BuildX plug-in.
/// </summary>
public sealed class BuildxPluginException : Exception
{
public BuildxPluginException(string message)
: base(message)
{
}
public BuildxPluginException(string message, Exception innerException)
: base(message, innerException)
{
}
}
using System;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin;
/// <summary>
/// Represents user-facing errors raised by the BuildX plug-in.
/// </summary>
public sealed class BuildxPluginException : Exception
{
public BuildxPluginException(string message)
: base(message)
{
}
public BuildxPluginException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -1,6 +1,6 @@
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// <summary>
/// Result of persisting bytes into the local CAS.
/// </summary>
public sealed record CasWriteResult(string Algorithm, string Digest, string Path);
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// <summary>
/// Result of persisting bytes into the local CAS.
/// </summary>
public sealed record CasWriteResult(string Algorithm, string Digest, string Path);

View File

@@ -1,16 +1,16 @@
using System;
using System.IO;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// <summary>
/// Minimal filesystem-backed CAS used when the BuildX generator runs inside CI.
/// </summary>
public sealed class LocalCasClient
{
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// <summary>
/// Minimal filesystem-backed CAS used when the BuildX generator runs inside CI.
/// </summary>
public sealed class LocalCasClient
{
private readonly string rootDirectory;
private readonly string algorithm;
private readonly ICryptoHash hash;
@@ -24,49 +24,49 @@ public sealed class LocalCasClient
this.hash = hash ?? throw new ArgumentNullException(nameof(hash));
algorithm = options.Algorithm.ToLowerInvariant();
if (!string.Equals(algorithm, "sha256", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("Only the sha256 algorithm is supported.", nameof(options));
}
if (!string.Equals(algorithm, "sha256", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("Only the sha256 algorithm is supported.", nameof(options));
}
rootDirectory = Path.GetFullPath(options.RootDirectory);
}
public Task<CasWriteResult> VerifyWriteAsync(CancellationToken cancellationToken)
{
ReadOnlyMemory<byte> probe = "stellaops-buildx-probe"u8.ToArray();
return WriteAsync(probe, cancellationToken);
}
public async Task<CasWriteResult> WriteAsync(ReadOnlyMemory<byte> content, CancellationToken cancellationToken)
{
var digest = ComputeDigest(content.Span);
var path = BuildObjectPath(digest);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.Read,
bufferSize: 16 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
await stream.WriteAsync(content, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
return new CasWriteResult(algorithm, digest, path);
}
private string BuildObjectPath(string digest)
{
// Layout: <root>/<algorithm>/<first two>/<rest>.bin
var prefix = digest.Substring(0, 2);
var suffix = digest[2..];
return Path.Combine(rootDirectory, algorithm, prefix, $"{suffix}.bin");
}
}
public Task<CasWriteResult> VerifyWriteAsync(CancellationToken cancellationToken)
{
ReadOnlyMemory<byte> probe = "stellaops-buildx-probe"u8.ToArray();
return WriteAsync(probe, cancellationToken);
}
public async Task<CasWriteResult> WriteAsync(ReadOnlyMemory<byte> content, CancellationToken cancellationToken)
{
var digest = ComputeDigest(content.Span);
var path = BuildObjectPath(digest);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.Read,
bufferSize: 16 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
await stream.WriteAsync(content, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
return new CasWriteResult(algorithm, digest, path);
}
private string BuildObjectPath(string digest)
{
// Layout: <root>/<algorithm>/<first two>/<rest>.bin
var prefix = digest.Substring(0, 2);
var suffix = digest[2..];
return Path.Combine(rootDirectory, algorithm, prefix, $"{suffix}.bin");
}
private string ComputeDigest(ReadOnlySpan<byte> content)
{
return hash.ComputeHashHex(content, HashAlgorithms.Sha256);

View File

@@ -1,40 +1,40 @@
using System;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// <summary>
/// Configuration for the on-disk content-addressable store used during CI.
/// </summary>
public sealed record LocalCasOptions
{
private string rootDirectory = string.Empty;
private string algorithm = "sha256";
public string RootDirectory
{
get => rootDirectory;
init
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Root directory must be provided.", nameof(value));
}
rootDirectory = value;
}
}
public string Algorithm
{
get => algorithm;
init
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Algorithm must be provided.", nameof(value));
}
algorithm = value;
}
}
}
using System;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// <summary>
/// Configuration for the on-disk content-addressable store used during CI.
/// </summary>
public sealed record LocalCasOptions
{
private string rootDirectory = string.Empty;
private string algorithm = "sha256";
public string RootDirectory
{
get => rootDirectory;
init
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Root directory must be provided.", nameof(value));
}
rootDirectory = value;
}
}
public string Algorithm
{
get => algorithm;
init
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Algorithm must be provided.", nameof(value));
}
algorithm = value;
}
}
}

View File

@@ -1,13 +1,13 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Represents an OCI artifact descriptor emitted by the BuildX generator.
/// </summary>
public sealed record DescriptorArtifact(
[property: JsonPropertyName("mediaType")] string MediaType,
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("size")] long Size,
[property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string> Annotations);
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Represents an OCI artifact descriptor emitted by the BuildX generator.
/// </summary>
public sealed record DescriptorArtifact(
[property: JsonPropertyName("mediaType")] string MediaType,
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("size")] long Size,
[property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string> Annotations);

View File

@@ -1,17 +1,17 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Root payload describing BuildX generator output with provenance placeholders.
/// </summary>
public sealed record DescriptorDocument(
[property: JsonPropertyName("schema")] string Schema,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("generator")] DescriptorGeneratorMetadata Generator,
[property: JsonPropertyName("subject")] DescriptorSubject Subject,
[property: JsonPropertyName("artifact")] DescriptorArtifact Artifact,
[property: JsonPropertyName("provenance")] DescriptorProvenance Provenance,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Root payload describing BuildX generator output with provenance placeholders.
/// </summary>
public sealed record DescriptorDocument(
[property: JsonPropertyName("schema")] string Schema,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("generator")] DescriptorGeneratorMetadata Generator,
[property: JsonPropertyName("subject")] DescriptorSubject Subject,
[property: JsonPropertyName("artifact")] DescriptorArtifact Artifact,
[property: JsonPropertyName("provenance")] DescriptorProvenance Provenance,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);

View File

@@ -1,21 +1,21 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Builds immutable OCI descriptors enriched with provenance placeholders.
/// </summary>
public sealed class DescriptorGenerator
{
public const string Schema = "stellaops.buildx.descriptor.v1";
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Builds immutable OCI descriptors enriched with provenance placeholders.
/// </summary>
public sealed class DescriptorGenerator
{
public const string Schema = "stellaops.buildx.descriptor.v1";
private readonly TimeProvider _timeProvider;
private readonly ICryptoHash _hash;
@@ -24,66 +24,66 @@ public sealed class DescriptorGenerator
_timeProvider = timeProvider ?? TimeProvider.System;
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
}
public async Task<DescriptorDocument> CreateAsync(DescriptorRequest request, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
throw new BuildxPluginException("Image digest must be provided.");
}
if (string.IsNullOrWhiteSpace(request.SbomPath))
{
throw new BuildxPluginException("SBOM path must be provided.");
}
var sbomFile = new FileInfo(request.SbomPath);
if (!sbomFile.Exists)
{
throw new BuildxPluginException($"SBOM file '{request.SbomPath}' was not found.");
}
public async Task<DescriptorDocument> CreateAsync(DescriptorRequest request, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
throw new BuildxPluginException("Image digest must be provided.");
}
if (string.IsNullOrWhiteSpace(request.SbomPath))
{
throw new BuildxPluginException("SBOM path must be provided.");
}
var sbomFile = new FileInfo(request.SbomPath);
if (!sbomFile.Exists)
{
throw new BuildxPluginException($"SBOM file '{request.SbomPath}' was not found.");
}
var sbomDigest = await ComputeFileDigestAsync(sbomFile, cancellationToken).ConfigureAwait(false);
var nonce = ComputeDeterministicNonce(request, sbomFile, sbomDigest);
var expectedDsseSha = ComputeExpectedDsseDigest(request.ImageDigest, sbomDigest, nonce);
var artifactAnnotations = BuildArtifactAnnotations(request, nonce, expectedDsseSha);
var subject = new DescriptorSubject(
MediaType: request.SubjectMediaType,
Digest: request.ImageDigest);
var artifact = new DescriptorArtifact(
MediaType: request.SbomMediaType,
Digest: sbomDigest,
Size: sbomFile.Length,
Annotations: artifactAnnotations);
var provenance = new DescriptorProvenance(
Status: "pending",
ExpectedDsseSha256: expectedDsseSha,
Nonce: nonce,
AttestorUri: request.AttestorUri,
PredicateType: request.PredicateType);
var generatorMetadata = new DescriptorGeneratorMetadata(
Name: request.GeneratorName ?? "StellaOps.Scanner.Sbomer.BuildXPlugin",
Version: request.GeneratorVersion);
var metadata = BuildDocumentMetadata(request, sbomFile, sbomDigest);
var subject = new DescriptorSubject(
MediaType: request.SubjectMediaType,
Digest: request.ImageDigest);
var artifact = new DescriptorArtifact(
MediaType: request.SbomMediaType,
Digest: sbomDigest,
Size: sbomFile.Length,
Annotations: artifactAnnotations);
var provenance = new DescriptorProvenance(
Status: "pending",
ExpectedDsseSha256: expectedDsseSha,
Nonce: nonce,
AttestorUri: request.AttestorUri,
PredicateType: request.PredicateType);
var generatorMetadata = new DescriptorGeneratorMetadata(
Name: request.GeneratorName ?? "StellaOps.Scanner.Sbomer.BuildXPlugin",
Version: request.GeneratorVersion);
var metadata = BuildDocumentMetadata(request, sbomFile, sbomDigest);
return new DescriptorDocument(
Schema: Schema,
GeneratedAt: _timeProvider.GetUtcNow(),
Generator: generatorMetadata,
Subject: subject,
Artifact: artifact,
Provenance: provenance,
Generator: generatorMetadata,
Subject: subject,
Artifact: artifact,
Provenance: provenance,
Metadata: metadata);
}
@@ -136,63 +136,63 @@ public sealed class DescriptorGenerator
var digest = _hash.ComputeHash(bytes, HashAlgorithms.Sha256);
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
}
private static IReadOnlyDictionary<string, string> BuildArtifactAnnotations(DescriptorRequest request, string nonce, string expectedDsse)
{
var annotations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["org.opencontainers.artifact.type"] = request.SbomArtifactType,
["org.stellaops.scanner.version"] = request.GeneratorVersion,
["org.stellaops.sbom.kind"] = request.SbomKind,
["org.stellaops.sbom.format"] = request.SbomFormat,
["org.stellaops.provenance.status"] = "pending",
["org.stellaops.provenance.dsse.sha256"] = expectedDsse,
["org.stellaops.provenance.nonce"] = nonce
};
if (!string.IsNullOrWhiteSpace(request.LicenseId))
{
annotations["org.stellaops.license.id"] = request.LicenseId!;
}
if (!string.IsNullOrWhiteSpace(request.SbomName))
{
annotations["org.opencontainers.image.title"] = request.SbomName!;
}
if (!string.IsNullOrWhiteSpace(request.Repository))
{
annotations["org.stellaops.repository"] = request.Repository!;
}
return annotations;
}
private static IReadOnlyDictionary<string, string> BuildDocumentMetadata(DescriptorRequest request, FileInfo fileInfo, string sbomDigest)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["sbomDigest"] = sbomDigest,
["sbomPath"] = fileInfo.FullName,
["sbomMediaType"] = request.SbomMediaType,
["subjectMediaType"] = request.SubjectMediaType
};
if (!string.IsNullOrWhiteSpace(request.Repository))
{
metadata["repository"] = request.Repository!;
}
if (!string.IsNullOrWhiteSpace(request.BuildRef))
{
metadata["buildRef"] = request.BuildRef!;
}
if (!string.IsNullOrWhiteSpace(request.AttestorUri))
{
metadata["attestorUri"] = request.AttestorUri!;
}
return metadata;
}
}
private static IReadOnlyDictionary<string, string> BuildArtifactAnnotations(DescriptorRequest request, string nonce, string expectedDsse)
{
var annotations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["org.opencontainers.artifact.type"] = request.SbomArtifactType,
["org.stellaops.scanner.version"] = request.GeneratorVersion,
["org.stellaops.sbom.kind"] = request.SbomKind,
["org.stellaops.sbom.format"] = request.SbomFormat,
["org.stellaops.provenance.status"] = "pending",
["org.stellaops.provenance.dsse.sha256"] = expectedDsse,
["org.stellaops.provenance.nonce"] = nonce
};
if (!string.IsNullOrWhiteSpace(request.LicenseId))
{
annotations["org.stellaops.license.id"] = request.LicenseId!;
}
if (!string.IsNullOrWhiteSpace(request.SbomName))
{
annotations["org.opencontainers.image.title"] = request.SbomName!;
}
if (!string.IsNullOrWhiteSpace(request.Repository))
{
annotations["org.stellaops.repository"] = request.Repository!;
}
return annotations;
}
private static IReadOnlyDictionary<string, string> BuildDocumentMetadata(DescriptorRequest request, FileInfo fileInfo, string sbomDigest)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["sbomDigest"] = sbomDigest,
["sbomPath"] = fileInfo.FullName,
["sbomMediaType"] = request.SbomMediaType,
["subjectMediaType"] = request.SubjectMediaType
};
if (!string.IsNullOrWhiteSpace(request.Repository))
{
metadata["repository"] = request.Repository!;
}
if (!string.IsNullOrWhiteSpace(request.BuildRef))
{
metadata["buildRef"] = request.BuildRef!;
}
if (!string.IsNullOrWhiteSpace(request.AttestorUri))
{
metadata["attestorUri"] = request.AttestorUri!;
}
return metadata;
}
}

View File

@@ -1,7 +1,7 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
public sealed record DescriptorGeneratorMetadata(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string Version);
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
public sealed record DescriptorGeneratorMetadata(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string Version);

View File

@@ -1,13 +1,13 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Provenance placeholders that the Attestor will fulfil post-build.
/// </summary>
public sealed record DescriptorProvenance(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
[property: JsonPropertyName("nonce")] string Nonce,
[property: JsonPropertyName("attestorUri")] string? AttestorUri,
[property: JsonPropertyName("predicateType")] string PredicateType);
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Provenance placeholders that the Attestor will fulfil post-build.
/// </summary>
public sealed record DescriptorProvenance(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("expectedDsseSha256")] string ExpectedDsseSha256,
[property: JsonPropertyName("nonce")] string Nonce,
[property: JsonPropertyName("attestorUri")] string? AttestorUri,
[property: JsonPropertyName("predicateType")] string PredicateType);

View File

@@ -1,45 +1,45 @@
using System;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Request for generating BuildX descriptor artifacts.
/// </summary>
public sealed record DescriptorRequest
{
public string ImageDigest { get; init; } = string.Empty;
public string SbomPath { get; init; } = string.Empty;
public string SbomMediaType { get; init; } = "application/vnd.cyclonedx+json";
public string SbomFormat { get; init; } = "cyclonedx-json";
public string SbomArtifactType { get; init; } = "application/vnd.stellaops.sbom.layer+json";
public string SbomKind { get; init; } = "inventory";
public string SubjectMediaType { get; init; } = "application/vnd.oci.image.manifest.v1+json";
public string GeneratorVersion { get; init; } = "0.0.0";
public string? GeneratorName { get; init; }
public string? LicenseId { get; init; }
public string? SbomName { get; init; }
public string? Repository { get; init; }
public string? BuildRef { get; init; }
public string? AttestorUri { get; init; }
public string PredicateType { get; init; } = "https://slsa.dev/provenance/v1";
public DescriptorRequest Validate()
{
if (string.IsNullOrWhiteSpace(ImageDigest))
{
throw new BuildxPluginException("Image digest is required.");
}
if (!ImageDigest.Contains(':', StringComparison.Ordinal))
{
throw new BuildxPluginException("Image digest must include the algorithm prefix, e.g. 'sha256:...'.");
}
if (string.IsNullOrWhiteSpace(SbomPath))
{
throw new BuildxPluginException("SBOM path is required.");
}
return this;
}
}
using System;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
/// <summary>
/// Request for generating BuildX descriptor artifacts.
/// </summary>
public sealed record DescriptorRequest
{
public string ImageDigest { get; init; } = string.Empty;
public string SbomPath { get; init; } = string.Empty;
public string SbomMediaType { get; init; } = "application/vnd.cyclonedx+json";
public string SbomFormat { get; init; } = "cyclonedx-json";
public string SbomArtifactType { get; init; } = "application/vnd.stellaops.sbom.layer+json";
public string SbomKind { get; init; } = "inventory";
public string SubjectMediaType { get; init; } = "application/vnd.oci.image.manifest.v1+json";
public string GeneratorVersion { get; init; } = "0.0.0";
public string? GeneratorName { get; init; }
public string? LicenseId { get; init; }
public string? SbomName { get; init; }
public string? Repository { get; init; }
public string? BuildRef { get; init; }
public string? AttestorUri { get; init; }
public string PredicateType { get; init; } = "https://slsa.dev/provenance/v1";
public DescriptorRequest Validate()
{
if (string.IsNullOrWhiteSpace(ImageDigest))
{
throw new BuildxPluginException("Image digest is required.");
}
if (!ImageDigest.Contains(':', StringComparison.Ordinal))
{
throw new BuildxPluginException("Image digest must include the algorithm prefix, e.g. 'sha256:...'.");
}
if (string.IsNullOrWhiteSpace(SbomPath))
{
throw new BuildxPluginException("SBOM path is required.");
}
return this;
}
}

View File

@@ -1,7 +1,7 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
public sealed record DescriptorSubject(
[property: JsonPropertyName("mediaType")] string MediaType,
[property: JsonPropertyName("digest")] string Digest);
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
public sealed record DescriptorSubject(
[property: JsonPropertyName("mediaType")] string MediaType,
[property: JsonPropertyName("digest")] string Digest);

View File

@@ -1,18 +1,18 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Describes default Content Addressable Storage configuration for the plug-in.
/// </summary>
public sealed record BuildxPluginCas
{
[JsonPropertyName("protocol")]
public string Protocol { get; init; } = "filesystem";
[JsonPropertyName("defaultRoot")]
public string DefaultRoot { get; init; } = "cas";
[JsonPropertyName("compression")]
public string Compression { get; init; } = "zstd";
}
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Describes default Content Addressable Storage configuration for the plug-in.
/// </summary>
public sealed record BuildxPluginCas
{
[JsonPropertyName("protocol")]
public string Protocol { get; init; } = "filesystem";
[JsonPropertyName("defaultRoot")]
public string DefaultRoot { get; init; } = "cas";
[JsonPropertyName("compression")]
public string Compression { get; init; } = "zstd";
}

View File

@@ -1,20 +1,20 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Describes how the buildx plug-in executable should be invoked.
/// </summary>
public sealed record BuildxPluginEntryPoint
{
[JsonPropertyName("type")]
public string Type { get; init; } = "dotnet";
[JsonPropertyName("executable")]
public string Executable { get; init; } = string.Empty;
[JsonPropertyName("arguments")]
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
}
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Describes how the buildx plug-in executable should be invoked.
/// </summary>
public sealed record BuildxPluginEntryPoint
{
[JsonPropertyName("type")]
public string Type { get; init; } = "dotnet";
[JsonPropertyName("executable")]
public string Executable { get; init; } = string.Empty;
[JsonPropertyName("arguments")]
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
}

View File

@@ -1,20 +1,20 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Provides distribution information for the container image form-factor.
/// </summary>
public sealed record BuildxPluginImage
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("platforms")]
public IReadOnlyList<string> Platforms { get; init; } = Array.Empty<string>();
}
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Provides distribution information for the container image form-factor.
/// </summary>
public sealed record BuildxPluginImage
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("platforms")]
public IReadOnlyList<string> Platforms { get; init; } = Array.Empty<string>();
}

View File

@@ -1,49 +1,49 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Canonical manifest describing a buildx generator plug-in.
/// </summary>
public sealed record BuildxPluginManifest
{
public const string CurrentSchemaVersion = "1.0";
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("displayName")]
public string DisplayName { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("entryPoint")]
public BuildxPluginEntryPoint EntryPoint { get; init; } = new();
[JsonPropertyName("requiresRestart")]
public bool RequiresRestart { get; init; } = true;
[JsonPropertyName("capabilities")]
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
[JsonPropertyName("cas")]
public BuildxPluginCas Cas { get; init; } = new();
[JsonPropertyName("image")]
public BuildxPluginImage? Image { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
[JsonIgnore]
public string? SourcePath { get; init; }
[JsonIgnore]
public string? SourceDirectory { get; init; }
}
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Canonical manifest describing a buildx generator plug-in.
/// </summary>
public sealed record BuildxPluginManifest
{
public const string CurrentSchemaVersion = "1.0";
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("displayName")]
public string DisplayName { get; init; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; init; } = string.Empty;
[JsonPropertyName("entryPoint")]
public BuildxPluginEntryPoint EntryPoint { get; init; } = new();
[JsonPropertyName("requiresRestart")]
public bool RequiresRestart { get; init; } = true;
[JsonPropertyName("capabilities")]
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
[JsonPropertyName("cas")]
public BuildxPluginCas Cas { get; init; } = new();
[JsonPropertyName("image")]
public BuildxPluginImage? Image { get; init; }
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
[JsonIgnore]
public string? SourcePath { get; init; }
[JsonIgnore]
public string? SourceDirectory { get; init; }
}

View File

@@ -1,189 +1,189 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Loads buildx plug-in manifests from the restart-time plug-in directory.
/// </summary>
public sealed class BuildxPluginManifestLoader
{
public const string DefaultSearchPattern = "*.manifest.json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
PropertyNameCaseInsensitive = true
};
private readonly string manifestDirectory;
private readonly string searchPattern;
public BuildxPluginManifestLoader(string manifestDirectory, string? searchPattern = null)
{
if (string.IsNullOrWhiteSpace(manifestDirectory))
{
throw new ArgumentException("Manifest directory is required.", nameof(manifestDirectory));
}
this.manifestDirectory = Path.GetFullPath(manifestDirectory);
this.searchPattern = string.IsNullOrWhiteSpace(searchPattern)
? DefaultSearchPattern
: searchPattern;
}
/// <summary>
/// Loads all manifests in the configured directory.
/// </summary>
public async Task<IReadOnlyList<BuildxPluginManifest>> LoadAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(manifestDirectory))
{
return Array.Empty<BuildxPluginManifest>();
}
var manifests = new List<BuildxPluginManifest>();
foreach (var file in Directory.EnumerateFiles(manifestDirectory, searchPattern, SearchOption.TopDirectoryOnly))
{
if (IsHiddenPath(file))
{
continue;
}
var manifest = await DeserializeManifestAsync(file, cancellationToken).ConfigureAwait(false);
manifests.Add(manifest);
}
return manifests
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
/// <summary>
/// Loads the manifest with the specified identifier.
/// </summary>
public async Task<BuildxPluginManifest> LoadByIdAsync(string manifestId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(manifestId))
{
throw new ArgumentException("Manifest identifier is required.", nameof(manifestId));
}
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
var manifest = manifests.FirstOrDefault(m => string.Equals(m.Id, manifestId, StringComparison.OrdinalIgnoreCase));
if (manifest is null)
{
throw new BuildxPluginException($"Buildx plug-in manifest '{manifestId}' was not found in '{manifestDirectory}'.");
}
return manifest;
}
/// <summary>
/// Loads the first available manifest.
/// </summary>
public async Task<BuildxPluginManifest> LoadDefaultAsync(CancellationToken cancellationToken)
{
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
if (manifests.Count == 0)
{
throw new BuildxPluginException($"No buildx plug-in manifests were discovered under '{manifestDirectory}'.");
}
return manifests[0];
}
private static bool IsHiddenPath(string path)
{
var directory = Path.GetDirectoryName(path);
while (!string.IsNullOrEmpty(directory))
{
var segment = Path.GetFileName(directory);
if (segment.StartsWith(".", StringComparison.Ordinal))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
private static async Task<BuildxPluginManifest> DeserializeManifestAsync(string file, CancellationToken cancellationToken)
{
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
BuildxPluginManifest? manifest;
try
{
manifest = await JsonSerializer.DeserializeAsync<BuildxPluginManifest>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (JsonException ex)
{
throw new BuildxPluginException($"Failed to parse manifest '{file}'.", ex);
}
if (manifest is null)
{
throw new BuildxPluginException($"Manifest '{file}' is empty or invalid.");
}
ValidateManifest(manifest, file);
var directory = Path.GetDirectoryName(file);
return manifest with
{
SourcePath = file,
SourceDirectory = directory
};
}
private static void ValidateManifest(BuildxPluginManifest manifest, string file)
{
if (!string.Equals(manifest.SchemaVersion, BuildxPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
{
throw new BuildxPluginException(
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{BuildxPluginManifest.CurrentSchemaVersion}'.");
}
if (string.IsNullOrWhiteSpace(manifest.Id))
{
throw new BuildxPluginException($"Manifest '{file}' must specify a non-empty 'id'.");
}
if (manifest.EntryPoint is null)
{
throw new BuildxPluginException($"Manifest '{file}' must specify an 'entryPoint'.");
}
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Executable))
{
throw new BuildxPluginException($"Manifest '{file}' must specify an executable entry point.");
}
if (!manifest.RequiresRestart)
{
throw new BuildxPluginException($"Manifest '{file}' must enforce restart-required activation.");
}
if (manifest.Cas is null)
{
throw new BuildxPluginException($"Manifest '{file}' must define CAS defaults.");
}
if (string.IsNullOrWhiteSpace(manifest.Cas.DefaultRoot))
{
throw new BuildxPluginException($"Manifest '{file}' must specify a CAS default root directory.");
}
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
/// <summary>
/// Loads buildx plug-in manifests from the restart-time plug-in directory.
/// </summary>
public sealed class BuildxPluginManifestLoader
{
public const string DefaultSearchPattern = "*.manifest.json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
PropertyNameCaseInsensitive = true
};
private readonly string manifestDirectory;
private readonly string searchPattern;
public BuildxPluginManifestLoader(string manifestDirectory, string? searchPattern = null)
{
if (string.IsNullOrWhiteSpace(manifestDirectory))
{
throw new ArgumentException("Manifest directory is required.", nameof(manifestDirectory));
}
this.manifestDirectory = Path.GetFullPath(manifestDirectory);
this.searchPattern = string.IsNullOrWhiteSpace(searchPattern)
? DefaultSearchPattern
: searchPattern;
}
/// <summary>
/// Loads all manifests in the configured directory.
/// </summary>
public async Task<IReadOnlyList<BuildxPluginManifest>> LoadAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(manifestDirectory))
{
return Array.Empty<BuildxPluginManifest>();
}
var manifests = new List<BuildxPluginManifest>();
foreach (var file in Directory.EnumerateFiles(manifestDirectory, searchPattern, SearchOption.TopDirectoryOnly))
{
if (IsHiddenPath(file))
{
continue;
}
var manifest = await DeserializeManifestAsync(file, cancellationToken).ConfigureAwait(false);
manifests.Add(manifest);
}
return manifests
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
/// <summary>
/// Loads the manifest with the specified identifier.
/// </summary>
public async Task<BuildxPluginManifest> LoadByIdAsync(string manifestId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(manifestId))
{
throw new ArgumentException("Manifest identifier is required.", nameof(manifestId));
}
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
var manifest = manifests.FirstOrDefault(m => string.Equals(m.Id, manifestId, StringComparison.OrdinalIgnoreCase));
if (manifest is null)
{
throw new BuildxPluginException($"Buildx plug-in manifest '{manifestId}' was not found in '{manifestDirectory}'.");
}
return manifest;
}
/// <summary>
/// Loads the first available manifest.
/// </summary>
public async Task<BuildxPluginManifest> LoadDefaultAsync(CancellationToken cancellationToken)
{
var manifests = await LoadAsync(cancellationToken).ConfigureAwait(false);
if (manifests.Count == 0)
{
throw new BuildxPluginException($"No buildx plug-in manifests were discovered under '{manifestDirectory}'.");
}
return manifests[0];
}
private static bool IsHiddenPath(string path)
{
var directory = Path.GetDirectoryName(path);
while (!string.IsNullOrEmpty(directory))
{
var segment = Path.GetFileName(directory);
if (segment.StartsWith(".", StringComparison.Ordinal))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
private static async Task<BuildxPluginManifest> DeserializeManifestAsync(string file, CancellationToken cancellationToken)
{
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
BuildxPluginManifest? manifest;
try
{
manifest = await JsonSerializer.DeserializeAsync<BuildxPluginManifest>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (JsonException ex)
{
throw new BuildxPluginException($"Failed to parse manifest '{file}'.", ex);
}
if (manifest is null)
{
throw new BuildxPluginException($"Manifest '{file}' is empty or invalid.");
}
ValidateManifest(manifest, file);
var directory = Path.GetDirectoryName(file);
return manifest with
{
SourcePath = file,
SourceDirectory = directory
};
}
private static void ValidateManifest(BuildxPluginManifest manifest, string file)
{
if (!string.Equals(manifest.SchemaVersion, BuildxPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
{
throw new BuildxPluginException(
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{BuildxPluginManifest.CurrentSchemaVersion}'.");
}
if (string.IsNullOrWhiteSpace(manifest.Id))
{
throw new BuildxPluginException($"Manifest '{file}' must specify a non-empty 'id'.");
}
if (manifest.EntryPoint is null)
{
throw new BuildxPluginException($"Manifest '{file}' must specify an 'entryPoint'.");
}
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Executable))
{
throw new BuildxPluginException($"Manifest '{file}' must specify an executable entry point.");
}
if (!manifest.RequiresRestart)
{
throw new BuildxPluginException($"Manifest '{file}' must enforce restart-required activation.");
}
if (manifest.Cas is null)
{
throw new BuildxPluginException($"Manifest '{file}' must define CAS defaults.");
}
if (string.IsNullOrWhiteSpace(manifest.Cas.DefaultRoot))
{
throw new BuildxPluginException($"Manifest '{file}' must specify a CAS default root directory.");
}
}
}