Add SBOM, symbols, traces, and VEX files for CVE-2022-21661 SQLi case
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Created CycloneDX and SPDX SBOM files for both reachable and unreachable images.
- Added symbols.json detailing function entry and sink points in the WordPress code.
- Included runtime traces for function calls in both reachable and unreachable scenarios.
- Developed OpenVEX files indicating vulnerability status and justification for both cases.
- Updated README for evaluator harness to guide integration with scanner output.
This commit is contained in:
master
2025-11-08 20:53:45 +02:00
parent 515975edc5
commit 536f6249a6
837 changed files with 37279 additions and 14675 deletions

View File

@@ -1,8 +1,8 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
@@ -11,23 +11,25 @@ namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
/// </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();
private readonly string rootDirectory;
private readonly string algorithm;
private readonly ICryptoHash hash;
public LocalCasClient(LocalCasOptions options, ICryptoHash hash)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
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));
}
rootDirectory = Path.GetFullPath(options.RootDirectory);
rootDirectory = Path.GetFullPath(options.RootDirectory);
}
public Task<CasWriteResult> VerifyWriteAsync(CancellationToken cancellationToken)
@@ -65,10 +67,8 @@ public sealed class LocalCasClient
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();
}
}
private string ComputeDigest(ReadOnlySpan<byte> content)
{
return hash.ComputeHashHex(content, HashAlgorithms.Sha256);
}
}

View File

@@ -2,10 +2,10 @@ 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;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
@@ -16,13 +16,14 @@ 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;
}
private readonly TimeProvider _timeProvider;
private readonly ICryptoHash _hash;
public DescriptorGenerator(TimeProvider timeProvider, ICryptoHash hash)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
}
public async Task<DescriptorDocument> CreateAsync(DescriptorRequest request, CancellationToken cancellationToken)
{
@@ -78,7 +79,7 @@ public sealed class DescriptorGenerator
return new DescriptorDocument(
Schema: Schema,
GeneratedAt: timeProvider.GetUtcNow(),
GeneratedAt: _timeProvider.GetUtcNow(),
Generator: generatorMetadata,
Subject: subject,
Artifact: artifact,
@@ -86,7 +87,7 @@ public sealed class DescriptorGenerator
Metadata: metadata);
}
private static string ComputeDeterministicNonce(DescriptorRequest request, FileInfo sbomFile, string sbomDigest)
private string ComputeDeterministicNonce(DescriptorRequest request, FileInfo sbomFile, string sbomDigest)
{
var builder = new StringBuilder();
builder.AppendLine("stellaops.buildx.nonce.v1");
@@ -108,45 +109,33 @@ public sealed class DescriptorGenerator
builder.AppendLine(request.PredicateType);
var payload = Encoding.UTF8.GetBytes(builder.ToString());
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(payload, hash);
var digest = _hash.ComputeHash(payload, HashAlgorithms.Sha256);
Span<byte> nonceBytes = stackalloc byte[16];
hash[..16].CopyTo(nonceBytes);
digest.AsSpan(0, 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 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);
var digest = await _hash.ComputeHashAsync(stream, HashAlgorithms.Sha256, cancellationToken).ConfigureAwait(false);
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
}
private string ComputeExpectedDsseDigest(string imageDigest, string sbomDigest, string nonce)
{
var payload = $"{imageDigest}\n{sbomDigest}\n{nonce}";
var bytes = Encoding.UTF8.GetBytes(payload);
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)
{

View File

@@ -1,438 +1,440 @@
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;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Surface;
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).");
Console.WriteLine(" --surface-layer-fragments <path> Persist layer fragments JSON into Surface.FS.");
Console.WriteLine(" --surface-entrytrace-graph <path> Persist EntryTrace graph JSON into Surface.FS.");
Console.WriteLine(" --surface-entrytrace-ndjson <path> Persist EntryTrace NDJSON into Surface.FS.");
Console.WriteLine(" --surface-cache-root <path> Override Surface cache root (defaults to CAS root).");
Console.WriteLine(" --surface-bucket <name> Bucket name used in Surface CAS URIs (default scanner-artifacts).");
Console.WriteLine(" --surface-tenant <tenant> Tenant identifier recorded in the Surface manifest.");
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 manifestDirectory = ResolveManifestDirectory(args);
var loader = new BuildxPluginManifestLoader(manifestDirectory);
var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false);
var casRoot = ResolveCasRoot(args, manifest);
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);
}
await TryPublishSurfaceArtifactsAsync(args, request, casRoot, version, cancellationToken).ConfigureAwait(false);
var json = JsonSerializer.Serialize(document, DescriptorJsonOptions);
Console.WriteLine(json);
return 0;
}
private static async Task TryPublishSurfaceArtifactsAsync(
string[] args,
DescriptorRequest descriptorRequest,
string casRoot,
string generatorVersion,
CancellationToken cancellationToken)
{
var surfaceOptions = ResolveSurfaceOptions(args, descriptorRequest, casRoot, generatorVersion);
if (surfaceOptions is null || !surfaceOptions.HasArtifacts)
{
return;
}
var writer = new SurfaceManifestWriter(TimeProvider.System);
var result = await writer.WriteAsync(surfaceOptions, cancellationToken).ConfigureAwait(false);
if (result is null)
{
return;
}
Console.Error.WriteLine($"surface manifest stored: {result.ManifestUri} ({result.Document.Artifacts.Count} artefacts)");
}
private static SurfaceOptions? ResolveSurfaceOptions(
string[] args,
DescriptorRequest descriptorRequest,
string casRoot,
string generatorVersion)
{
var layerFragmentsPath = GetOption(args, "--surface-layer-fragments")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_LAYER_FRAGMENTS");
var entryTraceGraphPath = GetOption(args, "--surface-entrytrace-graph")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ENTRYTRACE_GRAPH");
var entryTraceNdjsonPath = GetOption(args, "--surface-entrytrace-ndjson")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ENTRYTRACE_NDJSON");
if (string.IsNullOrWhiteSpace(layerFragmentsPath) &&
string.IsNullOrWhiteSpace(entryTraceGraphPath) &&
string.IsNullOrWhiteSpace(entryTraceNdjsonPath))
{
return null;
}
var cacheRoot = GetOption(args, "--surface-cache-root")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_CACHE_ROOT")
?? casRoot;
var bucket = GetOption(args, "--surface-bucket")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_BUCKET")
?? SurfaceCasLayout.DefaultBucket;
var rootPrefix = GetOption(args, "--surface-root-prefix")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ROOT_PREFIX")
?? SurfaceCasLayout.DefaultRootPrefix;
var tenant = GetOption(args, "--surface-tenant")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_TENANT")
?? "default";
var component = GetOption(args, "--surface-component")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_COMPONENT")
?? "scanner.buildx";
var componentVersion = GetOption(args, "--surface-component-version")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_COMPONENT_VERSION")
?? generatorVersion;
var workerInstance = GetOption(args, "--surface-worker-instance")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_WORKER_INSTANCE")
?? Environment.MachineName;
var attemptValue = GetOption(args, "--surface-attempt")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ATTEMPT");
var attempt = 1;
if (!string.IsNullOrWhiteSpace(attemptValue) && int.TryParse(attemptValue, out var parsedAttempt) && parsedAttempt > 0)
{
attempt = parsedAttempt;
}
var scanId = GetOption(args, "--surface-scan-id")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_SCAN_ID")
?? descriptorRequest.SbomName
?? descriptorRequest.ImageDigest;
var manifestOutput = GetOption(args, "--surface-manifest-output")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_MANIFEST_OUTPUT");
return new SurfaceOptions(
CacheRoot: cacheRoot,
CacheBucket: bucket,
RootPrefix: rootPrefix,
Tenant: tenant,
Component: component,
ComponentVersion: componentVersion,
WorkerInstance: workerInstance,
Attempt: attempt,
ImageDigest: descriptorRequest.ImageDigest,
ScanId: scanId,
LayerFragmentsPath: layerFragmentsPath,
EntryTraceGraphPath: entryTraceGraphPath,
EntryTraceNdjsonPath: entryTraceNdjsonPath,
ManifestOutputPath: manifestOutput);
}
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;
}
}
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.Cryptography;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Surface;
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 hash = CryptoHashFactory.CreateDefault();
var casClient = new LocalCasClient(new LocalCasOptions
{
RootDirectory = casRoot,
Algorithm = "sha256"
}, hash);
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).");
Console.WriteLine(" --surface-layer-fragments <path> Persist layer fragments JSON into Surface.FS.");
Console.WriteLine(" --surface-entrytrace-graph <path> Persist EntryTrace graph JSON into Surface.FS.");
Console.WriteLine(" --surface-entrytrace-ndjson <path> Persist EntryTrace NDJSON into Surface.FS.");
Console.WriteLine(" --surface-cache-root <path> Override Surface cache root (defaults to CAS root).");
Console.WriteLine(" --surface-bucket <name> Bucket name used in Surface CAS URIs (default scanner-artifacts).");
Console.WriteLine(" --surface-tenant <tenant> Tenant identifier recorded in the Surface manifest.");
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 manifestDirectory = ResolveManifestDirectory(args);
var loader = new BuildxPluginManifestLoader(manifestDirectory);
var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false);
var casRoot = ResolveCasRoot(args, manifest);
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, CryptoHashFactory.CreateDefault());
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);
}
await TryPublishSurfaceArtifactsAsync(args, request, casRoot, version, cancellationToken).ConfigureAwait(false);
var json = JsonSerializer.Serialize(document, DescriptorJsonOptions);
Console.WriteLine(json);
return 0;
}
private static async Task TryPublishSurfaceArtifactsAsync(
string[] args,
DescriptorRequest descriptorRequest,
string casRoot,
string generatorVersion,
CancellationToken cancellationToken)
{
var surfaceOptions = ResolveSurfaceOptions(args, descriptorRequest, casRoot, generatorVersion);
if (surfaceOptions is null || !surfaceOptions.HasArtifacts)
{
return;
}
var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault());
var result = await writer.WriteAsync(surfaceOptions, cancellationToken).ConfigureAwait(false);
if (result is null)
{
return;
}
Console.Error.WriteLine($"surface manifest stored: {result.ManifestUri} ({result.Document.Artifacts.Count} artefacts)");
}
private static SurfaceOptions? ResolveSurfaceOptions(
string[] args,
DescriptorRequest descriptorRequest,
string casRoot,
string generatorVersion)
{
var layerFragmentsPath = GetOption(args, "--surface-layer-fragments")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_LAYER_FRAGMENTS");
var entryTraceGraphPath = GetOption(args, "--surface-entrytrace-graph")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ENTRYTRACE_GRAPH");
var entryTraceNdjsonPath = GetOption(args, "--surface-entrytrace-ndjson")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ENTRYTRACE_NDJSON");
if (string.IsNullOrWhiteSpace(layerFragmentsPath) &&
string.IsNullOrWhiteSpace(entryTraceGraphPath) &&
string.IsNullOrWhiteSpace(entryTraceNdjsonPath))
{
return null;
}
var cacheRoot = GetOption(args, "--surface-cache-root")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_CACHE_ROOT")
?? casRoot;
var bucket = GetOption(args, "--surface-bucket")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_BUCKET")
?? SurfaceCasLayout.DefaultBucket;
var rootPrefix = GetOption(args, "--surface-root-prefix")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ROOT_PREFIX")
?? SurfaceCasLayout.DefaultRootPrefix;
var tenant = GetOption(args, "--surface-tenant")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_TENANT")
?? "default";
var component = GetOption(args, "--surface-component")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_COMPONENT")
?? "scanner.buildx";
var componentVersion = GetOption(args, "--surface-component-version")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_COMPONENT_VERSION")
?? generatorVersion;
var workerInstance = GetOption(args, "--surface-worker-instance")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_WORKER_INSTANCE")
?? Environment.MachineName;
var attemptValue = GetOption(args, "--surface-attempt")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_ATTEMPT");
var attempt = 1;
if (!string.IsNullOrWhiteSpace(attemptValue) && int.TryParse(attemptValue, out var parsedAttempt) && parsedAttempt > 0)
{
attempt = parsedAttempt;
}
var scanId = GetOption(args, "--surface-scan-id")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_SCAN_ID")
?? descriptorRequest.SbomName
?? descriptorRequest.ImageDigest;
var manifestOutput = GetOption(args, "--surface-manifest-output")
?? Environment.GetEnvironmentVariable("STELLAOPS_SURFACE_MANIFEST_OUTPUT");
return new SurfaceOptions(
CacheRoot: cacheRoot,
CacheBucket: bucket,
RootPrefix: rootPrefix,
Tenant: tenant,
Component: component,
ComponentVersion: componentVersion,
WorkerInstance: workerInstance,
Attempt: attempt,
ImageDigest: descriptorRequest.ImageDigest,
ScanId: scanId,
LayerFragmentsPath: layerFragmentsPath,
EntryTraceGraphPath: entryTraceGraphPath,
EntryTraceNdjsonPath: entryTraceNdjsonPath,
ManifestOutputPath: manifestOutput);
}
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;
}
}

