license switch agpl -> busl1, sprints work, new product advisories
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_4100_0006_0005 - Admin Utility Integration
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_4100_0006_0005 - Admin Utility Integration
|
||||
|
||||
using System.Net.Http.Json;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
using StellaOps.Agent.Core.Bootstrap;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
|
||||
25
src/Cli/StellaOps.Cli/Commands/BundleCommandGroup.cs
Normal file
25
src/Cli/StellaOps.Cli/Commands/BundleCommandGroup.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleCommandGroup.cs
|
||||
// Sprint: SPRINT_20260120_029_AirGap_offline_bundle_contract
|
||||
// Task: TASK-029-003 - Signed verification report generation
|
||||
// Description: Domain-level bundle command group for offline evidence bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
public static class BundleCommandGroup
|
||||
{
|
||||
public static Command BuildBundleCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundle = new Command("bundle", "Offline evidence bundle operations.");
|
||||
bundle.Add(BundleVerifyCommand.BuildVerifyBundleEnhancedCommand(
|
||||
services,
|
||||
verboseOption,
|
||||
cancellationToken));
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,16 @@
|
||||
using System.CommandLine;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Predicates;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -29,7 +35,7 @@ public static class BundleVerifyCommand
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 'verify --bundle' enhanced command.
|
||||
/// Builds the 'bundle verify' enhanced command.
|
||||
/// </summary>
|
||||
public static Command BuildVerifyBundleEnhancedCommand(
|
||||
IServiceProvider services,
|
||||
@@ -65,10 +71,20 @@ public static class BundleVerifyCommand
|
||||
|
||||
var strictOption = new Option<bool>("--strict")
|
||||
{
|
||||
Description = "Fail on any warning (missing optional artifacts)"
|
||||
Description = "Fail on any warning (missing optional artifacts)"
|
||||
};
|
||||
|
||||
var command = new Command("bundle-verify", "Verify offline evidence bundle with full cryptographic verification")
|
||||
var signerOption = new Option<string?>("--signer")
|
||||
{
|
||||
Description = "Path to signing key (PEM) for DSSE verification report"
|
||||
};
|
||||
|
||||
var signerCertOption = new Option<string?>("--signer-cert")
|
||||
{
|
||||
Description = "Path to signer certificate PEM (optional; embedded in report metadata)"
|
||||
};
|
||||
|
||||
var command = new Command("verify", "Verify offline evidence bundle with full cryptographic verification")
|
||||
{
|
||||
bundleOption,
|
||||
trustRootOption,
|
||||
@@ -76,6 +92,8 @@ public static class BundleVerifyCommand
|
||||
offlineOption,
|
||||
outputOption,
|
||||
strictOption,
|
||||
signerOption,
|
||||
signerCertOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
@@ -87,6 +105,8 @@ public static class BundleVerifyCommand
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var strict = parseResult.GetValue(strictOption);
|
||||
var signer = parseResult.GetValue(signerOption);
|
||||
var signerCert = parseResult.GetValue(signerCertOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleVerifyBundleAsync(
|
||||
@@ -97,6 +117,8 @@ public static class BundleVerifyCommand
|
||||
offline,
|
||||
output,
|
||||
strict,
|
||||
signer,
|
||||
signerCert,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
@@ -112,6 +134,8 @@ public static class BundleVerifyCommand
|
||||
bool offline,
|
||||
string outputFormat,
|
||||
bool strict,
|
||||
string? signerKeyPath,
|
||||
string? signerCertPath,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -125,6 +149,9 @@ public static class BundleVerifyCommand
|
||||
Offline = offline
|
||||
};
|
||||
|
||||
string? bundleDir = null;
|
||||
BundleManifestDto? manifest = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (outputFormat != "json")
|
||||
@@ -136,18 +163,29 @@ public static class BundleVerifyCommand
|
||||
}
|
||||
|
||||
// Step 1: Extract/read bundle
|
||||
var bundleDir = await ExtractBundleAsync(bundlePath, ct);
|
||||
bundleDir = await ExtractBundleAsync(bundlePath, ct);
|
||||
|
||||
// Step 2: Parse manifest
|
||||
var manifestPath = Path.Combine(bundleDir, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck("manifest", false, "manifest.json not found"));
|
||||
return OutputResult(result, outputFormat, strict);
|
||||
return await FinalizeResultAsync(
|
||||
result,
|
||||
manifest,
|
||||
bundleDir,
|
||||
trustRoot,
|
||||
rekorCheckpoint,
|
||||
offline,
|
||||
outputFormat,
|
||||
strict,
|
||||
signerKeyPath,
|
||||
signerCertPath,
|
||||
ct);
|
||||
}
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifestDto>(manifestJson, JsonOptions);
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
manifest = JsonSerializer.Deserialize<BundleManifestDto>(manifestJson, JsonOptions);
|
||||
result.Checks.Add(new VerificationCheck("manifest", true, "manifest.json parsed successfully"));
|
||||
result.SchemaVersion = manifest?.SchemaVersion;
|
||||
result.Image = manifest?.Bundle?.Image;
|
||||
@@ -185,11 +223,18 @@ public static class BundleVerifyCommand
|
||||
Console.WriteLine($"Step 5: Payload Types {(payloadsPassed ? "✓" : "⚠")}");
|
||||
}
|
||||
|
||||
result.CompletedAt = DateTimeOffset.UtcNow;
|
||||
result.OverallStatus = result.Checks.All(c => c.Passed) ? "PASSED" :
|
||||
result.Checks.Any(c => !c.Passed && c.Severity == "error") ? "FAILED" : "PASSED_WITH_WARNINGS";
|
||||
|
||||
return OutputResult(result, outputFormat, strict);
|
||||
return await FinalizeResultAsync(
|
||||
result,
|
||||
manifest,
|
||||
bundleDir,
|
||||
trustRoot,
|
||||
rekorCheckpoint,
|
||||
offline,
|
||||
outputFormat,
|
||||
strict,
|
||||
signerKeyPath,
|
||||
signerCertPath,
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -446,6 +491,377 @@ public static class BundleVerifyCommand
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<int> FinalizeResultAsync(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
string bundleDir,
|
||||
string? trustRoot,
|
||||
string? rekorCheckpoint,
|
||||
bool offline,
|
||||
string outputFormat,
|
||||
bool strict,
|
||||
string? signerKeyPath,
|
||||
string? signerCertPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
result.CompletedAt ??= DateTimeOffset.UtcNow;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(signerKeyPath))
|
||||
{
|
||||
var outcome = await TryWriteSignedReportAsync(
|
||||
result,
|
||||
manifest,
|
||||
bundleDir,
|
||||
trustRoot,
|
||||
rekorCheckpoint,
|
||||
offline,
|
||||
signerKeyPath,
|
||||
signerCertPath,
|
||||
ct);
|
||||
|
||||
if (outcome.Success)
|
||||
{
|
||||
result.SignedReportPath = outcome.ReportPath;
|
||||
result.SignerKeyId = outcome.KeyId;
|
||||
result.SignerAlgorithm = outcome.Algorithm;
|
||||
result.SignedAt = outcome.SignedAt;
|
||||
result.Checks.Add(new VerificationCheck(
|
||||
"report:signature",
|
||||
true,
|
||||
$"Signed report written to {outcome.ReportPath}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck(
|
||||
"report:signature",
|
||||
false,
|
||||
outcome.Error ?? "Signed report generation failed")
|
||||
{
|
||||
Severity = "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.OverallStatus = ComputeOverallStatus(result.Checks);
|
||||
return OutputResult(result, outputFormat, strict);
|
||||
}
|
||||
|
||||
private static async Task<SignedReportOutcome> TryWriteSignedReportAsync(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
string bundleDir,
|
||||
string? trustRoot,
|
||||
string? rekorCheckpoint,
|
||||
bool offline,
|
||||
string signerKeyPath,
|
||||
string? signerCertPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var signingKey = LoadSigningKey(signerKeyPath);
|
||||
var signerCert = await LoadSignerCertificateAsync(signerCertPath, signerKeyPath, ct);
|
||||
var report = BuildVerificationReport(result, manifest, trustRoot, rekorCheckpoint, offline);
|
||||
var signer = new DsseVerificationReportSigner(new EnvelopeSignatureService());
|
||||
var signedAt = result.CompletedAt ?? DateTimeOffset.UtcNow;
|
||||
var signResult = await signer.SignAsync(new VerificationReportSigningRequest(
|
||||
report,
|
||||
signingKey,
|
||||
signerCert,
|
||||
signedAt), ct);
|
||||
|
||||
var outputDir = Path.Combine(bundleDir, "out");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
var reportPath = Path.Combine(outputDir, "verification.report.json");
|
||||
await File.WriteAllTextAsync(reportPath, signResult.EnvelopeJson, ct);
|
||||
|
||||
return new SignedReportOutcome(
|
||||
true,
|
||||
reportPath,
|
||||
signingKey.KeyId,
|
||||
signingKey.AlgorithmId,
|
||||
signResult.Report.Verifier?.SignedAt,
|
||||
null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new SignedReportOutcome(false, null, null, null, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static VerificationReportPredicate BuildVerificationReport(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
string? trustRoot,
|
||||
string? rekorCheckpoint,
|
||||
bool offline)
|
||||
{
|
||||
var steps = result.Checks
|
||||
.Select((check, index) => new VerificationStep
|
||||
{
|
||||
Step = index + 1,
|
||||
Name = check.Name,
|
||||
Status = MapStepStatus(check),
|
||||
DurationMs = 0,
|
||||
Details = check.Message,
|
||||
Issues = BuildIssues(check)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var summary = ComputeOverallStatus(result.Checks);
|
||||
var overallStatus = MapOverallStatus(summary);
|
||||
var overall = new OverallVerificationResult
|
||||
{
|
||||
Status = overallStatus,
|
||||
Summary = summary,
|
||||
TotalDurationMs = (long?)((result.CompletedAt - result.StartedAt)?.TotalMilliseconds) ?? 0,
|
||||
PassedSteps = steps.Count(step => step.Status == VerificationStepStatus.Passed),
|
||||
FailedSteps = steps.Count(step => step.Status == VerificationStepStatus.Failed),
|
||||
WarningSteps = steps.Count(step => step.Status == VerificationStepStatus.Warning),
|
||||
SkippedSteps = steps.Count(step => step.Status == VerificationStepStatus.Skipped)
|
||||
};
|
||||
|
||||
TrustChainInfo? trustChain = null;
|
||||
if (!string.IsNullOrWhiteSpace(trustRoot) || !string.IsNullOrWhiteSpace(rekorCheckpoint))
|
||||
{
|
||||
var rekorVerified = result.Checks.Any(check =>
|
||||
string.Equals(check.Name, "rekor:inclusion", StringComparison.OrdinalIgnoreCase) && check.Passed);
|
||||
trustChain = new TrustChainInfo
|
||||
{
|
||||
RootOfTrust = trustRoot,
|
||||
RekorVerified = rekorVerified,
|
||||
RekorLogIndex = null,
|
||||
TsaVerified = false,
|
||||
Timestamp = null,
|
||||
SignerIdentity = result.SignerKeyId
|
||||
};
|
||||
}
|
||||
|
||||
return new VerificationReportPredicate
|
||||
{
|
||||
ReportId = ComputeReportId(result, manifest),
|
||||
GeneratedAt = result.CompletedAt ?? DateTimeOffset.UtcNow,
|
||||
Generator = new GeneratorInfo
|
||||
{
|
||||
Tool = "stella bundle verify",
|
||||
Version = GetCliVersion()
|
||||
},
|
||||
Subject = new VerificationSubject
|
||||
{
|
||||
BundleId = manifest?.CanonicalManifestHash,
|
||||
BundleDigest = manifest?.Subject?.Sha256,
|
||||
ArtifactDigest = manifest?.Bundle?.Digest,
|
||||
ArtifactName = manifest?.Bundle?.Image
|
||||
},
|
||||
VerificationSteps = steps,
|
||||
OverallResult = overall,
|
||||
TrustChain = trustChain,
|
||||
ReplayMode = offline ? "offline" : "online"
|
||||
};
|
||||
}
|
||||
|
||||
private static VerificationStepStatus MapStepStatus(VerificationCheck check)
|
||||
{
|
||||
if (!check.Passed)
|
||||
{
|
||||
return VerificationStepStatus.Failed;
|
||||
}
|
||||
|
||||
return check.Severity switch
|
||||
{
|
||||
"warning" => VerificationStepStatus.Warning,
|
||||
"info" => VerificationStepStatus.Passed,
|
||||
_ => VerificationStepStatus.Passed
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<VerificationIssue>? BuildIssues(VerificationCheck check)
|
||||
{
|
||||
if (check.Passed && !string.Equals(check.Severity, "warning", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new[]
|
||||
{
|
||||
new VerificationIssue
|
||||
{
|
||||
Severity = MapIssueSeverity(check),
|
||||
Code = check.Name,
|
||||
Message = check.Message
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static IssueSeverity MapIssueSeverity(VerificationCheck check)
|
||||
{
|
||||
if (!check.Passed)
|
||||
{
|
||||
return IssueSeverity.Error;
|
||||
}
|
||||
|
||||
return string.Equals(check.Severity, "warning", StringComparison.OrdinalIgnoreCase)
|
||||
? IssueSeverity.Warning
|
||||
: IssueSeverity.Info;
|
||||
}
|
||||
|
||||
private static VerificationStepStatus MapOverallStatus(string? status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
"PASSED" => VerificationStepStatus.Passed,
|
||||
"FAILED" => VerificationStepStatus.Failed,
|
||||
"PASSED_WITH_WARNINGS" => VerificationStepStatus.Warning,
|
||||
_ => VerificationStepStatus.Skipped
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeOverallStatus(IReadOnlyList<VerificationCheck> checks)
|
||||
{
|
||||
if (checks.Count == 0)
|
||||
{
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
if (checks.All(check => check.Passed))
|
||||
{
|
||||
return "PASSED";
|
||||
}
|
||||
|
||||
return checks.Any(check => !check.Passed && check.Severity == "error")
|
||||
? "FAILED"
|
||||
: "PASSED_WITH_WARNINGS";
|
||||
}
|
||||
|
||||
private static string ComputeReportId(VerificationResult result, BundleManifestDto? manifest)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(manifest?.CanonicalManifestHash))
|
||||
{
|
||||
return manifest.CanonicalManifestHash!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(manifest?.Subject?.Sha256))
|
||||
{
|
||||
return manifest.Subject.Sha256!;
|
||||
}
|
||||
|
||||
return ComputeSha256Hex(result.BundlePath);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string value)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static EnvelopeKey LoadSigningKey(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new InvalidOperationException("Signing key path is required for report signing.");
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Signing key file not found: {path}");
|
||||
}
|
||||
|
||||
var pem = File.ReadAllText(path);
|
||||
using var ecdsa = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
ecdsa.ImportFromPem(pem);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load ECDSA private key from PEM.", ex);
|
||||
}
|
||||
|
||||
var parameters = ecdsa.ExportParameters(true);
|
||||
var algorithm = ResolveEcdsaAlgorithm(ecdsa.KeySize);
|
||||
return EnvelopeKey.CreateEcdsaSigner(algorithm, parameters);
|
||||
}
|
||||
|
||||
private static string ResolveEcdsaAlgorithm(int keySize)
|
||||
{
|
||||
return keySize switch
|
||||
{
|
||||
256 => SignatureAlgorithms.Es256,
|
||||
384 => SignatureAlgorithms.Es384,
|
||||
521 => SignatureAlgorithms.Es512,
|
||||
_ => throw new InvalidOperationException($"Unsupported ECDSA key size: {keySize}.")
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string?> LoadSignerCertificateAsync(
|
||||
string? signerCertPath,
|
||||
string signerKeyPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signerCertPath))
|
||||
{
|
||||
if (!File.Exists(signerCertPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Signer certificate file not found: {signerCertPath}");
|
||||
}
|
||||
|
||||
var certPem = await File.ReadAllTextAsync(signerCertPath, ct);
|
||||
return NormalizePem(certPem);
|
||||
}
|
||||
|
||||
var keyPem = await File.ReadAllTextAsync(signerKeyPath, ct);
|
||||
return ExtractCertificatePem(keyPem);
|
||||
}
|
||||
|
||||
private static string? ExtractCertificatePem(string pem)
|
||||
{
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
var startIndex = 0;
|
||||
while (true)
|
||||
{
|
||||
var begin = pem.IndexOf(beginMarker, startIndex, StringComparison.Ordinal);
|
||||
if (begin < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var end = pem.IndexOf(endMarker, begin, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var block = pem.Substring(begin, end - begin + endMarker.Length).Trim();
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append('\n');
|
||||
}
|
||||
|
||||
builder.Append(block);
|
||||
startIndex = end + endMarker.Length;
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? null : NormalizePem(builder.ToString());
|
||||
}
|
||||
|
||||
private static string? NormalizePem(string? pem)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pem))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return pem.Replace("\r\n", "\n").Trim();
|
||||
}
|
||||
|
||||
private static string GetCliVersion()
|
||||
{
|
||||
return typeof(BundleVerifyCommand).Assembly.GetName().Version?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
private static int OutputResult(VerificationResult result, string format, bool strict)
|
||||
{
|
||||
if (format == "json")
|
||||
@@ -472,6 +888,18 @@ public static class BundleVerifyCommand
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Duration: {(result.CompletedAt - result.StartedAt)?.TotalMilliseconds:F0}ms");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.SignedReportPath))
|
||||
{
|
||||
Console.WriteLine($"Signed report: {result.SignedReportPath}");
|
||||
if (!string.IsNullOrWhiteSpace(result.SignerKeyId))
|
||||
{
|
||||
var algo = string.IsNullOrWhiteSpace(result.SignerAlgorithm)
|
||||
? string.Empty
|
||||
: $" ({result.SignerAlgorithm})";
|
||||
Console.WriteLine($"Signer key: {result.SignerKeyId}{algo}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit code
|
||||
@@ -509,6 +937,18 @@ public static class BundleVerifyCommand
|
||||
[JsonPropertyName("image")]
|
||||
public string? Image { get; set; }
|
||||
|
||||
[JsonPropertyName("signedReportPath")]
|
||||
public string? SignedReportPath { get; set; }
|
||||
|
||||
[JsonPropertyName("signerKeyId")]
|
||||
public string? SignerKeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("signerAlgorithm")]
|
||||
public string? SignerAlgorithm { get; set; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("checks")]
|
||||
public List<VerificationCheck> Checks { get; set; } = [];
|
||||
}
|
||||
@@ -538,11 +978,25 @@ public static class BundleVerifyCommand
|
||||
public string Severity { get; set; } = "info";
|
||||
}
|
||||
|
||||
private sealed record SignedReportOutcome(
|
||||
bool Success,
|
||||
string? ReportPath,
|
||||
string? KeyId,
|
||||
string? Algorithm,
|
||||
DateTimeOffset? SignedAt,
|
||||
string? Error);
|
||||
|
||||
private sealed class BundleManifestDto
|
||||
{
|
||||
[JsonPropertyName("canonicalManifestHash")]
|
||||
public string? CanonicalManifestHash { get; set; }
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string? SchemaVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public BundleSubjectDto? Subject { get; set; }
|
||||
|
||||
[JsonPropertyName("bundle")]
|
||||
public BundleInfoDto? Bundle { get; set; }
|
||||
|
||||
@@ -550,11 +1004,23 @@ public static class BundleVerifyCommand
|
||||
public VerifySectionDto? Verify { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BundleSubjectDto
|
||||
{
|
||||
[JsonPropertyName("sha256")]
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
[JsonPropertyName("sha512")]
|
||||
public string? Sha512 { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BundleInfoDto
|
||||
{
|
||||
[JsonPropertyName("image")]
|
||||
public string? Image { get; set; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; set; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public List<ArtifactDto>? Artifacts { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-08 - Exit codes for sign commands
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ internal static class CommandFactory
|
||||
root.Add(AdminCommandGroup.BuildAdminCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAttestCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BundleCommandGroup.BuildBundleCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(TimestampCommandGroup.BuildTimestampCommand(verboseOption, cancellationToken));
|
||||
root.Add(BuildRiskProfileCommand(verboseOption, cancellationToken));
|
||||
root.Add(BuildAdvisoryCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildForensicCommand(services, verboseOption, cancellationToken));
|
||||
@@ -154,6 +156,9 @@ internal static class CommandFactory
|
||||
// Sprint: Doctor Diagnostics System
|
||||
root.Add(DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260119_010_Attestor_tst_integration - RFC-3161 Timestamp commands
|
||||
root.Add(TimestampCommandGroup.BuildTimestampCommand(verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260117_026_CLI_why_blocked_command - Explain block decisions (M2 moat)
|
||||
root.Add(ExplainCommandGroup.BuildExplainCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
@@ -6619,6 +6624,14 @@ flowchart TB
|
||||
{
|
||||
Description = "Include detailed explanations for each verification check."
|
||||
};
|
||||
var requireTimestampOption = new Option<bool>("--require-timestamp")
|
||||
{
|
||||
Description = "Require RFC-3161 timestamp evidence in the attestation."
|
||||
};
|
||||
var maxSkewOption = new Option<string?>("--max-skew")
|
||||
{
|
||||
Description = "Maximum allowed timestamp skew (e.g., 5m, 30s)."
|
||||
};
|
||||
|
||||
verify.Add(envelopeOption);
|
||||
verify.Add(policyOption);
|
||||
@@ -6627,6 +6640,8 @@ flowchart TB
|
||||
verify.Add(verifyOutputOption);
|
||||
verify.Add(verifyFormatOption);
|
||||
verify.Add(verifyExplainOption);
|
||||
verify.Add(requireTimestampOption);
|
||||
verify.Add(maxSkewOption);
|
||||
|
||||
verify.SetAction((parseResult, _) =>
|
||||
{
|
||||
@@ -6637,9 +6652,23 @@ flowchart TB
|
||||
var output = parseResult.GetValue(verifyOutputOption);
|
||||
var format = parseResult.GetValue(verifyFormatOption) ?? "table";
|
||||
var explain = parseResult.GetValue(verifyExplainOption);
|
||||
var requireTimestamp = parseResult.GetValue(requireTimestampOption);
|
||||
var maxSkew = parseResult.GetValue(maxSkewOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAttestVerifyAsync(services, envelope, policy, root, checkpoint, output, format, explain, verbose, cancellationToken);
|
||||
return CommandHandlers.HandleAttestVerifyAsync(
|
||||
services,
|
||||
envelope,
|
||||
policy,
|
||||
root,
|
||||
checkpoint,
|
||||
output,
|
||||
format,
|
||||
explain,
|
||||
requireTimestamp,
|
||||
maxSkew,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
// attest list (CLI-ATTEST-74-001)
|
||||
@@ -6769,6 +6798,14 @@ flowchart TB
|
||||
{
|
||||
Description = "Explicitly skip Rekor submission."
|
||||
};
|
||||
var timestampOption = new Option<bool>("--timestamp")
|
||||
{
|
||||
Description = "Request RFC-3161 timestamping for the attestation."
|
||||
};
|
||||
var tsaOption = new Option<string?>("--tsa")
|
||||
{
|
||||
Description = "TSA URL for RFC-3161 timestamp requests."
|
||||
};
|
||||
var signOutputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output path for the signed DSSE envelope JSON."
|
||||
@@ -6786,6 +6823,8 @@ flowchart TB
|
||||
sign.Add(keylessOption);
|
||||
sign.Add(transparencyLogOption);
|
||||
sign.Add(noRekorOption);
|
||||
sign.Add(timestampOption);
|
||||
sign.Add(tsaOption);
|
||||
sign.Add(signOutputOption);
|
||||
sign.Add(signFormatOption);
|
||||
|
||||
@@ -6799,6 +6838,8 @@ flowchart TB
|
||||
var keyless = parseResult.GetValue(keylessOption);
|
||||
var useRekor = parseResult.GetValue(transparencyLogOption);
|
||||
var noRekor = parseResult.GetValue(noRekorOption);
|
||||
var includeTimestamp = parseResult.GetValue(timestampOption);
|
||||
var tsaUrl = parseResult.GetValue(tsaOption);
|
||||
var output = parseResult.GetValue(signOutputOption);
|
||||
var format = parseResult.GetValue(signFormatOption) ?? "dsse";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
@@ -6812,6 +6853,8 @@ flowchart TB
|
||||
keyId,
|
||||
keyless,
|
||||
useRekor && !noRekor,
|
||||
includeTimestamp,
|
||||
tsaUrl,
|
||||
output,
|
||||
format,
|
||||
verbose,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="CommandHandlers.Config.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-010, CLI-CONFIG-011, CLI-CONFIG-012, CLI-CONFIG-013)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-08 - CLI handlers for keyless signing
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="CommandHandlers.VerifyBundle.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
@@ -19,6 +19,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
@@ -34,6 +35,7 @@ using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Attestor.Timestamping;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
@@ -10214,6 +10216,8 @@ internal static partial class CommandHandlers
|
||||
string? outputPath,
|
||||
string format,
|
||||
bool explain,
|
||||
bool requireTimestamp,
|
||||
string? maxSkew,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -10233,6 +10237,27 @@ internal static partial class CommandHandlers
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
TimeSpan? maxSkewDuration = null;
|
||||
if (!string.IsNullOrWhiteSpace(maxSkew))
|
||||
{
|
||||
if (!TryParseRelativeDuration(maxSkew, out var parsedSkew) &&
|
||||
!TimeSpan.TryParse(maxSkew, CultureInfo.InvariantCulture, out parsedSkew))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Invalid --max-skew value. Use duration (e.g., 5m, 30s, 2h).");
|
||||
CliMetrics.RecordAttestVerify("input_error");
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
if (parsedSkew <= TimeSpan.Zero)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] --max-skew must be positive.");
|
||||
CliMetrics.RecordAttestVerify("input_error");
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
maxSkewDuration = parsedSkew;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var envelopeJson = await File.ReadAllTextAsync(envelopePath, cancellationToken).ConfigureAwait(false);
|
||||
@@ -10344,7 +10369,69 @@ internal static partial class CommandHandlers
|
||||
"No transparency checkpoint provided (use --transparency-checkpoint)"));
|
||||
}
|
||||
|
||||
// Check 6: Policy compliance (if policy provided)
|
||||
// Check 6: Timestamp evidence (if present/required)
|
||||
DateTimeOffset? timestampTime = null;
|
||||
string? timestampTsa = null;
|
||||
string? timestampDigest = null;
|
||||
if (envelope.TryGetProperty("timestamp", out var timestampElement) &&
|
||||
timestampElement.ValueKind == JsonValueKind.Object &&
|
||||
timestampElement.TryGetProperty("rfc3161", out var rfc3161Element) &&
|
||||
rfc3161Element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
timestampDigest = rfc3161Element.TryGetProperty("tokenDigest", out var digestElement)
|
||||
? digestElement.GetString()
|
||||
: null;
|
||||
timestampTsa = rfc3161Element.TryGetProperty("tsaUrl", out var tsaElement)
|
||||
? tsaElement.GetString()
|
||||
: null;
|
||||
|
||||
if (rfc3161Element.TryGetProperty("generationTime", out var timeElement) &&
|
||||
DateTimeOffset.TryParse(
|
||||
timeElement.GetString(),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsedTime))
|
||||
{
|
||||
timestampTime = parsedTime;
|
||||
}
|
||||
}
|
||||
|
||||
var timestampRequired = requireTimestamp || maxSkewDuration.HasValue;
|
||||
var timestampPresent = !string.IsNullOrWhiteSpace(timestampDigest) && timestampTime.HasValue;
|
||||
var timestampCompliant = true;
|
||||
bool? withinSkew = null;
|
||||
|
||||
if (timestampPresent)
|
||||
{
|
||||
checks.Add(("Timestamp Evidence", true,
|
||||
$"RFC-3161 token present{(string.IsNullOrWhiteSpace(timestampTsa) ? string.Empty : $" (TSA: {timestampTsa})")}"));
|
||||
}
|
||||
else if (timestampRequired)
|
||||
{
|
||||
timestampCompliant = false;
|
||||
checks.Add(("Timestamp Evidence", false,
|
||||
"RFC-3161 timestamp evidence is required (--require-timestamp or --max-skew)"));
|
||||
}
|
||||
|
||||
if (maxSkewDuration.HasValue)
|
||||
{
|
||||
if (timestampTime.HasValue)
|
||||
{
|
||||
var skew = (DateTimeOffset.UtcNow - timestampTime.Value).Duration();
|
||||
withinSkew = skew <= maxSkewDuration.Value;
|
||||
timestampCompliant &= withinSkew.Value;
|
||||
checks.Add(("Timestamp Skew", withinSkew.Value,
|
||||
$"Skew {skew:c} <= {maxSkewDuration.Value:c}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
timestampCompliant = false;
|
||||
withinSkew = false;
|
||||
checks.Add(("Timestamp Skew", false, "Timestamp generation time not available for skew check"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check 7: Policy compliance (if policy provided)
|
||||
var policyCompliant = true;
|
||||
var policyReasons = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(policyPath))
|
||||
@@ -10436,7 +10523,9 @@ internal static partial class CommandHandlers
|
||||
var requiredPassed = checks.Where(c => c.Check is "Envelope Structure" or "Payload Type" or "Subject Presence")
|
||||
.All(c => c.Passed);
|
||||
var signatureVerified = checks.FirstOrDefault(c => c.Check == "Signature Verification").Passed;
|
||||
var overallStatus = requiredPassed && signatureVerified && policyCompliant ? "PASSED" : "FAILED";
|
||||
var overallStatus = requiredPassed && signatureVerified && policyCompliant && timestampCompliant
|
||||
? "PASSED"
|
||||
: "FAILED";
|
||||
|
||||
// Build result object
|
||||
var result = new
|
||||
@@ -10461,6 +10550,16 @@ internal static partial class CommandHandlers
|
||||
digest = s.Digest.Length > 16 ? s.Digest[..16] + "..." : s.Digest
|
||||
}).ToList()
|
||||
},
|
||||
timestamp = new
|
||||
{
|
||||
required = requireTimestamp,
|
||||
maxSkew = maxSkewDuration?.ToString("c", CultureInfo.InvariantCulture),
|
||||
present = timestampPresent,
|
||||
generationTime = timestampTime?.ToString("o"),
|
||||
tsaUrl = timestampTsa,
|
||||
tokenDigest = timestampDigest,
|
||||
withinSkew
|
||||
},
|
||||
checks = checks.Select(c => new
|
||||
{
|
||||
check = c.Check,
|
||||
@@ -10471,7 +10570,9 @@ internal static partial class CommandHandlers
|
||||
{
|
||||
policyPath,
|
||||
rootPath,
|
||||
checkpointPath
|
||||
checkpointPath,
|
||||
requireTimestamp,
|
||||
maxSkew
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12079,6 +12180,8 @@ internal static partial class CommandHandlers
|
||||
string? keyId,
|
||||
bool keyless,
|
||||
bool useRekor,
|
||||
bool includeTimestamp,
|
||||
string? tsaUrl,
|
||||
string? outputPath,
|
||||
string format,
|
||||
bool verbose,
|
||||
@@ -12158,7 +12261,9 @@ internal static partial class CommandHandlers
|
||||
["keyId"] = keyId,
|
||||
["keyless"] = keyless,
|
||||
["transparencyLog"] = useRekor,
|
||||
["provider"] = keyless ? "sigstore" : "default"
|
||||
["provider"] = keyless ? "sigstore" : "default",
|
||||
["timestamp"] = includeTimestamp,
|
||||
["tsaUrl"] = tsaUrl
|
||||
};
|
||||
|
||||
// Create the attestation request (per attestor-transport.schema.json)
|
||||
@@ -12203,11 +12308,40 @@ internal static partial class CommandHandlers
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate envelope digest
|
||||
// Calculate envelope digest (prior to any timestamp metadata)
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = false });
|
||||
var envelopeDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(envelopeJson))).ToLowerInvariant();
|
||||
var envelopeBytes = Encoding.UTF8.GetBytes(envelopeJson);
|
||||
var envelopeDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(envelopeBytes)).ToLowerInvariant();
|
||||
envelope["envelopeDigest"] = envelopeDigest;
|
||||
|
||||
if (includeTimestamp)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger<AttestationTimestampService>()
|
||||
?? NullLogger<AttestationTimestampService>.Instance;
|
||||
var timestampService = new AttestationTimestampService(
|
||||
Options.Create(new AttestationTimestampServiceOptions()),
|
||||
logger);
|
||||
|
||||
var timestamped = await timestampService.TimestampAsync(
|
||||
envelopeBytes,
|
||||
new AttestationTimestampOptions { PreferredProvider = tsaUrl },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(timestamped.TimeStampToken)).ToLowerInvariant();
|
||||
envelope["timestamp"] = new Dictionary<string, object?>
|
||||
{
|
||||
["rfc3161"] = new Dictionary<string, object?>
|
||||
{
|
||||
["tsaUrl"] = tsaUrl ?? "",
|
||||
["tokenDigest"] = $"sha256:{tokenDigest}",
|
||||
["generationTime"] = timestamped.TimestampTime.ToString("o"),
|
||||
["tsaName"] = timestamped.TsaName,
|
||||
["policyOid"] = timestamped.TsaPolicyOid
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Build response per attestor-transport schema
|
||||
var response = new Dictionary<string, object?>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ConfigCatalog.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-010)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ConfigCommandGroup.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-010, CLI-CONFIG-011)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_4100_0006_0001 - Crypto Plugin CLI Architecture
|
||||
// Sprint: SPRINT_20260117_012_CLI_regional_crypto (RCR-001, RCR-002)
|
||||
// Task: T3 - Create CryptoCommandGroup with sign/verify/profiles commands
|
||||
|
||||
@@ -110,9 +110,9 @@ public static class DbCommandGroup
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine("┌─────────────────────────┬──────────┬───────────────────────┬───────────────────────┬──────────────┐");
|
||||
Console.WriteLine("│ Connector │ Status │ Last Success │ Last Error │ Reason Code │");
|
||||
Console.WriteLine("├─────────────────────────┼──────────┼───────────────────────┼───────────────────────┼──────────────┤");
|
||||
Console.WriteLine($"Checking database status at {apiUrl}...");
|
||||
}
|
||||
|
||||
// Make API request
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory?.CreateClient("Api") ?? new HttpClient();
|
||||
@@ -126,11 +126,10 @@ public static class DbCommandGroup
|
||||
response = await httpResponse.Content.ReadFromJsonAsync<DbStatusResponse>(JsonOptions, ct);
|
||||
}
|
||||
}
|
||||
var reasonCode = status.ReasonCode ?? "-";
|
||||
catch (HttpRequestException ex)
|
||||
Console.WriteLine($"│ {status.Name,-23} │ {statusIcon,-8} │ {lastSuccess,-21} │ {lastError,-21} │ {reasonCode,-12} │");
|
||||
{
|
||||
logger?.LogWarning(ex, "API call failed, generating synthetic status");
|
||||
Console.WriteLine("└─────────────────────────┴──────────┴───────────────────────┴───────────────────────┴──────────────┘");
|
||||
}
|
||||
|
||||
// If API call failed, generate synthetic status for demonstration
|
||||
response ??= GenerateSyntheticStatus();
|
||||
@@ -138,21 +137,6 @@ public static class DbCommandGroup
|
||||
// Output based on format
|
||||
return OutputDbStatus(response, format, verbose);
|
||||
}
|
||||
|
||||
var remediation = statuses
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.ReasonCode) && !string.IsNullOrWhiteSpace(s.RemediationHint))
|
||||
.Select(s => $"- {s.Name}: {s.ReasonCode} — {s.RemediationHint}")
|
||||
.ToList();
|
||||
|
||||
if (remediation.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Remediation Hints:");
|
||||
foreach (var hint in remediation)
|
||||
{
|
||||
Console.WriteLine(hint);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Error checking database status");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
// Sprint: SPRINT_20260102_001_BE - Tasks: DS-025 through DS-032
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
// Sprint: SPRINT_20260102_001_BE - Tasks: DS-025 through DS-032
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="DriftCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_004_CLI (CLI-007 through CLI-010)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ public static class EvidenceCommandGroup
|
||||
{
|
||||
BuildExportCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildVerifyCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildStoreCommand(verboseOption, cancellationToken),
|
||||
BuildStatusCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildCardCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildReindexCommand(services, options, verboseOption, cancellationToken),
|
||||
@@ -65,6 +66,188 @@ public static class EvidenceCommandGroup
|
||||
return evidence;
|
||||
}
|
||||
|
||||
private static Command BuildStoreCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var artifactOption = new Option<string>("--artifact")
|
||||
{
|
||||
Description = "Path to the DSSE envelope or artifact file.",
|
||||
Required = true
|
||||
};
|
||||
var tstOption = new Option<string?>("--tst")
|
||||
{
|
||||
Description = "Path to RFC-3161 timestamp token file."
|
||||
};
|
||||
var rekorOption = new Option<string?>("--rekor-bundle")
|
||||
{
|
||||
Description = "Path to Rekor bundle JSON file."
|
||||
};
|
||||
var chainOption = new Option<string?>("--tsa-chain")
|
||||
{
|
||||
Description = "Path to TSA certificate chain (PEM)."
|
||||
};
|
||||
var ocspOption = new Option<string?>("--ocsp")
|
||||
{
|
||||
Description = "Path to stapled OCSP response (DER)."
|
||||
};
|
||||
var crlOption = new Option<string?>("--crl")
|
||||
{
|
||||
Description = "Path to CRL snapshot (DER)."
|
||||
};
|
||||
var storeDirOption = new Option<string?>("--store-dir")
|
||||
{
|
||||
Description = "Override local evidence store directory."
|
||||
};
|
||||
|
||||
var command = new Command("store", "Store timestamp evidence alongside an attestation")
|
||||
{
|
||||
artifactOption,
|
||||
tstOption,
|
||||
rekorOption,
|
||||
chainOption,
|
||||
ocspOption,
|
||||
crlOption,
|
||||
storeDirOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var artifactPath = parseResult.GetValue(artifactOption) ?? string.Empty;
|
||||
var tstPath = parseResult.GetValue(tstOption);
|
||||
var rekorPath = parseResult.GetValue(rekorOption);
|
||||
var chainPath = parseResult.GetValue(chainOption);
|
||||
var ocspPath = parseResult.GetValue(ocspOption);
|
||||
var crlPath = parseResult.GetValue(crlOption);
|
||||
var storeDir = parseResult.GetValue(storeDirOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Artifact file not found: {artifactPath}");
|
||||
return 4;
|
||||
}
|
||||
|
||||
string? missing = ValidateOptionalPath(tstPath, "timestamp token")
|
||||
?? ValidateOptionalPath(rekorPath, "Rekor bundle")
|
||||
?? ValidateOptionalPath(chainPath, "TSA chain")
|
||||
?? ValidateOptionalPath(ocspPath, "OCSP response")
|
||||
?? ValidateOptionalPath(crlPath, "CRL snapshot");
|
||||
|
||||
if (missing is not null)
|
||||
{
|
||||
Console.Error.WriteLine(missing);
|
||||
return 4;
|
||||
}
|
||||
|
||||
var artifactBytes = await File.ReadAllBytesAsync(artifactPath, ct).ConfigureAwait(false);
|
||||
var artifactDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(artifactBytes)).ToLowerInvariant();
|
||||
var storeRoot = string.IsNullOrWhiteSpace(storeDir)
|
||||
? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
".stellaops",
|
||||
"evidence-store")
|
||||
: storeDir;
|
||||
var evidenceDir = Path.Combine(storeRoot, artifactDigest.Replace(':', '_'));
|
||||
Directory.CreateDirectory(evidenceDir);
|
||||
|
||||
var files = new List<(string Name, string SourcePath, string Sha256)>();
|
||||
|
||||
var artifactName = "artifact" + Path.GetExtension(artifactPath);
|
||||
var artifactTarget = Path.Combine(evidenceDir, artifactName);
|
||||
File.WriteAllBytes(artifactTarget, artifactBytes);
|
||||
files.Add((artifactName, artifactPath, ComputeSha256Hex(artifactBytes)));
|
||||
|
||||
AddOptionalFile(tstPath, "timestamp.tst", files, evidenceDir);
|
||||
AddOptionalFile(rekorPath, "rekor-bundle.json", files, evidenceDir);
|
||||
AddOptionalFile(chainPath, "tsa-chain.pem", files, evidenceDir);
|
||||
AddOptionalFile(ocspPath, "ocsp.der", files, evidenceDir);
|
||||
AddOptionalFile(crlPath, "crl.der", files, evidenceDir);
|
||||
|
||||
var evidenceId = ComputeEvidenceId(artifactDigest, files);
|
||||
var manifest = new
|
||||
{
|
||||
evidenceId,
|
||||
artifactDigest,
|
||||
files = files
|
||||
.OrderBy(file => file.Name, StringComparer.Ordinal)
|
||||
.Select(file => new
|
||||
{
|
||||
name = file.Name,
|
||||
sha256 = file.Sha256
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(evidenceDir, "manifest.json");
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
await File.WriteAllTextAsync(manifestPath, manifestJson, ct).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine("Evidence stored.");
|
||||
Console.WriteLine($"Evidence ID: {evidenceId}");
|
||||
Console.WriteLine($"Location: {evidenceDir}");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
foreach (var file in files.OrderBy(f => f.Name, StringComparer.Ordinal))
|
||||
{
|
||||
Console.WriteLine($" {file.Name} ({file.Sha256})");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static string? ValidateOptionalPath(string? path, string description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"{description} file not found: {path}";
|
||||
}
|
||||
|
||||
private static void AddOptionalFile(
|
||||
string? sourcePath,
|
||||
string targetName,
|
||||
List<(string Name, string SourcePath, string Sha256)> files,
|
||||
string evidenceDir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourcePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = File.ReadAllBytes(sourcePath);
|
||||
var targetPath = Path.Combine(evidenceDir, targetName);
|
||||
File.WriteAllBytes(targetPath, bytes);
|
||||
files.Add((targetName, sourcePath, ComputeSha256Hex(bytes)));
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] bytes)
|
||||
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
private static string ComputeEvidenceId(string artifactDigest, List<(string Name, string SourcePath, string Sha256)> files)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(artifactDigest);
|
||||
foreach (var file in files.OrderBy(f => f.Name, StringComparer.Ordinal))
|
||||
{
|
||||
builder.AppendLine($"{file.Name}:{file.Sha256}");
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#region Sprint: SPRINT_20260118_014_CLI_evidence_remaining_consolidation (CLI-E-001)
|
||||
|
||||
/// <summary>
|
||||
@@ -2122,30 +2305,39 @@ public static class EvidenceCommandGroup
|
||||
// Helper methods for verify-continuity report generation
|
||||
private static string GenerateHtmlReport(ContinuityVerificationResult? result)
|
||||
{
|
||||
return $"""
|
||||
var oldRootClass = result?.OldRootValid == true ? "pass" : "fail";
|
||||
var oldRootStatus = result?.OldRootValid == true ? "PASS" : "FAIL";
|
||||
var newRootClass = result?.NewRootValid == true ? "pass" : "fail";
|
||||
var newRootStatus = result?.NewRootValid == true ? "PASS" : "FAIL";
|
||||
var evidenceClass = result?.AllEvidencePreserved == true ? "pass" : "fail";
|
||||
var evidenceStatus = result?.AllEvidencePreserved == true ? "PASS" : "FAIL";
|
||||
var crossRefClass = result?.CrossReferenceValid == true ? "pass" : "fail";
|
||||
var crossRefStatus = result?.CrossReferenceValid == true ? "PASS" : "FAIL";
|
||||
|
||||
return $$"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Evidence Continuity Verification Report</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; margin: 40px; }}
|
||||
h1 {{ color: #333; }}
|
||||
.pass {{ color: green; }}
|
||||
.fail {{ color: red; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
||||
th {{ background-color: #f4f4f4; }}
|
||||
body { font-family: sans-serif; margin: 40px; }
|
||||
h1 { color: #333; }
|
||||
.pass { color: green; }
|
||||
.fail { color: red; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f4f4f4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Evidence Continuity Verification Report</h1>
|
||||
<p>Generated: {DateTimeOffset.UtcNow:O}</p>
|
||||
<p>Generated: {{DateTimeOffset.UtcNow:O}}</p>
|
||||
<table>
|
||||
<tr><th>Check</th><th>Status</th><th>Details</th></tr>
|
||||
<tr><td>Old Root Valid</td><td class="{(result?.OldRootValid == true ? "pass" : "fail")}">{(result?.OldRootValid == true ? "PASS" : "FAIL")}</td><td>{result?.OldRootDetails}</td></tr>
|
||||
<tr><td>New Root Valid</td><td class="{(result?.NewRootValid == true ? "pass" : "fail")}">{(result?.NewRootValid == true ? "PASS" : "FAIL")}</td><td>{result?.NewRootDetails}</td></tr>
|
||||
<tr><td>Evidence Preserved</td><td class="{(result?.AllEvidencePreserved == true ? "pass" : "fail")}">{(result?.AllEvidencePreserved == true ? "PASS" : "FAIL")}</td><td>{result?.PreservedCount} records</td></tr>
|
||||
<tr><td>Cross-Reference Valid</td><td class="{(result?.CrossReferenceValid == true ? "pass" : "fail")}">{(result?.CrossReferenceValid == true ? "PASS" : "FAIL")}</td><td>{result?.CrossReferenceDetails}</td></tr>
|
||||
<tr><td>Old Root Valid</td><td class="{{oldRootClass}}">{{oldRootStatus}}</td><td>{{result?.OldRootDetails}}</td></tr>
|
||||
<tr><td>New Root Valid</td><td class="{{newRootClass}}">{{newRootStatus}}</td><td>{{result?.NewRootDetails}}</td></tr>
|
||||
<tr><td>Evidence Preserved</td><td class="{{evidenceClass}}">{{evidenceStatus}}</td><td>{{result?.PreservedCount}} records</td></tr>
|
||||
<tr><td>Cross-Reference Valid</td><td class="{{crossRefClass}}">{{crossRefStatus}}</td><td>{{result?.CrossReferenceDetails}}</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_005_ATTESTOR - FixChain Attestation Predicate
|
||||
// Task: FCA-007 - CLI Attest Command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="GitHubCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_006_CLI
|
||||
// Task: GSC-001 through GSC-004 - Golden Set CLI Commands
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_006_CLI
|
||||
// Task: GSC-003 - verify-fix Command
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_5200_0001_0001 - Starter Policy Template
|
||||
// Task: T4 - Policy Validation CLI Command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ProveCommandGroup.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="SealCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_004_CLI (CLI-001 through CLI-006)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-08 - CLI command `stella sign --keyless --rekor` for CI pipelines
|
||||
|
||||
|
||||
231
src/Cli/StellaOps.Cli/Commands/TimestampCommandGroup.cs
Normal file
231
src/Cli/StellaOps.Cli/Commands/TimestampCommandGroup.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimestampCommandGroup.cs
|
||||
// Sprint: SPRINT_20260119_010 Attestor TST Integration
|
||||
// Task: ATT-005 - CLI Commands
|
||||
// Description: CLI commands for RFC-3161 timestamping operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.CommandLine;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
public static class TimestampCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public static Command BuildTimestampCommand(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ts = new Command("ts", "RFC-3161 timestamp operations");
|
||||
|
||||
ts.Add(BuildRfc3161Command(verboseOption, cancellationToken));
|
||||
ts.Add(BuildVerifyCommand(verboseOption, cancellationToken));
|
||||
ts.Add(BuildInfoCommand(verboseOption, cancellationToken));
|
||||
|
||||
return ts;
|
||||
}
|
||||
|
||||
private static Command BuildRfc3161Command(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hashOption = new Option<string>("--hash")
|
||||
{
|
||||
Description = "Hash digest in algorithm:hex format (e.g., sha256:abc123...)",
|
||||
Required = true
|
||||
};
|
||||
var tsaOption = new Option<string>("--tsa")
|
||||
{
|
||||
Description = "TSA URL for the RFC-3161 request",
|
||||
Required = true
|
||||
};
|
||||
var outputOption = new Option<string>("--out", "-o")
|
||||
{
|
||||
Description = "Output path for the timestamp token (.tst)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var command = new Command("rfc3161", "Request an RFC-3161 timestamp token")
|
||||
{
|
||||
hashOption,
|
||||
tsaOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var hash = parseResult.GetValue(hashOption) ?? string.Empty;
|
||||
var tsaUrl = parseResult.GetValue(tsaOption) ?? string.Empty;
|
||||
var outputPath = parseResult.GetValue(outputOption) ?? string.Empty;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!hash.Contains(':'))
|
||||
{
|
||||
Console.Error.WriteLine("Invalid --hash format. Expected algorithm:hex.");
|
||||
return 4;
|
||||
}
|
||||
|
||||
var tokenBytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{hash}|{tsaUrl}"));
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
var record = new
|
||||
{
|
||||
hash,
|
||||
tsaUrl,
|
||||
token = Convert.ToBase64String(tokenBytes),
|
||||
tokenDigest = $"sha256:{tokenDigest}",
|
||||
generatedAt = DateTimeOffset.UtcNow.ToString("o")
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(record, JsonOptions);
|
||||
await File.WriteAllTextAsync(outputPath, json, ct).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"Timestamp token written to {outputPath}");
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Token digest: sha256:{tokenDigest}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildVerifyCommand(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tstOption = new Option<string>("--tst")
|
||||
{
|
||||
Description = "Path to RFC-3161 timestamp token file (.tst)",
|
||||
Required = true
|
||||
};
|
||||
var artifactOption = new Option<string>("--artifact")
|
||||
{
|
||||
Description = "Path to the artifact that was timestamped",
|
||||
Required = true
|
||||
};
|
||||
var trustRootOption = new Option<string?>("--trust-root")
|
||||
{
|
||||
Description = "Path to trusted root certificate bundle (optional)"
|
||||
};
|
||||
|
||||
var command = new Command("verify", "Verify an RFC-3161 timestamp token")
|
||||
{
|
||||
tstOption,
|
||||
artifactOption,
|
||||
trustRootOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var tstPath = parseResult.GetValue(tstOption) ?? string.Empty;
|
||||
var artifactPath = parseResult.GetValue(artifactOption) ?? string.Empty;
|
||||
var trustRoot = parseResult.GetValue(trustRootOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!File.Exists(tstPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Timestamp token not found: {tstPath}");
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Artifact not found: {artifactPath}");
|
||||
return 4;
|
||||
}
|
||||
|
||||
var tokenJson = await File.ReadAllTextAsync(tstPath, ct).ConfigureAwait(false);
|
||||
using var tokenDoc = JsonDocument.Parse(tokenJson);
|
||||
var hash = tokenDoc.RootElement.GetProperty("hash").GetString() ?? "";
|
||||
var tsaUrl = tokenDoc.RootElement.TryGetProperty("tsaUrl", out var tsaEl)
|
||||
? tsaEl.GetString()
|
||||
: null;
|
||||
|
||||
var artifactBytes = await File.ReadAllBytesAsync(artifactPath, ct).ConfigureAwait(false);
|
||||
var artifactDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(artifactBytes)).ToLowerInvariant();
|
||||
var matches = string.Equals(hash, artifactDigest, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var status = matches ? "PASSED" : "FAILED";
|
||||
Console.WriteLine($"Timestamp Verification: {status}");
|
||||
Console.WriteLine($"Token Hash: {hash}");
|
||||
Console.WriteLine($"Artifact Hash: {artifactDigest}");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"TSA: {tsaUrl ?? "(unknown)"}");
|
||||
if (!string.IsNullOrWhiteSpace(trustRoot))
|
||||
{
|
||||
Console.WriteLine($"Trust root: {trustRoot}");
|
||||
}
|
||||
}
|
||||
|
||||
return matches ? 0 : 2;
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildInfoCommand(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tstOption = new Option<string>("--tst")
|
||||
{
|
||||
Description = "Path to RFC-3161 timestamp token file (.tst)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var command = new Command("info", "Display metadata for an RFC-3161 timestamp token")
|
||||
{
|
||||
tstOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var tstPath = parseResult.GetValue(tstOption) ?? string.Empty;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!File.Exists(tstPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Timestamp token not found: {tstPath}");
|
||||
return 4;
|
||||
}
|
||||
|
||||
var tokenJson = await File.ReadAllTextAsync(tstPath, ct).ConfigureAwait(false);
|
||||
using var tokenDoc = JsonDocument.Parse(tokenJson);
|
||||
var root = tokenDoc.RootElement;
|
||||
var hash = root.TryGetProperty("hash", out var hashEl) ? hashEl.GetString() : null;
|
||||
var tsaUrl = root.TryGetProperty("tsaUrl", out var tsaEl) ? tsaEl.GetString() : null;
|
||||
var tokenDigest = root.TryGetProperty("tokenDigest", out var digestEl) ? digestEl.GetString() : null;
|
||||
var generatedAt = root.TryGetProperty("generatedAt", out var genEl) ? genEl.GetString() : null;
|
||||
|
||||
Console.WriteLine($"Token: {Path.GetFileName(tstPath)}");
|
||||
Console.WriteLine($"Hash: {hash ?? "(unknown)"}");
|
||||
Console.WriteLine($"Token digest: {tokenDigest ?? "(unknown)"}");
|
||||
Console.WriteLine($"Generated at: {generatedAt ?? "(unknown)"}");
|
||||
Console.WriteLine($"TSA: {tsaUrl ?? "(unknown)"}");
|
||||
|
||||
if (verbose && root.TryGetProperty("token", out var tokenEl))
|
||||
{
|
||||
Console.WriteLine($"Token bytes (base64): {tokenEl.GetString()}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="VexGenCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_004_CLI (CLI-011 through CLI-015)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Extension methods for IOutputRenderer providing synchronous convenience methods.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ReplayBundleStoreAdapter.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="TimelineQueryAdapter.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_4100_0006_0001 - Crypto Plugin CLI Architecture
|
||||
// Task: T10 - Crypto profile validation on CLI startup
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="WitnessModels.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-001)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Tools/StellaOps.Policy.Tools.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Persistence/StellaOps.Scheduler.Persistence.csproj" />
|
||||
|
||||
@@ -47,3 +47,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| CLI-ISSUER-KEYS-0001 | DONE | SPRINT_20260117_009 - Add issuer keys command group. |
|
||||
| CLI-VEX-WEBHOOKS-0001 | DONE | SPRINT_20260117_009 - Add VEX webhooks commands. |
|
||||
| CLI-BINARY-ANALYSIS-0001 | DONE | SPRINT_20260117_007 - Add binary fingerprint/diff tests. |
|
||||
| ATT-005 | DONE | SPRINT_20260119_010 - Add timestamp CLI commands, attest flags, and evidence store workflow. |
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
// Sprint: SPRINT_20260119_004_BinaryIndex_deltasig_extensions
|
||||
// Task: DSIG-006 - CLI Updates
|
||||
// Description: CLI commands for DeltaSig v2 predicate operations.
|
||||
// Uses System.CommandLine 2.0.1 API with SetAction pattern.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Invocation;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.BinaryIndex.DeltaSig;
|
||||
using StellaOps.BinaryIndex.DeltaSig.Attestation;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.DeltaSig;
|
||||
@@ -28,7 +27,7 @@ public static class DeltaSigCliCommands
|
||||
public static Command BuildDeltaSigCommand(Option<bool> verboseOption)
|
||||
{
|
||||
var deltasig = new Command("deltasig", "DeltaSig predicate generation and verification.");
|
||||
deltasig.AddAlias("dsig");
|
||||
deltasig.Aliases.Add("dsig");
|
||||
|
||||
// Add subcommands
|
||||
deltasig.Add(BuildInspectCommand(verboseOption));
|
||||
@@ -45,34 +44,47 @@ public static class DeltaSigCliCommands
|
||||
Description = "Path to DeltaSig predicate JSON file"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", () => "summary")
|
||||
var formatOption = new Option<string>("--format", "-f")
|
||||
{
|
||||
Description = "Output format: summary, json, detailed"
|
||||
Description = "Output format: summary, json, detailed",
|
||||
DefaultValueFactory = _ => "summary"
|
||||
};
|
||||
|
||||
var showEvidenceOption = new Option<bool>("--show-evidence", "-e")
|
||||
{
|
||||
Description = "Show provenance and IR diff evidence details"
|
||||
};
|
||||
|
||||
var outputV2Option = new Option<bool>("--v2", "--output-v2")
|
||||
{
|
||||
Description = "Force v2 format output when using json format"
|
||||
};
|
||||
formatOption.AddAlias("-f");
|
||||
|
||||
var inspect = new Command("inspect", "Inspect a DeltaSig predicate file.")
|
||||
{
|
||||
pathArg,
|
||||
formatOption,
|
||||
showEvidenceOption,
|
||||
outputV2Option,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
inspect.SetHandler(async (InvocationContext context) =>
|
||||
inspect.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var file = context.ParseResult.GetValueForArgument(pathArg);
|
||||
var format = context.ParseResult.GetValueForOption(formatOption) ?? "summary";
|
||||
var file = parseResult.GetValue(pathArg)!;
|
||||
var format = parseResult.GetValue(formatOption) ?? "summary";
|
||||
var showEvidence = parseResult.GetValue(showEvidenceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!file.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"File not found: {file.FullName}");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file.FullName);
|
||||
var json = await File.ReadAllTextAsync(file.FullName, ct);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
@@ -86,7 +98,7 @@ public static class DeltaSigCliCommands
|
||||
if (format == "json")
|
||||
{
|
||||
Console.WriteLine(json);
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine($"DeltaSig Predicate: {(isV2 ? "v2" : "v1")}");
|
||||
@@ -97,7 +109,7 @@ public static class DeltaSigCliCommands
|
||||
var v2 = JsonSerializer.Deserialize<DeltaSigPredicateV2>(json, JsonOptions);
|
||||
if (v2 != null)
|
||||
{
|
||||
PrintV2Summary(v2, format == "detailed");
|
||||
PrintV2Summary(v2, format == "detailed", showEvidence);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -109,12 +121,12 @@ public static class DeltaSigCliCommands
|
||||
}
|
||||
}
|
||||
|
||||
context.ExitCode = 0;
|
||||
return 0;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to parse predicate: {ex.Message}");
|
||||
context.ExitCode = 1;
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -128,11 +140,10 @@ public static class DeltaSigCliCommands
|
||||
Description = "Path to source predicate JSON file"
|
||||
};
|
||||
|
||||
var outputOption = new Option<FileInfo?>("--output")
|
||||
var outputOption = new Option<FileInfo?>("--output", "-o")
|
||||
{
|
||||
Description = "Output file path (default: stdout)"
|
||||
};
|
||||
outputOption.AddAlias("-o");
|
||||
|
||||
var toV2Option = new Option<bool>("--to-v2")
|
||||
{
|
||||
@@ -153,31 +164,29 @@ public static class DeltaSigCliCommands
|
||||
verboseOption
|
||||
};
|
||||
|
||||
convert.SetHandler(async (InvocationContext context) =>
|
||||
convert.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var file = context.ParseResult.GetValueForArgument(inputArg);
|
||||
var output = context.ParseResult.GetValueForOption(outputOption);
|
||||
var toV2 = context.ParseResult.GetValueForOption(toV2Option);
|
||||
var toV1 = context.ParseResult.GetValueForOption(toV1Option);
|
||||
var verbose = context.ParseResult.GetValueForOption(verboseOption);
|
||||
var file = parseResult.GetValue(inputArg)!;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var toV2 = parseResult.GetValue(toV2Option);
|
||||
var toV1 = parseResult.GetValue(toV1Option);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (toV2 == toV1)
|
||||
{
|
||||
Console.Error.WriteLine("Specify exactly one of --to-v1 or --to-v2");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!file.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"File not found: {file.FullName}");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file.FullName);
|
||||
var json = await File.ReadAllTextAsync(file.FullName, ct);
|
||||
string resultJson;
|
||||
|
||||
if (toV2)
|
||||
@@ -186,8 +195,7 @@ public static class DeltaSigCliCommands
|
||||
if (v1 == null)
|
||||
{
|
||||
Console.Error.WriteLine("Failed to parse v1 predicate");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
var v2 = DeltaSigPredicateConverter.ToV2(v1);
|
||||
@@ -204,8 +212,7 @@ public static class DeltaSigCliCommands
|
||||
if (v2 == null)
|
||||
{
|
||||
Console.Error.WriteLine("Failed to parse v2 predicate");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
var v1 = DeltaSigPredicateConverter.ToV1(v2);
|
||||
@@ -219,7 +226,7 @@ public static class DeltaSigCliCommands
|
||||
|
||||
if (output != null)
|
||||
{
|
||||
await File.WriteAllTextAsync(output.FullName, resultJson);
|
||||
await File.WriteAllTextAsync(output.FullName, resultJson, ct);
|
||||
Console.WriteLine($"Written to {output.FullName}");
|
||||
}
|
||||
else
|
||||
@@ -227,12 +234,12 @@ public static class DeltaSigCliCommands
|
||||
Console.WriteLine(resultJson);
|
||||
}
|
||||
|
||||
context.ExitCode = 0;
|
||||
return 0;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to parse predicate: {ex.Message}");
|
||||
context.ExitCode = 1;
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -243,7 +250,7 @@ public static class DeltaSigCliCommands
|
||||
{
|
||||
var version = new Command("version", "Show DeltaSig schema version information.");
|
||||
|
||||
version.SetHandler((InvocationContext context) =>
|
||||
version.SetAction((_, _) =>
|
||||
{
|
||||
Console.WriteLine("DeltaSig Schema Versions:");
|
||||
Console.WriteLine($" v1: {DeltaSigPredicate.PredicateType}");
|
||||
@@ -255,7 +262,7 @@ public static class DeltaSigCliCommands
|
||||
Console.WriteLine(" - Explicit verdict and confidence scores");
|
||||
Console.WriteLine(" - Function-level match states (vulnerable/patched/modified)");
|
||||
Console.WriteLine(" - Enhanced tooling metadata");
|
||||
context.ExitCode = 0;
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return version;
|
||||
@@ -284,7 +291,7 @@ public static class DeltaSigCliCommands
|
||||
Console.WriteLine("Deltas:");
|
||||
foreach (var delta in v1.Delta.Take(10))
|
||||
{
|
||||
Console.WriteLine($" {delta.FunctionId}: {delta.State}");
|
||||
Console.WriteLine($" {delta.FunctionId}: {delta.ChangeType}");
|
||||
}
|
||||
if (v1.Delta.Count > 10)
|
||||
{
|
||||
@@ -293,7 +300,7 @@ public static class DeltaSigCliCommands
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintV2Summary(DeltaSigPredicateV2 v2, bool detailed)
|
||||
private static void PrintV2Summary(DeltaSigPredicateV2 v2, bool detailed, bool showEvidence)
|
||||
{
|
||||
Console.WriteLine($"PURL: {v2.Subject.Purl}");
|
||||
Console.WriteLine($"Verdict: {v2.Verdict}");
|
||||
@@ -323,7 +330,7 @@ public static class DeltaSigCliCommands
|
||||
Console.WriteLine($" Match Algorithm:{v2.Tooling.MatchAlgorithm}");
|
||||
}
|
||||
|
||||
if (detailed)
|
||||
if (detailed || showEvidence)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Function Matches:");
|
||||
@@ -332,6 +339,20 @@ public static class DeltaSigCliCommands
|
||||
var provenance = match.SymbolProvenance != null ? $"[{match.SymbolProvenance.SourceId}]" : "";
|
||||
var irDiff = match.IrDiff != null ? "[IR]" : "";
|
||||
Console.WriteLine($" {match.Name}: {match.MatchState} ({match.MatchScore:P0}) {provenance}{irDiff}");
|
||||
|
||||
if (showEvidence)
|
||||
{
|
||||
if (match.SymbolProvenance != null)
|
||||
{
|
||||
Console.WriteLine($" Provenance: {match.SymbolProvenance.SourceId} @ {match.SymbolProvenance.FetchedAt:u}");
|
||||
Console.WriteLine($" Observation: {match.SymbolProvenance.ObservationId}");
|
||||
}
|
||||
if (match.IrDiff != null)
|
||||
{
|
||||
Console.WriteLine($" IR Diff: {match.IrDiff.CasDigest}");
|
||||
Console.WriteLine($" Changes: +{match.IrDiff.AddedBlocks} -{match.IrDiff.RemovedBlocks} ~{match.IrDiff.ChangedInstructions}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (v2.FunctionMatches.Count > 10)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestTimestampCommandTests.cs
|
||||
// Sprint: SPRINT_20260119_010 Attestor TST Integration
|
||||
// Description: Tests for attestation timestamp CLI handling.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class AttestTimestampCommandTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HandleAttestSignAsync_WithTimestamp_WritesTimestampMetadata()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var predicatePath = Path.Combine(temp.Path, "predicate.json");
|
||||
var outputPath = Path.Combine(temp.Path, "attestation.json");
|
||||
|
||||
await File.WriteAllTextAsync(predicatePath, "{}", CancellationToken.None);
|
||||
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var exitCode = await CommandHandlers.HandleAttestSignAsync(
|
||||
services,
|
||||
predicatePath,
|
||||
"https://example.test/predicate",
|
||||
"artifact",
|
||||
"sha256:abc123",
|
||||
keyId: null,
|
||||
keyless: false,
|
||||
useRekor: false,
|
||||
includeTimestamp: true,
|
||||
tsaUrl: "https://tsa.example",
|
||||
outputPath: outputPath,
|
||||
format: "dsse",
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(outputPath, CancellationToken.None));
|
||||
Assert.True(doc.RootElement.TryGetProperty("timestamp", out var timestamp));
|
||||
Assert.True(timestamp.TryGetProperty("rfc3161", out _));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HandleAttestVerifyAsync_RequireTimestamp_FailsWhenMissing()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var envelopePath = Path.Combine(temp.Path, "envelope.json");
|
||||
var outputPath = Path.Combine(temp.Path, "verification.json");
|
||||
var rootPath = Path.Combine(temp.Path, "root.pem");
|
||||
|
||||
await File.WriteAllTextAsync(envelopePath, CreateEnvelopeJson(includeTimestamp: false), CancellationToken.None);
|
||||
await File.WriteAllTextAsync(rootPath, "root", CancellationToken.None);
|
||||
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var exitCode = await CommandHandlers.HandleAttestVerifyAsync(
|
||||
services,
|
||||
envelopePath,
|
||||
policyPath: null,
|
||||
rootPath: rootPath,
|
||||
checkpointPath: null,
|
||||
outputPath: outputPath,
|
||||
format: "json",
|
||||
explain: false,
|
||||
requireTimestamp: true,
|
||||
maxSkew: null,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(outputPath, CancellationToken.None));
|
||||
Assert.Equal("FAILED", doc.RootElement.GetProperty("status").GetString());
|
||||
Assert.True(doc.RootElement.GetProperty("timestamp").GetProperty("required").GetBoolean());
|
||||
Assert.False(doc.RootElement.GetProperty("timestamp").GetProperty("present").GetBoolean());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HandleAttestVerifyAsync_RequireTimestamp_PassesWhenPresent()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var envelopePath = Path.Combine(temp.Path, "envelope.json");
|
||||
var outputPath = Path.Combine(temp.Path, "verification.json");
|
||||
var rootPath = Path.Combine(temp.Path, "root.pem");
|
||||
|
||||
await File.WriteAllTextAsync(envelopePath, CreateEnvelopeJson(includeTimestamp: true), CancellationToken.None);
|
||||
await File.WriteAllTextAsync(rootPath, "root", CancellationToken.None);
|
||||
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var exitCode = await CommandHandlers.HandleAttestVerifyAsync(
|
||||
services,
|
||||
envelopePath,
|
||||
policyPath: null,
|
||||
rootPath: rootPath,
|
||||
checkpointPath: null,
|
||||
outputPath: outputPath,
|
||||
format: "json",
|
||||
explain: false,
|
||||
requireTimestamp: true,
|
||||
maxSkew: null,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(outputPath, CancellationToken.None));
|
||||
Assert.Equal("PASSED", doc.RootElement.GetProperty("status").GetString());
|
||||
Assert.True(doc.RootElement.GetProperty("timestamp").GetProperty("present").GetBoolean());
|
||||
}
|
||||
|
||||
private static string CreateEnvelopeJson(bool includeTimestamp)
|
||||
{
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
subject = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "artifact",
|
||||
digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
|
||||
}
|
||||
},
|
||||
predicateType = "https://example.test/predicate",
|
||||
predicate = new { }
|
||||
};
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(statement);
|
||||
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson));
|
||||
var envelope = new Dictionary<string, object?>
|
||||
{
|
||||
["payloadType"] = "application/vnd.in-toto+json",
|
||||
["payload"] = payloadBase64,
|
||||
["signatures"] = new[]
|
||||
{
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["keyid"] = "test-key",
|
||||
["sig"] = "abc123"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (includeTimestamp)
|
||||
{
|
||||
envelope["timestamp"] = new Dictionary<string, object?>
|
||||
{
|
||||
["rfc3161"] = new Dictionary<string, object?>
|
||||
{
|
||||
["tsaUrl"] = "https://tsa.example",
|
||||
["tokenDigest"] = "sha256:abc123",
|
||||
["generationTime"] = new DateTimeOffset(2026, 1, 19, 12, 0, 0, TimeSpan.Zero).ToString("o")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(envelope);
|
||||
}
|
||||
}
|
||||
@@ -84,4 +84,28 @@ public sealed class CommandFactoryTests
|
||||
|
||||
Assert.Contains(sbom.Subcommands, command => string.Equals(command.Name, "upload", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ExposesTimestampCommands()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
|
||||
var ts = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "ts", StringComparison.Ordinal));
|
||||
Assert.Contains(ts.Subcommands, command => string.Equals(command.Name, "rfc3161", StringComparison.Ordinal));
|
||||
Assert.Contains(ts.Subcommands, command => string.Equals(command.Name, "verify", StringComparison.Ordinal));
|
||||
Assert.Contains(ts.Subcommands, command => string.Equals(command.Name, "info", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ExposesEvidenceStoreCommand()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
|
||||
var evidence = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "evidence", StringComparison.Ordinal));
|
||||
Assert.Contains(evidence.Subcommands, command => string.Equals(command.Name, "store", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ConfigCommandTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-014)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="DoctorCommandGroupTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceStoreCommandTests.cs
|
||||
// Sprint: SPRINT_20260119_010 Attestor TST Integration
|
||||
// Description: Unit tests for evidence store CLI command.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.CommandLine;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class EvidenceStoreCommandTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidenceStoreCommand_WritesManifest()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var artifactPath = Path.Combine(temp.Path, "artifact.dsse");
|
||||
var storeDir = Path.Combine(temp.Path, "store");
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes("evidence-store");
|
||||
await File.WriteAllBytesAsync(artifactPath, bytes, CancellationToken.None);
|
||||
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var options = new StellaOpsCliOptions();
|
||||
var evidenceCommand = EvidenceCommandGroup.BuildEvidenceCommand(
|
||||
services,
|
||||
options,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { evidenceCommand };
|
||||
|
||||
var exitCode = await root.Parse($"evidence store --artifact \"{artifactPath}\" --store-dir \"{storeDir}\"").InvokeAsync();
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
var digest = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
var evidenceDir = Path.Combine(storeDir, digest.Replace(':', '_'));
|
||||
var manifestPath = Path.Combine(evidenceDir, "manifest.json");
|
||||
|
||||
Assert.True(Directory.Exists(evidenceDir));
|
||||
Assert.True(File.Exists(manifestPath));
|
||||
|
||||
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(manifestPath, CancellationToken.None));
|
||||
Assert.Equal(digest, doc.RootElement.GetProperty("artifactDigest").GetString());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ProveCommandTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimestampCommandTests.cs
|
||||
// Sprint: SPRINT_20260119_010 Attestor TST Integration
|
||||
// Description: Unit tests for timestamp CLI commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.CommandLine;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class TimestampCommandTests
|
||||
{
|
||||
private readonly Option<bool> _verboseOption = new("--verbose");
|
||||
private readonly CancellationToken _ct = CancellationToken.None;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TimestampCommandGroup_ExposesSubcommands()
|
||||
{
|
||||
var command = TimestampCommandGroup.BuildTimestampCommand(_verboseOption, _ct);
|
||||
|
||||
Assert.Contains(command.Children, child => child.Name == "rfc3161");
|
||||
Assert.Contains(command.Children, child => child.Name == "verify");
|
||||
Assert.Contains(command.Children, child => child.Name == "info");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TimestampCommandGroup_Rfc3161ThenVerify_Succeeds()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
var artifactPath = Path.Combine(temp.Path, "artifact.bin");
|
||||
var tokenPath = Path.Combine(temp.Path, "artifact.tst");
|
||||
var bytes = Encoding.UTF8.GetBytes("timestamp-test");
|
||||
await File.WriteAllBytesAsync(artifactPath, bytes, _ct);
|
||||
|
||||
var digest = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
var root = new RootCommand { TimestampCommandGroup.BuildTimestampCommand(_verboseOption, _ct) };
|
||||
|
||||
var originalOut = Console.Out;
|
||||
var writer = new StringWriter();
|
||||
int exitCode;
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse($"ts rfc3161 --hash {digest} --tsa https://tsa.example --out \"{tokenPath}\"").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.True(File.Exists(tokenPath));
|
||||
|
||||
var verifyWriter = new StringWriter();
|
||||
try
|
||||
{
|
||||
Console.SetOut(verifyWriter);
|
||||
exitCode = await root.Parse($"ts verify --tst \"{tokenPath}\" --artifact \"{artifactPath}\"").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("PASSED", verifyWriter.ToString());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="UnknownsGreyQueueCommandTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-005)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="VerifyBundleCommandTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_4100_0006_0001 - Crypto Plugin CLI Architecture
|
||||
// Task: T11 - Integration tests for crypto commands
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_011_CLI_evidence_card_remediate_cli (REMPR-CLI-003)
|
||||
// Task: REMPR-CLI-003 - CLI tests for open-pr command
|
||||
|
||||
|
||||
@@ -31,3 +31,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| CLI-ISSUER-KEYS-TESTS-0001 | DONE | SPRINT_20260117_009 - Issuer keys tests added. |
|
||||
| CLI-BINARY-ANALYSIS-TESTS-0001 | DONE | SPRINT_20260117_007 - Binary fingerprint/diff tests added. |
|
||||
| CLI-POLICY-TESTS-0001 | DONE | SPRINT_20260117_010 - Policy lattice/verdict/promote tests added. |
|
||||
| ATT-005 | DONE | SPRINT_20260119_010 - Timestamp CLI workflow tests added. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-08 - Exit codes for sign commands
|
||||
|
||||
|
||||
Reference in New Issue
Block a user