save progress

This commit is contained in:
StellaOps Bot
2026-01-03 11:02:24 +02:00
parent ca578801fd
commit 83c37243e0
446 changed files with 22798 additions and 4031 deletions

View File

@@ -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;

View File

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