View File

@@ -1,17 +1,17 @@
<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>
<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>

View File

@@ -1,8 +1,8 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Surface;
@@ -67,11 +67,11 @@ internal static class SurfaceCasLayout
return $"cas://{normalizedBucket}/{normalizedKey}";
}
public static string ComputeDigest(ReadOnlySpan<byte> content)
public static string ComputeDigest(ICryptoHash hash, ReadOnlySpan<byte> content, string algorithmId = HashAlgorithms.Sha256)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(content, hash);
return $"{Sha256}:{Convert.ToHexString(hash).ToLowerInvariant()}";
var hex = hash.ComputeHashHex(content, algorithmId);
var prefix = algorithmId.Equals(HashAlgorithms.Sha256, StringComparison.OrdinalIgnoreCase) ? Sha256 : algorithmId.ToLowerInvariant();
return prefix + ":" + hex;
}
public static async Task<string> WriteBytesAsync(string rootDirectory, string objectKey, byte[] bytes, CancellationToken cancellationToken)

View File

@@ -6,6 +6,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
using StellaOps.Scanner.Surface.FS;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Surface;
@@ -19,10 +20,12 @@ internal sealed class SurfaceManifestWriter
};
private readonly TimeProvider _timeProvider;
private readonly ICryptoHash _hash;
public SurfaceManifestWriter(TimeProvider timeProvider)
public SurfaceManifestWriter(TimeProvider timeProvider, ICryptoHash hash)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
}
public async Task<SurfaceManifestWriteResult?> WriteAsync(SurfaceOptions options, CancellationToken cancellationToken)
@@ -71,7 +74,7 @@ internal sealed class SurfaceManifestWriter
View: null,
CasKind: SurfaceCasKind.EntryTraceGraph,
FilePath: EnsurePath(options.EntryTraceGraphPath!, "EntryTrace graph path is required."));
artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, cancellationToken).ConfigureAwait(false));
artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, _hash, cancellationToken).ConfigureAwait(false));
}
if (!string.IsNullOrWhiteSpace(options.EntryTraceNdjsonPath))
@@ -83,7 +86,7 @@ internal sealed class SurfaceManifestWriter
View: null,
CasKind: SurfaceCasKind.EntryTraceNdjson,
FilePath: EnsurePath(options.EntryTraceNdjsonPath!, "EntryTrace NDJSON path is required."));
artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, cancellationToken).ConfigureAwait(false));
artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, _hash, cancellationToken).ConfigureAwait(false));
}
if (!string.IsNullOrWhiteSpace(options.LayerFragmentsPath))
@@ -95,7 +98,7 @@ internal sealed class SurfaceManifestWriter
View: "inventory",
CasKind: SurfaceCasKind.LayerFragments,
FilePath: EnsurePath(options.LayerFragmentsPath!, "Layer fragments path is required."));
artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, cancellationToken).ConfigureAwait(false));
artifacts.Add(await PersistArtifactAsync(descriptor, cacheRoot, bucket, rootPrefix, _hash, cancellationToken).ConfigureAwait(false));
}
if (artifacts.Count == 0)
@@ -127,7 +130,7 @@ internal sealed class SurfaceManifestWriter
};
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifestDocument, ManifestSerializerOptions);
var manifestDigest = SurfaceCasLayout.ComputeDigest(manifestBytes);
var manifestDigest = SurfaceCasLayout.ComputeDigest(_hash, manifestBytes);
var manifestKey = SurfaceCasLayout.BuildObjectKey(rootPrefix, SurfaceCasKind.Manifest, manifestDigest);
var manifestPath = await SurfaceCasLayout.WriteBytesAsync(cacheRoot, manifestKey, manifestBytes, cancellationToken).ConfigureAwait(false);
var manifestUri = SurfaceCasLayout.BuildCasUri(bucket, manifestKey);
@@ -157,6 +160,7 @@ internal sealed class SurfaceManifestWriter
string cacheRoot,
string bucket,
string rootPrefix,
ICryptoHash hash,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -167,7 +171,7 @@ internal sealed class SurfaceManifestWriter
}
var content = await File.ReadAllBytesAsync(descriptor.FilePath, cancellationToken).ConfigureAwait(false);
var digest = SurfaceCasLayout.ComputeDigest(content);
var digest = SurfaceCasLayout.ComputeDigest(hash, content);
var objectKey = SurfaceCasLayout.BuildObjectKey(rootPrefix, descriptor.CasKind, digest);
var filePath = await SurfaceCasLayout.WriteBytesAsync(cacheRoot, objectKey, content, cancellationToken).ConfigureAwait(false);
var uri = SurfaceCasLayout.BuildCasUri(bucket, objectKey);

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using StellaOps.Configuration;
using StellaOps.Scanner.Storage;
namespace StellaOps.Scanner.WebService.Options;
@@ -76,6 +77,11 @@ public sealed class ScannerWebServiceOptions
/// </summary>
public EventsOptions Events { get; set; } = new();
/// <summary>
/// Sovereign cryptography configuration for this host.
/// </summary>
public StellaOpsCryptoOptions Crypto { get; set; } = new();
/// <summary>
/// Runtime ingestion configuration.
/// </summary>

