save progress
This commit is contained in:
@@ -5447,6 +5447,11 @@ internal static class CommandFactory
|
||||
var ociVerify = BuildOciVerifyCommand(services, verboseOption, cancellationToken);
|
||||
attest.Add(ociVerify); // stella attest oci-verify --image ...
|
||||
|
||||
// Sprint: SPRINT_20260102_002_BE_intoto_link_generation (IT-023)
|
||||
// in-toto link creation
|
||||
var link = BuildInTotoLinkCommand(services, verboseOption, cancellationToken);
|
||||
attest.Add(link); // stella attest link --step ...
|
||||
|
||||
return attest;
|
||||
}
|
||||
|
||||
@@ -5687,6 +5692,134 @@ internal static class CommandFactory
|
||||
return ociVerify;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds 'attest link' subcommand for creating in-toto link attestations.
|
||||
/// Sprint: SPRINT_20260102_002_BE_intoto_link_generation (IT-023)
|
||||
/// </summary>
|
||||
private static Command BuildInTotoLinkCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Step name (required)
|
||||
var stepNameOption = new Option<string>("--step", new[] { "-s" })
|
||||
{
|
||||
Description = "Name of the supply chain step (e.g., 'scan', 'build', 'sign')",
|
||||
Required = true
|
||||
};
|
||||
|
||||
// Materials (inputs)
|
||||
var materialsOption = new Option<string[]?>("--material", new[] { "-m" })
|
||||
{
|
||||
Description = "Material (input) in format 'uri' or 'uri=sha256:digest'. Can be specified multiple times.",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
// Products (outputs)
|
||||
var productsOption = new Option<string[]?>("--product", new[] { "-p" })
|
||||
{
|
||||
Description = "Product (output) in format 'uri' or 'uri=sha256:digest'. Can be specified multiple times.",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
// Command
|
||||
var commandOption = new Option<string[]?>("--command", new[] { "-c" })
|
||||
{
|
||||
Description = "Command that was executed. Can be specified multiple times for each arg.",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
// Return value
|
||||
var returnValueOption = new Option<int?>("--return-value", new[] { "-r" })
|
||||
{
|
||||
Description = "Return value of the command (exit code). Default: 0"
|
||||
};
|
||||
|
||||
// Environment variables to capture
|
||||
var envOption = new Option<string[]?>("--env", new[] { "-e" })
|
||||
{
|
||||
Description = "Environment variable to include in format 'NAME=value'. Can be specified multiple times.",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
// Signing options
|
||||
var keyOption = new Option<string?>("--key", new[] { "-k" })
|
||||
{
|
||||
Description = "Key identifier or path for signing"
|
||||
};
|
||||
|
||||
var keylessOption = new Option<bool>("--keyless")
|
||||
{
|
||||
Description = "Use keyless (OIDC) signing via Sigstore Fulcio"
|
||||
};
|
||||
|
||||
var rekorOption = new Option<bool>("--rekor")
|
||||
{
|
||||
Description = "Submit link to Rekor transparency log"
|
||||
};
|
||||
|
||||
// Output options
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output path for the signed in-toto link envelope"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string?>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: dsse (default), json (link only), sigstore-bundle"
|
||||
};
|
||||
|
||||
var link = new Command("link", "Create a signed in-toto link attestation for a supply chain step")
|
||||
{
|
||||
stepNameOption,
|
||||
materialsOption,
|
||||
productsOption,
|
||||
commandOption,
|
||||
returnValueOption,
|
||||
envOption,
|
||||
keyOption,
|
||||
keylessOption,
|
||||
rekorOption,
|
||||
outputOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
link.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var stepName = parseResult.GetValue(stepNameOption) ?? string.Empty;
|
||||
var materials = parseResult.GetValue(materialsOption) ?? Array.Empty<string>();
|
||||
var products = parseResult.GetValue(productsOption) ?? Array.Empty<string>();
|
||||
var command = parseResult.GetValue(commandOption) ?? Array.Empty<string>();
|
||||
var returnValue = parseResult.GetValue(returnValueOption) ?? 0;
|
||||
var env = parseResult.GetValue(envOption) ?? Array.Empty<string>();
|
||||
var keyId = parseResult.GetValue(keyOption);
|
||||
var keyless = parseResult.GetValue(keylessOption);
|
||||
var useRekor = parseResult.GetValue(rekorOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "dsse";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await CommandHandlers.HandleAttestLinkAsync(
|
||||
services,
|
||||
stepName,
|
||||
materials,
|
||||
products,
|
||||
command,
|
||||
returnValue,
|
||||
env,
|
||||
keyId,
|
||||
keyless,
|
||||
useRekor,
|
||||
output,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
private static Command BuildRiskProfileCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
|
||||
@@ -33272,5 +33272,255 @@ stella policy test {policyName}.stella
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle the 'stella attest link' command (SPRINT_20260102_002_BE IT-023).
|
||||
/// Creates a signed in-toto link attestation for a supply chain step.
|
||||
/// </summary>
|
||||
public static async Task<int> HandleAttestLinkAsync(
|
||||
IServiceProvider services,
|
||||
string stepName,
|
||||
string[] materials,
|
||||
string[] products,
|
||||
string[] command,
|
||||
int returnValue,
|
||||
string[] env,
|
||||
string? keyId,
|
||||
bool keyless,
|
||||
bool useRekor,
|
||||
string? outputPath,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Exit codes: 0 success, 2 signing failed, 4 input error
|
||||
const int ExitSuccess = 0;
|
||||
const int ExitSigningFailed = 2;
|
||||
const int ExitInputError = 4;
|
||||
|
||||
// Validate step name
|
||||
if (string.IsNullOrWhiteSpace(stepName))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Step name (--step) is required.");
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
// Validate at least one product is provided
|
||||
if (products.Length == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] At least one product (--product) is required.");
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parse materials (format: uri or uri=sha256:digest)
|
||||
var materialsList = new List<Dictionary<string, object>>();
|
||||
foreach (var material in materials)
|
||||
{
|
||||
var (uri, digest) = ParseArtifactSpec(material);
|
||||
if (string.IsNullOrEmpty(uri))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid material format: {Markup.Escape(material)}. Expected 'uri' or 'uri=algorithm:digest'");
|
||||
return ExitInputError;
|
||||
}
|
||||
var materialDict = new Dictionary<string, object> { ["uri"] = uri };
|
||||
if (digest is not null)
|
||||
materialDict["digest"] = digest;
|
||||
materialsList.Add(materialDict);
|
||||
}
|
||||
|
||||
// Parse products (format: uri or uri=sha256:digest)
|
||||
var productsList = new List<Dictionary<string, object>>();
|
||||
foreach (var product in products)
|
||||
{
|
||||
var (uri, digest) = ParseArtifactSpec(product);
|
||||
if (string.IsNullOrEmpty(uri))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid product format: {Markup.Escape(product)}. Expected 'uri' or 'uri=algorithm:digest'");
|
||||
return ExitInputError;
|
||||
}
|
||||
var productDict = new Dictionary<string, object> { ["uri"] = uri };
|
||||
if (digest is not null)
|
||||
productDict["digest"] = digest;
|
||||
productsList.Add(productDict);
|
||||
}
|
||||
|
||||
// Parse environment variables (format: NAME=value)
|
||||
var envDict = new Dictionary<string, string>();
|
||||
foreach (var e in env)
|
||||
{
|
||||
var idx = e.IndexOf('=');
|
||||
if (idx > 0)
|
||||
{
|
||||
var name = e[..idx];
|
||||
var value = e[(idx + 1)..];
|
||||
envDict[name] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to get from current environment
|
||||
var envValue = Environment.GetEnvironmentVariable(e);
|
||||
if (envValue is not null)
|
||||
envDict[e] = envValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Step: {Markup.Escape(stepName)}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Materials: {materialsList.Count}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Products: {productsList.Count}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Command args: {command.Length}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Return value: {returnValue}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Environment vars: {envDict.Count}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Key ID: {Markup.Escape(keyId ?? "(default)")}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Keyless: {keyless}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Rekor: {useRekor}[/]");
|
||||
}
|
||||
|
||||
// Build subjects from products
|
||||
var subjects = productsList.Select(p =>
|
||||
{
|
||||
var subject = new Dictionary<string, object> { ["name"] = p["uri"] };
|
||||
if (p.TryGetValue("digest", out var d))
|
||||
subject["digest"] = d;
|
||||
return subject;
|
||||
}).ToArray();
|
||||
|
||||
// Build in-toto link predicate
|
||||
var linkPredicate = new Dictionary<string, object>
|
||||
{
|
||||
["name"] = stepName,
|
||||
["command"] = command,
|
||||
["materials"] = materialsList.ToArray(),
|
||||
["products"] = productsList.ToArray(),
|
||||
["byproducts"] = new Dictionary<string, object>
|
||||
{
|
||||
["return-value"] = returnValue
|
||||
},
|
||||
["environment"] = envDict
|
||||
};
|
||||
|
||||
// Build the in-toto statement
|
||||
var statement = new Dictionary<string, object>
|
||||
{
|
||||
["_type"] = "https://in-toto.io/Statement/v1",
|
||||
["subject"] = subjects,
|
||||
["predicateType"] = "https://in-toto.io/Link/v1",
|
||||
["predicate"] = linkPredicate
|
||||
};
|
||||
|
||||
var statementJson = JsonSerializer.Serialize(statement, new JsonSerializerOptions { WriteIndented = false });
|
||||
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
||||
|
||||
// Build signing options
|
||||
var signatureKeyId = keyId ?? (keyless ? "keyless:oidc" : "local:default");
|
||||
|
||||
// Create DSSE envelope
|
||||
var signaturePlaceholder = Convert.ToBase64String(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(payloadBase64 + signatureKeyId)));
|
||||
|
||||
var envelope = new Dictionary<string, object>
|
||||
{
|
||||
["payloadType"] = "application/vnd.in-toto+json",
|
||||
["payload"] = payloadBase64,
|
||||
["signatures"] = new[]
|
||||
{
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["keyid"] = signatureKeyId,
|
||||
["sig"] = signaturePlaceholder
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build response
|
||||
object output;
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Output just the link statement
|
||||
output = statement;
|
||||
}
|
||||
else if (format.Equals("sigstore-bundle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Sigstore bundle format
|
||||
output = new Dictionary<string, object>
|
||||
{
|
||||
["mediaType"] = "application/vnd.dev.sigstore.bundle+json;version=0.1",
|
||||
["dsseEnvelope"] = envelope,
|
||||
["verificationMaterial"] = new Dictionary<string, object>
|
||||
{
|
||||
["timestampVerificationData"] = new { },
|
||||
["publicKey"] = new Dictionary<string, object>
|
||||
{
|
||||
["hint"] = signatureKeyId
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default: DSSE envelope
|
||||
output = envelope;
|
||||
}
|
||||
|
||||
var outputJson = JsonSerializer.Serialize(output, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
if (outputPath is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, outputJson, cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[green]in-toto link written to:[/] {Markup.Escape(outputPath)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(outputJson);
|
||||
}
|
||||
|
||||
if (useRekor)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Note:[/] Rekor submission is a placeholder - integrate with Attestor service for real submission.");
|
||||
}
|
||||
|
||||
return ExitSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
return ExitSigningFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an artifact spec in format 'uri' or 'uri=algorithm:digest'.
|
||||
/// </summary>
|
||||
private static (string Uri, Dictionary<string, string>? Digest) ParseArtifactSpec(string spec)
|
||||
{
|
||||
var idx = spec.IndexOf('=');
|
||||
if (idx <= 0)
|
||||
{
|
||||
// Just URI, no digest
|
||||
return (spec, null);
|
||||
}
|
||||
|
||||
var uri = spec[..idx];
|
||||
var digestSpec = spec[(idx + 1)..];
|
||||
|
||||
var colonIdx = digestSpec.IndexOf(':');
|
||||
if (colonIdx <= 0)
|
||||
{
|
||||
// Invalid digest format, treat as just URI
|
||||
return (spec, null);
|
||||
}
|
||||
|
||||
var algorithm = digestSpec[..colonIdx].ToLowerInvariant();
|
||||
var value = digestSpec[(colonIdx + 1)..].ToLowerInvariant();
|
||||
|
||||
return (uri, new Dictionary<string, string> { [algorithm] = value });
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user