Sprint: SPRINT_4100_0006_0001 Status: COMPLETED Implemented plugin-based crypto command architecture for regional compliance with build-time distribution selection (GOST/eIDAS/SM) and runtime validation. ## New Commands - `stella crypto sign` - Sign artifacts with regional crypto providers - `stella crypto verify` - Verify signatures with trust policy support - `stella crypto profiles` - List available crypto providers & capabilities ## Build-Time Distribution Selection ```bash # International (default - BouncyCastle) dotnet build src/Cli/StellaOps.Cli/StellaOps.Cli.csproj # Russia distribution (GOST R 34.10-2012) dotnet build -p:StellaOpsEnableGOST=true # EU distribution (eIDAS Regulation 910/2014) dotnet build -p:StellaOpsEnableEIDAS=true # China distribution (SM2/SM3/SM4) dotnet build -p:StellaOpsEnableSM=true ``` ## Key Features - Build-time conditional compilation prevents export control violations - Runtime crypto profile validation on CLI startup - 8 predefined profiles (international, russia-prod/dev, eu-prod/dev, china-prod/dev) - Comprehensive configuration with environment variable substitution - Integration tests with distribution-specific assertions - Full migration path from deprecated `cryptoru` CLI ## Files Added - src/Cli/StellaOps.Cli/Commands/CryptoCommandGroup.cs - src/Cli/StellaOps.Cli/Commands/CommandHandlers.Crypto.cs - src/Cli/StellaOps.Cli/Services/CryptoProfileValidator.cs - src/Cli/StellaOps.Cli/appsettings.crypto.yaml.example - src/Cli/__Tests/StellaOps.Cli.Tests/CryptoCommandTests.cs - docs/cli/crypto-commands.md - docs/implplan/SPRINT_4100_0006_0001_COMPLETION_SUMMARY.md ## Files Modified - src/Cli/StellaOps.Cli/StellaOps.Cli.csproj (conditional plugin refs) - src/Cli/StellaOps.Cli/Program.cs (plugin registration + validation) - src/Cli/StellaOps.Cli/Commands/CommandFactory.cs (command wiring) - src/Scanner/__Libraries/StellaOps.Scanner.Core/Configuration/PoEConfiguration.cs (fix) ## Compliance - GOST (Russia): GOST R 34.10-2012, FSB certified - eIDAS (EU): Regulation (EU) No 910/2014, QES/AES/AdES - SM (China): GM/T 0003-2012 (SM2), OSCCA certified ## Migration `cryptoru` CLI deprecated → sunset date: 2025-07-01 - `cryptoru providers` → `stella crypto profiles` - `cryptoru sign` → `stella crypto sign` ## Testing ✅ All crypto code compiles successfully ✅ Integration tests pass ✅ Build verification for all distributions (international/GOST/eIDAS/SM) Next: SPRINT_4100_0006_0002 (eIDAS plugin implementation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
338 lines
11 KiB
C#
338 lines
11 KiB
C#
using System.Collections.Immutable;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace StellaOps.Policy.Engine.Attestation;
|
|
|
|
/// <summary>
|
|
/// Predicate for DSSE-wrapped policy verdict attestations.
|
|
/// URI: https://stellaops.dev/predicates/policy-verdict@v1
|
|
/// </summary>
|
|
public sealed record VerdictPredicate
|
|
{
|
|
public const string PredicateType = "https://stellaops.dev/predicates/policy-verdict@v1";
|
|
|
|
public VerdictPredicate(
|
|
string tenantId,
|
|
string policyId,
|
|
int policyVersion,
|
|
string runId,
|
|
string findingId,
|
|
DateTimeOffset evaluatedAt,
|
|
VerdictInfo verdict,
|
|
IEnumerable<VerdictRuleExecution>? ruleChain = null,
|
|
IEnumerable<VerdictEvidence>? evidence = null,
|
|
IEnumerable<VerdictVexImpact>? vexImpacts = null,
|
|
VerdictReachability? reachability = null,
|
|
ImmutableSortedDictionary<string, string>? metadata = null)
|
|
{
|
|
Type = PredicateType;
|
|
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
|
|
PolicyId = Validation.EnsureSimpleIdentifier(policyId, nameof(policyId));
|
|
if (policyVersion <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(policyVersion), policyVersion, "Policy version must be positive.");
|
|
}
|
|
|
|
PolicyVersion = policyVersion;
|
|
RunId = Validation.EnsureId(runId, nameof(runId));
|
|
FindingId = Validation.EnsureSimpleIdentifier(findingId, nameof(findingId));
|
|
EvaluatedAt = Validation.NormalizeTimestamp(evaluatedAt);
|
|
Verdict = verdict ?? throw new ArgumentNullException(nameof(verdict));
|
|
RuleChain = NormalizeRuleChain(ruleChain);
|
|
Evidence = NormalizeEvidence(evidence);
|
|
VexImpacts = NormalizeVexImpacts(vexImpacts);
|
|
Reachability = reachability;
|
|
Metadata = NormalizeMetadata(metadata);
|
|
}
|
|
|
|
[JsonPropertyName("_type")]
|
|
public string Type { get; }
|
|
|
|
public string TenantId { get; }
|
|
|
|
public string PolicyId { get; }
|
|
|
|
public int PolicyVersion { get; }
|
|
|
|
public string RunId { get; }
|
|
|
|
public string FindingId { get; }
|
|
|
|
public DateTimeOffset EvaluatedAt { get; }
|
|
|
|
public VerdictInfo Verdict { get; }
|
|
|
|
public ImmutableArray<VerdictRuleExecution> RuleChain { get; }
|
|
|
|
public ImmutableArray<VerdictEvidence> Evidence { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
|
public ImmutableArray<VerdictVexImpact> VexImpacts { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public VerdictReachability? Reachability { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
|
public ImmutableSortedDictionary<string, string> Metadata { get; }
|
|
|
|
private static ImmutableArray<VerdictRuleExecution> NormalizeRuleChain(IEnumerable<VerdictRuleExecution>? rules)
|
|
{
|
|
if (rules is null)
|
|
{
|
|
return ImmutableArray<VerdictRuleExecution>.Empty;
|
|
}
|
|
|
|
return rules
|
|
.Where(static rule => rule is not null)
|
|
.Select(static rule => rule!)
|
|
.ToImmutableArray();
|
|
}
|
|
|
|
private static ImmutableArray<VerdictEvidence> NormalizeEvidence(IEnumerable<VerdictEvidence>? evidence)
|
|
{
|
|
if (evidence is null)
|
|
{
|
|
return ImmutableArray<VerdictEvidence>.Empty;
|
|
}
|
|
|
|
return evidence
|
|
.Where(static item => item is not null)
|
|
.Select(static item => item!)
|
|
.OrderBy(static item => item.Type, StringComparer.Ordinal)
|
|
.ThenBy(static item => item.Reference, StringComparer.Ordinal)
|
|
.ToImmutableArray();
|
|
}
|
|
|
|
private static ImmutableArray<VerdictVexImpact> NormalizeVexImpacts(IEnumerable<VerdictVexImpact>? impacts)
|
|
{
|
|
if (impacts is null)
|
|
{
|
|
return ImmutableArray<VerdictVexImpact>.Empty;
|
|
}
|
|
|
|
return impacts
|
|
.Where(static impact => impact is not null)
|
|
.Select(static impact => impact!)
|
|
.OrderBy(static impact => impact.StatementId, StringComparer.Ordinal)
|
|
.ToImmutableArray();
|
|
}
|
|
|
|
private static ImmutableSortedDictionary<string, string> NormalizeMetadata(ImmutableSortedDictionary<string, string>? metadata)
|
|
{
|
|
if (metadata is null || metadata.Count == 0)
|
|
{
|
|
return ImmutableSortedDictionary<string, string>.Empty;
|
|
}
|
|
|
|
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
|
foreach (var entry in metadata)
|
|
{
|
|
var key = Validation.TrimToNull(entry.Key)?.ToLowerInvariant();
|
|
var value = Validation.TrimToNull(entry.Value);
|
|
if (key is not null && value is not null)
|
|
{
|
|
builder[key] = value;
|
|
}
|
|
}
|
|
|
|
return builder.ToImmutable();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verdict information (status, severity, score, rationale).
|
|
/// </summary>
|
|
public sealed record VerdictInfo
|
|
{
|
|
public VerdictInfo(
|
|
string status,
|
|
string severity,
|
|
double score,
|
|
string? rationale = null)
|
|
{
|
|
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
|
|
Severity = Validation.TrimToNull(severity) ?? throw new ArgumentNullException(nameof(severity));
|
|
Score = score < 0 || score > 100
|
|
? throw new ArgumentOutOfRangeException(nameof(score), score, "Score must be between 0 and 100.")
|
|
: score;
|
|
Rationale = Validation.TrimToNull(rationale);
|
|
}
|
|
|
|
public string Status { get; }
|
|
|
|
public string Severity { get; }
|
|
|
|
public double Score { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public string? Rationale { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rule execution entry in verdict rule chain.
|
|
/// </summary>
|
|
public sealed record VerdictRuleExecution
|
|
{
|
|
public VerdictRuleExecution(
|
|
string ruleId,
|
|
string action,
|
|
string decision,
|
|
double? score = null)
|
|
{
|
|
RuleId = Validation.EnsureSimpleIdentifier(ruleId, nameof(ruleId));
|
|
Action = Validation.TrimToNull(action) ?? throw new ArgumentNullException(nameof(action));
|
|
Decision = Validation.TrimToNull(decision) ?? throw new ArgumentNullException(nameof(decision));
|
|
Score = score;
|
|
}
|
|
|
|
public string RuleId { get; }
|
|
|
|
public string Action { get; }
|
|
|
|
public string Decision { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public double? Score { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Evidence item considered during verdict evaluation.
|
|
/// </summary>
|
|
public sealed record VerdictEvidence
|
|
{
|
|
public VerdictEvidence(
|
|
string type,
|
|
string reference,
|
|
string source,
|
|
string status,
|
|
string? digest = null,
|
|
double? weight = null,
|
|
ImmutableSortedDictionary<string, string>? metadata = null)
|
|
{
|
|
Type = Validation.TrimToNull(type) ?? throw new ArgumentNullException(nameof(type));
|
|
Reference = Validation.TrimToNull(reference) ?? throw new ArgumentNullException(nameof(reference));
|
|
Source = Validation.TrimToNull(source) ?? throw new ArgumentNullException(nameof(source));
|
|
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
|
|
Digest = Validation.TrimToNull(digest);
|
|
Weight = weight;
|
|
Metadata = NormalizeMetadata(metadata);
|
|
}
|
|
|
|
public string Type { get; }
|
|
|
|
public string Reference { get; }
|
|
|
|
public string Source { get; }
|
|
|
|
public string Status { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public string? Digest { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public double? Weight { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
|
public ImmutableSortedDictionary<string, string> Metadata { get; }
|
|
|
|
private static ImmutableSortedDictionary<string, string> NormalizeMetadata(ImmutableSortedDictionary<string, string>? metadata)
|
|
{
|
|
if (metadata is null || metadata.Count == 0)
|
|
{
|
|
return ImmutableSortedDictionary<string, string>.Empty;
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// VEX statement impact on verdict.
|
|
/// </summary>
|
|
public sealed record VerdictVexImpact
|
|
{
|
|
public VerdictVexImpact(
|
|
string statementId,
|
|
string provider,
|
|
string status,
|
|
bool accepted,
|
|
string? justification = null)
|
|
{
|
|
StatementId = Validation.TrimToNull(statementId) ?? throw new ArgumentNullException(nameof(statementId));
|
|
Provider = Validation.TrimToNull(provider) ?? throw new ArgumentNullException(nameof(provider));
|
|
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
|
|
Accepted = accepted;
|
|
Justification = Validation.TrimToNull(justification);
|
|
}
|
|
|
|
public string StatementId { get; }
|
|
|
|
public string Provider { get; }
|
|
|
|
public string Status { get; }
|
|
|
|
public bool Accepted { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public string? Justification { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reachability analysis results for verdict.
|
|
/// </summary>
|
|
public sealed record VerdictReachability
|
|
{
|
|
public VerdictReachability(
|
|
string status,
|
|
IEnumerable<VerdictReachabilityPath>? paths = null)
|
|
{
|
|
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
|
|
Paths = NormalizePaths(paths);
|
|
}
|
|
|
|
public string Status { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
|
public ImmutableArray<VerdictReachabilityPath> Paths { get; }
|
|
|
|
private static ImmutableArray<VerdictReachabilityPath> NormalizePaths(IEnumerable<VerdictReachabilityPath>? paths)
|
|
{
|
|
if (paths is null)
|
|
{
|
|
return ImmutableArray<VerdictReachabilityPath>.Empty;
|
|
}
|
|
|
|
return paths
|
|
.Where(static path => path is not null)
|
|
.Select(static path => path!)
|
|
.ToImmutableArray();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reachability path from entrypoint to sink.
|
|
/// </summary>
|
|
public sealed record VerdictReachabilityPath
|
|
{
|
|
public VerdictReachabilityPath(
|
|
string entrypoint,
|
|
string sink,
|
|
string confidence,
|
|
string? digest = null)
|
|
{
|
|
Entrypoint = Validation.TrimToNull(entrypoint) ?? throw new ArgumentNullException(nameof(entrypoint));
|
|
Sink = Validation.TrimToNull(sink) ?? throw new ArgumentNullException(nameof(sink));
|
|
Confidence = Validation.TrimToNull(confidence) ?? throw new ArgumentNullException(nameof(confidence));
|
|
Digest = Validation.TrimToNull(digest);
|
|
}
|
|
|
|
public string Entrypoint { get; }
|
|
|
|
public string Sink { get; }
|
|
|
|
public string Confidence { get; }
|
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
|
public string? Digest { get; }
|
|
}
|