finish off sprint advisories and sprints
This commit is contained in:
@@ -64,6 +64,9 @@ public static class SbomCommandGroup
|
||||
// Sprint: SPRINT_20260119_022_Scanner_dependency_reachability (TASK-022-009)
|
||||
sbom.Add(BuildReachabilityAnalysisCommand(verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260123_041_Scanner_sbom_oci_deterministic_publication (041-05)
|
||||
sbom.Add(BuildPublishCommand(verboseOption, cancellationToken));
|
||||
|
||||
return sbom;
|
||||
}
|
||||
|
||||
@@ -3855,6 +3858,244 @@ public static class SbomCommandGroup
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Publish Command (041-05)
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'sbom publish' command for OCI SBOM publication.
|
||||
/// Sprint: SPRINT_20260123_041_Scanner_sbom_oci_deterministic_publication (041-05)
|
||||
/// </summary>
|
||||
private static Command BuildPublishCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var imageOption = new Option<string>("--image", "-i")
|
||||
{
|
||||
Description = "Target image reference (registry/repo@sha256:... or registry/repo:tag)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var fileOption = new Option<string?>("--file", "-f")
|
||||
{
|
||||
Description = "Path to SBOM file. If omitted, fetches from Scanner CAS for this image."
|
||||
};
|
||||
|
||||
var formatOption = new Option<SbomPublishFormat?>("--format")
|
||||
{
|
||||
Description = "SBOM format (cdx or spdx). Auto-detected from file content if omitted."
|
||||
};
|
||||
|
||||
var overwriteOption = new Option<bool>("--overwrite")
|
||||
{
|
||||
Description = "Supersede the current active SBOM referrer for this image."
|
||||
};
|
||||
overwriteOption.SetDefaultValue(false);
|
||||
|
||||
var registryOption = new Option<string?>("--registry-url")
|
||||
{
|
||||
Description = "Override registry URL (defaults to parsed from --image)."
|
||||
};
|
||||
|
||||
var cmd = new Command("publish", "Publish a canonical SBOM as an OCI referrer artifact to a container image")
|
||||
{
|
||||
imageOption,
|
||||
fileOption,
|
||||
formatOption,
|
||||
overwriteOption,
|
||||
registryOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
cmd.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var image = parseResult.GetValue(imageOption)!;
|
||||
var filePath = parseResult.GetValue(fileOption);
|
||||
var format = parseResult.GetValue(formatOption);
|
||||
var overwrite = parseResult.GetValue(overwriteOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Load SBOM content
|
||||
string sbomContent;
|
||||
if (filePath is not null)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: SBOM file not found: {filePath}");
|
||||
return;
|
||||
}
|
||||
sbomContent = await File.ReadAllTextAsync(filePath, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("Error: --file is required (CAS fetch not yet implemented).");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Auto-detect format if not specified
|
||||
var detectedFormat = format ?? DetectSbomPublishFormat(sbomContent);
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Format: {detectedFormat}");
|
||||
}
|
||||
|
||||
// 3. Normalize (strip volatile fields, canonicalize)
|
||||
var normalizer = new StellaOps.AirGap.Importer.Reconciliation.Parsers.SbomNormalizer(
|
||||
new StellaOps.AirGap.Importer.Reconciliation.NormalizationOptions
|
||||
{
|
||||
SortArrays = true,
|
||||
LowercaseUris = true,
|
||||
StripTimestamps = true,
|
||||
StripVolatileFields = true,
|
||||
NormalizeKeys = false // Preserve original key casing for SBOM specs
|
||||
});
|
||||
|
||||
var sbomFormat = detectedFormat == SbomPublishFormat.Cdx
|
||||
? StellaOps.AirGap.Importer.Reconciliation.SbomFormat.CycloneDx
|
||||
: StellaOps.AirGap.Importer.Reconciliation.SbomFormat.Spdx;
|
||||
|
||||
var canonicalJson = normalizer.Normalize(sbomContent, sbomFormat);
|
||||
var canonicalBytes = Encoding.UTF8.GetBytes(canonicalJson);
|
||||
|
||||
// 4. Compute digest for display
|
||||
var hash = SHA256.HashData(canonicalBytes);
|
||||
var blobDigest = $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Canonical SBOM size: {canonicalBytes.Length} bytes");
|
||||
Console.WriteLine($"Canonical digest: {blobDigest}");
|
||||
}
|
||||
|
||||
// 5. Parse image reference
|
||||
var imageRef = ParseImageReference(image);
|
||||
if (imageRef is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Could not parse image reference: {image}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Create publisher and publish
|
||||
var registryClient = CreateRegistryClient(imageRef.Registry);
|
||||
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<StellaOps.Attestor.Oci.Services.SbomOciPublisher>.Instance;
|
||||
var publisher = new StellaOps.Attestor.Oci.Services.SbomOciPublisher(registryClient, logger);
|
||||
|
||||
var artifactFormat = detectedFormat == SbomPublishFormat.Cdx
|
||||
? StellaOps.Attestor.Oci.Services.SbomArtifactFormat.CycloneDx
|
||||
: StellaOps.Attestor.Oci.Services.SbomArtifactFormat.Spdx;
|
||||
|
||||
StellaOps.Attestor.Oci.Services.SbomPublishResult result;
|
||||
|
||||
if (overwrite)
|
||||
{
|
||||
// Resolve existing active SBOM to get its digest for supersede
|
||||
var active = await publisher.ResolveActiveAsync(imageRef, artifactFormat, ct);
|
||||
if (active is null)
|
||||
{
|
||||
Console.WriteLine("No existing SBOM referrer found; publishing as version 1.");
|
||||
result = await publisher.PublishAsync(new StellaOps.Attestor.Oci.Services.SbomPublishRequest
|
||||
{
|
||||
CanonicalBytes = canonicalBytes,
|
||||
ImageRef = imageRef,
|
||||
Format = artifactFormat
|
||||
}, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Superseding existing SBOM v{active.Version} ({active.ManifestDigest[..19]}...)");
|
||||
result = await publisher.SupersedeAsync(new StellaOps.Attestor.Oci.Services.SbomSupersedeRequest
|
||||
{
|
||||
CanonicalBytes = canonicalBytes,
|
||||
ImageRef = imageRef,
|
||||
Format = artifactFormat,
|
||||
PriorManifestDigest = active.ManifestDigest
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await publisher.PublishAsync(new StellaOps.Attestor.Oci.Services.SbomPublishRequest
|
||||
{
|
||||
CanonicalBytes = canonicalBytes,
|
||||
ImageRef = imageRef,
|
||||
Format = artifactFormat
|
||||
}, ct);
|
||||
}
|
||||
|
||||
// 7. Output result
|
||||
Console.WriteLine($"Published SBOM as OCI referrer:");
|
||||
Console.WriteLine($" Blob digest: {result.BlobDigest}");
|
||||
Console.WriteLine($" Manifest digest: {result.ManifestDigest}");
|
||||
Console.WriteLine($" Version: {result.Version}");
|
||||
Console.WriteLine($" Artifact type: {result.ArtifactType}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine(ex.StackTrace);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static SbomPublishFormat DetectSbomPublishFormat(string content)
|
||||
{
|
||||
if (content.Contains("\"bomFormat\"", StringComparison.Ordinal) ||
|
||||
content.Contains("\"specVersion\"", StringComparison.Ordinal))
|
||||
{
|
||||
return SbomPublishFormat.Cdx;
|
||||
}
|
||||
return SbomPublishFormat.Spdx;
|
||||
}
|
||||
|
||||
private static StellaOps.Attestor.Oci.Services.OciReference? ParseImageReference(string image)
|
||||
{
|
||||
// Parse formats: registry/repo@sha256:... or registry/repo:tag
|
||||
string registry;
|
||||
string repository;
|
||||
string digest;
|
||||
|
||||
var atIdx = image.IndexOf('@');
|
||||
if (atIdx > 0)
|
||||
{
|
||||
var namePart = image[..atIdx];
|
||||
digest = image[(atIdx + 1)..];
|
||||
|
||||
var firstSlash = namePart.IndexOf('/');
|
||||
if (firstSlash <= 0) return null;
|
||||
|
||||
registry = namePart[..firstSlash];
|
||||
repository = namePart[(firstSlash + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Tag-based reference not directly supported for publish (needs digest)
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!digest.StartsWith("sha256:", StringComparison.Ordinal)) return null;
|
||||
|
||||
return new StellaOps.Attestor.Oci.Services.OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repository,
|
||||
Digest = digest
|
||||
};
|
||||
}
|
||||
|
||||
private static StellaOps.Attestor.Oci.Services.IOciRegistryClient CreateRegistryClient(string _registry)
|
||||
{
|
||||
// In production, this would use HttpOciRegistryClient with auth.
|
||||
// For now, use the CLI's configured registry client.
|
||||
return new StellaOps.Cli.Services.OciAttestationRegistryClient(
|
||||
new HttpClient(),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<StellaOps.Cli.Services.OciAttestationRegistryClient>.Instance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3908,3 +4149,15 @@ public enum NtiaComplianceOutputFormat
|
||||
Summary,
|
||||
Json
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format for publish command.
|
||||
/// Sprint: SPRINT_20260123_041_Scanner_sbom_oci_deterministic_publication (041-05)
|
||||
/// </summary>
|
||||
public enum SbomPublishFormat
|
||||
{
|
||||
/// <summary>CycloneDX format.</summary>
|
||||
Cdx,
|
||||
/// <summary>SPDX format.</summary>
|
||||
Spdx
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user