Resolve Concelier/Excititor merge conflicts
This commit is contained in:
12
src/StellaOps.Scanner.Sbomer.BuildXPlugin/AGENTS.md
Normal file
12
src/StellaOps.Scanner.Sbomer.BuildXPlugin/AGENTS.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# StellaOps.Scanner.Sbomer.BuildXPlugin — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement the build-time SBOM generator described in `docs/ARCHITECTURE_SCANNER.md` and new buildx dossier requirements:
|
||||
- Provide a deterministic BuildKit/Buildx generator that produces layer SBOM fragments and uploads them to local CAS.
|
||||
- Emit OCI annotations (+provenance) compatible with Scanner.Emit and Attestor hand-offs.
|
||||
- Respect restart-time plug-in policy (`plugins/scanner/buildx/` manifests) and keep CI overhead ≤300 ms per layer.
|
||||
|
||||
## Expectations
|
||||
- Read architecture + upcoming Buildx addendum before coding.
|
||||
- Ensure graceful fallback to post-build scan when generator unavailable.
|
||||
- Provide integration tests with mock BuildKit, and update `TASKS.md` as states change.
|
||||
@@ -0,0 +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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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;
|
||||
|
||||
public LocalCasClient(LocalCasOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
algorithm = options.Algorithm.ToLowerInvariant();
|
||||
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");
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
SHA256.HashData(content, buffer);
|
||||
return Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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;
|
||||
|
||||
public DescriptorGenerator(TimeProvider timeProvider)
|
||||
{
|
||||
timeProvider ??= TimeProvider.System;
|
||||
this.timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return new DescriptorDocument(
|
||||
Schema: Schema,
|
||||
GeneratedAt: timeProvider.GetUtcNow(),
|
||||
Generator: generatorMetadata,
|
||||
Subject: subject,
|
||||
Artifact: artifact,
|
||||
Provenance: provenance,
|
||||
Metadata: metadata);
|
||||
}
|
||||
|
||||
private static string ComputeDeterministicNonce(DescriptorRequest request, FileInfo sbomFile, string sbomDigest)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("stellaops.buildx.nonce.v1");
|
||||
builder.AppendLine(request.ImageDigest);
|
||||
builder.AppendLine(sbomDigest);
|
||||
builder.AppendLine(sbomFile.Length.ToString(CultureInfo.InvariantCulture));
|
||||
builder.AppendLine(request.SbomMediaType);
|
||||
builder.AppendLine(request.SbomFormat);
|
||||
builder.AppendLine(request.SbomKind);
|
||||
builder.AppendLine(request.SbomArtifactType);
|
||||
builder.AppendLine(request.SubjectMediaType);
|
||||
builder.AppendLine(request.GeneratorVersion);
|
||||
builder.AppendLine(request.GeneratorName ?? string.Empty);
|
||||
builder.AppendLine(request.LicenseId ?? string.Empty);
|
||||
builder.AppendLine(request.SbomName ?? string.Empty);
|
||||
builder.AppendLine(request.Repository ?? string.Empty);
|
||||
builder.AppendLine(request.BuildRef ?? string.Empty);
|
||||
builder.AppendLine(request.AttestorUri ?? string.Empty);
|
||||
builder.AppendLine(request.PredicateType);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(payload, hash);
|
||||
|
||||
Span<byte> nonceBytes = stackalloc byte[16];
|
||||
hash[..16].CopyTo(nonceBytes);
|
||||
return Convert.ToHexString(nonceBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileDigestAsync(FileInfo file, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
file.FullName,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 128 * 1024,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
var buffer = new byte[128 * 1024];
|
||||
int bytesRead;
|
||||
while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
hash.AppendData(buffer, 0, bytesRead);
|
||||
}
|
||||
|
||||
var digest = hash.GetHashAndReset();
|
||||
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeExpectedDsseDigest(string imageDigest, string sbomDigest, string nonce)
|
||||
{
|
||||
var payload = $"{imageDigest}\n{sbomDigest}\n{nonce}";
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return $"sha256:{Convert.ToHexString(hash).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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +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";
|
||||
}
|
||||
@@ -0,0 +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>();
|
||||
}
|
||||
@@ -0,0 +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>();
|
||||
}
|
||||
@@ -0,0 +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; }
|
||||
}
|
||||
@@ -0,0 +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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
327
src/StellaOps.Scanner.Sbomer.BuildXPlugin/Program.cs
Normal file
327
src/StellaOps.Scanner.Sbomer.BuildXPlugin/Program.cs
Normal file
@@ -0,0 +1,327 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
|
||||
using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
|
||||
|
||||
namespace StellaOps.Scanner.Sbomer.BuildXPlugin;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly JsonSerializerOptions ManifestPrintOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions DescriptorJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
cancellation.Cancel();
|
||||
};
|
||||
|
||||
var command = args.Length > 0 ? args[0].ToLowerInvariant() : "handshake";
|
||||
var commandArgs = args.Skip(1).ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
return command switch
|
||||
{
|
||||
"handshake" => await RunHandshakeAsync(commandArgs, cancellation.Token).ConfigureAwait(false),
|
||||
"manifest" => await RunManifestAsync(commandArgs, cancellation.Token).ConfigureAwait(false),
|
||||
"descriptor" or "annotate" => await RunDescriptorAsync(commandArgs, cancellation.Token).ConfigureAwait(false),
|
||||
"version" => RunVersion(),
|
||||
"help" or "--help" or "-h" => PrintHelp(),
|
||||
_ => UnknownCommand(command)
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine("Operation cancelled.");
|
||||
return 130;
|
||||
}
|
||||
catch (BuildxPluginException ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
return 2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Unhandled error: {ex}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> RunHandshakeAsync(string[] args, CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestDirectory = ResolveManifestDirectory(args);
|
||||
var loader = new BuildxPluginManifestLoader(manifestDirectory);
|
||||
var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var casRoot = ResolveCasRoot(args, manifest);
|
||||
var casClient = new LocalCasClient(new LocalCasOptions
|
||||
{
|
||||
RootDirectory = casRoot,
|
||||
Algorithm = "sha256"
|
||||
});
|
||||
|
||||
var result = await casClient.VerifyWriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"handshake ok: {manifest.Id}@{manifest.Version} → {result.Algorithm}:{result.Digest}");
|
||||
Console.WriteLine(result.Path);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> RunManifestAsync(string[] args, CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestDirectory = ResolveManifestDirectory(args);
|
||||
var loader = new BuildxPluginManifestLoader(manifestDirectory);
|
||||
var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, ManifestPrintOptions);
|
||||
Console.WriteLine(json);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int RunVersion()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? assembly.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
Console.WriteLine(version);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int PrintHelp()
|
||||
{
|
||||
Console.WriteLine("StellaOps BuildX SBOM generator");
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" stellaops-buildx [handshake|manifest|descriptor|version]");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Commands:");
|
||||
Console.WriteLine(" handshake Probe the local CAS and ensure manifests are discoverable.");
|
||||
Console.WriteLine(" manifest Print the resolved manifest JSON.");
|
||||
Console.WriteLine(" descriptor Emit OCI descriptor + provenance placeholder for the provided SBOM.");
|
||||
Console.WriteLine(" version Print the plug-in version.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Options:");
|
||||
Console.WriteLine(" --manifest <path> Override the manifest directory.");
|
||||
Console.WriteLine(" --cas <path> Override the CAS root directory.");
|
||||
Console.WriteLine(" --image <digest> (descriptor) Image digest the SBOM belongs to.");
|
||||
Console.WriteLine(" --sbom <path> (descriptor) Path to the SBOM file to describe.");
|
||||
Console.WriteLine(" --attestor <url> (descriptor) Optional Attestor endpoint for provenance placeholders.");
|
||||
Console.WriteLine(" --attestor-token <token> Bearer token for Attestor requests (or STELLAOPS_ATTESTOR_TOKEN).");
|
||||
Console.WriteLine(" --attestor-insecure Skip TLS verification for Attestor requests (dev/test only).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int UnknownCommand(string command)
|
||||
{
|
||||
Console.Error.WriteLine($"Unknown command '{command}'. Use 'help' for usage.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static string ResolveManifestDirectory(string[] args)
|
||||
{
|
||||
var explicitPath = GetOption(args, "--manifest")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_BUILDX_MANIFEST_DIR");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(explicitPath))
|
||||
{
|
||||
return Path.GetFullPath(explicitPath);
|
||||
}
|
||||
|
||||
var defaultDirectory = Path.Combine(AppContext.BaseDirectory, "plugins", "scanner", "buildx");
|
||||
if (Directory.Exists(defaultDirectory))
|
||||
{
|
||||
return defaultDirectory;
|
||||
}
|
||||
|
||||
return AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
private static string ResolveCasRoot(string[] args, BuildxPluginManifest manifest)
|
||||
{
|
||||
var overrideValue = GetOption(args, "--cas")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SCANNER_CAS_ROOT");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(overrideValue))
|
||||
{
|
||||
return Path.GetFullPath(overrideValue);
|
||||
}
|
||||
|
||||
var manifestDefault = manifest.Cas.DefaultRoot;
|
||||
if (!string.IsNullOrWhiteSpace(manifestDefault))
|
||||
{
|
||||
if (Path.IsPathRooted(manifestDefault))
|
||||
{
|
||||
return Path.GetFullPath(manifestDefault);
|
||||
}
|
||||
|
||||
var baseDirectory = manifest.SourceDirectory ?? AppContext.BaseDirectory;
|
||||
return Path.GetFullPath(Path.Combine(baseDirectory, manifestDefault));
|
||||
}
|
||||
|
||||
return Path.Combine(AppContext.BaseDirectory, "cas");
|
||||
}
|
||||
|
||||
private static async Task<int> RunDescriptorAsync(string[] args, CancellationToken cancellationToken)
|
||||
{
|
||||
var imageDigest = RequireOption(args, "--image");
|
||||
var sbomPath = RequireOption(args, "--sbom");
|
||||
|
||||
var sbomMediaType = GetOption(args, "--media-type") ?? "application/vnd.cyclonedx+json";
|
||||
var sbomFormat = GetOption(args, "--sbom-format") ?? "cyclonedx-json";
|
||||
var sbomKind = GetOption(args, "--sbom-kind") ?? "inventory";
|
||||
var artifactType = GetOption(args, "--artifact-type") ?? "application/vnd.stellaops.sbom.layer+json";
|
||||
var subjectMediaType = GetOption(args, "--subject-media-type") ?? "application/vnd.oci.image.manifest.v1+json";
|
||||
var predicateType = GetOption(args, "--predicate-type") ?? "https://slsa.dev/provenance/v1";
|
||||
var licenseId = GetOption(args, "--license-id") ?? Environment.GetEnvironmentVariable("STELLAOPS_LICENSE_ID");
|
||||
var repository = GetOption(args, "--repository");
|
||||
var buildRef = GetOption(args, "--build-ref");
|
||||
var sbomName = GetOption(args, "--sbom-name") ?? Path.GetFileName(sbomPath);
|
||||
|
||||
var attestorUriText = GetOption(args, "--attestor") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_URL");
|
||||
var attestorToken = GetOption(args, "--attestor-token") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_TOKEN");
|
||||
var attestorInsecure = GetFlag(args, "--attestor-insecure")
|
||||
|| string.Equals(Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_INSECURE"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
Uri? attestorUri = null;
|
||||
if (!string.IsNullOrWhiteSpace(attestorUriText))
|
||||
{
|
||||
attestorUri = new Uri(attestorUriText, UriKind.Absolute);
|
||||
}
|
||||
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? assembly.GetName().Version?.ToString()
|
||||
?? "0.0.0";
|
||||
|
||||
var request = new DescriptorRequest
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
SbomPath = sbomPath,
|
||||
SbomMediaType = sbomMediaType,
|
||||
SbomFormat = sbomFormat,
|
||||
SbomKind = sbomKind,
|
||||
SbomArtifactType = artifactType,
|
||||
SubjectMediaType = subjectMediaType,
|
||||
PredicateType = predicateType,
|
||||
GeneratorVersion = version,
|
||||
GeneratorName = assembly.GetName().Name,
|
||||
LicenseId = licenseId,
|
||||
SbomName = sbomName,
|
||||
Repository = repository,
|
||||
BuildRef = buildRef,
|
||||
AttestorUri = attestorUri?.ToString()
|
||||
}.Validate();
|
||||
|
||||
var generator = new DescriptorGenerator(TimeProvider.System);
|
||||
var document = await generator.CreateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (attestorUri is not null)
|
||||
{
|
||||
using var httpClient = CreateAttestorHttpClient(attestorUri, attestorToken, attestorInsecure);
|
||||
var attestorClient = new AttestorClient(httpClient);
|
||||
await attestorClient.SendPlaceholderAsync(attestorUri, document, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(document, DescriptorJsonOptions);
|
||||
Console.WriteLine(json);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string? GetOption(string[] args, string optionName)
|
||||
{
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var argument = args[i];
|
||||
if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (i + 1 >= args.Length)
|
||||
{
|
||||
throw new BuildxPluginException($"Option '{optionName}' requires a value.");
|
||||
}
|
||||
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
if (argument.StartsWith(optionName + "=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return argument[(optionName.Length + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool GetFlag(string[] args, string optionName)
|
||||
{
|
||||
foreach (var argument in args)
|
||||
{
|
||||
if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string RequireOption(string[] args, string optionName)
|
||||
{
|
||||
var value = GetOption(args, optionName);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new BuildxPluginException($"Option '{optionName}' is required.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static HttpClient CreateAttestorHttpClient(Uri attestorUri, string? bearerToken, bool insecure)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
CheckCertificateRevocationList = true,
|
||||
};
|
||||
|
||||
if (insecure && string.Equals(attestorUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
#pragma warning disable S4830 // Explicitly gated by --attestor-insecure flag/env for dev/test usage.
|
||||
handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
|
||||
#pragma warning restore S4830
|
||||
}
|
||||
|
||||
var client = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bearerToken))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>StellaOps.Scanner.Sbomer.BuildXPlugin</AssemblyName>
|
||||
<RootNamespace>StellaOps.Scanner.Sbomer.BuildXPlugin</RootNamespace>
|
||||
<Version>0.1.0-alpha</Version>
|
||||
<FileVersion>0.1.0.0</FileVersion>
|
||||
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
||||
<InformationalVersion>0.1.0-alpha</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="stellaops.sbom-indexer.manifest.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
9
src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md
Normal file
9
src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# BuildX Plugin Task Board (Sprint 9)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SP9-BLDX-09-001 | DONE | BuildX Guild | SCANNER-EMIT-10-601 (awareness) | Scaffold buildx driver, manifest, local CAS handshake; ensure plugin loads from `plugins/scanner/buildx/`. | Plugin manifest + loader tests; local CAS writes succeed; restart required to activate. |
|
||||
| SP9-BLDX-09-002 | DONE | BuildX Guild | SP9-BLDX-09-001 | Emit OCI annotations + provenance metadata for Attestor handoff (image + SBOM). | OCI descriptors include DSSE/provenance placeholders; Attestor mock accepts payload. |
|
||||
| SP9-BLDX-09-003 | DONE | BuildX Guild | SP9-BLDX-09-002 | CI demo pipeline: build sample image, produce SBOM, verify backend report wiring. | GitHub/CI job runs sample build within 5 s overhead; artifacts saved; documentation updated. |
|
||||
| SP9-BLDX-09-004 | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-002 | Stabilize descriptor nonce derivation so repeated builds emit deterministic placeholders. | Repeated descriptor runs with fixed inputs yield identical JSON; regression tests cover nonce determinism. |
|
||||
| SP9-BLDX-09-005 | DONE (2025-10-19) | BuildX Guild | SP9-BLDX-09-004 | Integrate determinism check in GitHub/Gitea workflows and capture sample artifacts. | Determinism step runs in `.gitea/workflows/build-test-deploy.yml` and `samples/ci/buildx-demo`, producing matching descriptors + archived artifacts. |
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.sbom-indexer",
|
||||
"displayName": "StellaOps SBOM BuildX Generator",
|
||||
"version": "0.1.0-alpha",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"executable": "StellaOps.Scanner.Sbomer.BuildXPlugin.dll",
|
||||
"arguments": [
|
||||
"handshake"
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"generator",
|
||||
"sbom"
|
||||
],
|
||||
"cas": {
|
||||
"protocol": "filesystem",
|
||||
"defaultRoot": "cas",
|
||||
"compression": "zstd"
|
||||
},
|
||||
"image": {
|
||||
"name": "stellaops/sbom-indexer",
|
||||
"digest": null,
|
||||
"platforms": [
|
||||
"linux/amd64",
|
||||
"linux/arm64"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"org.stellaops.plugin.kind": "buildx-generator",
|
||||
"org.stellaops.restart.required": "true"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user