581 lines
20 KiB
C#
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
|
|
}
|