sprints and audit work
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Collections.Immutable;
|
||||
global using System.Linq;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
global using System.Text.Json.Serialization;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace StellaOps.Policy.Explainability;
|
||||
|
||||
/// <summary>
|
||||
/// Renders verdict rationales in multiple formats.
|
||||
/// </summary>
|
||||
public interface IVerdictRationaleRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a complete verdict rationale from verdict components.
|
||||
/// </summary>
|
||||
VerdictRationale Render(VerdictRationaleInput input);
|
||||
|
||||
/// <summary>
|
||||
/// Renders rationale as plain text (4-line format).
|
||||
/// </summary>
|
||||
string RenderPlainText(VerdictRationale rationale);
|
||||
|
||||
/// <summary>
|
||||
/// Renders rationale as Markdown.
|
||||
/// </summary>
|
||||
string RenderMarkdown(VerdictRationale rationale);
|
||||
|
||||
/// <summary>
|
||||
/// Renders rationale as canonical JSON (RFC 8785).
|
||||
/// </summary>
|
||||
string RenderJson(VerdictRationale rationale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for verdict rationale rendering.
|
||||
/// </summary>
|
||||
public sealed record VerdictRationaleInput
|
||||
{
|
||||
public required VerdictReference VerdictRef { get; init; }
|
||||
public required string Cve { get; init; }
|
||||
public required ComponentIdentity Component { get; init; }
|
||||
public ReachabilityDetail? Reachability { get; init; }
|
||||
public required string PolicyClauseId { get; init; }
|
||||
public required string PolicyRuleDescription { get; init; }
|
||||
public required IReadOnlyList<string> PolicyConditions { get; init; }
|
||||
public AttestationReference? PathWitness { get; init; }
|
||||
public IReadOnlyList<AttestationReference>? VexStatements { get; init; }
|
||||
public AttestationReference? Provenance { get; init; }
|
||||
public required string Verdict { get; init; }
|
||||
public double? Score { get; init; }
|
||||
public required string Recommendation { get; init; }
|
||||
public MitigationGuidance? Mitigation { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
public required string VerdictDigest { get; init; }
|
||||
public string? PolicyDigest { get; init; }
|
||||
public string? EvidenceDigest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Policy.Explainability;
|
||||
|
||||
public static class ExplainabilityServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddVerdictExplainability(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IVerdictRationaleRenderer, VerdictRationaleRenderer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,197 @@
|
||||
namespace StellaOps.Policy.Explainability;
|
||||
|
||||
/// <summary>
|
||||
/// Structured verdict rationale following the 4-line template.
|
||||
/// Line 1: Evidence summary
|
||||
/// Line 2: Policy clause that triggered the decision
|
||||
/// Line 3: Attestations and proofs supporting the verdict
|
||||
/// Line 4: Final decision with score and recommendation
|
||||
/// </summary>
|
||||
public sealed record VerdictRationale
|
||||
{
|
||||
/// <summary>Schema version for forward compatibility.</summary>
|
||||
[JsonPropertyName("schema_version")]
|
||||
public string SchemaVersion { get; init; } = "1.0";
|
||||
|
||||
/// <summary>Unique rationale ID (content-addressed).</summary>
|
||||
[JsonPropertyName("rationale_id")]
|
||||
public required string RationaleId { get; init; }
|
||||
|
||||
/// <summary>Reference to the verdict being explained.</summary>
|
||||
[JsonPropertyName("verdict_ref")]
|
||||
public required VerdictReference VerdictRef { get; init; }
|
||||
|
||||
/// <summary>Line 1: Evidence summary.</summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public required RationaleEvidence Evidence { get; init; }
|
||||
|
||||
/// <summary>Line 2: Policy clause that triggered the decision.</summary>
|
||||
[JsonPropertyName("policy_clause")]
|
||||
public required RationalePolicyClause PolicyClause { get; init; }
|
||||
|
||||
/// <summary>Line 3: Attestations and proofs supporting the verdict.</summary>
|
||||
[JsonPropertyName("attestations")]
|
||||
public required RationaleAttestations Attestations { get; init; }
|
||||
|
||||
/// <summary>Line 4: Final decision with score and recommendation.</summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required RationaleDecision Decision { get; init; }
|
||||
|
||||
/// <summary>Generation timestamp (UTC).</summary>
|
||||
[JsonPropertyName("generated_at")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>Input digests for reproducibility.</summary>
|
||||
[JsonPropertyName("input_digests")]
|
||||
public required RationaleInputDigests InputDigests { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Reference to the verdict being explained.</summary>
|
||||
public sealed record VerdictReference
|
||||
{
|
||||
[JsonPropertyName("attestation_id")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_digest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("policy_id")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
[JsonPropertyName("cve")]
|
||||
public string? Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("component_purl")]
|
||||
public string? ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Line 1: Evidence summary.</summary>
|
||||
public sealed record RationaleEvidence
|
||||
{
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("component")]
|
||||
public required ComponentIdentity Component { get; init; }
|
||||
|
||||
[JsonPropertyName("reachability")]
|
||||
public ReachabilityDetail? Reachability { get; init; }
|
||||
|
||||
[JsonPropertyName("formatted_text")]
|
||||
public required string FormattedText { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ComponentIdentity
|
||||
{
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("ecosystem")]
|
||||
public string? Ecosystem { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReachabilityDetail
|
||||
{
|
||||
[JsonPropertyName("vulnerable_function")]
|
||||
public string? VulnerableFunction { get; init; }
|
||||
|
||||
[JsonPropertyName("entry_point")]
|
||||
public string? EntryPoint { get; init; }
|
||||
|
||||
[JsonPropertyName("path_summary")]
|
||||
public string? PathSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Line 2: Policy clause reference.</summary>
|
||||
public sealed record RationalePolicyClause
|
||||
{
|
||||
[JsonPropertyName("clause_id")]
|
||||
public required string ClauseId { get; init; }
|
||||
|
||||
[JsonPropertyName("rule_description")]
|
||||
public required string RuleDescription { get; init; }
|
||||
|
||||
[JsonPropertyName("conditions")]
|
||||
public required IReadOnlyList<string> Conditions { get; init; }
|
||||
|
||||
[JsonPropertyName("formatted_text")]
|
||||
public required string FormattedText { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Line 3: Attestations and proofs.</summary>
|
||||
public sealed record RationaleAttestations
|
||||
{
|
||||
[JsonPropertyName("path_witness")]
|
||||
public AttestationReference? PathWitness { get; init; }
|
||||
|
||||
[JsonPropertyName("vex_statements")]
|
||||
public IReadOnlyList<AttestationReference>? VexStatements { get; init; }
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public AttestationReference? Provenance { get; init; }
|
||||
|
||||
[JsonPropertyName("formatted_text")]
|
||||
public required string FormattedText { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AttestationReference
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Line 4: Final decision.</summary>
|
||||
public sealed record RationaleDecision
|
||||
{
|
||||
[JsonPropertyName("verdict")]
|
||||
public required string Verdict { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("recommendation")]
|
||||
public required string Recommendation { get; init; }
|
||||
|
||||
[JsonPropertyName("mitigation")]
|
||||
public MitigationGuidance? Mitigation { get; init; }
|
||||
|
||||
[JsonPropertyName("formatted_text")]
|
||||
public required string FormattedText { get; init; }
|
||||
}
|
||||
|
||||
public sealed record MitigationGuidance
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Input digests for reproducibility.</summary>
|
||||
public sealed record RationaleInputDigests
|
||||
{
|
||||
[JsonPropertyName("verdict_digest")]
|
||||
public required string VerdictDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("policy_digest")]
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_digest")]
|
||||
public string? EvidenceDigest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Policy.Explainability;
|
||||
|
||||
/// <summary>
|
||||
/// Renders verdict rationales in multiple formats following the 4-line template.
|
||||
/// </summary>
|
||||
public sealed class VerdictRationaleRenderer : IVerdictRationaleRenderer
|
||||
{
|
||||
private readonly ILogger<VerdictRationaleRenderer> _logger;
|
||||
|
||||
public VerdictRationaleRenderer(ILogger<VerdictRationaleRenderer> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public VerdictRationale Render(VerdictRationaleInput input)
|
||||
{
|
||||
var evidence = RenderEvidence(input);
|
||||
var policyClause = RenderPolicyClause(input);
|
||||
var attestations = RenderAttestations(input);
|
||||
var decision = RenderDecision(input);
|
||||
|
||||
var inputDigests = new RationaleInputDigests
|
||||
{
|
||||
VerdictDigest = input.VerdictDigest,
|
||||
PolicyDigest = input.PolicyDigest,
|
||||
EvidenceDigest = input.EvidenceDigest
|
||||
};
|
||||
|
||||
var rationale = new VerdictRationale
|
||||
{
|
||||
RationaleId = string.Empty, // Will be computed below
|
||||
VerdictRef = input.VerdictRef,
|
||||
Evidence = evidence,
|
||||
PolicyClause = policyClause,
|
||||
Attestations = attestations,
|
||||
Decision = decision,
|
||||
GeneratedAt = input.GeneratedAt,
|
||||
InputDigests = inputDigests
|
||||
};
|
||||
|
||||
// Compute content-addressed ID
|
||||
var rationaleId = ComputeRationaleId(rationale);
|
||||
return rationale with { RationaleId = rationaleId };
|
||||
}
|
||||
|
||||
public string RenderPlainText(VerdictRationale rationale)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(rationale.Evidence.FormattedText);
|
||||
sb.AppendLine(rationale.PolicyClause.FormattedText);
|
||||
sb.AppendLine(rationale.Attestations.FormattedText);
|
||||
sb.AppendLine(rationale.Decision.FormattedText);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public string RenderMarkdown(VerdictRationale rationale)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"## Verdict Rationale: {rationale.Evidence.Cve}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Evidence");
|
||||
sb.AppendLine(rationale.Evidence.FormattedText);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Policy Clause");
|
||||
sb.AppendLine(rationale.PolicyClause.FormattedText);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Attestations");
|
||||
sb.AppendLine(rationale.Attestations.FormattedText);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("### Decision");
|
||||
sb.AppendLine(rationale.Decision.FormattedText);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"*Rationale ID: `{rationale.RationaleId}`*");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public string RenderJson(VerdictRationale rationale)
|
||||
{
|
||||
return CanonJson.Serialize(rationale);
|
||||
}
|
||||
|
||||
private RationaleEvidence RenderEvidence(VerdictRationaleInput input)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.Append($"CVE-{input.Cve.Replace("CVE-", "")} in `{input.Component.Name ?? input.Component.Purl}` {input.Component.Version}");
|
||||
|
||||
if (input.Reachability != null)
|
||||
{
|
||||
text.Append($"; symbol `{input.Reachability.VulnerableFunction}` reachable from `{input.Reachability.EntryPoint}`");
|
||||
if (!string.IsNullOrEmpty(input.Reachability.PathSummary))
|
||||
{
|
||||
text.Append($" ({input.Reachability.PathSummary})");
|
||||
}
|
||||
}
|
||||
|
||||
text.Append('.');
|
||||
|
||||
return new RationaleEvidence
|
||||
{
|
||||
Cve = input.Cve,
|
||||
Component = input.Component,
|
||||
Reachability = input.Reachability,
|
||||
FormattedText = text.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private RationalePolicyClause RenderPolicyClause(VerdictRationaleInput input)
|
||||
{
|
||||
var text = $"Policy {input.PolicyClauseId}: {input.PolicyRuleDescription}";
|
||||
if (input.PolicyConditions.Any())
|
||||
{
|
||||
text += $" ({string.Join(", ", input.PolicyConditions)})";
|
||||
}
|
||||
text += ".";
|
||||
|
||||
return new RationalePolicyClause
|
||||
{
|
||||
ClauseId = input.PolicyClauseId,
|
||||
RuleDescription = input.PolicyRuleDescription,
|
||||
Conditions = input.PolicyConditions,
|
||||
FormattedText = text
|
||||
};
|
||||
}
|
||||
|
||||
private RationaleAttestations RenderAttestations(VerdictRationaleInput input)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (input.PathWitness != null)
|
||||
{
|
||||
parts.Add($"Path witness: {input.PathWitness.Summary ?? input.PathWitness.Id}");
|
||||
}
|
||||
|
||||
if (input.VexStatements?.Any() == true)
|
||||
{
|
||||
var vexSummary = string.Join(", ", input.VexStatements.Select(v => v.Summary ?? v.Id));
|
||||
parts.Add($"VEX statements: {vexSummary}");
|
||||
}
|
||||
|
||||
if (input.Provenance != null)
|
||||
{
|
||||
parts.Add($"Provenance: {input.Provenance.Summary ?? input.Provenance.Id}");
|
||||
}
|
||||
|
||||
var text = parts.Any()
|
||||
? string.Join("; ", parts) + "."
|
||||
: "No attestations available.";
|
||||
|
||||
return new RationaleAttestations
|
||||
{
|
||||
PathWitness = input.PathWitness,
|
||||
VexStatements = input.VexStatements,
|
||||
Provenance = input.Provenance,
|
||||
FormattedText = text
|
||||
};
|
||||
}
|
||||
|
||||
private RationaleDecision RenderDecision(VerdictRationaleInput input)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.Append($"{input.Verdict}");
|
||||
|
||||
if (input.Score.HasValue)
|
||||
{
|
||||
text.Append($" (score {input.Score.Value:F2})");
|
||||
}
|
||||
|
||||
text.Append($". {input.Recommendation}");
|
||||
|
||||
if (input.Mitigation != null)
|
||||
{
|
||||
text.Append($": {input.Mitigation.Action}");
|
||||
if (!string.IsNullOrEmpty(input.Mitigation.Details))
|
||||
{
|
||||
text.Append($" ({input.Mitigation.Details})");
|
||||
}
|
||||
}
|
||||
|
||||
text.Append('.');
|
||||
|
||||
return new RationaleDecision
|
||||
{
|
||||
Verdict = input.Verdict,
|
||||
Score = input.Score,
|
||||
Recommendation = input.Recommendation,
|
||||
Mitigation = input.Mitigation,
|
||||
FormattedText = text.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private string ComputeRationaleId(VerdictRationale rationale)
|
||||
{
|
||||
var canonicalJson = CanonJson.Serialize(rationale with { RationaleId = string.Empty });
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
|
||||
return $"rat:sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user