View File

@@ -48,13 +48,15 @@ builder.Configuration.AddStellaOpsDefaults(options =>
var contentRoot = builder.Environment.ContentRootPath;
var bootstrapOptions = builder.Configuration.BindOptions<ScannerWebServiceOptions>(
ScannerWebServiceOptions.SectionName,
(opts, _) =>
{
ScannerWebServiceOptionsPostConfigure.Apply(opts, contentRoot);
ScannerWebServiceOptionsValidator.Validate(opts);
});
var bootstrapOptions = builder.Configuration.BindOptions<ScannerWebServiceOptions>(
ScannerWebServiceOptions.SectionName,
(opts, _) =>
{
ScannerWebServiceOptionsPostConfigure.Apply(opts, contentRoot);
ScannerWebServiceOptionsValidator.Validate(opts);
});
builder.Services.AddStellaOpsCrypto(bootstrapOptions.Crypto);
builder.Services.AddOptions<ScannerWebServiceOptions>()
.Bind(builder.Configuration.GetSection(ScannerWebServiceOptions.SectionName))

View File

@@ -7,6 +7,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.ObjectStore;
@@ -36,6 +37,7 @@ internal sealed class SurfacePointerService : ISurfacePointerService
private readonly ISurfaceEnvironment _surfaceEnvironment;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SurfacePointerService> _logger;
private readonly ICryptoHash _hash;
public SurfacePointerService(
LinkRepository linkRepository,
@@ -43,7 +45,8 @@ internal sealed class SurfacePointerService : ISurfacePointerService
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
ISurfaceEnvironment surfaceEnvironment,
TimeProvider timeProvider,
ILogger<SurfacePointerService> logger)
ILogger<SurfacePointerService> logger,
ICryptoHash hash)
{
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
@@ -51,6 +54,7 @@ internal sealed class SurfacePointerService : ISurfacePointerService
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
}
public async Task<SurfacePointersDto?> TryBuildAsync(string imageDigest, CancellationToken cancellationToken)
@@ -275,15 +279,9 @@ internal sealed class SurfacePointerService : ISurfacePointerService
? string.Empty
: value.Trim().TrimEnd('/');
private static string ComputeDigest(ReadOnlySpan<byte> payload)
private string ComputeDigest(ReadOnlySpan<byte> payload)
{
Span<byte> hash = stackalloc byte[32];
if (!SHA256.TryHashData(payload, hash, out _))
{
using var sha = SHA256.Create();
hash = sha.ComputeHash(payload.ToArray());
}
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
var hex = _hash.ComputeHashHex(payload, HashAlgorithms.Sha256);
return $"sha256:{hex}";
}
}

View File

@@ -4,6 +4,7 @@
|----|--------|----------|------------|-------------|---------------|
| SCAN-REPLAY-186-001 | TODO | Scanner WebService Guild | REPLAY-CORE-185-001 | Implement scan `record` mode producing replay manifests/bundles, capture policy/feed/tool hashes, and update `docs/modules/scanner/architecture.md` referencing `docs/replay/DETERMINISTIC_REPLAY.md` Section 6. | API/worker integration tests cover record mode; docs merged; replay artifacts stored per spec. |
| SCANNER-SURFACE-02 | DONE (2025-11-05) | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata.<br>2025-11-05: Surface pointers projected through scan/report endpoints, orchestrator samples + DSSE fixtures refreshed with manifest block, readiness tests updated to use validator stub. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. |
| SCANNER-CRYPTO-90-001 | TODO | Scanner WebService Guild, Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Route hashing/signing flows (`ScanIdGenerator`, `ReportSigner`, Sbomer Buildx plugin) through `ICryptoProviderRegistry` so sovereign deployments can select `ru.cryptopro.csp` / `ru.pkcs11` providers. Reference `docs/security/crypto-routing-audit-2025-11-07.md`. | Config toggles verified for default + RU bundles; report/scan APIs emit signatures via registry-backed providers; regression tests updated. |
| SCANNER-ENV-02 | TODO (2025-11-06) | Scanner WebService Guild, Ops Guild | SURFACE-ENV-02 | Wire Surface.Env helpers into WebService hosting (cache roots, feature flags) and document configuration.<br>2025-11-02: Cache root resolution switched to helper; feature flag bindings updated; Helm/Compose updates pending review.<br>2025-11-05 14:55Z: Aligning readiness checks, docs, and Helm/Compose templates with Surface.Env outputs and planning test coverage for configuration fallbacks.<br>2025-11-06 17:05Z: Surface.Env documentation/README refreshed; warning catalogue captured for ops handoff.<br>2025-11-06 07:45Z: Helm values (dev/stage/prod/airgap/mirror) and Compose examples updated with `SCANNER_SURFACE_*` defaults plus rollout warning note in `deploy/README.md`.<br>2025-11-06 07:55Z: Paused; follow-up automation captured under `DEVOPS-OPENSSL-11-001/002` and pending Surface.Env readiness tests. | Service uses helper; env table documented; helm/compose templates updated. |
> 2025-11-05 19:18Z: Added configurator to project wiring and unit test ensuring Surface.Env cache root is honoured.
| SCANNER-SECRETS-02 | DONE (2025-11-06) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).<br>2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress.<br>2025-11-06: Restarting work to eliminate file-based secrets, plumb provider handles through report/export services, and extend failure/rotation tests.<br>2025-11-06 21:40Z: Added configurator + storage post-config to hydrate artifact/CAS credentials from `cas-access` secrets with unit coverage.<br>2025-11-06 23:58Z: Registry & attestation secrets now resolved via Surface.Secrets (options + tests updated); dotnet test suites executed with .NET 10 RC2 runtime where available. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. |

View File

