938 lines
33 KiB
C#
938 lines
33 KiB
C#
// -----------------------------------------------------------------------------
|
|
// BinaryCommandHandlers.cs
|
|
// Sprint: SPRINT_3850_0001_0001_oci_storage_cli
|
|
// Tasks: T3, T4, T5, T6
|
|
// Description: Command handlers for binary reachability operations.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Spectre.Console;
|
|
using StellaOps.Scanner.CallGraph;
|
|
using StellaOps.Scanner.CallGraph.Binary;
|
|
|
|
namespace StellaOps.Cli.Commands.Binary;
|
|
|
|
/// <summary>
|
|
/// Command handlers for binary reachability CLI commands.
|
|
/// </summary>
|
|
internal static class BinaryCommandHandlers
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
|
|
/// <summary>
|
|
/// Handle 'stella binary submit' command.
|
|
/// </summary>
|
|
public static async Task<int> HandleSubmitAsync(
|
|
IServiceProvider services,
|
|
string? graphPath,
|
|
string? binaryPath,
|
|
bool analyze,
|
|
bool sign,
|
|
string? registry,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("binary-submit");
|
|
|
|
if (string.IsNullOrWhiteSpace(graphPath) && string.IsNullOrWhiteSpace(binaryPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Either --graph or --binary must be specified.");
|
|
return ExitCodes.InvalidArguments;
|
|
}
|
|
|
|
if (analyze && string.IsNullOrWhiteSpace(binaryPath))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] --analyze requires --binary.");
|
|
return ExitCodes.InvalidArguments;
|
|
}
|
|
|
|
try
|
|
{
|
|
await AnsiConsole.Status()
|
|
.StartAsync("Submitting binary graph...", async ctx =>
|
|
{
|
|
if (analyze)
|
|
{
|
|
ctx.Status("Analyzing binary...");
|
|
AnsiConsole.MarkupLine($"[yellow]Analyzing binary:[/] {binaryPath}");
|
|
// TODO: Invoke binary analysis service
|
|
await Task.Delay(100, cancellationToken);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(graphPath))
|
|
{
|
|
ctx.Status($"Reading graph from {graphPath}...");
|
|
if (!File.Exists(graphPath))
|
|
{
|
|
throw new FileNotFoundException($"Graph file not found: {graphPath}");
|
|
}
|
|
|
|
var graphJson = await File.ReadAllTextAsync(graphPath, cancellationToken);
|
|
AnsiConsole.MarkupLine($"[green]✓[/] Graph loaded: {graphJson.Length} bytes");
|
|
}
|
|
|
|
if (sign)
|
|
{
|
|
ctx.Status("Signing graph with DSSE...");
|
|
AnsiConsole.MarkupLine("[yellow]Signing:[/] Generating DSSE attestation");
|
|
// TODO: Invoke signing service
|
|
await Task.Delay(100, cancellationToken);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(registry))
|
|
{
|
|
ctx.Status($"Pushing to {registry}...");
|
|
AnsiConsole.MarkupLine($"[yellow]Pushing:[/] {registry}");
|
|
// TODO: Invoke OCI push service
|
|
await Task.Delay(100, cancellationToken);
|
|
}
|
|
|
|
ctx.Status("Submitting to Scanner API...");
|
|
// TODO: Invoke Scanner API
|
|
await Task.Delay(100, cancellationToken);
|
|
});
|
|
|
|
var mockDigest = "blake3:abc123def456789...";
|
|
|
|
AnsiConsole.MarkupLine($"[green]✓ Graph submitted successfully[/]");
|
|
AnsiConsole.MarkupLine($" Digest: [cyan]{mockDigest}[/]");
|
|
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation(
|
|
"Binary graph submitted: graph={GraphPath}, binary={BinaryPath}, sign={Sign}",
|
|
graphPath,
|
|
binaryPath,
|
|
sign);
|
|
}
|
|
|
|
return ExitCodes.Success;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
|
logger.LogError(ex, "Failed to submit binary graph");
|
|
return ExitCodes.GeneralError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella binary info' command.
|
|
/// </summary>
|
|
public static async Task<int> HandleInfoAsync(
|
|
IServiceProvider services,
|
|
string hash,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("binary-info");
|
|
|
|
try
|
|
{
|
|
// TODO: Query Scanner API for graph info
|
|
await Task.Delay(50, cancellationToken);
|
|
|
|
var mockInfo = new
|
|
{
|
|
Digest = hash,
|
|
Format = "ELF x86_64",
|
|
BuildId = "gnu-build-id:5f0c7c3c...",
|
|
Nodes = 1247,
|
|
Edges = 3891,
|
|
Entrypoints = 5,
|
|
Attestation = "Signed (Rekor #12345678)"
|
|
};
|
|
|
|
if (format == "json")
|
|
{
|
|
var json = JsonSerializer.Serialize(mockInfo, JsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Binary Graph:[/] {mockInfo.Digest}");
|
|
AnsiConsole.MarkupLine($"Format: {mockInfo.Format}");
|
|
AnsiConsole.MarkupLine($"Build-ID: {mockInfo.BuildId}");
|
|
AnsiConsole.MarkupLine($"Nodes: [cyan]{mockInfo.Nodes}[/]");
|
|
AnsiConsole.MarkupLine($"Edges: [cyan]{mockInfo.Edges}[/]");
|
|
AnsiConsole.MarkupLine($"Entrypoints: [cyan]{mockInfo.Entrypoints}[/]");
|
|
AnsiConsole.MarkupLine($"Attestation: [green]{mockInfo.Attestation}[/]");
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation("Retrieved graph info for {Hash}", hash);
|
|
}
|
|
|
|
return ExitCodes.Success;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
|
logger.LogError(ex, "Failed to retrieve graph info for {Hash}", hash);
|
|
return ExitCodes.GeneralError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella binary symbols' command.
|
|
/// </summary>
|
|
public static async Task<int> HandleSymbolsAsync(
|
|
IServiceProvider services,
|
|
string hash,
|
|
bool strippedOnly,
|
|
bool exportedOnly,
|
|
bool entrypointsOnly,
|
|
string? search,
|
|
string format,
|
|
int limit,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("binary-symbols");
|
|
|
|
try
|
|
{
|
|
// TODO: Query Scanner API for symbols
|
|
await Task.Delay(50, cancellationToken);
|
|
|
|
var mockSymbols = new[]
|
|
{
|
|
new { Symbol = "main", Type = "entrypoint", Exported = true, Stripped = false },
|
|
new { Symbol = "ssl_connect", Type = "function", Exported = true, Stripped = false },
|
|
new { Symbol = "verify_cert", Type = "function", Exported = false, Stripped = false },
|
|
new { Symbol = "sub_401234", Type = "function", Exported = false, Stripped = true }
|
|
};
|
|
|
|
var filtered = mockSymbols.AsEnumerable();
|
|
|
|
if (strippedOnly)
|
|
filtered = filtered.Where(s => s.Stripped);
|
|
if (exportedOnly)
|
|
filtered = filtered.Where(s => s.Exported);
|
|
if (entrypointsOnly)
|
|
filtered = filtered.Where(s => s.Type == "entrypoint");
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
{
|
|
var pattern = search.Replace("*", ".*");
|
|
filtered = filtered.Where(s => System.Text.RegularExpressions.Regex.IsMatch(s.Symbol, pattern));
|
|
}
|
|
|
|
var results = filtered.Take(limit).ToArray();
|
|
|
|
if (format == "json")
|
|
{
|
|
var json = JsonSerializer.Serialize(results, JsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
var table = new Table();
|
|
table.AddColumn("Symbol");
|
|
table.AddColumn("Type");
|
|
table.AddColumn("Exported");
|
|
table.AddColumn("Stripped");
|
|
|
|
foreach (var sym in results)
|
|
{
|
|
table.AddRow(
|
|
sym.Symbol,
|
|
sym.Type,
|
|
sym.Exported ? "[green]yes[/]" : "no",
|
|
sym.Stripped ? "[yellow]yes[/]" : "no");
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
AnsiConsole.MarkupLine($"\n[dim]Showing {results.Length} symbols (limit: {limit})[/]");
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation(
|
|
"Retrieved {Count} symbols for {Hash}",
|
|
results.Length,
|
|
hash);
|
|
}
|
|
|
|
return ExitCodes.Success;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
|
logger.LogError(ex, "Failed to retrieve symbols for {Hash}", hash);
|
|
return ExitCodes.GeneralError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella binary verify' command.
|
|
/// </summary>
|
|
public static async Task<int> HandleVerifyAsync(
|
|
IServiceProvider services,
|
|
string graphPath,
|
|
string dssePath,
|
|
string? publicKey,
|
|
string? rekorUrl,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("binary-verify");
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(graphPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Graph file not found: {graphPath}");
|
|
return ExitCodes.FileNotFound;
|
|
}
|
|
|
|
if (!File.Exists(dssePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] DSSE envelope not found: {dssePath}");
|
|
return ExitCodes.FileNotFound;
|
|
}
|
|
|
|
await AnsiConsole.Status()
|
|
.StartAsync("Verifying attestation...", async ctx =>
|
|
{
|
|
ctx.Status("Parsing DSSE envelope...");
|
|
await Task.Delay(50, cancellationToken);
|
|
|
|
ctx.Status("Verifying signature...");
|
|
// TODO: Invoke signature verification
|
|
await Task.Delay(100, cancellationToken);
|
|
|
|
ctx.Status("Verifying graph digest...");
|
|
// TODO: Verify graph hash matches predicate
|
|
await Task.Delay(50, cancellationToken);
|
|
|
|
if (!string.IsNullOrWhiteSpace(rekorUrl))
|
|
{
|
|
ctx.Status("Verifying Rekor inclusion...");
|
|
// TODO: Verify Rekor transparency log
|
|
await Task.Delay(100, cancellationToken);
|
|
}
|
|
});
|
|
|
|
AnsiConsole.MarkupLine("[green]✓ Verification successful[/]");
|
|
AnsiConsole.MarkupLine(" Signature: [green]Valid[/]");
|
|
AnsiConsole.MarkupLine(" Graph digest: [green]Matches[/]");
|
|
|
|
if (!string.IsNullOrWhiteSpace(rekorUrl))
|
|
{
|
|
AnsiConsole.MarkupLine($" Rekor: [green]Verified (entry #12345678)[/]");
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation(
|
|
"Verified graph attestation: graph={GraphPath}, dsse={DssePath}",
|
|
graphPath,
|
|
dssePath);
|
|
}
|
|
|
|
return ExitCodes.Success;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]✗ Verification failed:[/] {ex.Message}");
|
|
logger.LogError(ex, "Failed to verify attestation");
|
|
return ExitCodes.VerificationFailed;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella binary inspect' command (SCANINT-14).
|
|
/// </summary>
|
|
public static async Task<int> HandleInspectAsync(
|
|
IServiceProvider services,
|
|
string filePath,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("binary-inspect");
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {filePath}");
|
|
return ExitCodes.FileNotFound;
|
|
}
|
|
|
|
await AnsiConsole.Status()
|
|
.StartAsync("Analyzing binary...", async ctx =>
|
|
{
|
|
await Task.Delay(100, cancellationToken);
|
|
});
|
|
|
|
// Compute file hashes and extract identity
|
|
using var stream = File.OpenRead(filePath);
|
|
var sha256 = System.Security.Cryptography.SHA256.HashData(stream);
|
|
stream.Position = 0;
|
|
|
|
// Read ELF/PE/Mach-O header to determine format and architecture
|
|
var header = new byte[64];
|
|
await stream.ReadExactlyAsync(header, cancellationToken);
|
|
|
|
var binaryFormat = DetectFormat(header);
|
|
var architecture = DetectArchitecture(header, binaryFormat);
|
|
var buildId = ExtractBuildId(filePath); // Placeholder
|
|
|
|
var fileInfo = new FileInfo(filePath);
|
|
|
|
var result = new
|
|
{
|
|
Path = filePath,
|
|
Size = fileInfo.Length,
|
|
Format = binaryFormat,
|
|
Architecture = architecture,
|
|
BuildId = buildId ?? "(not found)",
|
|
Sha256 = Convert.ToHexStringLower(sha256),
|
|
BinaryKey = buildId ?? Convert.ToHexStringLower(sha256[..16])
|
|
};
|
|
|
|
if (format == "json")
|
|
{
|
|
var json = JsonSerializer.Serialize(result, JsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Binary:[/] {result.Path}");
|
|
AnsiConsole.MarkupLine($"Size: {result.Size:N0} bytes");
|
|
AnsiConsole.MarkupLine($"Format: [cyan]{result.Format}[/]");
|
|
AnsiConsole.MarkupLine($"Architecture: [cyan]{result.Architecture}[/]");
|
|
AnsiConsole.MarkupLine($"Build-ID: [cyan]{result.BuildId}[/]");
|
|
AnsiConsole.MarkupLine($"SHA256: [dim]{result.Sha256}[/]");
|
|
AnsiConsole.MarkupLine($"Binary Key: [green]{result.BinaryKey}[/]");
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation("Inspected binary: {Path}", filePath);
|
|
}
|
|
|
|
return ExitCodes.Success;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
|
logger.LogError(ex, "Failed to inspect binary {Path}", filePath);
|
|
return ExitCodes.GeneralError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella binary lookup' command (SCANINT-15).
|
|
/// </summary>
|
|
public static async Task<int> HandleLookupAsync(
|
|
IServiceProvider services,
|
|
string buildId,
|
|
string? distro,
|
|
string? release,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("binary-lookup");
|
|
|
|
try
|
|
{
|
|
await AnsiConsole.Status()
|
|
.StartAsync("Looking up vulnerabilities...", async ctx =>
|
|
{
|
|
// TODO: Call BinaryIndex API
|
|
await Task.Delay(100, cancellationToken);
|
|
});
|
|
|
|
// Mock results for now - in production, call IBinaryVulnerabilityService
|
|
var mockResults = new[]
|
|
{
|
|
new
|
|
{
|
|
CveId = "CVE-2024-1234",
|
|
Purl = "pkg:deb/debian/openssl@1.1.1n-0+deb11u3",
|
|
Method = "buildid_catalog",
|
|
Confidence = 0.95,
|
|
FixStatus = distro != null ? "fixed" : "unknown",
|
|
FixedVersion = distro != null ? "1.1.1n-0+deb11u4" : null
|
|
}
|
|
};
|
|
|
|
if (format == "json")
|
|
{
|
|
var json = JsonSerializer.Serialize(new
|
|
{
|
|
BuildId = buildId,
|
|
Distro = distro,
|
|
Release = release,
|
|
Matches = mockResults
|
|
}, JsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Build-ID:[/] {buildId}");
|
|
if (distro != null)
|
|
{
|
|
AnsiConsole.MarkupLine($"Distro: {distro}/{release ?? "any"}");
|
|
}
|
|
AnsiConsole.MarkupLine("");
|
|
|
|
if (mockResults.Length == 0)
|
|
{
|
|
AnsiConsole.MarkupLine("[green]No vulnerabilities found[/]");
|
|
}
|
|
else
|
|
{
|
|
var table = new Table();
|
|
table.AddColumn("CVE");
|
|
table.AddColumn("Package");
|
|
table.AddColumn("Method");
|
|
table.AddColumn("Confidence");
|
|
table.AddColumn("Fix Status");
|
|
|
|
foreach (var match in mockResults)
|
|
{
|
|
var statusMarkup = match.FixStatus switch
|
|
{
|
|
"fixed" => $"[green]Fixed ({match.FixedVersion})[/]",
|
|
"vulnerable" => "[red]Vulnerable[/]",
|
|
_ => "[yellow]Unknown[/]"
|
|
};
|
|
|
|
table.AddRow(
|
|
match.CveId,
|
|
match.Purl,
|
|
match.Method,
|
|
$"{match.Confidence:P0}",
|
|
statusMarkup);
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
}
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation("Looked up Build-ID: {BuildId}", buildId);
|
|
}
|
|
|
|
return ExitCodes.Success;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
|
logger.LogError(ex, "Failed to lookup Build-ID {BuildId}", buildId);
|
|
return ExitCodes.GeneralError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella binary fingerprint' command (SCANINT-16).
|
|
/// </summary>
|
|
public static async Task<int> HandleFingerprintAsync(
|
|
IServiceProvider services,
|
|
string filePath,
|
|
string algorithm,
|
|
string? function,
|
|
string format,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("binary-fingerprint");
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {filePath}");
|
|
return ExitCodes.FileNotFound;
|
|
}
|
|
|
|
await AnsiConsole.Status()
|
|
.StartAsync($"Generating {algorithm} fingerprint...", async ctx =>
|
|
{
|
|
// TODO: Call actual fingerprinting service
|
|
await Task.Delay(200, cancellationToken);
|
|
});
|
|
|
|
// Mock fingerprint generation
|
|
using var stream = File.OpenRead(filePath);
|
|
var fileHash = System.Security.Cryptography.SHA256.HashData(stream);
|
|
|
|
// Simulate fingerprint based on algorithm
|
|
var fingerprintId = algorithm switch
|
|
{
|
|
"basic-block" => $"bb:{Convert.ToHexStringLower(fileHash[..16])}",
|
|
"cfg" => $"cfg:{Convert.ToHexStringLower(fileHash[..16])}",
|
|
"string-refs" => $"str:{Convert.ToHexStringLower(fileHash[..16])}",
|
|
_ => $"comb:{Convert.ToHexStringLower(fileHash[..16])}"
|
|
};
|
|
|
|
var result = new
|
|
{
|
|
File = filePath,
|
|
Algorithm = algorithm,
|
|
Function = function,
|
|
FingerprintId = fingerprintId,
|
|
FingerprintHash = Convert.ToHexStringLower(fileHash),
|
|
GeneratedAt = (services.GetService<TimeProvider>() ?? TimeProvider.System).GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
|
|
};
|
|
|
|
if (format == "json")
|
|
{
|
|
var json = JsonSerializer.Serialize(result, JsonOptions);
|
|
AnsiConsole.WriteLine(json);
|
|
}
|
|
else if (format == "hex")
|
|
{
|
|
AnsiConsole.WriteLine(result.FingerprintHash);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[bold]Fingerprint:[/] {result.FingerprintId}");
|
|
AnsiConsole.MarkupLine($"Algorithm: [cyan]{result.Algorithm}[/]");
|
|
if (function != null)
|
|
{
|
|
AnsiConsole.MarkupLine($"Function: [cyan]{function}[/]");
|
|
}
|
|
AnsiConsole.MarkupLine($"Hash: [dim]{result.FingerprintHash}[/]");
|
|
AnsiConsole.MarkupLine($"Generated: {result.GeneratedAt}");
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation(
|
|
"Generated fingerprint for {Path} using {Algorithm}",
|
|
filePath,
|
|
algorithm);
|
|
}
|
|
|
|
return ExitCodes.Success;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
|
logger.LogError(ex, "Failed to fingerprint {Path}", filePath);
|
|
return ExitCodes.GeneralError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle 'stella binary callgraph' command (CALLGRAPH-01).
|
|
/// Extracts call graph from native binary and computes deterministic SHA-256 digest.
|
|
/// </summary>
|
|
public static async Task<int> HandleCallGraphAsync(
|
|
IServiceProvider services,
|
|
string filePath,
|
|
string format,
|
|
string? outputPath,
|
|
string? emitSbomPath,
|
|
string? scanId,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
|
var logger = loggerFactory.CreateLogger("binary-callgraph");
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(filePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {filePath}");
|
|
return ExitCodes.FileNotFound;
|
|
}
|
|
|
|
// Resolve scan ID (auto-generate if not provided)
|
|
var timeProvider = services.GetService<TimeProvider>() ?? TimeProvider.System;
|
|
var effectiveScanId = scanId ?? $"cli-{Path.GetFileName(filePath)}-{timeProvider.GetUtcNow():yyyyMMddHHmmss}";
|
|
|
|
CallGraphSnapshot snapshot = null!;
|
|
|
|
await AnsiConsole.Status()
|
|
.StartAsync("Extracting binary call graph...", async ctx =>
|
|
{
|
|
ctx.Status("Loading binary...");
|
|
|
|
// Get the binary call graph extractor
|
|
var timeProvider = services.GetService<TimeProvider>() ?? TimeProvider.System;
|
|
var extractorLogger = loggerFactory.CreateLogger<BinaryCallGraphExtractor>();
|
|
var extractor = new BinaryCallGraphExtractor(extractorLogger, timeProvider);
|
|
|
|
ctx.Status("Analyzing symbols and relocations...");
|
|
|
|
var request = new CallGraphExtractionRequest(
|
|
ScanId: effectiveScanId,
|
|
Language: "native",
|
|
TargetPath: filePath);
|
|
|
|
snapshot = await extractor.ExtractAsync(request, cancellationToken);
|
|
|
|
ctx.Status("Computing digest...");
|
|
});
|
|
|
|
// Format output based on requested format
|
|
string output;
|
|
switch (format)
|
|
{
|
|
case "digest":
|
|
output = snapshot.GraphDigest;
|
|
break;
|
|
|
|
case "json":
|
|
output = JsonSerializer.Serialize(new
|
|
{
|
|
scanId = snapshot.ScanId,
|
|
graphDigest = snapshot.GraphDigest,
|
|
language = snapshot.Language,
|
|
extractedAt = snapshot.ExtractedAt.ToString("O", CultureInfo.InvariantCulture),
|
|
nodeCount = snapshot.Nodes.Length,
|
|
edgeCount = snapshot.Edges.Length,
|
|
entrypointCount = snapshot.EntrypointIds.Length,
|
|
nodes = snapshot.Nodes,
|
|
edges = snapshot.Edges,
|
|
entrypointIds = snapshot.EntrypointIds
|
|
}, JsonOptions);
|
|
break;
|
|
|
|
case "summary":
|
|
default:
|
|
output = string.Join(Environment.NewLine,
|
|
[
|
|
$"GraphDigest: {snapshot.GraphDigest}",
|
|
$"ScanId: {snapshot.ScanId}",
|
|
$"Language: {snapshot.Language}",
|
|
$"ExtractedAt: {snapshot.ExtractedAt:O}",
|
|
$"Nodes: {snapshot.Nodes.Length}",
|
|
$"Edges: {snapshot.Edges.Length}",
|
|
$"Entrypoints: {snapshot.EntrypointIds.Length}"
|
|
]);
|
|
break;
|
|
}
|
|
|
|
// Write output
|
|
if (!string.IsNullOrWhiteSpace(outputPath))
|
|
{
|
|
await File.WriteAllTextAsync(outputPath, output, cancellationToken);
|
|
AnsiConsole.MarkupLine($"[green]Output written to:[/] {outputPath}");
|
|
}
|
|
else if (format == "digest")
|
|
{
|
|
// For digest-only format, just output the digest
|
|
Console.WriteLine(output);
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.WriteLine(output);
|
|
}
|
|
|
|
// Inject into SBOM if requested
|
|
if (!string.IsNullOrWhiteSpace(emitSbomPath))
|
|
{
|
|
var sbomResult = await InjectCallGraphDigestIntoSbomAsync(
|
|
emitSbomPath,
|
|
filePath,
|
|
snapshot,
|
|
cancellationToken);
|
|
|
|
if (sbomResult == 0)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Callgraph digest injected into SBOM:[/] {emitSbomPath}");
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Failed to inject digest into SBOM");
|
|
}
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger.LogInformation(
|
|
"Extracted call graph for {Path}: {Nodes} nodes, {Edges} edges, digest={Digest}",
|
|
filePath,
|
|
snapshot.Nodes.Length,
|
|
snapshot.Edges.Length,
|
|
snapshot.GraphDigest);
|
|
}
|
|
|
|
return ExitCodes.Success;
|
|
}
|
|
catch (NotSupportedException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
|
logger.LogError(ex, "Unsupported binary format for {Path}", filePath);
|
|
return ExitCodes.InvalidArguments;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
|
logger.LogError(ex, "Failed to extract call graph for {Path}", filePath);
|
|
return ExitCodes.GeneralError;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Injects callgraph digest as a property into a CycloneDX SBOM.
|
|
/// </summary>
|
|
private static async Task<int> InjectCallGraphDigestIntoSbomAsync(
|
|
string sbomPath,
|
|
string binaryPath,
|
|
CallGraphSnapshot snapshot,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!File.Exists(sbomPath))
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
try
|
|
{
|
|
var sbomJson = await File.ReadAllTextAsync(sbomPath, cancellationToken);
|
|
var doc = JsonNode.Parse(sbomJson) as JsonObject;
|
|
|
|
if (doc == null)
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
// Ensure metadata.properties exists
|
|
var metadata = doc["metadata"] as JsonObject;
|
|
if (metadata == null)
|
|
{
|
|
metadata = new JsonObject();
|
|
doc["metadata"] = metadata;
|
|
}
|
|
|
|
var properties = metadata["properties"] as JsonArray;
|
|
if (properties == null)
|
|
{
|
|
properties = new JsonArray();
|
|
metadata["properties"] = properties;
|
|
}
|
|
|
|
var binaryName = Path.GetFileName(binaryPath);
|
|
|
|
// Add callgraph properties using stellaops namespace
|
|
properties.Add(new JsonObject
|
|
{
|
|
["name"] = $"stellaops:callgraph:digest:{binaryName}",
|
|
["value"] = snapshot.GraphDigest
|
|
});
|
|
properties.Add(new JsonObject
|
|
{
|
|
["name"] = $"stellaops:callgraph:nodeCount:{binaryName}",
|
|
["value"] = snapshot.Nodes.Length.ToString(CultureInfo.InvariantCulture)
|
|
});
|
|
properties.Add(new JsonObject
|
|
{
|
|
["name"] = $"stellaops:callgraph:edgeCount:{binaryName}",
|
|
["value"] = snapshot.Edges.Length.ToString(CultureInfo.InvariantCulture)
|
|
});
|
|
properties.Add(new JsonObject
|
|
{
|
|
["name"] = $"stellaops:callgraph:entrypointCount:{binaryName}",
|
|
["value"] = snapshot.EntrypointIds.Length.ToString(CultureInfo.InvariantCulture)
|
|
});
|
|
properties.Add(new JsonObject
|
|
{
|
|
["name"] = $"stellaops:callgraph:extractedAt:{binaryName}",
|
|
["value"] = snapshot.ExtractedAt.ToString("O", CultureInfo.InvariantCulture)
|
|
});
|
|
|
|
// Write updated SBOM
|
|
var updatedJson = doc.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
|
await File.WriteAllTextAsync(sbomPath, updatedJson, cancellationToken);
|
|
|
|
return 0;
|
|
}
|
|
catch
|
|
{
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private static string DetectFormat(byte[] header)
|
|
{
|
|
// ELF magic: 0x7f 'E' 'L' 'F'
|
|
if (header[0] == 0x7f && header[1] == 'E' && header[2] == 'L' && header[3] == 'F')
|
|
return "ELF";
|
|
|
|
// PE magic: 'M' 'Z'
|
|
if (header[0] == 'M' && header[1] == 'Z')
|
|
return "PE";
|
|
|
|
// Mach-O magic
|
|
if ((header[0] == 0xfe && header[1] == 0xed && header[2] == 0xfa && header[3] == 0xce) ||
|
|
(header[0] == 0xfe && header[1] == 0xed && header[2] == 0xfa && header[3] == 0xcf) ||
|
|
(header[0] == 0xcf && header[1] == 0xfa && header[2] == 0xed && header[3] == 0xfe) ||
|
|
(header[0] == 0xce && header[1] == 0xfa && header[2] == 0xed && header[3] == 0xfe))
|
|
return "Mach-O";
|
|
|
|
return "Unknown";
|
|
}
|
|
|
|
private static string DetectArchitecture(byte[] header, string format)
|
|
{
|
|
if (format == "ELF" && header.Length >= 19)
|
|
{
|
|
return header[18] switch
|
|
{
|
|
0x03 => "x86",
|
|
0x3e => "x86_64",
|
|
0xb7 => "aarch64",
|
|
0x28 => "arm",
|
|
_ => "unknown"
|
|
};
|
|
}
|
|
|
|
if (format == "PE")
|
|
{
|
|
return "x86/x86_64"; // Would need to parse PE header properly
|
|
}
|
|
|
|
if (format == "Mach-O")
|
|
{
|
|
// Check for 64-bit magic
|
|
if (header[3] == 0xcf || header[0] == 0xcf)
|
|
return "x86_64/aarch64";
|
|
return "x86/arm";
|
|
}
|
|
|
|
return "unknown";
|
|
}
|
|
|
|
private static string? ExtractBuildId(string filePath)
|
|
{
|
|
// In production, this would parse ELF .note.gnu.build-id section
|
|
// For now, return null
|
|
return null;
|
|
}
|
|
}
|
|
|
|
internal static class ExitCodes
|
|
{
|
|
public const int Success = 0;
|
|
public const int GeneralError = 1;
|
|
public const int InvalidArguments = 2;
|
|
public const int FileNotFound = 3;
|
|
public const int VerificationFailed = 4;
|
|
}
|