save checkpoint
This commit is contained in:
@@ -31950,10 +31950,18 @@ stella policy test {policyName}.stella
|
||||
AnsiConsole.MarkupLine($" Entries: {result.Entries}");
|
||||
AnsiConsole.MarkupLine($" Created: {result.CreatedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown"}");
|
||||
AnsiConsole.MarkupLine($" Portable: {(result.Portable ? "yes" : "no")}");
|
||||
if (!string.IsNullOrWhiteSpace(result.Profile))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" Profile: {Markup.Escape(result.Profile)}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Bundle verification failed:[/] {Markup.Escape(result.ErrorMessage ?? "Unknown error")}");
|
||||
if (!string.IsNullOrWhiteSpace(result.ErrorCode))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [grey]Code: {Markup.Escape(result.ErrorCode)}[/]");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(result.ErrorDetail))
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(result.ErrorDetail)}[/]");
|
||||
|
||||
@@ -26,6 +26,7 @@ internal static class VerifyCommandGroup
|
||||
verify.Add(BuildVerifyOfflineCommand(services, verboseOption, cancellationToken));
|
||||
verify.Add(BuildVerifyImageCommand(services, verboseOption, cancellationToken));
|
||||
verify.Add(BuildVerifyBundleCommand(services, verboseOption, cancellationToken));
|
||||
verify.Add(BuildVerifyReleaseCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260118_012_CLI_verification_consolidation (CLI-V-002)
|
||||
// stella verify attestation - moved from stella attest verify
|
||||
@@ -225,6 +226,101 @@ internal static class VerifyCommandGroup
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildVerifyReleaseCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundleArg = new Argument<string>("bundle")
|
||||
{
|
||||
Description = "Path to the promotion DSSE bundle file."
|
||||
};
|
||||
|
||||
var sbomOption = new Option<string?>("--sbom")
|
||||
{
|
||||
Description = "Path to SBOM file for material verification."
|
||||
};
|
||||
|
||||
var vexOption = new Option<string?>("--vex")
|
||||
{
|
||||
Description = "Path to VEX file for material verification."
|
||||
};
|
||||
|
||||
var trustRootOption = new Option<string?>("--trust-root")
|
||||
{
|
||||
Description = "Path to trusted certificate chain."
|
||||
};
|
||||
|
||||
var checkpointOption = new Option<string?>("--checkpoint")
|
||||
{
|
||||
Description = "Path to Rekor checkpoint for verification."
|
||||
};
|
||||
|
||||
var skipSignatureOption = new Option<bool>("--skip-signature")
|
||||
{
|
||||
Description = "Skip signature verification."
|
||||
};
|
||||
|
||||
var skipRekorOption = new Option<bool>("--skip-rekor")
|
||||
{
|
||||
Description = "Skip Rekor inclusion proof verification."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output as JSON for CI integration."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant", "-t")
|
||||
{
|
||||
Description = "Tenant identifier."
|
||||
};
|
||||
|
||||
var command = new Command("release", "Verify a release promotion bundle chain (source, build, signature, and transparency evidence).")
|
||||
{
|
||||
bundleArg,
|
||||
sbomOption,
|
||||
vexOption,
|
||||
trustRootOption,
|
||||
checkpointOption,
|
||||
skipSignatureOption,
|
||||
skipRekorOption,
|
||||
jsonOption,
|
||||
tenantOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction((parseResult, _) =>
|
||||
{
|
||||
var bundlePath = parseResult.GetValue(bundleArg) ?? string.Empty;
|
||||
var sbom = parseResult.GetValue(sbomOption);
|
||||
var vex = parseResult.GetValue(vexOption);
|
||||
var trustRoot = parseResult.GetValue(trustRootOption);
|
||||
var checkpoint = parseResult.GetValue(checkpointOption);
|
||||
var skipSignature = parseResult.GetValue(skipSignatureOption);
|
||||
var skipRekor = parseResult.GetValue(skipRekorOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandlePromotionVerifyAsync(
|
||||
services,
|
||||
bundlePath,
|
||||
sbom,
|
||||
vex,
|
||||
trustRoot,
|
||||
checkpoint,
|
||||
skipSignature,
|
||||
skipRekor,
|
||||
emitJson,
|
||||
tenant,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
#region Sprint: SPRINT_20260118_012_CLI_verification_consolidation
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Buffers;
|
||||
using System.Formats.Tar;
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
@@ -14,6 +15,25 @@ namespace StellaOps.Cli.Services;
|
||||
/// </summary>
|
||||
internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
{
|
||||
private const string PortableProfileVersion = "1.0";
|
||||
private const string PortableCanonicalBomPath = "canonical_bom.json";
|
||||
private const string PortableDsseEnvelopePath = "dsse_envelope.json";
|
||||
private const string PortableManifestSigPath = "manifest.sig";
|
||||
private const string PortableComponentsParquetPath = "components.parquet";
|
||||
|
||||
private const string ErrManifestMissing = "ERR_MANIFEST_MISSING";
|
||||
private const string ErrManifestSchema = "ERR_MANIFEST_SCHEMA";
|
||||
private const string ErrManifestSignatureMissing = "ERR_MANIFEST_SIGNATURE_MISSING";
|
||||
private const string ErrManifestSignatureInvalid = "ERR_MANIFEST_SIGNATURE_INVALID";
|
||||
private const string ErrFileMissing = "ERR_FILE_MISSING";
|
||||
private const string ErrFileSizeMismatch = "ERR_FILE_SIZE_MISMATCH";
|
||||
private const string ErrFileDigestMismatch = "ERR_FILE_DIGEST_MISMATCH";
|
||||
private const string ErrDssePayloadDigest = "ERR_DSSE_PAYLOAD_DIGEST";
|
||||
private const string ErrRekorTileMissing = "ERR_REKOR_TILE_MISSING";
|
||||
private const string ErrRekorReferenceUncovered = "ERR_REKOR_REFERENCE_UNCOVERED";
|
||||
private const string ErrRekorRootMismatch = "ERR_REKOR_ROOT_MISMATCH";
|
||||
private const string ErrParquetFingerprint = "ERR_PARQUET_FINGERPRINT_MISMATCH";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -80,17 +100,22 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
ex.Message);
|
||||
}
|
||||
|
||||
// Step 4: Verify DSSE signature
|
||||
if (IsPortableManifest(contents.ManifestJson))
|
||||
{
|
||||
return VerifyPortableBundle(contents, offline);
|
||||
}
|
||||
|
||||
// Legacy verification path
|
||||
var signatureValid = VerifyDsseSignature(contents, offline, out var signatureError);
|
||||
if (!signatureValid && !string.IsNullOrEmpty(signatureError))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE signature verification failed",
|
||||
signatureError);
|
||||
signatureError,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
// Step 5: Verify TSA (only if not offline)
|
||||
if (!offline && contents.Signature is not null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(contents.Signature.TimestampAuthority) ||
|
||||
@@ -103,7 +128,6 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Build success result
|
||||
return new DevPortalBundleVerificationResult
|
||||
{
|
||||
Status = "verified",
|
||||
@@ -114,6 +138,7 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
Entries = contents.Manifest?.Entries?.Count ?? 0,
|
||||
CreatedAt = contents.Manifest?.CreatedAt ?? contents.BundleMetadata?.CreatedAt,
|
||||
Portable = contents.BundleMetadata?.PortableGeneratedAt is not null,
|
||||
Profile = "legacy",
|
||||
ExitCode = DevPortalVerifyExitCode.Success
|
||||
};
|
||||
}
|
||||
@@ -160,24 +185,25 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
await entry.DataStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
|
||||
var json = System.Text.Encoding.UTF8.GetString(memoryStream.ToArray());
|
||||
var bytes = memoryStream.ToArray();
|
||||
contents.Files[entry.Name] = bytes;
|
||||
|
||||
switch (entry.Name)
|
||||
{
|
||||
case "manifest.json":
|
||||
contents.ManifestJson = json;
|
||||
contents.Manifest = JsonSerializer.Deserialize<BundleManifest>(json, SerializerOptions);
|
||||
contents.ManifestJson = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
contents.Manifest = JsonSerializer.Deserialize<BundleManifest>(bytes, SerializerOptions);
|
||||
break;
|
||||
case "signature.json":
|
||||
contents.SignatureJson = json;
|
||||
contents.Signature = JsonSerializer.Deserialize<BundleSignature>(json, SerializerOptions);
|
||||
contents.SignatureJson = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
contents.Signature = JsonSerializer.Deserialize<BundleSignature>(bytes, SerializerOptions);
|
||||
break;
|
||||
case "bundle.json":
|
||||
contents.BundleMetadataJson = json;
|
||||
contents.BundleMetadata = JsonSerializer.Deserialize<BundleMetadataDocument>(json, SerializerOptions);
|
||||
contents.BundleMetadataJson = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
contents.BundleMetadata = JsonSerializer.Deserialize<BundleMetadataDocument>(bytes, SerializerOptions);
|
||||
break;
|
||||
case "checksums.txt":
|
||||
contents.ChecksumsText = json;
|
||||
contents.ChecksumsText = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -245,8 +271,431 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
return true;
|
||||
}
|
||||
|
||||
private DevPortalBundleVerificationResult VerifyPortableBundle(BundleContents contents, bool offline)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contents.ManifestJson))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Portable manifest is missing",
|
||||
null,
|
||||
ErrManifestMissing);
|
||||
}
|
||||
|
||||
PortableManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<PortableManifest>(contents.ManifestJson!, SerializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Portable manifest is invalid JSON",
|
||||
ex.Message,
|
||||
ErrManifestSchema);
|
||||
}
|
||||
|
||||
if (!ValidatePortableManifestSchema(manifest, out var schemaError))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Portable manifest schema validation failed",
|
||||
schemaError,
|
||||
ErrManifestSchema);
|
||||
}
|
||||
|
||||
if (!contents.Files.TryGetValue(PortableManifestSigPath, out var manifestSigBytes))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest signature is missing",
|
||||
PortableManifestSigPath,
|
||||
ErrManifestSignatureMissing);
|
||||
}
|
||||
|
||||
ManifestSignatureEnvelope? manifestSig;
|
||||
try
|
||||
{
|
||||
manifestSig = JsonSerializer.Deserialize<ManifestSignatureEnvelope>(manifestSigBytes, SerializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest signature is invalid JSON",
|
||||
ex.Message,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
if (manifestSig is null || string.IsNullOrWhiteSpace(manifestSig.Payload) || manifestSig.Signatures.Count == 0)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest signature is incomplete",
|
||||
null,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
byte[] detachedPayload;
|
||||
try
|
||||
{
|
||||
detachedPayload = Convert.FromBase64String(manifestSig.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest payload encoding is invalid",
|
||||
ex.Message,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
var canonicalManifest = CanonicalizeJson(contents.ManifestJson!);
|
||||
if (!detachedPayload.AsSpan().SequenceEqual(canonicalManifest))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Detached manifest payload does not match canonical manifest",
|
||||
null,
|
||||
ErrManifestSignatureInvalid);
|
||||
}
|
||||
|
||||
foreach (var file in manifest!.Files.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!contents.Files.TryGetValue(file.Key, out var bytes))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Manifest references missing file",
|
||||
file.Key,
|
||||
ErrFileMissing);
|
||||
}
|
||||
|
||||
if (bytes.LongLength != file.Value.Size)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.ChecksumMismatch,
|
||||
"File size does not match manifest",
|
||||
file.Key,
|
||||
ErrFileSizeMismatch);
|
||||
}
|
||||
|
||||
var digest = ComputeSha256Hex(bytes);
|
||||
if (!string.Equals(digest, file.Value.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.ChecksumMismatch,
|
||||
"File digest does not match manifest",
|
||||
file.Key,
|
||||
ErrFileDigestMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
if (!contents.Files.TryGetValue(PortableDsseEnvelopePath, out var dsseEnvelopeBytes))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE envelope file is missing",
|
||||
PortableDsseEnvelopePath,
|
||||
ErrFileMissing);
|
||||
}
|
||||
|
||||
DsseEnvelope? dsseEnvelope;
|
||||
try
|
||||
{
|
||||
dsseEnvelope = JsonSerializer.Deserialize<DsseEnvelope>(dsseEnvelopeBytes, SerializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE envelope JSON is invalid",
|
||||
ex.Message,
|
||||
ErrDssePayloadDigest);
|
||||
}
|
||||
|
||||
if (dsseEnvelope is null || string.IsNullOrWhiteSpace(dsseEnvelope.Payload))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE envelope payload is missing",
|
||||
null,
|
||||
ErrDssePayloadDigest);
|
||||
}
|
||||
|
||||
byte[] dssePayloadBytes;
|
||||
try
|
||||
{
|
||||
dssePayloadBytes = Convert.FromBase64String(dsseEnvelope.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE payload encoding is invalid",
|
||||
ex.Message,
|
||||
ErrDssePayloadDigest);
|
||||
}
|
||||
|
||||
var payloadDigest = ComputeSha256Hex(dssePayloadBytes);
|
||||
if (!string.Equals(payloadDigest, manifest.Digests.DssePayloadDigest.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"DSSE payload digest does not match manifest",
|
||||
null,
|
||||
ErrDssePayloadDigest);
|
||||
}
|
||||
|
||||
if (!contents.Files.TryGetValue(PortableCanonicalBomPath, out var canonicalBomBytes))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Canonical BOM file is missing",
|
||||
PortableCanonicalBomPath,
|
||||
ErrFileMissing);
|
||||
}
|
||||
|
||||
var canonicalBomDigest = ComputeSha256Hex(canonicalBomBytes);
|
||||
if (!string.Equals(canonicalBomDigest, manifest.Digests.CanonicalBomSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.ChecksumMismatch,
|
||||
"Canonical BOM digest does not match manifest",
|
||||
null,
|
||||
ErrFileDigestMismatch);
|
||||
}
|
||||
|
||||
if (!IsHex64(manifest.Rekor.RootHash))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Rekor root hash is invalid",
|
||||
manifest.Rekor.RootHash,
|
||||
ErrRekorRootMismatch);
|
||||
}
|
||||
|
||||
foreach (var tileRef in manifest.Rekor.TileRefs)
|
||||
{
|
||||
if (!manifest.Files.ContainsKey(tileRef.Path) || !contents.Files.ContainsKey(tileRef.Path))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Rekor tile reference is missing from bundle",
|
||||
tileRef.Path,
|
||||
ErrRekorTileMissing);
|
||||
}
|
||||
|
||||
var requiredCovers = new[]
|
||||
{
|
||||
$"SHA256:{manifest.Artifact.Digest.Sha256.ToUpperInvariant()}",
|
||||
$"SHA256:{manifest.Digests.CanonicalBomSha256.ToUpperInvariant()}"
|
||||
};
|
||||
|
||||
foreach (var requiredCover in requiredCovers)
|
||||
{
|
||||
if (!tileRef.Covers.Contains(requiredCover, StringComparer.Ordinal))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Rekor tile cover set is incomplete",
|
||||
requiredCover,
|
||||
ErrRekorReferenceUncovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.Files.TryGetValue(PortableComponentsParquetPath, out var parquetFile))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parquetFile.SchemaFingerprint))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Parquet schema fingerprint is missing",
|
||||
PortableComponentsParquetPath,
|
||||
ErrParquetFingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
if (!offline)
|
||||
{
|
||||
if (manifest.Verifiers.RekorPub is null || string.IsNullOrWhiteSpace(manifest.Verifiers.RekorPub.KeyMaterial))
|
||||
{
|
||||
return DevPortalBundleVerificationResult.Failed(
|
||||
DevPortalVerifyExitCode.SignatureFailure,
|
||||
"Rekor verifier key material is missing for online verification",
|
||||
null,
|
||||
ErrRekorRootMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeOffset? createdAt = null;
|
||||
if (DateTimeOffset.TryParse(manifest.CreatedUtc, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedCreatedAt))
|
||||
{
|
||||
createdAt = parsedCreatedAt;
|
||||
}
|
||||
|
||||
return new DevPortalBundleVerificationResult
|
||||
{
|
||||
Status = "verified",
|
||||
BundleId = manifest.Compatibility?.LegacyBundleId ?? manifest.Artifact.Name,
|
||||
RootHash = $"sha256:{manifest.Rekor.RootHash}",
|
||||
Entries = manifest.Files.Count,
|
||||
CreatedAt = createdAt,
|
||||
Portable = true,
|
||||
Profile = "portable-v1",
|
||||
ExitCode = DevPortalVerifyExitCode.Success
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsPortableManifest(string? manifestJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestJson))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(manifestJson);
|
||||
return doc.RootElement.TryGetProperty("spec_version", out var specVersion)
|
||||
&& string.Equals(specVersion.GetString(), PortableProfileVersion, StringComparison.Ordinal);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ValidatePortableManifestSchema(PortableManifest? manifest, out string error)
|
||||
{
|
||||
if (manifest is null)
|
||||
{
|
||||
error = "Manifest is null.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(manifest.SpecVersion, PortableProfileVersion, StringComparison.Ordinal))
|
||||
{
|
||||
error = "spec_version must be 1.0.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Artifact is null || manifest.Files is null || manifest.Digests is null || manifest.Rekor is null || manifest.Verifiers is null)
|
||||
{
|
||||
error = "Required top-level fields are missing.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Files.Count == 0
|
||||
|| !manifest.Files.ContainsKey(PortableCanonicalBomPath)
|
||||
|| !manifest.Files.ContainsKey(PortableDsseEnvelopePath))
|
||||
{
|
||||
error = "Required portable files are missing.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsHex64(manifest.Artifact.Digest.Sha256)
|
||||
|| !IsHex64(manifest.Digests.CanonicalBomSha256)
|
||||
|| !IsHex64(manifest.Digests.DssePayloadDigest.Sha256))
|
||||
{
|
||||
error = "Digest format is invalid.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Rekor.TileRefs.Count == 0)
|
||||
{
|
||||
error = "Rekor tile_refs are required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[] CanonicalizeJson(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false });
|
||||
WriteCanonicalElement(writer, document.RootElement);
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalElement(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonicalElement(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonicalElement(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(element.GetBoolean());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported JSON token kind '{element.ValueKind}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] bytes)
|
||||
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
private static bool IsHex64(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class BundleContents
|
||||
{
|
||||
public Dictionary<string, byte[]> Files { get; } = new(StringComparer.Ordinal);
|
||||
public string? ManifestJson { get; set; }
|
||||
public BundleManifest? Manifest { get; set; }
|
||||
public string? SignatureJson { get; set; }
|
||||
@@ -256,6 +705,87 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
|
||||
public string? ChecksumsText { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableManifest
|
||||
{
|
||||
public string SpecVersion { get; set; } = string.Empty;
|
||||
public string CreatedUtc { get; set; } = string.Empty;
|
||||
public PortableArtifact Artifact { get; set; } = new();
|
||||
public Dictionary<string, PortableManifestFile> Files { get; set; } = new(StringComparer.Ordinal);
|
||||
public PortableDigests Digests { get; set; } = new();
|
||||
public PortableRekor Rekor { get; set; } = new();
|
||||
public PortableVerifiers Verifiers { get; set; } = new();
|
||||
public PortableCompatibility? Compatibility { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableArtifact
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Version { get; set; } = string.Empty;
|
||||
public PortableShaDigest Digest { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class PortableManifestFile
|
||||
{
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
public long Size { get; set; }
|
||||
public string? SchemaFingerprint { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableDigests
|
||||
{
|
||||
public string CanonicalBomSha256 { get; set; } = string.Empty;
|
||||
public PortableShaDigest DssePayloadDigest { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class PortableShaDigest
|
||||
{
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class PortableRekor
|
||||
{
|
||||
public string RootHash { get; set; } = string.Empty;
|
||||
public List<PortableTileReference> TileRefs { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class PortableTileReference
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public List<string> Covers { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class PortableVerifiers
|
||||
{
|
||||
public PortableRekorVerifier? RekorPub { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableRekorVerifier
|
||||
{
|
||||
public string? KeyMaterial { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PortableCompatibility
|
||||
{
|
||||
public string? LegacyBundleId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ManifestSignatureEnvelope
|
||||
{
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
public List<ManifestSignature> Signatures { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class ManifestSignature
|
||||
{
|
||||
public string Keyid { get; set; } = string.Empty;
|
||||
public string Sig { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class DsseEnvelope
|
||||
{
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class BundleManifest
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
@@ -335,20 +865,24 @@ public sealed class DevPortalBundleVerificationResult
|
||||
public int Entries { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public bool Portable { get; set; }
|
||||
public string? Profile { get; set; }
|
||||
public DevPortalVerifyExitCode ExitCode { get; set; } = DevPortalVerifyExitCode.Unexpected;
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? ErrorDetail { get; set; }
|
||||
public string? ErrorCode { get; set; }
|
||||
|
||||
public static DevPortalBundleVerificationResult Failed(
|
||||
DevPortalVerifyExitCode exitCode,
|
||||
string message,
|
||||
string? detail = null)
|
||||
string? detail = null,
|
||||
string? errorCode = null)
|
||||
=> new()
|
||||
{
|
||||
Status = "failed",
|
||||
ExitCode = exitCode,
|
||||
ErrorMessage = message,
|
||||
ErrorDetail = detail
|
||||
ErrorDetail = detail,
|
||||
ErrorCode = errorCode
|
||||
};
|
||||
|
||||
public string ToJson()
|
||||
@@ -368,11 +902,15 @@ public sealed class DevPortalBundleVerificationResult
|
||||
if (CreatedAt.HasValue)
|
||||
output["createdAt"] = CreatedAt.Value.ToString("O", CultureInfo.InvariantCulture);
|
||||
output["entries"] = Entries;
|
||||
if (ErrorCode is not null)
|
||||
output["errorCode"] = ErrorCode;
|
||||
if (ErrorDetail is not null)
|
||||
output["errorDetail"] = ErrorDetail;
|
||||
if (ErrorMessage is not null)
|
||||
output["errorMessage"] = ErrorMessage;
|
||||
output["portable"] = Portable;
|
||||
if (Profile is not null)
|
||||
output["profile"] = Profile;
|
||||
if (RootHash is not null)
|
||||
output["rootHash"] = RootHash;
|
||||
output["status"] = Status;
|
||||
|
||||
@@ -59,7 +59,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT_20260208_030-CORE | DONE | Added `stella advise ask --file` batch processing and `stella advise export` conversation history command surfaces (2026-02-08). |
|
||||
| SPRINT_20260208_033-CORE | DONE | Unknowns export schema/versioning envelope and CLI option integration completed (2026-02-08). |
|
||||
| STS-004 | DONE | SPRINT_20260210_004 - Added `stella verify release` command that maps to promotion bundle verification flow. |
|
||||
|
||||
| SPRINT_20260208_031-CORE | DONE | Compare verification overlay options, builder, and output/model integration completed (2026-02-08).
|
||||
|
||||
|
||||
| PAPI-005 | DONE | SPRINT_20260210_005 - DevPortal portable-v1 verify parity and deterministic error-code output completed; CLI verifier paths validated in suite run (1173 passed) on 2026-02-10. |
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class CommandFactoryTests
|
||||
|
||||
var verify = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "verify", StringComparison.Ordinal));
|
||||
Assert.Contains(verify.Subcommands, command => string.Equals(command.Name, "offline", StringComparison.Ordinal));
|
||||
Assert.Contains(verify.Subcommands, command => string.Equals(command.Name, "release", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -133,7 +133,7 @@ public sealed class AttestVerifyGoldenTests
|
||||
Timestamp: 2026-01-14T10:30:00Z
|
||||
""";
|
||||
|
||||
actual.Trim().Should().Be(expected.Trim());
|
||||
actual.Replace("\r\n", "\n").Trim().Should().Be(expected.Replace("\r\n", "\n").Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -137,6 +137,7 @@ public class VerificationConsolidationTests
|
||||
// - offline (existing)
|
||||
// - image (existing)
|
||||
// - bundle (existing)
|
||||
// - release (new - promotion verification alias)
|
||||
// - attestation (new - from attest verify)
|
||||
// - vex (new - from vex verify)
|
||||
// - patch (new - from patchverify)
|
||||
@@ -147,6 +148,7 @@ public class VerificationConsolidationTests
|
||||
"offline",
|
||||
"image",
|
||||
"bundle",
|
||||
"release",
|
||||
"attestation",
|
||||
"vex",
|
||||
"patch",
|
||||
@@ -154,7 +156,7 @@ public class VerificationConsolidationTests
|
||||
};
|
||||
|
||||
// This test validates the expected structure
|
||||
Assert.Equal(7, expectedSubcommands.Length);
|
||||
Assert.Equal(8, expectedSubcommands.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -11,19 +11,19 @@ public class PolicyCliIntegrationTests
|
||||
private const string ValidPolicySource = @"
|
||||
policy ""Test Policy"" syntax ""stella-dsl@1"" {
|
||||
metadata {
|
||||
author: ""test@example.com""
|
||||
version: ""1.0.0""
|
||||
author = ""test@example.com""
|
||||
version = ""1.0.0""
|
||||
}
|
||||
|
||||
settings {
|
||||
default_action: ""allow""
|
||||
default_action = ""allow""
|
||||
}
|
||||
|
||||
rule allow_all (10) {
|
||||
rule allow_all priority 10 {
|
||||
when true
|
||||
then {
|
||||
allow()
|
||||
}
|
||||
then
|
||||
severity := ""info""
|
||||
because ""allow all for integration test""
|
||||
}
|
||||
}
|
||||
";
|
||||
@@ -33,7 +33,7 @@ policy ""Invalid Policy""
|
||||
// Missing syntax declaration
|
||||
{
|
||||
metadata {
|
||||
author: ""test@example.com""
|
||||
author = ""test@example.com""
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Services;
|
||||
using Xunit;
|
||||
@@ -111,6 +112,164 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
Assert.True(result.Portable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsProfileAndNoErrorCode()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle();
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("verified", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.Success, result.ExitCode);
|
||||
Assert.True(result.Portable);
|
||||
Assert.Equal("portable-v1", result.Profile);
|
||||
Assert.Null(result.ErrorCode);
|
||||
|
||||
var json = result.ToJson();
|
||||
Assert.Contains("\"profile\":\"portable-v1\"", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"errorCode\":", json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsManifestSignatureMissing_WhenDetachedSignatureIsAbsent()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateAfterSign: files => files.Remove("manifest.sig"));
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_MANIFEST_SIGNATURE_MISSING", result.ErrorCode);
|
||||
Assert.Contains("\"errorCode\":\"ERR_MANIFEST_SIGNATURE_MISSING\"", result.ToJson(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsManifestSignatureInvalid_WhenDetachedPayloadDoesNotMatchManifest()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateAfterSign: files =>
|
||||
{
|
||||
var manifestNode = JsonNode.Parse(files["manifest.json"])!.AsObject();
|
||||
manifestNode["createdUtc"] = "2026-02-10T12:00:00Z";
|
||||
files["manifest.json"] = Encoding.UTF8.GetBytes(manifestNode.ToJsonString());
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_MANIFEST_SIGNATURE_INVALID", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsManifestSchema_WhenSpecVersionIsInvalid()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) => manifest["specVersion"] = "2.0");
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_MANIFEST_SCHEMA", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsDssePayloadDigest_WhenPayloadDigestMismatchIsDetected()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) =>
|
||||
{
|
||||
var digests = manifest["digests"]!.AsObject();
|
||||
var payloadDigest = digests["dssePayloadDigest"]!.AsObject();
|
||||
payloadDigest["sha256"] = new string('c', 64);
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_DSSE_PAYLOAD_DIGEST", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsRekorRootMismatch_WhenRootHashIsInvalid()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) =>
|
||||
{
|
||||
var rekor = manifest["rekor"]!.AsObject();
|
||||
rekor["rootHash"] = "bad-root";
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_REKOR_ROOT_MISMATCH", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsRekorReferenceUncovered_WhenTileCoverageIsIncomplete()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) =>
|
||||
{
|
||||
var rekor = manifest["rekor"]!.AsObject();
|
||||
var tileRefs = rekor["tileRefs"]!.AsArray();
|
||||
var firstTile = tileRefs[0]!.AsObject();
|
||||
firstTile["covers"] = new JsonArray();
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_REKOR_REFERENCE_UNCOVERED", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsRekorTileMissing_WhenTileRefPathIsNotDeclaredInFiles()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, _) =>
|
||||
{
|
||||
var files = manifest["files"]!.AsObject();
|
||||
files.Remove("rekor/tile.tar");
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_REKOR_TILE_MISSING", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_PortableV1_ReturnsParquetFingerprintMismatch_WhenSchemaFingerprintIsMissing()
|
||||
{
|
||||
var bundlePath = CreatePortableV1Bundle(
|
||||
mutateBeforeSign: (manifest, files) =>
|
||||
{
|
||||
var parquetBytes = Encoding.UTF8.GetBytes("parquet-placeholder");
|
||||
files["components.parquet"] = parquetBytes;
|
||||
|
||||
var manifestFiles = manifest["files"]!.AsObject();
|
||||
manifestFiles["components.parquet"] = new JsonObject
|
||||
{
|
||||
["sha256"] = ComputeSha256Hex(parquetBytes),
|
||||
["size"] = parquetBytes.Length
|
||||
};
|
||||
});
|
||||
|
||||
var result = await _verifier.VerifyBundleAsync(bundlePath, offline: true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("failed", result.Status);
|
||||
Assert.Equal(DevPortalVerifyExitCode.SignatureFailure, result.ExitCode);
|
||||
Assert.Equal("ERR_PARQUET_FINGERPRINT_MISMATCH", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_OutputsKeysSortedAlphabetically()
|
||||
{
|
||||
@@ -284,16 +443,216 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private string CreatePortableV1Bundle(
|
||||
Action<JsonObject, Dictionary<string, byte[]>>? mutateBeforeSign = null,
|
||||
Action<Dictionary<string, byte[]>>? mutateAfterSign = null)
|
||||
{
|
||||
var bundlePath = Path.Combine(_tempDir, $"portable-v1-{Guid.NewGuid():N}.tgz");
|
||||
var files = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
|
||||
var artifactDigest = new string('a', 64);
|
||||
var rootHash = new string('b', 64);
|
||||
var legacyBundleId = "c3d4e5f6-a7b8-9012-cdef-345678901234";
|
||||
|
||||
var canonicalBomBytes = Encoding.UTF8.GetBytes("{\"schemaVersion\":\"1.0\",\"entries\":[]}");
|
||||
files["canonical_bom.json"] = canonicalBomBytes;
|
||||
var canonicalBomDigest = ComputeSha256Hex(canonicalBomBytes);
|
||||
|
||||
var dssePayloadBytes = Encoding.UTF8.GetBytes("{\"subject\":\"portable-v1\"}");
|
||||
var dsseEnvelope = new JsonObject
|
||||
{
|
||||
["payloadType"] = "application/vnd.in-toto+json",
|
||||
["payload"] = Convert.ToBase64String(dssePayloadBytes),
|
||||
["signatures"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["keyid"] = "test-key",
|
||||
["sig"] = Convert.ToBase64String(Encoding.UTF8.GetBytes("dsse-signature"))
|
||||
}
|
||||
}
|
||||
};
|
||||
files["dsse_envelope.json"] = Encoding.UTF8.GetBytes(dsseEnvelope.ToJsonString());
|
||||
var dssePayloadDigest = ComputeSha256Hex(dssePayloadBytes);
|
||||
|
||||
var tileTarBytes = Encoding.UTF8.GetBytes("portable-tile");
|
||||
files["rekor/tile.tar"] = tileTarBytes;
|
||||
|
||||
var manifest = new JsonObject
|
||||
{
|
||||
["spec_version"] = "1.0",
|
||||
["specVersion"] = "1.0",
|
||||
["createdUtc"] = "2025-12-07T10:30:00Z",
|
||||
["artifact"] = new JsonObject
|
||||
{
|
||||
["name"] = "evidence/portable-v1",
|
||||
["version"] = "1.0.0",
|
||||
["digest"] = new JsonObject
|
||||
{
|
||||
["sha256"] = artifactDigest
|
||||
},
|
||||
["mediaType"] = "application/vnd.stellaops.evidence.bundle+json"
|
||||
},
|
||||
["files"] = new JsonObject
|
||||
{
|
||||
["canonical_bom.json"] = new JsonObject
|
||||
{
|
||||
["sha256"] = canonicalBomDigest,
|
||||
["size"] = canonicalBomBytes.Length
|
||||
},
|
||||
["dsse_envelope.json"] = new JsonObject
|
||||
{
|
||||
["sha256"] = ComputeSha256Hex(files["dsse_envelope.json"]),
|
||||
["size"] = files["dsse_envelope.json"].Length
|
||||
},
|
||||
["rekor/tile.tar"] = new JsonObject
|
||||
{
|
||||
["sha256"] = ComputeSha256Hex(tileTarBytes),
|
||||
["size"] = tileTarBytes.Length
|
||||
}
|
||||
},
|
||||
["digests"] = new JsonObject
|
||||
{
|
||||
["canonicalBomSha256"] = canonicalBomDigest,
|
||||
["dssePayloadDigest"] = new JsonObject
|
||||
{
|
||||
["sha256"] = dssePayloadDigest
|
||||
}
|
||||
},
|
||||
["rekor"] = new JsonObject
|
||||
{
|
||||
["logId"] = "rekor.sigstore.dev",
|
||||
["apiVersion"] = "2",
|
||||
["tileRefs"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["path"] = "rekor/tile.tar",
|
||||
["covers"] = new JsonArray
|
||||
{
|
||||
$"SHA256:{artifactDigest.ToUpperInvariant()}",
|
||||
$"SHA256:{canonicalBomDigest.ToUpperInvariant()}"
|
||||
}
|
||||
}
|
||||
},
|
||||
["rootHash"] = rootHash
|
||||
},
|
||||
["verifiers"] = new JsonObject
|
||||
{
|
||||
["rekorPub"] = new JsonObject
|
||||
{
|
||||
["keyMaterial"] = "rekor-test-key"
|
||||
}
|
||||
},
|
||||
["compatibility"] = new JsonObject
|
||||
{
|
||||
["legacyBundleId"] = legacyBundleId
|
||||
}
|
||||
};
|
||||
|
||||
mutateBeforeSign?.Invoke(manifest, files);
|
||||
|
||||
var manifestJson = manifest.ToJsonString();
|
||||
files["manifest.json"] = Encoding.UTF8.GetBytes(manifestJson);
|
||||
|
||||
var canonicalManifestBytes = CanonicalizeJson(manifestJson);
|
||||
var manifestSignature = new JsonObject
|
||||
{
|
||||
["payload"] = Convert.ToBase64String(canonicalManifestBytes),
|
||||
["signatures"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["keyid"] = "manifest-key",
|
||||
["sig"] = Convert.ToBase64String(Encoding.UTF8.GetBytes("manifest-signature"))
|
||||
}
|
||||
}
|
||||
};
|
||||
files["manifest.sig"] = Encoding.UTF8.GetBytes(manifestSignature.ToJsonString());
|
||||
|
||||
mutateAfterSign?.Invoke(files);
|
||||
|
||||
CreateTgzBundle(bundlePath, files);
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
private static byte[] CanonicalizeJson(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false });
|
||||
WriteCanonicalElement(writer, document.RootElement);
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalElement(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonicalElement(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonicalElement(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
|
||||
break;
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(element.GetBoolean());
|
||||
break;
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported JSON token kind '{element.ValueKind}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] value)
|
||||
=> Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
|
||||
|
||||
private static void CreateTgzBundle(string bundlePath, string manifestJson, object signature, object bundleMetadata)
|
||||
{
|
||||
var files = new Dictionary<string, byte[]>(StringComparer.Ordinal)
|
||||
{
|
||||
["manifest.json"] = Encoding.UTF8.GetBytes(manifestJson),
|
||||
["signature.json"] = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(signature)),
|
||||
["bundle.json"] = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundleMetadata)),
|
||||
["checksums.txt"] = Encoding.UTF8.GetBytes($"# checksums\n{new string('f', 64)} sbom/cyclonedx.json\n")
|
||||
};
|
||||
|
||||
CreateTgzBundle(bundlePath, files);
|
||||
}
|
||||
|
||||
private static void CreateTgzBundle(string bundlePath, IReadOnlyDictionary<string, byte[]> files)
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using (var gzipStream = new GZipStream(memoryStream, CompressionLevel.Optimal, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzipStream))
|
||||
{
|
||||
AddTarEntry(tarWriter, "manifest.json", manifestJson);
|
||||
AddTarEntry(tarWriter, "signature.json", JsonSerializer.Serialize(signature));
|
||||
AddTarEntry(tarWriter, "bundle.json", JsonSerializer.Serialize(bundleMetadata));
|
||||
AddTarEntry(tarWriter, "checksums.txt", $"# checksums\n{new string('f', 64)} sbom/cyclonedx.json\n");
|
||||
foreach (var file in files.OrderBy(f => f.Key, StringComparer.Ordinal))
|
||||
{
|
||||
AddTarEntry(tarWriter, file.Key, file.Value);
|
||||
}
|
||||
}
|
||||
|
||||
memoryStream.Position = 0;
|
||||
@@ -301,7 +660,7 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
memoryStream.CopyTo(fileStream);
|
||||
}
|
||||
|
||||
private static void AddTarEntry(TarWriter writer, string name, string content)
|
||||
private static void AddTarEntry(TarWriter writer, string name, byte[] content)
|
||||
{
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
|
||||
{
|
||||
@@ -309,8 +668,7 @@ public sealed class DevPortalBundleVerifierTests : IDisposable
|
||||
ModificationTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entry.DataStream = new MemoryStream(bytes);
|
||||
entry.DataStream = new MemoryStream(content);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT_20260208_030-TESTS | DONE | Added isolated advise parity validation in StellaOps.Cli.AdviseParity.Tests; command passed (2 tests, 2026-02-08).
|
||||
| SPRINT_20260208_033-TESTS | DONE | Added isolated Unknowns export deterministic validation in StellaOps.Cli.UnknownsExport.Tests; command passed (3 tests, 2026-02-08).
|
||||
| STS-005 | DONE | SPRINT_20260210_004 - Updated command structure coverage for `verify release` and verification consolidation list (execution blocked by pre-existing Policy.Determinization compile errors). |
|
||||
|
||||
| SPRINT_20260208_031-TESTS | DONE | Isolated compare overlay deterministic validation added in StellaOps.Cli.CompareOverlay.Tests; command passed (3 tests, 2026-02-08).
|
||||
|
||||
@@ -45,3 +46,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
|
||||
|
||||
| PAPI-005-TESTS | DONE | SPRINT_20260210_005 - DevPortal portable-v1 verifier matrix hardened with manifest/DSSE/Rekor/Parquet fail-closed tests; CLI suite passed (1182 passed) on 2026-02-10. |
|
||||
|
||||
Reference in New Issue
Block a user