@@ -3,6 +3,7 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using StellaOps.Configuration;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Worker.Options;
@@ -17,13 +18,15 @@ public sealed class ScannerWorkerOptions
public PollingOptions Polling { get; } = new();
public AuthorityOptions Authority { get; } = new();
public AuthorityOptions Authority { get; } = new();
public TelemetryOptions Telemetry { get; } = new();
public ShutdownOptions Shutdown { get; } = new();
public AnalyzerOptions Analyzers { get; } = new();
public StellaOpsCryptoOptions Crypto { get; } = new();
public sealed class QueueOptions
{

View File

@@ -1,15 +1,14 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Linq;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Internal;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
using StellaOps.Scanner.Analyzers.OS;
@@ -22,9 +21,9 @@ using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Diagnostics;
namespace StellaOps.Scanner.Worker.Processing;
namespace StellaOps.Scanner.Worker.Processing;
internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
{
private readonly IServiceScopeFactory _scopeFactory;
@@ -32,17 +31,19 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
private readonly ILanguageAnalyzerPluginCatalog _languageCatalog;
private readonly ScannerWorkerOptions _options;
private readonly ILogger<CompositeScanAnalyzerDispatcher> _logger;
private readonly ICryptoHash _hash;
private readonly ScannerWorkerMetrics _metrics;
private IReadOnlyList<string> _osPluginDirectories = Array.Empty<string>();
private IReadOnlyList<string> _languagePluginDirectories = Array.Empty<string>();
public CompositeScanAnalyzerDispatcher(
IServiceScopeFactory scopeFactory,
IOSAnalyzerPluginCatalog osCatalog,
private IReadOnlyList<string> _osPluginDirectories = Array.Empty<string>();
private IReadOnlyList<string> _languagePluginDirectories = Array.Empty<string>();
public CompositeScanAnalyzerDispatcher(
IServiceScopeFactory scopeFactory,
IOSAnalyzerPluginCatalog osCatalog,
ILanguageAnalyzerPluginCatalog languageCatalog,
IOptions<ScannerWorkerOptions> options,
ILogger<CompositeScanAnalyzerDispatcher> logger,
ScannerWorkerMetrics metrics)
ScannerWorkerMetrics metrics,
ICryptoHash hash)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_osCatalog = osCatalog ?? throw new ArgumentNullException(nameof(osCatalog));
@@ -50,97 +51,98 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
LoadPlugins();
}
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
using var scope = _scopeFactory.CreateScope();
var services = scope.ServiceProvider;
var osAnalyzers = _osCatalog.CreateAnalyzers(services);
var languageAnalyzers = _languageCatalog.CreateAnalyzers(services);
if (osAnalyzers.Count == 0 && languageAnalyzers.Count == 0)
{
_logger.LogWarning("No analyzer plug-ins available; skipping analyzer stage for job {JobId}.", context.JobId);
return;
}
var metadata = new Dictionary<string, string>(context.Lease.Metadata, StringComparer.Ordinal);
var rootfsPath = ResolvePath(metadata, _options.Analyzers.RootFilesystemMetadataKey);
var workspacePath = ResolvePath(metadata, _options.Analyzers.WorkspaceMetadataKey) ?? rootfsPath;
if (osAnalyzers.Count > 0)
{
await ExecuteOsAnalyzersAsync(context, osAnalyzers, services, rootfsPath, workspacePath, cancellationToken)
.ConfigureAwait(false);
}
if (languageAnalyzers.Count > 0)
{
await ExecuteLanguageAnalyzersAsync(context, languageAnalyzers, services, workspacePath, cancellationToken)
.ConfigureAwait(false);
}
}
private async Task ExecuteOsAnalyzersAsync(
ScanJobContext context,
IReadOnlyList<IOSPackageAnalyzer> analyzers,
IServiceProvider services,
string? rootfsPath,
string? workspacePath,
CancellationToken cancellationToken)
{
if (rootfsPath is null)
{
_logger.LogWarning(
"Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate root filesystem. OS analyzers skipped.",
_options.Analyzers.RootFilesystemMetadataKey,
context.JobId);
return;
}
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var results = new List<OSPackageAnalyzerResult>(analyzers.Count);
foreach (var analyzer in analyzers)
{
cancellationToken.ThrowIfCancellationRequested();
var analyzerLogger = loggerFactory.CreateLogger(analyzer.GetType());
var analyzerContext = new OSPackageAnalyzerContext(rootfsPath, workspacePath, context.TimeProvider, analyzerLogger, context.Lease.Metadata);
try
{
var result = await analyzer.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Analyzer {AnalyzerId} failed for job {JobId}.", analyzer.AnalyzerId, context.JobId);
}
}
if (results.Count == 0)
{
return;
}
var dictionary = results.ToDictionary(result => result.AnalyzerId, StringComparer.OrdinalIgnoreCase);
context.Analysis.Set(ScanAnalysisKeys.OsPackageAnalyzers, dictionary);
var fragments = OsComponentMapper.ToLayerFragments(results);
if (!fragments.IsDefaultOrEmpty)
{
context.Analysis.AppendLayerFragments(fragments);
context.Analysis.Set(ScanAnalysisKeys.OsComponentFragments, fragments);
}
}
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
LoadPlugins();
}
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
using var scope = _scopeFactory.CreateScope();
var services = scope.ServiceProvider;
var osAnalyzers = _osCatalog.CreateAnalyzers(services);
var languageAnalyzers = _languageCatalog.CreateAnalyzers(services);
if (osAnalyzers.Count == 0 && languageAnalyzers.Count == 0)
{
_logger.LogWarning("No analyzer plug-ins available; skipping analyzer stage for job {JobId}.", context.JobId);
return;
}
var metadata = new Dictionary<string, string>(context.Lease.Metadata, StringComparer.Ordinal);
var rootfsPath = ResolvePath(metadata, _options.Analyzers.RootFilesystemMetadataKey);
var workspacePath = ResolvePath(metadata, _options.Analyzers.WorkspaceMetadataKey) ?? rootfsPath;
if (osAnalyzers.Count > 0)
{
await ExecuteOsAnalyzersAsync(context, osAnalyzers, services, rootfsPath, workspacePath, cancellationToken)
.ConfigureAwait(false);
}
if (languageAnalyzers.Count > 0)
{
await ExecuteLanguageAnalyzersAsync(context, languageAnalyzers, services, workspacePath, cancellationToken)
.ConfigureAwait(false);
}
}
private async Task ExecuteOsAnalyzersAsync(
ScanJobContext context,
IReadOnlyList<IOSPackageAnalyzer> analyzers,
IServiceProvider services,
string? rootfsPath,
string? workspacePath,
CancellationToken cancellationToken)
{
if (rootfsPath is null)
{
_logger.LogWarning(
"Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate root filesystem. OS analyzers skipped.",
_options.Analyzers.RootFilesystemMetadataKey,
context.JobId);
return;
}
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var results = new List<OSPackageAnalyzerResult>(analyzers.Count);
foreach (var analyzer in analyzers)
{
cancellationToken.ThrowIfCancellationRequested();
var analyzerLogger = loggerFactory.CreateLogger(analyzer.GetType());
var analyzerContext = new OSPackageAnalyzerContext(rootfsPath, workspacePath, context.TimeProvider, analyzerLogger, context.Lease.Metadata);
try
{
var result = await analyzer.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Analyzer {AnalyzerId} failed for job {JobId}.", analyzer.AnalyzerId, context.JobId);
}
}
if (results.Count == 0)
{
return;
}
var dictionary = results.ToDictionary(result => result.AnalyzerId, StringComparer.OrdinalIgnoreCase);
context.Analysis.Set(ScanAnalysisKeys.OsPackageAnalyzers, dictionary);
var fragments = OsComponentMapper.ToLayerFragments(results);
if (!fragments.IsDefaultOrEmpty)
{
context.Analysis.AppendLayerFragments(fragments);
context.Analysis.Set(ScanAnalysisKeys.OsComponentFragments, fragments);
}
}
private async Task ExecuteLanguageAnalyzersAsync(
ScanJobContext context,
IReadOnlyList<ILanguageAnalyzer> analyzers,
@@ -189,7 +191,7 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
context.JobId);
var fallbackBytes = Encoding.UTF8.GetBytes(workspacePath);
workspaceFingerprint = Convert.ToHexString(SHA256.HashData(fallbackBytes)).ToLowerInvariant();
workspaceFingerprint = _hash.ComputeHashHex(fallbackBytes, HashAlgorithms.Sha256);
}
var cache = services.GetRequiredService<ISurfaceCache>();
@@ -261,85 +263,85 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
context.Analysis.Set(ScanAnalysisKeys.LanguageComponentFragments, immutableFragments);
}
}
private void LoadPlugins()
{
_osPluginDirectories = NormalizeDirectories(_options.Analyzers.PluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "os"));
for (var i = 0; i < _osPluginDirectories.Count; i++)
{
var directory = _osPluginDirectories[i];
var seal = i == _osPluginDirectories.Count - 1;
try
{
_osCatalog.LoadFromDirectory(directory, seal);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load OS analyzer plug-ins from {Directory}.", directory);
}
}
_languagePluginDirectories = NormalizeDirectories(_options.Analyzers.LanguagePluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "lang"));
for (var i = 0; i < _languagePluginDirectories.Count; i++)
{
var directory = _languagePluginDirectories[i];
var seal = i == _languagePluginDirectories.Count - 1;
try
{
_languageCatalog.LoadFromDirectory(directory, seal);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load language analyzer plug-ins from {Directory}.", directory);
}
}
}
private static IReadOnlyList<string> NormalizeDirectories(IEnumerable<string> configured, string fallbackRelative)
{
var directories = new List<string>();
foreach (var configuredPath in configured ?? Array.Empty<string>())
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
continue;
}
var path = configuredPath;
if (!Path.IsPathRooted(path))
{
path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path));
}
directories.Add(path);
}
if (directories.Count == 0)
{
var fallback = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, fallbackRelative));
directories.Add(fallback);
}
return new ReadOnlyCollection<string>(directories);
}
private static string? ResolvePath(IReadOnlyDictionary<string, string> metadata, string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return Path.IsPathRooted(trimmed)
? trimmed
: Path.GetFullPath(trimmed);
}
}
private void LoadPlugins()
{
_osPluginDirectories = NormalizeDirectories(_options.Analyzers.PluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "os"));
for (var i = 0; i < _osPluginDirectories.Count; i++)
{
var directory = _osPluginDirectories[i];
var seal = i == _osPluginDirectories.Count - 1;
try
{
_osCatalog.LoadFromDirectory(directory, seal);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load OS analyzer plug-ins from {Directory}.", directory);
}
}
_languagePluginDirectories = NormalizeDirectories(_options.Analyzers.LanguagePluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "lang"));
for (var i = 0; i < _languagePluginDirectories.Count; i++)
{
var directory = _languagePluginDirectories[i];
var seal = i == _languagePluginDirectories.Count - 1;
try
{
_languageCatalog.LoadFromDirectory(directory, seal);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load language analyzer plug-ins from {Directory}.", directory);
}
}
}
private static IReadOnlyList<string> NormalizeDirectories(IEnumerable<string> configured, string fallbackRelative)
{
var directories = new List<string>();
foreach (var configuredPath in configured ?? Array.Empty<string>())
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
continue;
}
var path = configuredPath;
if (!Path.IsPathRooted(path))
{
path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path));
}
directories.Add(path);
}
if (directories.Count == 0)
{
var fallback = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, fallbackRelative));
directories.Add(fallback);
}
return new ReadOnlyCollection<string>(directories);
}
private static string? ResolvePath(IReadOnlyDictionary<string, string> metadata, string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return Path.IsPathRooted(trimmed)
? trimmed
: Path.GetFullPath(trimmed);
}
}

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -56,6 +55,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
private readonly ISurfaceCache _surfaceCache;
private readonly ISurfaceSecretProvider _surfaceSecrets;
private readonly IServiceProvider _serviceProvider;
private readonly ICryptoHash _hash;
public EntryTraceExecutionService(
IEntryTraceAnalyzer analyzer,
@@ -69,7 +69,8 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
ISurfaceEnvironment surfaceEnvironment,
ISurfaceCache surfaceCache,
ISurfaceSecretProvider surfaceSecrets,
IServiceProvider serviceProvider)
IServiceProvider serviceProvider,
ICryptoHash hash)
{
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
_entryTraceOptions = (entryTraceOptions ?? throw new ArgumentNullException(nameof(entryTraceOptions))).Value ?? new EntryTraceAnalyzerOptions();
@@ -83,6 +84,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
_surfaceCache = surfaceCache ?? throw new ArgumentNullException(nameof(surfaceCache));
_surfaceSecrets = surfaceSecrets ?? throw new ArgumentNullException(nameof(surfaceSecrets));
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
}
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
@@ -376,7 +378,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
return true;
}
private static SurfaceCacheKey CreateCacheKey(
private SurfaceCacheKey CreateCacheKey(
string imageDigest,
EntryTraceImageContext context,
string tenant,
@@ -390,11 +392,11 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
builder.Append('|').Append(ComputeEnvironmentFingerprint(context.Context.Environment));
builder.Append('|').Append(optionsFingerprint);
var hash = ComputeSha256(builder.ToString());
return new SurfaceCacheKey(CacheNamespace, tenant, hash);
var fingerprint = ComputeSha256(builder.ToString());
return new SurfaceCacheKey(CacheNamespace, tenant, fingerprint);
}
private static string ComputeOptionsFingerprint(EntryTraceAnalyzerOptions options)
private string ComputeOptionsFingerprint(EntryTraceAnalyzerOptions options)
{
var builder = new StringBuilder();
builder.Append(options.MaxDepth);
@@ -404,7 +406,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
return ComputeSha256(builder.ToString());
}
private static string ComputeEntrypointSignature(EntrypointSpecification specification)
private string ComputeEntrypointSignature(EntrypointSpecification specification)
{
var builder = new StringBuilder();
builder.AppendJoin(',', specification.Entrypoint);
@@ -415,7 +417,7 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
return ComputeSha256(builder.ToString());
}
private static string ComputeEnvironmentFingerprint(ImmutableDictionary<string, string> environment)
private string ComputeEnvironmentFingerprint(ImmutableDictionary<string, string> environment)
{
if (environment.Count == 0)
{
@@ -431,12 +433,10 @@ public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
return ComputeSha256(builder.ToString());
}
private static string ComputeSha256(string value)
private string ComputeSha256(string value)
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(value);
var hash = sha.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
return _hash.ComputeHashHex(bytes, HashAlgorithms.Sha256);
}
private static string? ResolvePath(

View File

@@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -15,6 +14,7 @@ using StellaOps.Scanner.Storage.ObjectStore;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.Storage.Services;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Worker.Processing.Surface;
@@ -58,6 +58,7 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
private readonly ISurfaceEnvironment _surfaceEnvironment;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SurfaceManifestPublisher> _logger;
private readonly ICryptoHash _hash;
public SurfaceManifestPublisher(
IArtifactObjectStore objectStore,
@@ -66,7 +67,8 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
IOptions<ScannerStorageOptions> storageOptions,
ISurfaceEnvironment surfaceEnvironment,
TimeProvider timeProvider,
ILogger<SurfaceManifestPublisher> logger)
ILogger<SurfaceManifestPublisher> logger,
ICryptoHash hash)
{
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
@@ -75,6 +77,7 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
}
public async Task<SurfaceManifestPublishResult> PublishAsync(SurfaceManifestRequest request, CancellationToken cancellationToken)
@@ -245,14 +248,13 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
_ => format.ToString().ToLowerInvariant()
};
private static string ComputeDigest(ReadOnlySpan<byte> content)
private string ComputeDigest(ReadOnlySpan<byte> content)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(content, hash);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
var hex = _hash.ComputeHashHex(content, HashAlgorithms.Sha256);
return $"sha256:{hex}";
}
private static string ComputeDigest(byte[] content)
private string ComputeDigest(byte[] content)
=> ComputeDigest(content.AsSpan());
private static string NormalizeDigest(string digest)

