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
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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user