release orchestrator v1 draft and build fixes
This commit is contained in:
580
src/Cli/StellaOps.Cli/Commands/PatchAttestCommandGroup.cs
Normal file
580
src/Cli/StellaOps.Cli/Commands/PatchAttestCommandGroup.cs
Normal file
@@ -0,0 +1,580 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PatchAttestCommandGroup.cs
|
||||
// Sprint: SPRINT_20260111_001_005_CLI_attest_patch
|
||||
// Task: Patch attestation command for creating DSSE-signed patch evidence
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Feedser.BinaryAnalysis.Models;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for patch attestation operations.
|
||||
/// Creates DSSE-signed attestations from before/after binary analysis.
|
||||
/// </summary>
|
||||
public static class PatchAttestCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 'attest patch' command.
|
||||
/// Creates a patch verification attestation from before/after binaries.
|
||||
/// </summary>
|
||||
public static Command BuildPatchAttestCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var cveOption = new Option<string>("--cve", "-c")
|
||||
{
|
||||
Description = "CVE identifier being attested (e.g., CVE-2024-1234)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var fromOption = new Option<FileInfo>("--from", "-f")
|
||||
{
|
||||
Description = "Path to vulnerable binary (before patch)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var toOption = new Option<FileInfo>("--to", "-t")
|
||||
{
|
||||
Description = "Path to patched binary (after patch)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var outputOption = new Option<FileInfo?>("--out", "-o")
|
||||
{
|
||||
Description = "Output DSSE envelope file (prints to stdout if not specified)"
|
||||
};
|
||||
|
||||
var purlOption = new Option<string?>("--purl", "-p")
|
||||
{
|
||||
Description = "Package URL for the component (e.g., pkg:rpm/openssl@1.1.1k-123.el8)"
|
||||
};
|
||||
|
||||
var keyOption = new Option<string?>("--key", "-k")
|
||||
{
|
||||
Description = "Path to private key for signing (PEM or PKCS#8)"
|
||||
};
|
||||
|
||||
var keylessOption = new Option<bool>("--sign-keyless")
|
||||
{
|
||||
Description = "Use Sigstore keyless signing (OIDC)"
|
||||
};
|
||||
|
||||
var noSignOption = new Option<bool>("--no-sign")
|
||||
{
|
||||
Description = "Skip signing (output unsigned attestation payload)"
|
||||
};
|
||||
|
||||
var noRekorOption = new Option<bool>("--no-rekor")
|
||||
{
|
||||
Description = "Skip Rekor transparency log publication"
|
||||
};
|
||||
|
||||
var publishOption = new Option<bool>("--publish")
|
||||
{
|
||||
Description = "Publish attestation to Authority service"
|
||||
};
|
||||
|
||||
var manifestOption = new Option<FileInfo?>("--manifest", "-m")
|
||||
{
|
||||
Description = "Patch manifest file for batch attestation (YAML)"
|
||||
};
|
||||
|
||||
var outDirOption = new Option<DirectoryInfo?>("--out-dir")
|
||||
{
|
||||
Description = "Output directory for batch attestations"
|
||||
};
|
||||
|
||||
var issuerOption = new Option<string?>("--issuer")
|
||||
{
|
||||
Description = "Issuer identifier for the attestation"
|
||||
};
|
||||
|
||||
var descriptionOption = new Option<string?>("--description")
|
||||
{
|
||||
Description = "Human-readable description of the patch"
|
||||
};
|
||||
|
||||
var patch = new Command("patch", "Create DSSE-signed patch verification attestation")
|
||||
{
|
||||
cveOption,
|
||||
fromOption,
|
||||
toOption,
|
||||
outputOption,
|
||||
purlOption,
|
||||
keyOption,
|
||||
keylessOption,
|
||||
noSignOption,
|
||||
noRekorOption,
|
||||
publishOption,
|
||||
manifestOption,
|
||||
outDirOption,
|
||||
issuerOption,
|
||||
descriptionOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
patch.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var cve = parseResult.GetValue(cveOption) ?? string.Empty;
|
||||
var from = parseResult.GetValue(fromOption)!;
|
||||
var to = parseResult.GetValue(toOption)!;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var purl = parseResult.GetValue(purlOption);
|
||||
var keyPath = parseResult.GetValue(keyOption);
|
||||
var keyless = parseResult.GetValue(keylessOption);
|
||||
var noSign = parseResult.GetValue(noSignOption);
|
||||
var noRekor = parseResult.GetValue(noRekorOption);
|
||||
var publish = parseResult.GetValue(publishOption);
|
||||
var manifest = parseResult.GetValue(manifestOption);
|
||||
var outDir = parseResult.GetValue(outDirOption);
|
||||
var issuer = parseResult.GetValue(issuerOption);
|
||||
var description = parseResult.GetValue(descriptionOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await ExecutePatchAttestAsync(
|
||||
cve,
|
||||
from,
|
||||
to,
|
||||
output,
|
||||
purl,
|
||||
keyPath,
|
||||
keyless,
|
||||
noSign,
|
||||
noRekor,
|
||||
publish,
|
||||
manifest,
|
||||
outDir,
|
||||
issuer,
|
||||
description,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecutePatchAttestAsync(
|
||||
string cve,
|
||||
FileInfo fromFile,
|
||||
FileInfo toFile,
|
||||
FileInfo? outputFile,
|
||||
string? purl,
|
||||
string? keyPath,
|
||||
bool keyless,
|
||||
bool noSign,
|
||||
bool noRekor,
|
||||
bool publish,
|
||||
FileInfo? manifest,
|
||||
DirectoryInfo? outDir,
|
||||
string? issuer,
|
||||
string? description,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate input files
|
||||
if (!fromFile.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Vulnerable binary not found: {fromFile.FullName}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!toFile.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Patched binary not found: {toFile.FullName}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine("Creating patch attestation...");
|
||||
Console.WriteLine($" CVE: {cve}");
|
||||
Console.WriteLine($" From (vulnerable): {fromFile.FullName}");
|
||||
Console.WriteLine($" To (patched): {toFile.FullName}");
|
||||
if (purl is not null)
|
||||
Console.WriteLine($" PURL: {purl}");
|
||||
if (outputFile is not null)
|
||||
Console.WriteLine($" Output: {outputFile.FullName}");
|
||||
Console.WriteLine($" Sign: {(noSign ? "disabled" : (keyless ? "keyless" : (keyPath is not null ? keyPath : "default")))}");
|
||||
Console.WriteLine($" Rekor: {(noRekor ? "disabled" : "enabled")}");
|
||||
}
|
||||
|
||||
// Read binary files
|
||||
var fromBytes = await File.ReadAllBytesAsync(fromFile.FullName, ct);
|
||||
var toBytes = await File.ReadAllBytesAsync(toFile.FullName, ct);
|
||||
|
||||
// Compute digests
|
||||
var fromDigest = ComputeSha256(fromBytes);
|
||||
var toDigest = ComputeSha256(toBytes);
|
||||
|
||||
// Extract basic binary information
|
||||
var fromSize = fromBytes.Length;
|
||||
var toSize = toBytes.Length;
|
||||
|
||||
// Compute simple section fingerprints (placeholder - real impl would use IBinaryFingerprinter)
|
||||
var sectionFingerprints = ComputeSimpleSectionFingerprints(fromBytes, toBytes);
|
||||
|
||||
// Build attestation predicate
|
||||
var attestedAt = DateTimeOffset.UtcNow;
|
||||
var predicate = new PatchVerificationPredicateDto
|
||||
{
|
||||
Cve = cve,
|
||||
VulnerableBinaryDigest = $"sha256:{fromDigest}",
|
||||
PatchedBinaryDigest = $"sha256:{toDigest}",
|
||||
VulnerableBinaryPath = fromFile.Name,
|
||||
PatchedBinaryPath = toFile.Name,
|
||||
Purl = purl,
|
||||
Fingerprints = new PatchFingerprintsDto
|
||||
{
|
||||
Sections = sectionFingerprints.Sections.ToList(),
|
||||
Functions = sectionFingerprints.Functions?.ToList(),
|
||||
Deltas = sectionFingerprints.Deltas?.ToList()
|
||||
},
|
||||
Issuer = issuer,
|
||||
Description = description,
|
||||
AttestedAt = attestedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
AttestorVersion = "1.0.0"
|
||||
};
|
||||
|
||||
// Build in-toto statement
|
||||
var statement = new InTotoStatementDto
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v0.1",
|
||||
PredicateType = "https://stellaops.org/patch-verification/v1",
|
||||
Subject = new List<SubjectDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = toFile.Name,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = toDigest
|
||||
}
|
||||
}
|
||||
},
|
||||
Predicate = predicate
|
||||
};
|
||||
|
||||
// Serialize statement
|
||||
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
|
||||
if (noSign)
|
||||
{
|
||||
// Output unsigned statement
|
||||
if (outputFile is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputFile.FullName, statementJson, ct);
|
||||
Console.WriteLine($"Unsigned attestation written to {outputFile.FullName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(statementJson);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Create DSSE envelope (placeholder - real impl would use actual signing)
|
||||
var envelope = CreateDsseEnvelope(statementJson, keyPath, keyless);
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
|
||||
|
||||
if (outputFile is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputFile.FullName, envelopeJson, ct);
|
||||
Console.WriteLine($"DSSE attestation written to {outputFile.FullName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(envelopeJson);
|
||||
}
|
||||
|
||||
if (publish)
|
||||
{
|
||||
Console.WriteLine("[yellow]Warning:[/] --publish not yet implemented. Use 'stella attest attach' to publish.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Attestation Summary:");
|
||||
Console.WriteLine($" CVE: {cve}");
|
||||
Console.WriteLine($" Vulnerable digest: sha256:{fromDigest[..16]}...");
|
||||
Console.WriteLine($" Patched digest: sha256:{toDigest[..16]}...");
|
||||
Console.WriteLine($" Section fingerprints: {sectionFingerprints.Sections.Count}");
|
||||
Console.WriteLine($" Attested at: {attestedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error creating patch attestation: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<SectionFingerprintDto> Sections, IReadOnlyList<FunctionFingerprintDto>? Functions, IReadOnlyList<DeltaDto>? Deltas)
|
||||
ComputeSimpleSectionFingerprints(byte[] fromBytes, byte[] toBytes)
|
||||
{
|
||||
// This is a simplified implementation that creates section-level fingerprints
|
||||
// Real implementation would use IBinaryFingerprinter from Feedser.BinaryAnalysis
|
||||
|
||||
var sections = new List<SectionFingerprintDto>();
|
||||
|
||||
// Create a simple fingerprint based on file sections
|
||||
// In reality, we'd parse ELF/PE headers and extract actual sections
|
||||
var chunkSize = 4096;
|
||||
var fromChunks = (int)Math.Ceiling(fromBytes.Length / (double)chunkSize);
|
||||
var toChunks = (int)Math.Ceiling(toBytes.Length / (double)chunkSize);
|
||||
|
||||
// Compare chunks to identify changed sections
|
||||
for (int i = 0; i < Math.Max(fromChunks, toChunks); i++)
|
||||
{
|
||||
var fromStart = i * chunkSize;
|
||||
var toStart = i * chunkSize;
|
||||
|
||||
byte[]? fromChunk = fromStart < fromBytes.Length
|
||||
? fromBytes.Skip(fromStart).Take(Math.Min(chunkSize, fromBytes.Length - fromStart)).ToArray()
|
||||
: null;
|
||||
|
||||
byte[]? toChunk = toStart < toBytes.Length
|
||||
? toBytes.Skip(toStart).Take(Math.Min(chunkSize, toBytes.Length - toStart)).ToArray()
|
||||
: null;
|
||||
|
||||
var status = (fromChunk, toChunk) switch
|
||||
{
|
||||
(null, not null) => "added",
|
||||
(not null, null) => "removed",
|
||||
(not null, not null) when !fromChunk.SequenceEqual(toChunk) => "modified",
|
||||
_ => "unchanged"
|
||||
};
|
||||
|
||||
if (status != "unchanged")
|
||||
{
|
||||
sections.Add(new SectionFingerprintDto
|
||||
{
|
||||
Name = $".section_{i}",
|
||||
Offset = (ulong)(i * chunkSize),
|
||||
VulnerableHash = fromChunk is not null ? ComputeSha256(fromChunk)[..16] : null,
|
||||
PatchedHash = toChunk is not null ? ComputeSha256(toChunk)[..16] : null,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no sections differ, add a summary section
|
||||
if (sections.Count == 0)
|
||||
{
|
||||
sections.Add(new SectionFingerprintDto
|
||||
{
|
||||
Name = ".text",
|
||||
Offset = 0,
|
||||
VulnerableHash = ComputeSha256(fromBytes)[..16],
|
||||
PatchedHash = ComputeSha256(toBytes)[..16],
|
||||
Status = "identical"
|
||||
});
|
||||
}
|
||||
|
||||
return (sections, null, null);
|
||||
}
|
||||
|
||||
private static DsseEnvelopeDto CreateDsseEnvelope(string payload, string? keyPath, bool keyless)
|
||||
{
|
||||
// This is a placeholder implementation
|
||||
// Real implementation would use actual DSSE signing via Attestor.Envelope
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
// Create placeholder signature
|
||||
// In production, this would use cryptographic signing
|
||||
var signatureData = $"placeholder-sig-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
||||
var signatureBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(signatureData));
|
||||
|
||||
var keyId = keyless
|
||||
? "sigstore-keyless"
|
||||
: keyPath ?? "local-key";
|
||||
|
||||
return new DsseEnvelopeDto
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = payloadBase64,
|
||||
Signatures = new List<DsseSignatureDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
KeyId = keyId,
|
||||
Sig = signatureBase64
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record InTotoStatementDto
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required List<SubjectDto> Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required PatchVerificationPredicateDto Predicate { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SubjectDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required Dictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
private sealed record PatchVerificationPredicateDto
|
||||
{
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerableBinaryDigest")]
|
||||
public required string VulnerableBinaryDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("patchedBinaryDigest")]
|
||||
public required string PatchedBinaryDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerableBinaryPath")]
|
||||
public string? VulnerableBinaryPath { get; init; }
|
||||
|
||||
[JsonPropertyName("patchedBinaryPath")]
|
||||
public string? PatchedBinaryPath { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprints")]
|
||||
public required PatchFingerprintsDto Fingerprints { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("attestedAt")]
|
||||
public required string AttestedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("attestorVersion")]
|
||||
public required string AttestorVersion { get; init; }
|
||||
}
|
||||
|
||||
private sealed record PatchFingerprintsDto
|
||||
{
|
||||
[JsonPropertyName("sections")]
|
||||
public required List<SectionFingerprintDto> Sections { get; init; }
|
||||
|
||||
[JsonPropertyName("functions")]
|
||||
public List<FunctionFingerprintDto>? Functions { get; init; }
|
||||
|
||||
[JsonPropertyName("deltas")]
|
||||
public List<DeltaDto>? Deltas { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SectionFingerprintDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public ulong Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerableHash")]
|
||||
public string? VulnerableHash { get; init; }
|
||||
|
||||
[JsonPropertyName("patchedHash")]
|
||||
public string? PatchedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
private sealed record FunctionFingerprintDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("address")]
|
||||
public ulong Address { get; init; }
|
||||
|
||||
[JsonPropertyName("cfgHash")]
|
||||
public string? CfgHash { get; init; }
|
||||
|
||||
[JsonPropertyName("instructionHash")]
|
||||
public string? InstructionHash { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DeltaDto
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("location")]
|
||||
public required string Location { get; init; }
|
||||
|
||||
[JsonPropertyName("before")]
|
||||
public string? Before { get; init; }
|
||||
|
||||
[JsonPropertyName("after")]
|
||||
public string? After { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DsseEnvelopeDto
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required List<DsseSignatureDto> Signatures { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DsseSignatureDto
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user