View File

@@ -3,7 +3,6 @@ using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -15,6 +14,7 @@ using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Worker.Processing.Surface;
@@ -37,6 +37,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
private readonly ISurfaceEnvironment _surfaceEnvironment;
private readonly ScannerWorkerMetrics _metrics;
private readonly ILogger<SurfaceManifestStageExecutor> _logger;
private readonly ICryptoHash _hash;
private readonly string _componentVersion;
public SurfaceManifestStageExecutor(
@@ -44,13 +45,15 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
ISurfaceCache surfaceCache,
ISurfaceEnvironment surfaceEnvironment,
ScannerWorkerMetrics metrics,
ILogger<SurfaceManifestStageExecutor> logger)
ILogger<SurfaceManifestStageExecutor> logger,
ICryptoHash hash)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_surfaceCache = surfaceCache ?? throw new ArgumentNullException(nameof(surfaceCache));
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
_componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
}
@@ -274,11 +277,10 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
return digest.Trim();
}
private static string ComputeDigest(ReadOnlySpan<byte> content)
private string ComputeDigest(ReadOnlySpan<byte> content)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(content, hash);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
var hex = _hash.ComputeHashHex(content, HashAlgorithms.Sha256);
return $"sha256:{hex}";
}
private static readonly IFormatProvider CultureInfoInvariant = System.Globalization.CultureInfo.InvariantCulture;

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Auth.Client;
using StellaOps.Configuration;
using StellaOps.Scanner.Cache;
using StellaOps.Scanner.Analyzers.OS.Plugin;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
@@ -71,6 +72,7 @@ builder.Services.AddSingleton<ScannerWorkerHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ScannerWorkerHostedService>());
var workerOptions = builder.Configuration.GetSection(ScannerWorkerOptions.SectionName).Get<ScannerWorkerOptions>() ?? new ScannerWorkerOptions();
builder.Services.AddStellaOpsCrypto(workerOptions.Crypto);
builder.Services.Configure<HostOptions>(options =>
{

View File

@@ -3,6 +3,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCAN-REPLAY-186-002 | TODO | Scanner Worker Guild | REPLAY-CORE-185-001 | Enforce deterministic analyzer execution when consuming replay input bundles, emit layer Merkle metadata, and author `docs/modules/scanner/deterministic-execution.md` summarising invariants from `docs/replay/DETERMINISTIC_REPLAY.md` Section 4. | Replay mode analyzers pass determinism tests; new doc merged; integration fixtures updated. |
| SCANNER-CRYPTO-90-001 | DONE (2025-11-08) | Scanner Worker Guild & Security Guild | SEC-CRYPTO-90-005 | Route remaining hashing and digest consumers (Surface pointers, manifest publishers, CAS helpers, Sbomer plugins) through ICryptoHash and the configured provider registry.<br>2025-11-08: Worker EntryTrace service, CAS helpers, and Sbomer plugin now depend on ICryptoHash; Local CAS + manifest writer persisted digests via providers; tests updated with CryptoHashFactory/TestCryptoHash helpers; runtime SHA256 calls removed. | No direct SHA256.Create() usage in worker runtime; constructors accept ICryptoHash; tests updated. |
| SCANNER-SURFACE-01 | DONE (2025-11-06) | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.<br>2025-11-02: Draft Surface.FS manifests emitted for sample scans; telemetry counters under review.<br>2025-11-06: Resuming with manifest writer abstraction, rotation metadata, and telemetry counters for Surface.FS persistence.<br>2025-11-06 21:05Z: Stage now persists manifest/payload caches, exports metrics to Prometheus/Grafana, and WebService pointer tests validate consumption. | Integration tests prove cache entries exist; telemetry counters exported. |
> 2025-11-05 19:18Z: Bound root directory to resolved Surface.Env settings and added unit coverage around the configurator.
> 2025-11-06 18:45Z: Resuming manifest persistence—planning publisher abstraction refactor, CAS storage wiring, and telemetry/test coverage.
@@ -10,3 +11,4 @@
> 2025-11-06 21:05Z: Completed Surface manifest cache + metrics work; tests/docs updated and task ready to close.
| SCANNER-ENV-01 | TODO (2025-11-06) | Scanner Worker Guild | SURFACE-ENV-02 | Replace ad-hoc environment reads with `StellaOps.Scanner.Surface.Env` helpers for cache roots and CAS endpoints.<br>2025-11-02: Worker bootstrap now resolves cache roots via helper; warning path documented; smoke tests running.<br>2025-11-05 14:55Z: Extending helper usage into cache/secrets configuration, updating worker validator wiring, and drafting docs/tests for new Surface.Env outputs.<br>2025-11-06 17:05Z: README/design docs updated with warning catalogue; startup logging guidance captured for ops runbooks.<br>2025-11-06 07:45Z: Helm/Compose env profiles (dev/stage/prod/airgap/mirror) now seed `SCANNER_SURFACE_*` defaults to keep worker cache roots aligned with Surface.Env helpers.<br>2025-11-06 07:55Z: Paused; pending automation tracked via `DEVOPS-OPENSSL-11-001/002` and Surface.Env test fixtures. | Worker boots with helper; misconfiguration warnings documented; smoke tests updated. |
| SCANNER-SECRETS-01 | DONE (2025-11-06) | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.<br>2025-11-02: Surface.Secrets provider wired for CAS token retrieval; integration tests added.<br>2025-11-06: Replaced registry credential plumbing with shared provider, added registry secret stage + metrics, and installed .NET 10 RC2 to validate parser/stage suites via targeted `dotnet test`. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. |
| SCAN-REACH-201-002 | DOING (2025-11-08) | Scanner Worker Guild | SIGNALS-24-002 | Implement language-aware reachability lifters (JVM/WALA, .NET Roslyn+IL, Go SSA, Node/Deno TS AST, Rust MIR, Swift SIL, shell/binary analyzers) emitting canonical SymbolIDs, CAS-stored callgraphs, and `reachability:*` SBOM tags consumed by Signals + Policy. | Fixture library + unit tests per language; CAS manifests published; SBOM components carry reachability tags; docs updated. |

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace StellaOps.Scanner.Reachability;
public sealed class ReachabilityGraphBuilder
{
private const string GraphSchemaVersion = "1.0";
private readonly HashSet<string> nodes = new(StringComparer.Ordinal);
private readonly HashSet<ReachabilityEdge> edges = new();
public ReachabilityGraphBuilder AddNode(string symbolId)
{
if (!string.IsNullOrWhiteSpace(symbolId))
{
nodes.Add(symbolId.Trim());
}
return this;
}
public ReachabilityGraphBuilder AddEdge(string from, string to, string kind = "call")
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
return this;
}
var edge = new ReachabilityEdge(from.Trim(), to.Trim(), string.IsNullOrWhiteSpace(kind) ? "call" : kind.Trim());
edges.Add(edge);
nodes.Add(edge.From);
nodes.Add(edge.To);
return this;
}
public string BuildJson(bool indented = true)
{
var payload = new ReachabilityGraphPayload
{
SchemaVersion = GraphSchemaVersion,
Nodes = nodes.Select(id => new ReachabilityNode(id)).ToList(),
Edges = edges.Select(edge => new ReachabilityEdgePayload(edge.From, edge.To, edge.Kind)).ToList()
};
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = indented
};
return JsonSerializer.Serialize(payload, options);
}
public static ReachabilityGraphBuilder FromFixture(string variantPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(variantPath);
var builder = new ReachabilityGraphBuilder();
foreach (var fileName in new[] { "callgraph.static.json", "callgraph.framework.json" })
{
var path = Path.Combine(variantPath, fileName);
if (!File.Exists(path))
{
continue;
}
using var stream = File.OpenRead(path);
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
if (root.TryGetProperty("nodes", out var nodesElement) && nodesElement.ValueKind == JsonValueKind.Array)
{
foreach (var node in nodesElement.EnumerateArray())
{
var sid = node.TryGetProperty("sid", out var sidElement)
? sidElement.GetString()
: node.GetProperty("id").GetString();
builder.AddNode(sid ?? string.Empty);
}
}
if (root.TryGetProperty("edges", out var edgesElement) && edgesElement.ValueKind == JsonValueKind.Array)
{
foreach (var edge in edgesElement.EnumerateArray())
{
var from = edge.TryGetProperty("from", out var fromEl)
? fromEl.GetString()
: edge.GetProperty("source").GetString();
var to = edge.TryGetProperty("to", out var toEl)
? toEl.GetString()
: edge.GetProperty("target").GetString();
var kind = edge.TryGetProperty("kind", out var kindEl)
? kindEl.GetString()
: edge.TryGetProperty("type", out var typeEl)
? typeEl.GetString()
: "call";
builder.AddEdge(from ?? string.Empty, to ?? string.Empty, kind ?? "call");
}
}
}
return builder;
}
private sealed record ReachabilityEdge(string From, string To, string Kind);
private sealed record ReachabilityNode(string Sid);
private sealed record ReachabilityEdgePayload(string From, string To, string Kind);
private sealed record ReachabilityGraphPayload
{
public string SchemaVersion { get; set; } = GraphSchemaVersion;
public List<ReachabilityNode> Nodes { get; set; } = new();
public List<ReachabilityEdgePayload> Edges { get; set; } = new();
}
}

