328 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			328 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using System;
 | |
| using System.IO;
 | |
| using System.Linq;
 | |
| using System.Reflection;
 | |
| using System.Text.Json;
 | |
| using System.Threading;
 | |
| using System.Threading.Tasks;
 | |
| using System.Net.Http;
 | |
| using System.Net.Http.Headers;
 | |
| using System.Text.Json.Serialization;
 | |
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Attestation;
 | |
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Cas;
 | |
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Descriptor;
 | |
| using StellaOps.Scanner.Sbomer.BuildXPlugin.Manifest;
 | |
| 
 | |
| namespace StellaOps.Scanner.Sbomer.BuildXPlugin;
 | |
| 
 | |
| internal static class Program
 | |
| {
 | |
|     private static readonly JsonSerializerOptions ManifestPrintOptions = new(JsonSerializerDefaults.Web)
 | |
|     {
 | |
|         WriteIndented = true,
 | |
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 | |
|     };
 | |
| 
 | |
|     private static readonly JsonSerializerOptions DescriptorJsonOptions = new(JsonSerializerDefaults.Web)
 | |
|     {
 | |
|         WriteIndented = true,
 | |
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 | |
|     };
 | |
| 
 | |
|     private static async Task<int> Main(string[] args)
 | |
|     {
 | |
|         using var cancellation = new CancellationTokenSource();
 | |
|         Console.CancelKeyPress += (_, eventArgs) =>
 | |
|         {
 | |
|             eventArgs.Cancel = true;
 | |
|             cancellation.Cancel();
 | |
|         };
 | |
| 
 | |
|         var command = args.Length > 0 ? args[0].ToLowerInvariant() : "handshake";
 | |
|         var commandArgs = args.Skip(1).ToArray();
 | |
| 
 | |
|         try
 | |
|         {
 | |
|             return command switch
 | |
|             {
 | |
|                 "handshake" => await RunHandshakeAsync(commandArgs, cancellation.Token).ConfigureAwait(false),
 | |
|                 "manifest" => await RunManifestAsync(commandArgs, cancellation.Token).ConfigureAwait(false),
 | |
|                 "descriptor" or "annotate" => await RunDescriptorAsync(commandArgs, cancellation.Token).ConfigureAwait(false),
 | |
|                 "version" => RunVersion(),
 | |
|                 "help" or "--help" or "-h" => PrintHelp(),
 | |
|                 _ => UnknownCommand(command)
 | |
|             };
 | |
|         }
 | |
|         catch (OperationCanceledException)
 | |
|         {
 | |
|             Console.Error.WriteLine("Operation cancelled.");
 | |
|             return 130;
 | |
|         }
 | |
|         catch (BuildxPluginException ex)
 | |
|         {
 | |
|             Console.Error.WriteLine(ex.Message);
 | |
|             return 2;
 | |
|         }
 | |
|         catch (Exception ex)
 | |
|         {
 | |
|             Console.Error.WriteLine($"Unhandled error: {ex}");
 | |
|             return 1;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private static async Task<int> RunHandshakeAsync(string[] args, CancellationToken cancellationToken)
 | |
|     {
 | |
|         var manifestDirectory = ResolveManifestDirectory(args);
 | |
|         var loader = new BuildxPluginManifestLoader(manifestDirectory);
 | |
|         var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false);
 | |
| 
 | |
|         var casRoot = ResolveCasRoot(args, manifest);
 | |
|         var casClient = new LocalCasClient(new LocalCasOptions
 | |
|         {
 | |
|             RootDirectory = casRoot,
 | |
|             Algorithm = "sha256"
 | |
|         });
 | |
| 
 | |
|         var result = await casClient.VerifyWriteAsync(cancellationToken).ConfigureAwait(false);
 | |
| 
 | |
|         Console.WriteLine($"handshake ok: {manifest.Id}@{manifest.Version} → {result.Algorithm}:{result.Digest}");
 | |
|         Console.WriteLine(result.Path);
 | |
|         return 0;
 | |
|     }
 | |
| 
 | |
|     private static async Task<int> RunManifestAsync(string[] args, CancellationToken cancellationToken)
 | |
|     {
 | |
|         var manifestDirectory = ResolveManifestDirectory(args);
 | |
|         var loader = new BuildxPluginManifestLoader(manifestDirectory);
 | |
|         var manifest = await loader.LoadDefaultAsync(cancellationToken).ConfigureAwait(false);
 | |
| 
 | |
|         var json = JsonSerializer.Serialize(manifest, ManifestPrintOptions);
 | |
|         Console.WriteLine(json);
 | |
|         return 0;
 | |
|     }
 | |
| 
 | |
|     private static int RunVersion()
 | |
|     {
 | |
|         var assembly = Assembly.GetExecutingAssembly();
 | |
|         var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
 | |
|                       ?? assembly.GetName().Version?.ToString()
 | |
|                       ?? "unknown";
 | |
|         Console.WriteLine(version);
 | |
|         return 0;
 | |
|     }
 | |
| 
 | |
|     private static int PrintHelp()
 | |
|     {
 | |
|         Console.WriteLine("StellaOps BuildX SBOM generator");
 | |
|         Console.WriteLine("Usage:");
 | |
|         Console.WriteLine("  stellaops-buildx [handshake|manifest|descriptor|version]");
 | |
|         Console.WriteLine();
 | |
|         Console.WriteLine("Commands:");
 | |
|         Console.WriteLine("  handshake   Probe the local CAS and ensure manifests are discoverable.");
 | |
|         Console.WriteLine("  manifest    Print the resolved manifest JSON.");
 | |
|         Console.WriteLine("  descriptor  Emit OCI descriptor + provenance placeholder for the provided SBOM.");
 | |
|         Console.WriteLine("  version     Print the plug-in version.");
 | |
|         Console.WriteLine();
 | |
|         Console.WriteLine("Options:");
 | |
|         Console.WriteLine("  --manifest <path>          Override the manifest directory.");
 | |
|         Console.WriteLine("  --cas <path>               Override the CAS root directory.");
 | |
|         Console.WriteLine("  --image <digest>           (descriptor) Image digest the SBOM belongs to.");
 | |
|         Console.WriteLine("  --sbom <path>              (descriptor) Path to the SBOM file to describe.");
 | |
|         Console.WriteLine("  --attestor <url>           (descriptor) Optional Attestor endpoint for provenance placeholders.");
 | |
|         Console.WriteLine("  --attestor-token <token>   Bearer token for Attestor requests (or STELLAOPS_ATTESTOR_TOKEN).");
 | |
|         Console.WriteLine("  --attestor-insecure        Skip TLS verification for Attestor requests (dev/test only).");
 | |
|         return 0;
 | |
|     }
 | |
| 
 | |
|     private static int UnknownCommand(string command)
 | |
|     {
 | |
|         Console.Error.WriteLine($"Unknown command '{command}'. Use 'help' for usage.");
 | |
|         return 1;
 | |
|     }
 | |
| 
 | |
|     private static string ResolveManifestDirectory(string[] args)
 | |
|     {
 | |
|         var explicitPath = GetOption(args, "--manifest")
 | |
|             ?? Environment.GetEnvironmentVariable("STELLAOPS_BUILDX_MANIFEST_DIR");
 | |
| 
 | |
|         if (!string.IsNullOrWhiteSpace(explicitPath))
 | |
|         {
 | |
|             return Path.GetFullPath(explicitPath);
 | |
|         }
 | |
| 
 | |
|         var defaultDirectory = Path.Combine(AppContext.BaseDirectory, "plugins", "scanner", "buildx");
 | |
|         if (Directory.Exists(defaultDirectory))
 | |
|         {
 | |
|             return defaultDirectory;
 | |
|         }
 | |
| 
 | |
|         return AppContext.BaseDirectory;
 | |
|     }
 | |
| 
 | |
|     private static string ResolveCasRoot(string[] args, BuildxPluginManifest manifest)
 | |
|     {
 | |
|         var overrideValue = GetOption(args, "--cas")
 | |
|             ?? Environment.GetEnvironmentVariable("STELLAOPS_SCANNER_CAS_ROOT");
 | |
| 
 | |
|         if (!string.IsNullOrWhiteSpace(overrideValue))
 | |
|         {
 | |
|             return Path.GetFullPath(overrideValue);
 | |
|         }
 | |
| 
 | |
|         var manifestDefault = manifest.Cas.DefaultRoot;
 | |
|         if (!string.IsNullOrWhiteSpace(manifestDefault))
 | |
|         {
 | |
|             if (Path.IsPathRooted(manifestDefault))
 | |
|             {
 | |
|                 return Path.GetFullPath(manifestDefault);
 | |
|             }
 | |
| 
 | |
|             var baseDirectory = manifest.SourceDirectory ?? AppContext.BaseDirectory;
 | |
|             return Path.GetFullPath(Path.Combine(baseDirectory, manifestDefault));
 | |
|         }
 | |
| 
 | |
|         return Path.Combine(AppContext.BaseDirectory, "cas");
 | |
|     }
 | |
| 
 | |
|     private static async Task<int> RunDescriptorAsync(string[] args, CancellationToken cancellationToken)
 | |
|     {
 | |
|         var imageDigest = RequireOption(args, "--image");
 | |
|         var sbomPath = RequireOption(args, "--sbom");
 | |
| 
 | |
|         var sbomMediaType = GetOption(args, "--media-type") ?? "application/vnd.cyclonedx+json";
 | |
|         var sbomFormat = GetOption(args, "--sbom-format") ?? "cyclonedx-json";
 | |
|         var sbomKind = GetOption(args, "--sbom-kind") ?? "inventory";
 | |
|         var artifactType = GetOption(args, "--artifact-type") ?? "application/vnd.stellaops.sbom.layer+json";
 | |
|         var subjectMediaType = GetOption(args, "--subject-media-type") ?? "application/vnd.oci.image.manifest.v1+json";
 | |
|         var predicateType = GetOption(args, "--predicate-type") ?? "https://slsa.dev/provenance/v1";
 | |
|         var licenseId = GetOption(args, "--license-id") ?? Environment.GetEnvironmentVariable("STELLAOPS_LICENSE_ID");
 | |
|         var repository = GetOption(args, "--repository");
 | |
|         var buildRef = GetOption(args, "--build-ref");
 | |
|         var sbomName = GetOption(args, "--sbom-name") ?? Path.GetFileName(sbomPath);
 | |
| 
 | |
|         var attestorUriText = GetOption(args, "--attestor") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_URL");
 | |
|         var attestorToken = GetOption(args, "--attestor-token") ?? Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_TOKEN");
 | |
|         var attestorInsecure = GetFlag(args, "--attestor-insecure")
 | |
|             || string.Equals(Environment.GetEnvironmentVariable("STELLAOPS_ATTESTOR_INSECURE"), "true", StringComparison.OrdinalIgnoreCase);
 | |
|         Uri? attestorUri = null;
 | |
|         if (!string.IsNullOrWhiteSpace(attestorUriText))
 | |
|         {
 | |
|             attestorUri = new Uri(attestorUriText, UriKind.Absolute);
 | |
|         }
 | |
| 
 | |
|         var assembly = Assembly.GetExecutingAssembly();
 | |
|         var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
 | |
|                       ?? assembly.GetName().Version?.ToString()
 | |
|                       ?? "0.0.0";
 | |
| 
 | |
|         var request = new DescriptorRequest
 | |
|         {
 | |
|             ImageDigest = imageDigest,
 | |
|             SbomPath = sbomPath,
 | |
|             SbomMediaType = sbomMediaType,
 | |
|             SbomFormat = sbomFormat,
 | |
|             SbomKind = sbomKind,
 | |
|             SbomArtifactType = artifactType,
 | |
|             SubjectMediaType = subjectMediaType,
 | |
|             PredicateType = predicateType,
 | |
|             GeneratorVersion = version,
 | |
|             GeneratorName = assembly.GetName().Name,
 | |
|             LicenseId = licenseId,
 | |
|             SbomName = sbomName,
 | |
|             Repository = repository,
 | |
|             BuildRef = buildRef,
 | |
|             AttestorUri = attestorUri?.ToString()
 | |
|         }.Validate();
 | |
| 
 | |
|         var generator = new DescriptorGenerator(TimeProvider.System);
 | |
|         var document = await generator.CreateAsync(request, cancellationToken).ConfigureAwait(false);
 | |
| 
 | |
|         if (attestorUri is not null)
 | |
|         {
 | |
|             using var httpClient = CreateAttestorHttpClient(attestorUri, attestorToken, attestorInsecure);
 | |
|             var attestorClient = new AttestorClient(httpClient);
 | |
|             await attestorClient.SendPlaceholderAsync(attestorUri, document, cancellationToken).ConfigureAwait(false);
 | |
|         }
 | |
| 
 | |
|         var json = JsonSerializer.Serialize(document, DescriptorJsonOptions);
 | |
|         Console.WriteLine(json);
 | |
|         return 0;
 | |
|     }
 | |
| 
 | |
|     private static string? GetOption(string[] args, string optionName)
 | |
|     {
 | |
|         for (var i = 0; i < args.Length; i++)
 | |
|         {
 | |
|             var argument = args[i];
 | |
|             if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase))
 | |
|             {
 | |
|                 if (i + 1 >= args.Length)
 | |
|                 {
 | |
|                     throw new BuildxPluginException($"Option '{optionName}' requires a value.");
 | |
|                 }
 | |
| 
 | |
|                 return args[i + 1];
 | |
|             }
 | |
| 
 | |
|             if (argument.StartsWith(optionName + "=", StringComparison.OrdinalIgnoreCase))
 | |
|             {
 | |
|                 return argument[(optionName.Length + 1)..];
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return null;
 | |
|     }
 | |
| 
 | |
|     private static bool GetFlag(string[] args, string optionName)
 | |
|     {
 | |
|         foreach (var argument in args)
 | |
|         {
 | |
|             if (string.Equals(argument, optionName, StringComparison.OrdinalIgnoreCase))
 | |
|             {
 | |
|                 return true;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     private static string RequireOption(string[] args, string optionName)
 | |
|     {
 | |
|         var value = GetOption(args, optionName);
 | |
|         if (string.IsNullOrWhiteSpace(value))
 | |
|         {
 | |
|             throw new BuildxPluginException($"Option '{optionName}' is required.");
 | |
|         }
 | |
| 
 | |
|         return value;
 | |
|     }
 | |
| 
 | |
|     private static HttpClient CreateAttestorHttpClient(Uri attestorUri, string? bearerToken, bool insecure)
 | |
|     {
 | |
|         var handler = new HttpClientHandler
 | |
|         {
 | |
|             CheckCertificateRevocationList = true,
 | |
|         };
 | |
| 
 | |
|         if (insecure && string.Equals(attestorUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
 | |
|         {
 | |
| #pragma warning disable S4830 // Explicitly gated by --attestor-insecure flag/env for dev/test usage.
 | |
|             handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
 | |
| #pragma warning restore S4830
 | |
|         }
 | |
| 
 | |
|         var client = new HttpClient(handler, disposeHandler: true)
 | |
|         {
 | |
|             Timeout = TimeSpan.FromSeconds(30)
 | |
|         };
 | |
|         client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
 | |
| 
 | |
|         if (!string.IsNullOrWhiteSpace(bearerToken))
 | |
|         {
 | |
|             client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
 | |
|         }
 | |
| 
 | |
|         return client;
 | |
|     }
 | |
| }
 |