Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/PatchAttestCommandGroup.cs
2026-01-12 12:24:17 +02:00

581 lines
20 KiB
C#

// -----------------------------------------------------------------------------
// 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
}