View File

@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Replay.Core;
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Helper that projects reachability artifacts into the replay manifest.
/// </summary>
public sealed class ReachabilityReplayWriter
{
/// <summary>
/// Attaches reachability graphs and runtime traces to the supplied replay manifest.
/// </summary>
public void AttachEvidence(
ReplayManifest manifest,
IEnumerable<ReachabilityReplayGraph>? graphs,
IEnumerable<ReachabilityReplayTrace>? traces)
{
ArgumentNullException.ThrowIfNull(manifest);
WriteGraphs(manifest, graphs);
WriteTraces(manifest, traces);
}
private static void WriteGraphs(ReplayManifest manifest, IEnumerable<ReachabilityReplayGraph>? graphs)
{
if (graphs is null)
{
return;
}
var sanitized = graphs
.Where(graph => graph is not null)
.Select(graph => NormalizeGraph(graph!))
.Where(graph => graph is not null)
.Select(graph => graph!)
.DistinctBy(graph => (graph.Kind, graph.CasUri, graph.Sha256, graph.Analyzer, graph.Version))
.OrderBy(graph => graph.CasUri, StringComparer.Ordinal)
.ThenBy(graph => graph.Kind, StringComparer.Ordinal)
.ToList();
foreach (var graph in sanitized)
{
manifest.AddReachabilityGraph(new ReplayReachabilityGraphReference
{
Kind = graph.Kind,
CasUri = graph.CasUri,
Sha256 = graph.Sha256,
Analyzer = graph.Analyzer,
Version = graph.Version
});
}
}
private static void WriteTraces(ReplayManifest manifest, IEnumerable<ReachabilityReplayTrace>? traces)
{
if (traces is null)
{
return;
}
var normalized = traces
.Where(trace => trace is not null)
.Select(trace => NormalizeTrace(trace!))
.Where(trace => trace is not null)
.Select(trace => trace!)
.ToList();
var collapsed = normalized
.GroupBy(trace => (trace.Source, trace.CasUri, trace.Sha256))
.Select(group => group.OrderBy(t => t.RecordedAt).First())
.OrderBy(trace => trace.RecordedAt)
.ThenBy(trace => trace.CasUri, StringComparer.Ordinal)
.ToList();
foreach (var trace in collapsed)
{
manifest.AddReachabilityTrace(new ReplayReachabilityTraceReference
{
Source = trace.Source,
CasUri = trace.CasUri,
Sha256 = trace.Sha256,
RecordedAt = trace.RecordedAt
});
}
}
private static NormalizedGraph? NormalizeGraph(ReachabilityReplayGraph graph)
{
var casUri = Normalize(graph.CasUri);
if (string.IsNullOrEmpty(casUri))
{
return null;
}
return new NormalizedGraph(
Kind: Normalize(graph.Kind) ?? "static",
CasUri: casUri,
Sha256: NormalizeHash(graph.Sha256),
Analyzer: Normalize(graph.Analyzer) ?? string.Empty,
Version: Normalize(graph.Version) ?? string.Empty);
}
private static NormalizedTrace? NormalizeTrace(ReachabilityReplayTrace trace)
{
var casUri = Normalize(trace.CasUri);
if (string.IsNullOrEmpty(casUri))
{
return null;
}
return new NormalizedTrace(
Source: Normalize(trace.Source) ?? string.Empty,
CasUri: casUri,
Sha256: NormalizeHash(trace.Sha256),
RecordedAt: trace.RecordedAt.ToUniversalTime());
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string NormalizeHash(string? hash)
=> string.IsNullOrWhiteSpace(hash) ? string.Empty : hash.Trim().ToLowerInvariant();
private sealed record NormalizedGraph(
string Kind,
string CasUri,
string Sha256,
string Analyzer,
string Version);
private sealed record NormalizedTrace(
string Source,
string CasUri,
string Sha256,
DateTimeOffset RecordedAt);
}
/// <summary>
/// Describes a CAS-backed reachability graph emitted by Scanner.
/// </summary>
public sealed record ReachabilityReplayGraph(
string? Kind,
string? CasUri,
string? Sha256,
string? Analyzer,
string? Version);
/// <summary>
/// Describes a runtime trace artifact emitted by Scanner/Zastava.
/// </summary>
public sealed record ReachabilityReplayTrace(
string? Source,
string? CasUri,
string? Sha256,
DateTimeOffset RecordedAt);

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="10.0.0-preview.7.25380.108" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -3,11 +3,11 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Surface.FS;
@@ -20,12 +20,14 @@ public sealed class FileSurfaceManifestStore :
{
private readonly ILogger<FileSurfaceManifestStore> _logger;
private readonly SurfaceManifestPathBuilder _pathBuilder;
private readonly ICryptoHash _hash;
private readonly SemaphoreSlim _publishGate = new(1, 1);
public FileSurfaceManifestStore(
IOptions<SurfaceCacheOptions> cacheOptions,
IOptions<SurfaceManifestStoreOptions> storeOptions,
ILogger<FileSurfaceManifestStore> logger)
ILogger<FileSurfaceManifestStore> logger,
ICryptoHash hash)
{
if (cacheOptions is null)
{
@@ -38,6 +40,7 @@ public sealed class FileSurfaceManifestStore :
}
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
_pathBuilder = new SurfaceManifestPathBuilder(cacheOptions.Value, storeOptions.Value);
}
@@ -183,11 +186,10 @@ public sealed class FileSurfaceManifestStore :
};
}
private static string ComputeDigest(ReadOnlySpan<byte> bytes)
private string ComputeDigest(ReadOnlySpan<byte> bytes)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
var digest = _hash.ComputeHash(bytes, HashAlgorithms.Sha256);
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
}
private static SurfaceManifestArtifact NormalizeArtifact(SurfaceManifestArtifact artifact)

