Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Engine/Attestation/VerdictPredicate.cs
master ef933db0d8 feat(cli): Implement crypto plugin CLI architecture with regional compliance
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>
2025-12-23 13:13:00 +02:00

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