View File

@@ -16,12 +16,15 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.7.25380.108" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,34 +1,35 @@
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
using Xunit;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Cas;
public sealed class LocalCasClientTests
{
[Fact]
public async Task VerifyWriteAsync_WritesProbeObject()
{
await using var temp = new TempDirectory();
var client = new LocalCasClient(new LocalCasOptions
{
RootDirectory = temp.Path,
Algorithm = "sha256"
});
var result = await client.VerifyWriteAsync(CancellationToken.None);
Assert.Equal("sha256", result.Algorithm);
Assert.True(File.Exists(result.Path));
var bytes = await File.ReadAllBytesAsync(result.Path);
Assert.Equal("stellaops-buildx-probe"u8.ToArray(), bytes);
var expectedDigest = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
Assert.Equal(expectedDigest, result.Digest);
}
}
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
using Xunit;
namespace StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.Cas;
public sealed class LocalCasClientTests
{
[Fact]
public async Task VerifyWriteAsync_WritesProbeObject()
{
await using var temp = new TempDirectory();
var client = new LocalCasClient(new LocalCasOptions
{
RootDirectory = temp.Path,
Algorithm = "sha256"
}, CryptoHashFactory.CreateDefault());
var result = await client.VerifyWriteAsync(CancellationToken.None);
Assert.Equal("sha256", result.Algorithm);
Assert.True(File.Exists(result.Path));
var bytes = await File.ReadAllBytesAsync(result.Path);
Assert.Equal("stellaops-buildx-probe"u8.ToArray(), bytes);
var expectedDigest = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
Assert.Equal(expectedDigest, result.Digest);
}
}

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Cryptography;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
@@ -36,15 +37,49 @@ public sealed class DescriptorCommandSurfaceTests
var manifestOutputPath = Path.Combine(temp.Path, "out", "surface-manifest.json");
var repoRoot = TestPathHelper.FindRepositoryRoot();
var manifestDirectory = Path.Combine(repoRoot, "src", "Scanner", "StellaOps.Scanner.Sbomer.BuildXPlugin");
var pluginAssembly = typeof(BuildxPluginManifest).Assembly.Location;
var normalizedRoot = repoRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var rootFolder = Path.GetFileName(normalizedRoot);
var actualRepoRoot = string.Equals(rootFolder, "src", StringComparison.OrdinalIgnoreCase)
? Directory.GetParent(normalizedRoot)?.FullName ?? normalizedRoot
: normalizedRoot;
var sourceRoot = string.Equals(rootFolder, "src", StringComparison.OrdinalIgnoreCase)
? normalizedRoot
: Path.Combine(actualRepoRoot, "src");
var pluginProjectRoot = Path.Combine(sourceRoot, "Scanner", "StellaOps.Scanner.Sbomer.BuildXPlugin");
var manifestDirectoryCandidates = new[]
{
Path.Combine(actualRepoRoot, "plugins", "scanner", "buildx", "StellaOps.Scanner.Sbomer.BuildXPlugin"),
pluginProjectRoot
};
var manifestDirectory = manifestDirectoryCandidates.FirstOrDefault(Directory.Exists)
?? throw new DirectoryNotFoundException(
$"BuildX manifest directory not found under '{string.Join("', '", manifestDirectoryCandidates)}'.");
var testsOutputDirectory = Path.GetDirectoryName(typeof(DescriptorCommandSurfaceTests).Assembly.Location)
?? throw new InvalidOperationException("Unable to resolve test assembly directory.");
var targetFramework = new DirectoryInfo(testsOutputDirectory).Name;
var configuration = Directory.GetParent(testsOutputDirectory)?.Name ?? "Debug";
var pluginAssembly = Path.Combine(
pluginProjectRoot,
"bin",
configuration,
targetFramework,
"StellaOps.Scanner.Sbomer.BuildXPlugin.dll");
if (!File.Exists(pluginAssembly))
{
throw new FileNotFoundException($"BuildX plug-in assembly not found at '{pluginAssembly}'.");
}
var psi = new ProcessStartInfo("dotnet")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
WorkingDirectory = repoRoot
WorkingDirectory = actualRepoRoot
};
psi.ArgumentList.Add(pluginAssembly);
@@ -82,7 +117,9 @@ public sealed class DescriptorCommandSurfaceTests
var descriptor = JsonSerializer.Deserialize<DescriptorDocument>(stdout, new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(descriptor);
Assert.Equal("stellaops.buildx.descriptor.v1", descriptor!.Schema);
Assert.Equal("sha256:d07d06ae82e1789a5b505731f3ec3add106e23a55395213c9a881c7e816c695c", descriptor.Artifact.Digest);
var hash = CryptoHashFactory.CreateDefault();
var expectedDigest = ComputeSha256Digest(hash, sbomPath);
Assert.Equal(expectedDigest, descriptor.Artifact.Digest);
Assert.Contains("surface manifest stored", stderr, StringComparison.OrdinalIgnoreCase);
Assert.True(File.Exists(manifestOutputPath));
@@ -109,14 +146,21 @@ public sealed class DescriptorCommandSurfaceTests
throw new InvalidOperationException($"Unsupported CAS URI {casUri}.");
}
var slashIndex = casUri.IndexOf(/, prefix.Length);
var slashIndex = casUri.IndexOf('/', prefix.Length);
if (slashIndex < 0)
{
throw new InvalidOperationException($"CAS URI {casUri} does not contain a bucket path.");
}
var relative = casUri[(slashIndex + 1)..];
var localPath = Path.Combine(casRoot, relative.Replace(/, Path.DirectorySeparatorChar));
var localPath = Path.Combine(casRoot, relative.Replace('/', Path.DirectorySeparatorChar));
return localPath;
}
private static string ComputeSha256Digest(ICryptoHash hash, string filePath)
{
var bytes = File.ReadAllBytes(filePath);
var digest = hash.ComputeHash(bytes, HashAlgorithms.Sha256);
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
}
}

View File

@@ -7,7 +7,8 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using StellaOps.Cryptography;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
using Xunit;
@@ -23,7 +24,7 @@ public sealed class DescriptorGeneratorTests
await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}");
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var generator = new DescriptorGenerator(fakeTime);
var generator = CreateGenerator(fakeTime);
var request = new DescriptorRequest
{
@@ -72,7 +73,7 @@ public sealed class DescriptorGeneratorTests
await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}");
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var generator = new DescriptorGenerator(fakeTime);
var generator = CreateGenerator(fakeTime);
var request = new DescriptorRequest
{
@@ -109,7 +110,7 @@ public sealed class DescriptorGeneratorTests
await File.WriteAllTextAsync(sbomPath, "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}");
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var generator = new DescriptorGenerator(fakeTime);
var generator = CreateGenerator(fakeTime);
var baseline = new DescriptorRequest
{
@@ -133,7 +134,10 @@ public sealed class DescriptorGeneratorTests
Assert.NotEqual(baselineDocument.Provenance.ExpectedDsseSha256, variantDocument.Provenance.ExpectedDsseSha256);
}
private static string ComputeSha256File(string path)
private static DescriptorGenerator CreateGenerator(TimeProvider timeProvider)
=> new(timeProvider, CryptoHashFactory.CreateDefault());
private static string ComputeSha256File(string path)
{
using var stream = File.OpenRead(path);
var hash = SHA256.HashData(stream);

View File

@@ -7,8 +7,9 @@ using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Cryptography;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
using Xunit;
@@ -48,7 +49,7 @@ public sealed class DescriptorGoldenTests
}.Validate();
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 12, 0, 0, TimeSpan.Zero));
var generator = new DescriptorGenerator(fakeTime);
var generator = CreateGenerator(fakeTime);
var document = await generator.CreateAsync(request, CancellationToken.None);
var actualJson = JsonSerializer.Serialize(document, SerializerOptions);
var normalizedJson = NormalizeDescriptorJson(actualJson, Path.GetFileName(sbomPath));
@@ -129,4 +130,6 @@ public sealed class DescriptorGoldenTests
break;
}
}
}
private static DescriptorGenerator CreateGenerator(TimeProvider timeProvider)
=> new(timeProvider, CryptoHashFactory.CreateDefault());
}

View File

@@ -3,6 +3,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Surface;
using StellaOps.Scanner.Sbomer.BuildXPlugin.Tests.TestUtilities;
using Xunit;
@@ -42,7 +43,7 @@ public sealed class SurfaceManifestWriterTests
EntryTraceNdjsonPath: ndjsonPath,
ManifestOutputPath: manifestOutputPath);
var writer = new SurfaceManifestWriter(TimeProvider.System);
var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault());
var result = await writer.WriteAsync(options, CancellationToken.None);
Assert.NotNull(result);
@@ -88,7 +89,7 @@ public sealed class SurfaceManifestWriterTests
EntryTraceNdjsonPath: null,
ManifestOutputPath: null);
var writer = new SurfaceManifestWriter(TimeProvider.System);
var writer = new SurfaceManifestWriter(TimeProvider.System, CryptoHashFactory.CreateDefault());
var result = await writer.WriteAsync(options, CancellationToken.None);
Assert.Null(result);
}

View File

@@ -22,7 +22,8 @@ using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
using Xunit;
using WorkerOptions = StellaOps.Scanner.Worker.Options.ScannerWorkerOptions;
namespace StellaOps.Scanner.Worker.Tests;
@@ -108,7 +109,8 @@ public sealed class CompositeScanAnalyzerDispatcherTests
languageCatalog,
options,
loggerFactory.CreateLogger<CompositeScanAnalyzerDispatcher>(),
metrics);
metrics,
new TestCryptoHash());
var lease = new TestJobLease(metadata);
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);

View File

@@ -19,6 +19,7 @@ using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
@@ -166,7 +167,8 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
ISurfaceCache? surfaceCache = null,
ISurfaceValidatorRunner? surfaceValidator = null,
ISurfaceSecretProvider? surfaceSecrets = null,
ISurfaceEnvironment? surfaceEnvironment = null)
ISurfaceEnvironment? surfaceEnvironment = null,
ICryptoHash? hash = null)
{
var workerOptions = new ScannerWorkerOptions();
var entryTraceOptions = new EntryTraceAnalyzerOptions();
@@ -176,6 +178,7 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
surfaceCache ??= new InMemorySurfaceCache();
surfaceValidator ??= new NoopSurfaceValidatorRunner();
surfaceSecrets ??= new StubSurfaceSecretProvider();
hash ??= new TestCryptoHash();
var serviceProvider = new ServiceCollection()
.AddSingleton<ISurfaceEnvironment>(surfaceEnvironment)
.BuildServiceProvider();
@@ -192,7 +195,8 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
surfaceEnvironment,
surfaceCache,
surfaceSecrets,
serviceProvider);
serviceProvider,
hash);
}
private static ScanJobContext CreateContext(IReadOnlyDictionary<string, string> metadata)

View File

@@ -7,7 +7,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
@@ -18,6 +17,7 @@ using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Processing.Surface;
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
using Xunit;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Worker.Tests;
@@ -34,12 +34,14 @@ public sealed class SurfaceManifestStageExecutorTests
using var listener = new WorkerMeterListener();
listener.Start();
var hash = new DefaultCryptoHash();
var executor = new SurfaceManifestStageExecutor(
publisher,
cache,
environment,
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance);
NullLogger<SurfaceManifestStageExecutor>.Instance,
hash);
var context = CreateContext();
@@ -68,12 +70,14 @@ public sealed class SurfaceManifestStageExecutorTests
using var listener = new WorkerMeterListener();
listener.Start();
var hash = new DefaultCryptoHash();
var executor = new SurfaceManifestStageExecutor(
publisher,
cache,
environment,
metrics,
NullLogger<SurfaceManifestStageExecutor>.Instance);
NullLogger<SurfaceManifestStageExecutor>.Instance,
hash);
var context = CreateContext();
PopulateAnalysis(context);

View File

@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Worker.Tests.TestInfrastructure;
internal sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
{
using var algorithm = CreateAlgorithm(algorithmId);
return algorithm.ComputeHash(data.ToArray());
}
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
using var algorithm = CreateAlgorithm(algorithmId);
await using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return algorithm.ComputeHash(buffer.ToArray());
}
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static HashAlgorithm CreateAlgorithm(string? algorithmId)
{
return algorithmId?.ToUpperInvariant() switch
{
null or "" or HashAlgorithms.Sha256 => SHA256.Create(),
HashAlgorithms.Sha512 => SHA512.Create(),
_ => throw new NotSupportedException($"Test crypto hash does not support algorithm {algorithmId}.")
};
}
}