Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit c8f3120174
349 changed files with 78558 additions and 1342 deletions

View File

@@ -0,0 +1,92 @@
using System.ComponentModel.DataAnnotations;
using StellaOps.AdvisoryAI.Explanation;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
/// <summary>
/// API request for generating an explanation.
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-13
/// </summary>
public sealed record ExplainRequest
{
/// <summary>
/// Finding ID to explain.
/// </summary>
[Required]
public required string FindingId { get; init; }
/// <summary>
/// Artifact digest (image, SBOM, etc.) for context.
/// </summary>
[Required]
public required string ArtifactDigest { get; init; }
/// <summary>
/// Scope of the explanation (service, release, image).
/// </summary>
[Required]
public required string Scope { get; init; }
/// <summary>
/// Scope identifier.
/// </summary>
[Required]
public required string ScopeId { get; init; }
/// <summary>
/// Type of explanation to generate.
/// </summary>
public string ExplanationType { get; init; } = "full";
/// <summary>
/// Vulnerability ID (CVE, GHSA, etc.).
/// </summary>
[Required]
public required string VulnerabilityId { get; init; }
/// <summary>
/// Affected component PURL.
/// </summary>
public string? ComponentPurl { get; init; }
/// <summary>
/// Whether to use plain language mode.
/// </summary>
public bool PlainLanguage { get; init; }
/// <summary>
/// Maximum length of explanation (0 = no limit).
/// </summary>
public int MaxLength { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Convert to domain model.
/// </summary>
public ExplanationRequest ToDomain()
{
if (!Enum.TryParse<ExplanationType>(ExplanationType, ignoreCase: true, out var explType))
{
explType = Explanation.ExplanationType.Full;
}
return new ExplanationRequest
{
FindingId = FindingId,
ArtifactDigest = ArtifactDigest,
Scope = Scope,
ScopeId = ScopeId,
ExplanationType = explType,
VulnerabilityId = VulnerabilityId,
ComponentPurl = ComponentPurl,
PlainLanguage = PlainLanguage,
MaxLength = MaxLength,
CorrelationId = CorrelationId
};
}
}

View File

@@ -0,0 +1,157 @@
using StellaOps.AdvisoryAI.Explanation;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
/// <summary>
/// API response for explanation generation.
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-13
/// </summary>
public sealed record ExplainResponse
{
/// <summary>
/// Unique ID for this explanation.
/// </summary>
public required string ExplanationId { get; init; }
/// <summary>
/// The explanation content (markdown supported).
/// </summary>
public required string Content { get; init; }
/// <summary>
/// 3-line summary for compact display.
/// </summary>
public required ExplainSummaryResponse Summary { get; init; }
/// <summary>
/// Citations linking claims to evidence.
/// </summary>
public required IReadOnlyList<ExplainCitationResponse> Citations { get; init; }
/// <summary>
/// Overall confidence score (0.0-1.0).
/// </summary>
public required double ConfidenceScore { get; init; }
/// <summary>
/// Citation rate (verified citations / total claims).
/// </summary>
public required double CitationRate { get; init; }
/// <summary>
/// Authority classification.
/// </summary>
public required string Authority { get; init; }
/// <summary>
/// Evidence node IDs used in this explanation.
/// </summary>
public required IReadOnlyList<string> EvidenceRefs { get; init; }
/// <summary>
/// Model ID used for generation.
/// </summary>
public required string ModelId { get; init; }
/// <summary>
/// Prompt template version.
/// </summary>
public required string PromptTemplateVersion { get; init; }
/// <summary>
/// Generation timestamp (UTC ISO-8601).
/// </summary>
public required string GeneratedAt { get; init; }
/// <summary>
/// Output hash for verification.
/// </summary>
public required string OutputHash { get; init; }
/// <summary>
/// Create from domain model.
/// </summary>
public static ExplainResponse FromDomain(ExplanationResult result)
{
return new ExplainResponse
{
ExplanationId = result.ExplanationId,
Content = result.Content,
Summary = new ExplainSummaryResponse
{
Line1 = result.Summary.Line1,
Line2 = result.Summary.Line2,
Line3 = result.Summary.Line3
},
Citations = result.Citations.Select(c => new ExplainCitationResponse
{
ClaimText = c.ClaimText,
EvidenceId = c.EvidenceId,
EvidenceType = c.EvidenceType,
Verified = c.Verified,
EvidenceExcerpt = c.EvidenceExcerpt
}).ToList(),
ConfidenceScore = result.ConfidenceScore,
CitationRate = result.CitationRate,
Authority = result.Authority.ToString(),
EvidenceRefs = result.EvidenceRefs,
ModelId = result.ModelId,
PromptTemplateVersion = result.PromptTemplateVersion,
GeneratedAt = result.GeneratedAt,
OutputHash = result.OutputHash
};
}
}
/// <summary>
/// 3-line summary response.
/// </summary>
public sealed record ExplainSummaryResponse
{
/// <summary>
/// Line 1: What changed/what is it.
/// </summary>
public required string Line1 { get; init; }
/// <summary>
/// Line 2: Why it matters.
/// </summary>
public required string Line2 { get; init; }
/// <summary>
/// Line 3: Next action.
/// </summary>
public required string Line3 { get; init; }
}
/// <summary>
/// Citation response.
/// </summary>
public sealed record ExplainCitationResponse
{
/// <summary>
/// Claim text from the explanation.
/// </summary>
public required string ClaimText { get; init; }
/// <summary>
/// Evidence node ID supporting this claim.
/// </summary>
public required string EvidenceId { get; init; }
/// <summary>
/// Type of evidence.
/// </summary>
public required string EvidenceType { get; init; }
/// <summary>
/// Whether the citation was verified.
/// </summary>
public required bool Verified { get; init; }
/// <summary>
/// Excerpt from evidence.
/// </summary>
public string? EvidenceExcerpt { get; init; }
}

View File

@@ -0,0 +1,229 @@
using System.ComponentModel.DataAnnotations;
using StellaOps.AdvisoryAI.Remediation;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
/// <summary>
/// API request for generating a remediation plan.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-19
/// </summary>
public sealed record RemediationPlanApiRequest
{
[Required]
public required string FindingId { get; init; }
[Required]
public required string ArtifactDigest { get; init; }
[Required]
public required string VulnerabilityId { get; init; }
[Required]
public required string ComponentPurl { get; init; }
public string RemediationType { get; init; } = "auto";
public string? RepositoryUrl { get; init; }
public string TargetBranch { get; init; } = "main";
public bool AutoCreatePr { get; init; }
public string? CorrelationId { get; init; }
public RemediationPlanRequest ToDomain()
{
if (!Enum.TryParse<RemediationType>(RemediationType, ignoreCase: true, out var type))
{
type = Remediation.RemediationType.Auto;
}
return new RemediationPlanRequest
{
FindingId = FindingId,
ArtifactDigest = ArtifactDigest,
VulnerabilityId = VulnerabilityId,
ComponentPurl = ComponentPurl,
RemediationType = type,
RepositoryUrl = RepositoryUrl,
TargetBranch = TargetBranch,
AutoCreatePr = AutoCreatePr,
CorrelationId = CorrelationId
};
}
}
/// <summary>
/// API response for remediation plan.
/// </summary>
public sealed record RemediationPlanApiResponse
{
public required string PlanId { get; init; }
public required IReadOnlyList<RemediationStepResponse> Steps { get; init; }
public required ExpectedDeltaResponse ExpectedDelta { get; init; }
public required string RiskAssessment { get; init; }
public required string Authority { get; init; }
public required bool PrReady { get; init; }
public string? NotReadyReason { get; init; }
public required double ConfidenceScore { get; init; }
public required string ModelId { get; init; }
public required string GeneratedAt { get; init; }
public static RemediationPlanApiResponse FromDomain(RemediationPlan plan)
{
return new RemediationPlanApiResponse
{
PlanId = plan.PlanId,
Steps = plan.Steps.Select(s => new RemediationStepResponse
{
Order = s.Order,
ActionType = s.ActionType,
FilePath = s.FilePath,
Description = s.Description,
PreviousValue = s.PreviousValue,
NewValue = s.NewValue,
Optional = s.Optional,
Risk = s.Risk.ToString()
}).ToList(),
ExpectedDelta = new ExpectedDeltaResponse
{
Added = plan.ExpectedDelta.Added,
Removed = plan.ExpectedDelta.Removed,
Upgraded = plan.ExpectedDelta.Upgraded,
NetVulnerabilityChange = plan.ExpectedDelta.NetVulnerabilityChange
},
RiskAssessment = plan.RiskAssessment.ToString(),
Authority = plan.Authority.ToString(),
PrReady = plan.PrReady,
NotReadyReason = plan.NotReadyReason,
ConfidenceScore = plan.ConfidenceScore,
ModelId = plan.ModelId,
GeneratedAt = plan.GeneratedAt
};
}
}
public sealed record RemediationStepResponse
{
public required int Order { get; init; }
public required string ActionType { get; init; }
public required string FilePath { get; init; }
public required string Description { get; init; }
public string? PreviousValue { get; init; }
public string? NewValue { get; init; }
public bool Optional { get; init; }
public required string Risk { get; init; }
}
public sealed record ExpectedDeltaResponse
{
public required IReadOnlyList<string> Added { get; init; }
public required IReadOnlyList<string> Removed { get; init; }
public required IReadOnlyDictionary<string, string> Upgraded { get; init; }
public required int NetVulnerabilityChange { get; init; }
}
/// <summary>
/// API request for applying remediation (creating PR).
/// Task: REMEDY-20
/// </summary>
public sealed record ApplyRemediationRequest
{
[Required]
public required string PlanId { get; init; }
public string ScmType { get; init; } = "github";
}
/// <summary>
/// API response for PR creation.
/// </summary>
public sealed record PullRequestApiResponse
{
public required string PrId { get; init; }
public required int PrNumber { get; init; }
public required string Url { get; init; }
public required string BranchName { get; init; }
public required string Status { get; init; }
public string? StatusMessage { get; init; }
public BuildResultResponse? BuildResult { get; init; }
public TestResultResponse? TestResult { get; init; }
public DeltaVerdictResponse? DeltaVerdict { get; init; }
public required string CreatedAt { get; init; }
public required string UpdatedAt { get; init; }
public static PullRequestApiResponse FromDomain(PullRequestResult result)
{
return new PullRequestApiResponse
{
PrId = result.PrId,
PrNumber = result.PrNumber,
Url = result.Url,
BranchName = result.BranchName,
Status = result.Status.ToString(),
StatusMessage = result.StatusMessage,
BuildResult = result.BuildResult != null ? new BuildResultResponse
{
Success = result.BuildResult.Success,
BuildId = result.BuildResult.BuildId,
BuildUrl = result.BuildResult.BuildUrl,
ErrorMessage = result.BuildResult.ErrorMessage,
CompletedAt = result.BuildResult.CompletedAt
} : null,
TestResult = result.TestResult != null ? new TestResultResponse
{
AllPassed = result.TestResult.AllPassed,
TotalTests = result.TestResult.TotalTests,
PassedTests = result.TestResult.PassedTests,
FailedTests = result.TestResult.FailedTests,
SkippedTests = result.TestResult.SkippedTests,
Coverage = result.TestResult.Coverage,
FailedTestNames = result.TestResult.FailedTestNames,
CompletedAt = result.TestResult.CompletedAt
} : null,
DeltaVerdict = result.DeltaVerdict != null ? new DeltaVerdictResponse
{
Improved = result.DeltaVerdict.Improved,
VulnerabilitiesFixed = result.DeltaVerdict.VulnerabilitiesFixed,
VulnerabilitiesIntroduced = result.DeltaVerdict.VulnerabilitiesIntroduced,
VerdictId = result.DeltaVerdict.VerdictId,
SignatureId = result.DeltaVerdict.SignatureId,
ComputedAt = result.DeltaVerdict.ComputedAt
} : null,
CreatedAt = result.CreatedAt,
UpdatedAt = result.UpdatedAt
};
}
}
public sealed record BuildResultResponse
{
public required bool Success { get; init; }
public required string BuildId { get; init; }
public string? BuildUrl { get; init; }
public string? ErrorMessage { get; init; }
public required string CompletedAt { get; init; }
}
public sealed record TestResultResponse
{
public required bool AllPassed { get; init; }
public required int TotalTests { get; init; }
public required int PassedTests { get; init; }
public required int FailedTests { get; init; }
public required int SkippedTests { get; init; }
public double Coverage { get; init; }
public IReadOnlyList<string> FailedTestNames { get; init; } = Array.Empty<string>();
public required string CompletedAt { get; init; }
}
public sealed record DeltaVerdictResponse
{
public required bool Improved { get; init; }
public required int VulnerabilitiesFixed { get; init; }
public required int VulnerabilitiesIntroduced { get; init; }
public required string VerdictId { get; init; }
public string? SignatureId { get; init; }
public required string ComputedAt { get; init; }
}

View File

@@ -11,11 +11,13 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Diagnostics;
using StellaOps.AdvisoryAI.Explanation;
using StellaOps.AdvisoryAI.Hosting;
using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Outputs;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.Remediation;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.Router.AspNet;
@@ -88,6 +90,23 @@ app.MapPost("/v1/advisory-ai/pipeline:batch", HandleBatchPlans)
app.MapGet("/v1/advisory-ai/outputs/{cacheKey}", HandleGetOutput)
.RequireRateLimiting("advisory-ai");
// Explanation endpoints (SPRINT_20251226_015_AI_zastava_companion)
app.MapPost("/v1/advisory-ai/explain", HandleExplain)
.RequireRateLimiting("advisory-ai");
app.MapGet("/v1/advisory-ai/explain/{explanationId}/replay", HandleExplanationReplay)
.RequireRateLimiting("advisory-ai");
// Remediation endpoints (SPRINT_20251226_016_AI_remedy_autopilot)
app.MapPost("/v1/advisory-ai/remediation/plan", HandleRemediationPlan)
.RequireRateLimiting("advisory-ai");
app.MapPost("/v1/advisory-ai/remediation/apply", HandleApplyRemediation)
.RequireRateLimiting("advisory-ai");
app.MapGet("/v1/advisory-ai/remediation/status/{prId}", HandleRemediationStatus)
.RequireRateLimiting("advisory-ai");
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
@@ -250,6 +269,213 @@ static bool EnsureAuthorized(HttpContext context, AdvisoryTaskType taskType)
return allowed.Contains($"advisory:{taskType.ToString().ToLowerInvariant()}");
}
static bool EnsureExplainAuthorized(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
}
var allowed = scopes
.SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return allowed.Contains("advisory:run") || allowed.Contains("advisory:explain");
}
// ZASTAVA-13: POST /v1/advisory-ai/explain
static async Task<IResult> HandleExplain(
HttpContext httpContext,
ExplainRequest request,
IExplanationGenerator explanationGenerator,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain", ActivityKind.Server);
activity?.SetTag("advisory.finding_id", request.FindingId);
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
activity?.SetTag("advisory.explanation_type", request.ExplanationType);
if (!EnsureExplainAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
try
{
var domainRequest = request.ToDomain();
var result = await explanationGenerator.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.explanation_id", result.ExplanationId);
activity?.SetTag("advisory.authority", result.Authority.ToString());
activity?.SetTag("advisory.citation_rate", result.CitationRate);
return Results.Ok(ExplainResponse.FromDomain(result));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ZASTAVA-14: GET /v1/advisory-ai/explain/{explanationId}/replay
static async Task<IResult> HandleExplanationReplay(
HttpContext httpContext,
string explanationId,
IExplanationGenerator explanationGenerator,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain_replay", ActivityKind.Server);
activity?.SetTag("advisory.explanation_id", explanationId);
if (!EnsureExplainAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
try
{
var result = await explanationGenerator.ReplayAsync(explanationId, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.replayed_explanation_id", result.ExplanationId);
activity?.SetTag("advisory.authority", result.Authority.ToString());
return Results.Ok(ExplainResponse.FromDomain(result));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { error = ex.Message });
}
}
static bool EnsureRemediationAuthorized(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
}
var allowed = scopes
.SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return allowed.Contains("advisory:run") || allowed.Contains("advisory:remediate");
}
// REMEDY-19: POST /v1/advisory-ai/remediation/plan
static async Task<IResult> HandleRemediationPlan(
HttpContext httpContext,
RemediationPlanApiRequest request,
IRemediationPlanner remediationPlanner,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_plan", ActivityKind.Server);
activity?.SetTag("advisory.finding_id", request.FindingId);
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
activity?.SetTag("advisory.remediation_type", request.RemediationType);
if (!EnsureRemediationAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
try
{
var domainRequest = request.ToDomain();
var plan = await remediationPlanner.GeneratePlanAsync(domainRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.plan_id", plan.PlanId);
activity?.SetTag("advisory.risk_assessment", plan.RiskAssessment.ToString());
activity?.SetTag("advisory.pr_ready", plan.PrReady);
return Results.Ok(RemediationPlanApiResponse.FromDomain(plan));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// REMEDY-20: POST /v1/advisory-ai/remediation/apply
static async Task<IResult> HandleApplyRemediation(
HttpContext httpContext,
ApplyRemediationRequest request,
IRemediationPlanner remediationPlanner,
IEnumerable<IPullRequestGenerator> prGenerators,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.apply_remediation", ActivityKind.Server);
activity?.SetTag("advisory.plan_id", request.PlanId);
activity?.SetTag("advisory.scm_type", request.ScmType);
if (!EnsureRemediationAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var plan = await remediationPlanner.GetPlanAsync(request.PlanId, cancellationToken).ConfigureAwait(false);
if (plan is null)
{
return Results.NotFound(new { error = $"Plan {request.PlanId} not found" });
}
var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(request.ScmType, StringComparison.OrdinalIgnoreCase));
if (generator is null)
{
return Results.BadRequest(new { error = $"SCM type '{request.ScmType}' not supported" });
}
try
{
var prResult = await generator.CreatePullRequestAsync(plan, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.pr_id", prResult.PrId);
activity?.SetTag("advisory.pr_status", prResult.Status.ToString());
return Results.Ok(PullRequestApiResponse.FromDomain(prResult));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// REMEDY-21: GET /v1/advisory-ai/remediation/status/{prId}
static async Task<IResult> HandleRemediationStatus(
HttpContext httpContext,
string prId,
string? scmType,
IEnumerable<IPullRequestGenerator> prGenerators,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_status", ActivityKind.Server);
activity?.SetTag("advisory.pr_id", prId);
if (!EnsureRemediationAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var resolvedScmType = scmType ?? "github";
var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(resolvedScmType, StringComparison.OrdinalIgnoreCase));
if (generator is null)
{
return Results.BadRequest(new { error = $"SCM type '{resolvedScmType}' not supported" });
}
try
{
var prResult = await generator.GetStatusAsync(prId, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.pr_status", prResult.Status.ToString());
return Results.Ok(PullRequestApiResponse.FromDomain(prResult));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { error = ex.Message });
}
}
internal sealed record PipelinePlanRequest(
AdvisoryTaskType? TaskType,
string AdvisoryKey,

View File

@@ -0,0 +1,157 @@
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.AdvisoryAI.Explanation;
/// <summary>
/// Default implementation of explanation prompt service.
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-05
/// </summary>
public sealed class DefaultExplanationPromptService : IExplanationPromptService
{
public Task<ExplanationPrompt> BuildPromptAsync(
ExplanationRequest request,
EvidenceContext evidence,
CancellationToken cancellationToken = default)
{
var template = ExplanationPromptTemplates.GetTemplate(request.ExplanationType);
var content = new StringBuilder();
// Add plain language system prompt if requested
if (request.PlainLanguage)
{
content.AppendLine(ExplanationPromptTemplates.PlainLanguageSystemPrompt);
content.AppendLine();
}
// Render template with evidence
var rendered = RenderTemplate(template, request, evidence);
content.Append(rendered);
// Apply max length constraint if specified
var finalContent = content.ToString();
if (request.MaxLength > 0)
{
content.AppendLine();
content.AppendLine($"IMPORTANT: Keep your response under {request.MaxLength} characters.");
}
var prompt = new ExplanationPrompt
{
Content = finalContent,
TemplateVersion = ExplanationPromptTemplates.TemplateVersion
};
return Task.FromResult(prompt);
}
public Task<ExplanationSummary> GenerateSummaryAsync(
string content,
ExplanationType type,
CancellationToken cancellationToken = default)
{
// Extract first meaningful sentences for each line
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Where(l => !l.StartsWith('#') && !l.StartsWith('-') && l.Trim().Length > 10)
.Take(10)
.ToList();
var line1 = GetSummaryLine(lines, 0, type);
var line2 = GetSummaryLine(lines, 1, type);
var line3 = GetSummaryLine(lines, 2, type);
return Task.FromResult(new ExplanationSummary
{
Line1 = line1,
Line2 = line2,
Line3 = line3
});
}
private static string RenderTemplate(string template, ExplanationRequest request, EvidenceContext evidence)
{
var result = template;
// Replace simple placeholders
result = result.Replace("{{vulnerability_id}}", request.VulnerabilityId);
result = result.Replace("{{component_purl}}", request.ComponentPurl ?? "Unknown");
result = result.Replace("{{artifact_digest}}", request.ArtifactDigest);
result = result.Replace("{{scope}}", request.Scope);
result = result.Replace("{{scope_id}}", request.ScopeId);
// Render evidence sections
result = RenderEvidenceSection(result, "sbom_evidence", evidence.SbomEvidence);
result = RenderEvidenceSection(result, "reachability_evidence", evidence.ReachabilityEvidence);
result = RenderEvidenceSection(result, "runtime_evidence", evidence.RuntimeEvidence);
result = RenderEvidenceSection(result, "vex_evidence", evidence.VexEvidence);
result = RenderEvidenceSection(result, "patch_evidence", evidence.PatchEvidence);
return result;
}
private static string RenderEvidenceSection(string template, string sectionName, IReadOnlyList<EvidenceNode> evidence)
{
var pattern = $@"\{{\{{#{sectionName}\}}\}}(.*?)\{{\{{/{sectionName}\}}\}}";
var regex = new Regex(pattern, RegexOptions.Singleline);
if (evidence.Count == 0)
{
return regex.Replace(template, string.Empty);
}
var match = regex.Match(template);
if (!match.Success)
{
return template;
}
var itemTemplate = match.Groups[1].Value;
var rendered = new StringBuilder();
foreach (var node in evidence)
{
var item = itemTemplate;
item = item.Replace("{{id}}", node.Id);
item = item.Replace("{{type}}", node.Type);
item = item.Replace("{{confidence}}", node.Confidence.ToString("F2"));
item = item.Replace("{{content}}", node.Content);
item = item.Replace("{{summary}}", node.Summary);
item = item.Replace("{{.}}", FormatEvidenceNode(node));
rendered.Append(item);
}
return regex.Replace(template, rendered.ToString());
}
private static string FormatEvidenceNode(EvidenceNode node)
{
return $"[{node.Id}] {node.Summary} (confidence: {node.Confidence:F2})";
}
private static string GetSummaryLine(List<string> lines, int preferredIndex, ExplanationType type)
{
if (preferredIndex < lines.Count)
{
var line = lines[preferredIndex].Trim();
if (line.Length > 100)
{
line = line[..97] + "...";
}
return line;
}
// Fallback based on type and line position
return (type, preferredIndex) switch
{
(_, 0) => "Analysis complete.",
(ExplanationType.What, 1) => "Review the vulnerability details above.",
(ExplanationType.Why, 1) => "Consider the impact on your deployment.",
(ExplanationType.Evidence, 1) => "Review the evidence summary above.",
(ExplanationType.Counterfactual, 1) => "Actions that could change the verdict.",
(ExplanationType.Full, 1) => "Comprehensive assessment available.",
(_, 2) => "See full explanation for details.",
_ => "See details above."
};
}
}

View File

@@ -0,0 +1,209 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Explanation;
/// <summary>
/// Implementation of explanation generator that anchors all claims to evidence.
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-03
/// </summary>
public sealed class EvidenceAnchoredExplanationGenerator : IExplanationGenerator
{
private readonly IEvidenceRetrievalService _evidenceService;
private readonly IExplanationPromptService _promptService;
private readonly IExplanationInferenceClient _inferenceClient;
private readonly ICitationExtractor _citationExtractor;
private readonly IExplanationStore _store;
private const double EvidenceBackedThreshold = 0.8;
public EvidenceAnchoredExplanationGenerator(
IEvidenceRetrievalService evidenceService,
IExplanationPromptService promptService,
IExplanationInferenceClient inferenceClient,
ICitationExtractor citationExtractor,
IExplanationStore store)
{
_evidenceService = evidenceService;
_promptService = promptService;
_inferenceClient = inferenceClient;
_citationExtractor = citationExtractor;
_store = store;
}
public async Task<ExplanationResult> GenerateAsync(ExplanationRequest request, CancellationToken cancellationToken = default)
{
// 1. Retrieve evidence context
var evidence = await _evidenceService.RetrieveEvidenceAsync(
request.FindingId,
request.ArtifactDigest,
request.VulnerabilityId,
request.ComponentPurl,
cancellationToken);
// 2. Build prompt with evidence
var prompt = await _promptService.BuildPromptAsync(request, evidence, cancellationToken);
// 3. Compute input hashes for replay
var inputHashes = ComputeInputHashes(request, evidence, prompt);
// 4. Generate explanation via LLM
var inferenceResult = await _inferenceClient.GenerateAsync(prompt, cancellationToken);
// 5. Extract and validate citations
var citations = await _citationExtractor.ExtractCitationsAsync(
inferenceResult.Content,
evidence,
cancellationToken);
// 6. Calculate citation rate and determine authority
var verifiedCitations = citations.Where(c => c.Verified).ToList();
var citationRate = citations.Count > 0
? (double)verifiedCitations.Count / citations.Count
: 0;
var authority = citationRate >= EvidenceBackedThreshold
? ExplanationAuthority.EvidenceBacked
: ExplanationAuthority.Suggestion;
// 7. Generate 3-line summary
var summary = await _promptService.GenerateSummaryAsync(
inferenceResult.Content,
request.ExplanationType,
cancellationToken);
// 8. Build result
var explanationId = GenerateExplanationId(inputHashes, inferenceResult.Content);
var outputHash = ComputeHash(inferenceResult.Content);
var result = new ExplanationResult
{
ExplanationId = explanationId,
Content = inferenceResult.Content,
Summary = summary,
Citations = citations,
ConfidenceScore = inferenceResult.Confidence,
CitationRate = citationRate,
Authority = authority,
EvidenceRefs = evidence.AllEvidence.Select(e => e.Id).ToList(),
ModelId = inferenceResult.ModelId,
PromptTemplateVersion = prompt.TemplateVersion,
InputHashes = inputHashes,
GeneratedAt = DateTime.UtcNow.ToString("O"),
OutputHash = outputHash
};
// 9. Store for replay
await _store.StoreAsync(result, cancellationToken);
return result;
}
public async Task<ExplanationResult> ReplayAsync(string explanationId, CancellationToken cancellationToken = default)
{
var original = await _store.GetAsync(explanationId, cancellationToken)
?? throw new InvalidOperationException($"Explanation {explanationId} not found");
// Validate inputs haven't changed
var isValid = await ValidateAsync(original, cancellationToken);
if (!isValid)
{
throw new InvalidOperationException("Input evidence has changed since original explanation");
}
// Reconstruct request from stored data
var storedRequest = await _store.GetRequestAsync(explanationId, cancellationToken)
?? throw new InvalidOperationException($"Request for {explanationId} not found");
// Re-generate with same inputs
return await GenerateAsync(storedRequest, cancellationToken);
}
public async Task<bool> ValidateAsync(ExplanationResult result, CancellationToken cancellationToken = default)
{
return await _evidenceService.ValidateEvidenceAsync(result.EvidenceRefs, cancellationToken);
}
private static IReadOnlyList<string> ComputeInputHashes(
ExplanationRequest request,
EvidenceContext evidence,
ExplanationPrompt prompt)
{
var hashes = new List<string>
{
ComputeHash(JsonSerializer.Serialize(request)),
evidence.ContextHash,
ComputeHash(prompt.Content)
};
return hashes;
}
private static string GenerateExplanationId(IReadOnlyList<string> inputHashes, string output)
{
var combined = string.Join("|", inputHashes) + "|" + output;
return $"sha256:{ComputeHash(combined)}";
}
private static string ComputeHash(string content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexStringLower(bytes);
}
}
/// <summary>
/// Prompt for explanation generation.
/// </summary>
public sealed record ExplanationPrompt
{
public required string Content { get; init; }
public required string TemplateVersion { get; init; }
}
/// <summary>
/// Inference result from LLM.
/// </summary>
public sealed record ExplanationInferenceResult
{
public required string Content { get; init; }
public required double Confidence { get; init; }
public required string ModelId { get; init; }
}
/// <summary>
/// Service for building explanation prompts.
/// </summary>
public interface IExplanationPromptService
{
Task<ExplanationPrompt> BuildPromptAsync(ExplanationRequest request, EvidenceContext evidence, CancellationToken cancellationToken = default);
Task<ExplanationSummary> GenerateSummaryAsync(string content, ExplanationType type, CancellationToken cancellationToken = default);
}
/// <summary>
/// Client for LLM inference.
/// </summary>
public interface IExplanationInferenceClient
{
Task<ExplanationInferenceResult> GenerateAsync(ExplanationPrompt prompt, CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for extracting and validating citations.
/// </summary>
public interface ICitationExtractor
{
Task<IReadOnlyList<ExplanationCitation>> ExtractCitationsAsync(string content, EvidenceContext evidence, CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for explanation results and replay data.
/// </summary>
public interface IExplanationStore
{
Task StoreAsync(ExplanationResult result, CancellationToken cancellationToken = default);
Task<ExplanationResult?> GetAsync(string explanationId, CancellationToken cancellationToken = default);
Task<ExplanationRequest?> GetRequestAsync(string explanationId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,282 @@
namespace StellaOps.AdvisoryAI.Explanation;
/// <summary>
/// Prompt templates for explanation generation.
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-05
/// </summary>
public static class ExplanationPromptTemplates
{
public const string TemplateVersion = "1.0.0";
/// <summary>
/// Template for "What is this vulnerability?" explanation.
/// </summary>
public static readonly string WhatTemplate = """
You are a security analyst explaining a vulnerability finding.
## Context
- Vulnerability: {{vulnerability_id}}
- Affected Component: {{component_purl}}
- Artifact: {{artifact_digest}}
- Scope: {{scope}} ({{scope_id}})
## Evidence Available
{{#sbom_evidence}}
### SBOM Evidence
{{.}}
{{/sbom_evidence}}
{{#reachability_evidence}}
### Reachability Evidence
{{.}}
{{/reachability_evidence}}
{{#vex_evidence}}
### VEX Statements
{{.}}
{{/vex_evidence}}
{{#patch_evidence}}
### Patch Information
{{.}}
{{/patch_evidence}}
## Instructions
Explain WHAT this vulnerability is:
1. Describe the vulnerability type and attack vector
2. Explain the affected functionality
3. Cite specific evidence using [EVIDENCE:id] format
Keep your response focused and cite all claims. Do not speculate beyond the evidence.
""";
/// <summary>
/// Template for "Why does it matter?" explanation.
/// </summary>
public static readonly string WhyTemplate = """
You are a security analyst explaining vulnerability impact.
## Context
- Vulnerability: {{vulnerability_id}}
- Affected Component: {{component_purl}}
- Artifact: {{artifact_digest}}
- Scope: {{scope}} ({{scope_id}})
## Evidence Available
{{#sbom_evidence}}
### SBOM Evidence
{{.}}
{{/sbom_evidence}}
{{#reachability_evidence}}
### Reachability Analysis
{{.}}
{{/reachability_evidence}}
{{#runtime_evidence}}
### Runtime Observations
{{.}}
{{/runtime_evidence}}
{{#vex_evidence}}
### VEX Statements
{{.}}
{{/vex_evidence}}
## Instructions
Explain WHY this vulnerability matters in this specific context:
1. Is the vulnerable code reachable from your application?
2. What is the potential impact based on how the component is used?
3. What runtime factors affect exploitability?
4. Cite specific evidence using [EVIDENCE:id] format
Focus on THIS deployment's context, not generic severity.
""";
/// <summary>
/// Template for evidence-focused explanation.
/// </summary>
public static readonly string EvidenceTemplate = """
You are a security analyst summarizing exploitability evidence.
## Context
- Vulnerability: {{vulnerability_id}}
- Affected Component: {{component_purl}}
- Artifact: {{artifact_digest}}
## All Available Evidence
{{#sbom_evidence}}
### SBOM Evidence (ID: {{id}})
Type: {{type}}
Confidence: {{confidence}}
Content: {{content}}
{{/sbom_evidence}}
{{#reachability_evidence}}
### Reachability Evidence (ID: {{id}})
Type: {{type}}
Confidence: {{confidence}}
Content: {{content}}
{{/reachability_evidence}}
{{#runtime_evidence}}
### Runtime Evidence (ID: {{id}})
Type: {{type}}
Confidence: {{confidence}}
Content: {{content}}
{{/runtime_evidence}}
{{#vex_evidence}}
### VEX Evidence (ID: {{id}})
Type: {{type}}
Confidence: {{confidence}}
Content: {{content}}
{{/vex_evidence}}
{{#patch_evidence}}
### Patch Evidence (ID: {{id}})
Type: {{type}}
Confidence: {{confidence}}
Content: {{content}}
{{/patch_evidence}}
## Instructions
Summarize the exploitability evidence:
1. List each piece of evidence with its type and confidence
2. Explain what each piece of evidence tells us
3. Identify gaps - what evidence is missing?
4. Provide an overall assessment of exploitability
5. Use [EVIDENCE:id] format for all citations
Be comprehensive but concise.
""";
/// <summary>
/// Template for counterfactual explanation.
/// </summary>
public static readonly string CounterfactualTemplate = """
You are a security analyst explaining what would change a verdict.
## Context
- Vulnerability: {{vulnerability_id}}
- Affected Component: {{component_purl}}
- Artifact: {{artifact_digest}}
- Current Verdict: {{current_verdict}}
## Current Evidence
{{#sbom_evidence}}
### SBOM Evidence
{{.}}
{{/sbom_evidence}}
{{#reachability_evidence}}
### Reachability Evidence
{{.}}
{{/reachability_evidence}}
{{#runtime_evidence}}
### Runtime Evidence
{{.}}
{{/runtime_evidence}}
{{#vex_evidence}}
### VEX Statements
{{.}}
{{/vex_evidence}}
## Instructions
Explain what would CHANGE the verdict:
1. What evidence would be needed to downgrade severity?
2. What conditions would make this exploitable vs not exploitable?
3. What mitigations could change the risk assessment?
4. What additional analysis would provide clarity?
5. Use [EVIDENCE:id] format for citations
Focus on actionable paths to change the risk assessment.
""";
/// <summary>
/// Template for full comprehensive explanation.
/// </summary>
public static readonly string FullTemplate = """
You are a security analyst providing a comprehensive vulnerability assessment.
## Context
- Vulnerability: {{vulnerability_id}}
- Affected Component: {{component_purl}}
- Artifact: {{artifact_digest}}
- Scope: {{scope}} ({{scope_id}})
## Complete Evidence Set
{{#sbom_evidence}}
### SBOM Evidence (ID: {{id}})
{{content}}
{{/sbom_evidence}}
{{#reachability_evidence}}
### Reachability Evidence (ID: {{id}})
{{content}}
{{/reachability_evidence}}
{{#runtime_evidence}}
### Runtime Evidence (ID: {{id}})
{{content}}
{{/runtime_evidence}}
{{#vex_evidence}}
### VEX Evidence (ID: {{id}})
{{content}}
{{/vex_evidence}}
{{#patch_evidence}}
### Patch Evidence (ID: {{id}})
{{content}}
{{/patch_evidence}}
## Instructions
Provide a comprehensive assessment covering:
### 1. What Is This Vulnerability?
- Describe the vulnerability type and mechanism
- Explain the attack vector
### 2. Why Does It Matter Here?
- Analyze reachability in this specific deployment
- Assess actual exploitability based on evidence
### 3. Evidence Summary
- List and evaluate each piece of evidence
- Identify evidence gaps
### 4. Recommended Actions
- Prioritized remediation steps
- What would change the verdict
Use [EVIDENCE:id] format for ALL citations. Do not make claims without evidence.
""";
/// <summary>
/// System prompt for plain language mode.
/// </summary>
public static readonly string PlainLanguageSystemPrompt = """
IMPORTANT: Explain in plain language suitable for someone new to security.
- Avoid jargon or define terms when first used
- Use analogies to explain technical concepts
- Focus on practical impact, not theoretical risk
- Keep sentences short and clear
""";
/// <summary>
/// Get template by explanation type.
/// </summary>
public static string GetTemplate(ExplanationType type) => type switch
{
ExplanationType.What => WhatTemplate,
ExplanationType.Why => WhyTemplate,
ExplanationType.Evidence => EvidenceTemplate,
ExplanationType.Counterfactual => CounterfactualTemplate,
ExplanationType.Full => FullTemplate,
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown explanation type")
};
}

View File

@@ -0,0 +1,90 @@
namespace StellaOps.AdvisoryAI.Explanation;
/// <summary>
/// Type of explanation to generate.
/// </summary>
public enum ExplanationType
{
/// <summary>
/// What is this vulnerability?
/// </summary>
What,
/// <summary>
/// Why does it matter in this context?
/// </summary>
Why,
/// <summary>
/// What evidence supports exploitability?
/// </summary>
Evidence,
/// <summary>
/// What would change the verdict?
/// </summary>
Counterfactual,
/// <summary>
/// Full comprehensive explanation.
/// </summary>
Full
}
/// <summary>
/// Request for generating an evidence-anchored explanation.
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-01
/// </summary>
public sealed record ExplanationRequest
{
/// <summary>
/// Finding ID to explain.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Artifact digest (image, SBOM, etc.) for context.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Scope of the explanation (service, release, image).
/// </summary>
public required string Scope { get; init; }
/// <summary>
/// Scope identifier.
/// </summary>
public required string ScopeId { get; init; }
/// <summary>
/// Type of explanation to generate.
/// </summary>
public required ExplanationType ExplanationType { get; init; }
/// <summary>
/// Vulnerability ID (CVE, GHSA, etc.).
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Affected component PURL.
/// </summary>
public string? ComponentPurl { get; init; }
/// <summary>
/// Whether to use plain language mode.
/// </summary>
public bool PlainLanguage { get; init; }
/// <summary>
/// Maximum length of explanation (0 = no limit).
/// </summary>
public int MaxLength { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; init; }
}

View File

@@ -0,0 +1,142 @@
namespace StellaOps.AdvisoryAI.Explanation;
/// <summary>
/// Citation linking an explanation claim to evidence.
/// </summary>
public sealed record ExplanationCitation
{
/// <summary>
/// Claim text from the explanation.
/// </summary>
public required string ClaimText { get; init; }
/// <summary>
/// Evidence node ID supporting this claim.
/// </summary>
public required string EvidenceId { get; init; }
/// <summary>
/// Type of evidence (sbom, reachability, runtime, vex, patch).
/// </summary>
public required string EvidenceType { get; init; }
/// <summary>
/// Whether the citation was verified against the evidence.
/// </summary>
public required bool Verified { get; init; }
/// <summary>
/// Excerpt from the evidence supporting the claim.
/// </summary>
public string? EvidenceExcerpt { get; init; }
}
/// <summary>
/// Authority level of the explanation.
/// </summary>
public enum ExplanationAuthority
{
/// <summary>
/// All claims are evidence-backed (≥80% citation rate, all verified).
/// </summary>
EvidenceBacked,
/// <summary>
/// AI suggestion requiring human review.
/// </summary>
Suggestion
}
/// <summary>
/// Result of explanation generation.
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-07
/// </summary>
public sealed record ExplanationResult
{
/// <summary>
/// Unique ID for this explanation.
/// </summary>
public required string ExplanationId { get; init; }
/// <summary>
/// The explanation content (markdown supported).
/// </summary>
public required string Content { get; init; }
/// <summary>
/// 3-line summary for compact display.
/// </summary>
public required ExplanationSummary Summary { get; init; }
/// <summary>
/// Citations linking claims to evidence.
/// </summary>
public required IReadOnlyList<ExplanationCitation> Citations { get; init; }
/// <summary>
/// Overall confidence score (0.0-1.0).
/// </summary>
public required double ConfidenceScore { get; init; }
/// <summary>
/// Citation rate (verified citations / total claims).
/// </summary>
public required double CitationRate { get; init; }
/// <summary>
/// Authority classification.
/// </summary>
public required ExplanationAuthority Authority { get; init; }
/// <summary>
/// Evidence node IDs used in this explanation.
/// </summary>
public required IReadOnlyList<string> EvidenceRefs { get; init; }
/// <summary>
/// Model ID used for generation.
/// </summary>
public required string ModelId { get; init; }
/// <summary>
/// Prompt template version.
/// </summary>
public required string PromptTemplateVersion { get; init; }
/// <summary>
/// Input hashes for replay.
/// </summary>
public required IReadOnlyList<string> InputHashes { get; init; }
/// <summary>
/// Generation timestamp (UTC ISO-8601).
/// </summary>
public required string GeneratedAt { get; init; }
/// <summary>
/// Output hash for verification.
/// </summary>
public required string OutputHash { get; init; }
}
/// <summary>
/// 3-line summary following the AI UX pattern.
/// </summary>
public sealed record ExplanationSummary
{
/// <summary>
/// Line 1: What changed/what is it.
/// </summary>
public required string Line1 { get; init; }
/// <summary>
/// Line 2: Why it matters.
/// </summary>
public required string Line2 { get; init; }
/// <summary>
/// Line 3: Next action.
/// </summary>
public required string Line3 { get; init; }
}

View File

@@ -0,0 +1,122 @@
namespace StellaOps.AdvisoryAI.Explanation;
/// <summary>
/// Evidence node for explanation anchoring.
/// </summary>
public sealed record EvidenceNode
{
/// <summary>
/// Unique ID (content-addressed hash).
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Type of evidence.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Human-readable summary.
/// </summary>
public required string Summary { get; init; }
/// <summary>
/// Full content for citation matching.
/// </summary>
public required string Content { get; init; }
/// <summary>
/// Source of the evidence.
/// </summary>
public required string Source { get; init; }
/// <summary>
/// Confidence in this evidence (0.0-1.0).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Timestamp when evidence was collected.
/// </summary>
public required string CollectedAt { get; init; }
}
/// <summary>
/// Aggregated evidence context for explanation generation.
/// </summary>
public sealed record EvidenceContext
{
/// <summary>
/// SBOM-related evidence.
/// </summary>
public required IReadOnlyList<EvidenceNode> SbomEvidence { get; init; }
/// <summary>
/// Reachability analysis evidence.
/// </summary>
public required IReadOnlyList<EvidenceNode> ReachabilityEvidence { get; init; }
/// <summary>
/// Runtime observation evidence.
/// </summary>
public required IReadOnlyList<EvidenceNode> RuntimeEvidence { get; init; }
/// <summary>
/// VEX statement evidence.
/// </summary>
public required IReadOnlyList<EvidenceNode> VexEvidence { get; init; }
/// <summary>
/// Patch/fix availability evidence.
/// </summary>
public required IReadOnlyList<EvidenceNode> PatchEvidence { get; init; }
/// <summary>
/// All evidence nodes combined.
/// </summary>
public IEnumerable<EvidenceNode> AllEvidence =>
SbomEvidence
.Concat(ReachabilityEvidence)
.Concat(RuntimeEvidence)
.Concat(VexEvidence)
.Concat(PatchEvidence);
/// <summary>
/// Hash of all evidence for replay verification.
/// </summary>
public required string ContextHash { get; init; }
}
/// <summary>
/// Service for retrieving evidence nodes for explanation anchoring.
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-04
/// </summary>
public interface IEvidenceRetrievalService
{
/// <summary>
/// Retrieve all relevant evidence for a finding.
/// </summary>
/// <param name="findingId">Finding ID.</param>
/// <param name="artifactDigest">Artifact digest for context.</param>
/// <param name="vulnerabilityId">Vulnerability ID.</param>
/// <param name="componentPurl">Optional component PURL filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Aggregated evidence context.</returns>
Task<EvidenceContext> RetrieveEvidenceAsync(
string findingId,
string artifactDigest,
string vulnerabilityId,
string? componentPurl = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Get a specific evidence node by ID.
/// </summary>
Task<EvidenceNode?> GetEvidenceNodeAsync(string evidenceId, CancellationToken cancellationToken = default);
/// <summary>
/// Validate that evidence still exists and hasn't changed.
/// </summary>
Task<bool> ValidateEvidenceAsync(IEnumerable<string> evidenceIds, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,33 @@
namespace StellaOps.AdvisoryAI.Explanation;
/// <summary>
/// Service for generating evidence-anchored explanations.
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-02
/// </summary>
public interface IExplanationGenerator
{
/// <summary>
/// Generate an explanation for a finding.
/// </summary>
/// <param name="request">Explanation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Explanation result with citations and evidence refs.</returns>
Task<ExplanationResult> GenerateAsync(ExplanationRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Replay an explanation with the same inputs.
/// </summary>
/// <param name="explanationId">Original explanation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Replayed explanation result.</returns>
Task<ExplanationResult> ReplayAsync(string explanationId, CancellationToken cancellationToken = default);
/// <summary>
/// Validate an explanation against its input hashes.
/// </summary>
/// <param name="result">Explanation result to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if valid, false if inputs have changed.</returns>
Task<bool> ValidateAsync(ExplanationResult result, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,360 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// AI-powered remediation planner implementation.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-03
/// </summary>
public sealed class AiRemediationPlanner : IRemediationPlanner
{
private readonly IPackageVersionResolver _versionResolver;
private readonly IRemediationPromptService _promptService;
private readonly IRemediationInferenceClient _inferenceClient;
private readonly IRemediationPlanStore _planStore;
public AiRemediationPlanner(
IPackageVersionResolver versionResolver,
IRemediationPromptService promptService,
IRemediationInferenceClient inferenceClient,
IRemediationPlanStore planStore)
{
_versionResolver = versionResolver;
_promptService = promptService;
_inferenceClient = inferenceClient;
_planStore = planStore;
}
public async Task<RemediationPlan> GeneratePlanAsync(
RemediationPlanRequest request,
CancellationToken cancellationToken = default)
{
// 1. Resolve package upgrade path
var versionResult = await _versionResolver.ResolveUpgradePathAsync(
request.ComponentPurl,
request.VulnerabilityId,
cancellationToken);
// 2. Determine remediation type if auto
var remediationType = request.RemediationType == RemediationType.Auto
? DetermineRemediationType(versionResult)
: request.RemediationType;
// 3. Build prompt with context
var prompt = await _promptService.BuildPromptAsync(
request,
versionResult,
remediationType,
cancellationToken);
// 4. Generate plan via LLM
var inferenceResult = await _inferenceClient.GeneratePlanAsync(prompt, cancellationToken);
// 5. Parse and validate steps
var steps = ParseSteps(inferenceResult.Content);
var riskAssessment = AssessRisk(steps, versionResult);
// 6. Determine authority and PR-readiness
var authority = DetermineAuthority(riskAssessment, versionResult);
var (prReady, notReadyReason) = DeterminePrReadiness(authority, steps, versionResult);
// 7. Build expected delta
var expectedDelta = BuildExpectedDelta(request, versionResult);
// 8. Build test requirements
var testRequirements = BuildTestRequirements(riskAssessment);
// 9. Compute input hashes
var inputHashes = ComputeInputHashes(request, versionResult, prompt);
// 10. Create plan
var planId = GeneratePlanId(inputHashes, inferenceResult.Content);
var plan = new RemediationPlan
{
PlanId = planId,
Request = request,
Steps = steps,
ExpectedDelta = expectedDelta,
RiskAssessment = riskAssessment,
TestRequirements = testRequirements,
Authority = authority,
PrReady = prReady,
NotReadyReason = notReadyReason,
ConfidenceScore = inferenceResult.Confidence,
ModelId = inferenceResult.ModelId,
GeneratedAt = DateTime.UtcNow.ToString("O"),
InputHashes = inputHashes,
EvidenceRefs = new List<string> { versionResult.CurrentVersion, versionResult.RecommendedVersion }
};
// 11. Store plan
await _planStore.StoreAsync(plan, cancellationToken);
return plan;
}
public async Task<bool> ValidatePlanAsync(string planId, CancellationToken cancellationToken = default)
{
var plan = await _planStore.GetAsync(planId, cancellationToken);
if (plan is null)
{
return false;
}
// Validate that upgrade path is still valid
var currentResult = await _versionResolver.ResolveUpgradePathAsync(
plan.Request.ComponentPurl,
plan.Request.VulnerabilityId,
cancellationToken);
return currentResult.RecommendedVersion == plan.EvidenceRefs[1];
}
public async Task<RemediationPlan?> GetPlanAsync(string planId, CancellationToken cancellationToken = default)
{
return await _planStore.GetAsync(planId, cancellationToken);
}
private static RemediationType DetermineRemediationType(VersionResolutionResult versionResult)
{
return versionResult.UpgradeType switch
{
"patch" => RemediationType.Bump,
"minor" => RemediationType.Bump,
"major" => RemediationType.Upgrade,
_ => RemediationType.Bump
};
}
private static IReadOnlyList<RemediationStep> ParseSteps(string content)
{
var steps = new List<RemediationStep>();
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var order = 1;
foreach (var line in lines)
{
if (line.TrimStart().StartsWith("- ") || line.TrimStart().StartsWith("* "))
{
var step = new RemediationStep
{
Order = order++,
ActionType = "update_package",
FilePath = "package.json", // Default, would be parsed from content
Description = line.TrimStart()[2..].Trim(),
Risk = RemediationRisk.Low
};
steps.Add(step);
}
}
if (steps.Count == 0)
{
// Fallback: create a single step from content
steps.Add(new RemediationStep
{
Order = 1,
ActionType = "update_package",
FilePath = "dependency_file",
Description = content.Length > 200 ? content[..200] : content,
Risk = RemediationRisk.Medium
});
}
return steps;
}
private static RemediationRisk AssessRisk(
IReadOnlyList<RemediationStep> steps,
VersionResolutionResult versionResult)
{
if (versionResult.BreakingChanges.Count > 0)
{
return RemediationRisk.High;
}
if (versionResult.UpgradeType == "major")
{
return RemediationRisk.High;
}
if (versionResult.UpgradeType == "minor")
{
return RemediationRisk.Medium;
}
return steps.Any(s => s.Risk == RemediationRisk.High)
? RemediationRisk.High
: steps.Any(s => s.Risk == RemediationRisk.Medium)
? RemediationRisk.Medium
: RemediationRisk.Low;
}
private static RemediationAuthority DetermineAuthority(
RemediationRisk risk,
VersionResolutionResult versionResult)
{
if (!versionResult.IsSafe)
{
return RemediationAuthority.Suggestion;
}
return risk switch
{
RemediationRisk.Low => RemediationAuthority.Draft,
RemediationRisk.Medium => RemediationAuthority.Draft,
RemediationRisk.High => RemediationAuthority.Suggestion,
_ => RemediationAuthority.Suggestion
};
}
private static (bool prReady, string? reason) DeterminePrReadiness(
RemediationAuthority authority,
IReadOnlyList<RemediationStep> steps,
VersionResolutionResult versionResult)
{
if (authority == RemediationAuthority.Suggestion)
{
return (false, "Remediation requires human review due to potential breaking changes");
}
if (!versionResult.IsSafe)
{
return (false, $"Upgrade path may introduce issues: {string.Join(", ", versionResult.BreakingChanges)}");
}
if (versionResult.NewVulnerabilities.Count > 0)
{
return (false, $"Upgrade introduces new vulnerabilities: {string.Join(", ", versionResult.NewVulnerabilities)}");
}
if (steps.Count == 0)
{
return (false, "No remediation steps could be determined");
}
return (true, null);
}
private static ExpectedSbomDelta BuildExpectedDelta(
RemediationPlanRequest request,
VersionResolutionResult versionResult)
{
return new ExpectedSbomDelta
{
Added = Array.Empty<string>(),
Removed = new List<string> { request.ComponentPurl },
Upgraded = new Dictionary<string, string>
{
{ request.ComponentPurl, $"{request.ComponentPurl.Split('@')[0]}@{versionResult.RecommendedVersion}" }
},
NetVulnerabilityChange = -versionResult.VulnerabilitiesFixed.Count + versionResult.NewVulnerabilities.Count
};
}
private static RemediationTestRequirements BuildTestRequirements(RemediationRisk risk)
{
return risk switch
{
RemediationRisk.Low => new RemediationTestRequirements
{
TestSuites = new List<string> { "unit" },
MinCoverage = 0,
RequireAllPass = true,
Timeout = TimeSpan.FromMinutes(10)
},
RemediationRisk.Medium => new RemediationTestRequirements
{
TestSuites = new List<string> { "unit", "integration" },
MinCoverage = 0.5,
RequireAllPass = true,
Timeout = TimeSpan.FromMinutes(30)
},
_ => new RemediationTestRequirements
{
TestSuites = new List<string> { "unit", "integration", "e2e" },
MinCoverage = 0.8,
RequireAllPass = true,
Timeout = TimeSpan.FromMinutes(60)
}
};
}
private static IReadOnlyList<string> ComputeInputHashes(
RemediationPlanRequest request,
VersionResolutionResult versionResult,
RemediationPrompt prompt)
{
return new List<string>
{
ComputeHash(JsonSerializer.Serialize(request)),
ComputeHash(JsonSerializer.Serialize(versionResult)),
ComputeHash(prompt.Content)
};
}
private static string GeneratePlanId(IReadOnlyList<string> inputHashes, string output)
{
var combined = string.Join("|", inputHashes) + "|" + output;
return $"plan:{ComputeHash(combined)[..16]}";
}
private static string ComputeHash(string content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexStringLower(bytes);
}
}
/// <summary>
/// Prompt for remediation planning.
/// </summary>
public sealed record RemediationPrompt
{
public required string Content { get; init; }
public required string TemplateVersion { get; init; }
}
/// <summary>
/// Inference result from LLM for remediation.
/// </summary>
public sealed record RemediationInferenceResult
{
public required string Content { get; init; }
public required double Confidence { get; init; }
public required string ModelId { get; init; }
}
/// <summary>
/// Service for building remediation prompts.
/// </summary>
public interface IRemediationPromptService
{
Task<RemediationPrompt> BuildPromptAsync(
RemediationPlanRequest request,
VersionResolutionResult versionResult,
RemediationType type,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Client for LLM inference for remediation.
/// </summary>
public interface IRemediationInferenceClient
{
Task<RemediationInferenceResult> GeneratePlanAsync(
RemediationPrompt prompt,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for remediation plans.
/// </summary>
public interface IRemediationPlanStore
{
Task StoreAsync(RemediationPlan plan, CancellationToken cancellationToken = default);
Task<RemediationPlan?> GetAsync(string planId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,126 @@
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// Azure DevOps implementation of pull request generator.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-11
/// </summary>
public sealed class AzureDevOpsPullRequestGenerator : IPullRequestGenerator
{
public string ScmType => "azure-devops";
public Task<PullRequestResult> CreatePullRequestAsync(
RemediationPlan plan,
CancellationToken cancellationToken = default)
{
if (!plan.PrReady)
{
return Task.FromResult(new PullRequestResult
{
PrId = $"ado-pr-{Guid.NewGuid():N}",
PrNumber = 0,
Url = string.Empty,
BranchName = string.Empty,
Status = PullRequestStatus.Failed,
StatusMessage = plan.NotReadyReason ?? "Plan is not PR-ready",
CreatedAt = DateTime.UtcNow.ToString("O"),
UpdatedAt = DateTime.UtcNow.ToString("O")
});
}
var branchName = GenerateBranchName(plan);
var prId = $"ado-pr-{Guid.NewGuid():N}";
var now = DateTime.UtcNow.ToString("O");
// In a real implementation, this would use Azure DevOps REST API
return Task.FromResult(new PullRequestResult
{
PrId = prId,
PrNumber = new Random().Next(1000, 9999),
Url = $"https://dev.azure.com/{ExtractOrgProject(plan.Request.RepositoryUrl)}/_git/{ExtractRepoName(plan.Request.RepositoryUrl)}/pullrequest/{prId}",
BranchName = branchName,
Status = PullRequestStatus.Creating,
StatusMessage = "Pull request is being created",
CreatedAt = now,
UpdatedAt = now
});
}
public Task<PullRequestResult> GetStatusAsync(
string prId,
CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow.ToString("O");
return Task.FromResult(new PullRequestResult
{
PrId = prId,
PrNumber = 0,
Url = string.Empty,
BranchName = string.Empty,
Status = PullRequestStatus.Open,
StatusMessage = "Waiting for build",
CreatedAt = now,
UpdatedAt = now
});
}
public Task UpdateWithDeltaVerdictAsync(
string prId,
DeltaVerdictResult deltaVerdict,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task ClosePullRequestAsync(
string prId,
string reason,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
private static string GenerateBranchName(RemediationPlan plan)
{
var vulnId = plan.Request.VulnerabilityId.Replace(":", "-").ToLowerInvariant();
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd");
return $"stellaops/fix-{vulnId}-{timestamp}";
}
private static string ExtractOrgProject(string? repositoryUrl)
{
if (string.IsNullOrEmpty(repositoryUrl))
{
return "org/project";
}
// Azure DevOps URL format: https://dev.azure.com/{org}/{project}/_git/{repo}
var uri = new Uri(repositoryUrl);
var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2)
{
return $"{segments[0]}/{segments[1]}";
}
return "org/project";
}
private static string ExtractRepoName(string? repositoryUrl)
{
if (string.IsNullOrEmpty(repositoryUrl))
{
return "repo";
}
var uri = new Uri(repositoryUrl);
var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
// Find _git segment and return the next one
for (int i = 0; i < segments.Length - 1; i++)
{
if (segments[i] == "_git")
{
return segments[i + 1];
}
}
return segments[^1];
}
}

View File

@@ -0,0 +1,125 @@
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// GitHub implementation of pull request generator.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-09
/// </summary>
public sealed class GitHubPullRequestGenerator : IPullRequestGenerator
{
private readonly IRemediationPlanStore _planStore;
public GitHubPullRequestGenerator(IRemediationPlanStore planStore)
{
_planStore = planStore;
}
public string ScmType => "github";
public async Task<PullRequestResult> CreatePullRequestAsync(
RemediationPlan plan,
CancellationToken cancellationToken = default)
{
// Validate plan is PR-ready
if (!plan.PrReady)
{
return new PullRequestResult
{
PrId = $"pr-{Guid.NewGuid():N}",
PrNumber = 0,
Url = string.Empty,
BranchName = string.Empty,
Status = PullRequestStatus.Failed,
StatusMessage = plan.NotReadyReason ?? "Plan is not PR-ready",
CreatedAt = DateTime.UtcNow.ToString("O"),
UpdatedAt = DateTime.UtcNow.ToString("O")
};
}
// Generate branch name
var branchName = GenerateBranchName(plan);
// In a real implementation, this would:
// 1. Create a new branch
// 2. Apply remediation steps (update files)
// 3. Commit changes
// 4. Create PR via GitHub API
var prId = $"gh-pr-{Guid.NewGuid():N}";
var now = DateTime.UtcNow.ToString("O");
return new PullRequestResult
{
PrId = prId,
PrNumber = new Random().Next(1000, 9999), // Placeholder
Url = $"https://github.com/{ExtractOwnerRepo(plan.Request.RepositoryUrl)}/pull/{prId}",
BranchName = branchName,
Status = PullRequestStatus.Creating,
StatusMessage = "Pull request is being created",
CreatedAt = now,
UpdatedAt = now
};
}
public Task<PullRequestResult> GetStatusAsync(
string prId,
CancellationToken cancellationToken = default)
{
// In a real implementation, this would query GitHub API
var now = DateTime.UtcNow.ToString("O");
return Task.FromResult(new PullRequestResult
{
PrId = prId,
PrNumber = 0,
Url = string.Empty,
BranchName = string.Empty,
Status = PullRequestStatus.Open,
StatusMessage = "Waiting for CI",
CreatedAt = now,
UpdatedAt = now
});
}
public Task UpdateWithDeltaVerdictAsync(
string prId,
DeltaVerdictResult deltaVerdict,
CancellationToken cancellationToken = default)
{
// In a real implementation, this would update PR description via GitHub API
return Task.CompletedTask;
}
public Task ClosePullRequestAsync(
string prId,
string reason,
CancellationToken cancellationToken = default)
{
// In a real implementation, this would close PR via GitHub API
return Task.CompletedTask;
}
private static string GenerateBranchName(RemediationPlan plan)
{
var vulnId = plan.Request.VulnerabilityId.Replace(":", "-").ToLowerInvariant();
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd");
return $"stellaops/fix-{vulnId}-{timestamp}";
}
private static string ExtractOwnerRepo(string? repositoryUrl)
{
if (string.IsNullOrEmpty(repositoryUrl))
{
return "owner/repo";
}
// Extract owner/repo from GitHub URL
var uri = new Uri(repositoryUrl);
var path = uri.AbsolutePath.Trim('/');
if (path.EndsWith(".git"))
{
path = path[..^4];
}
return path;
}
}

View File

@@ -0,0 +1,105 @@
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// GitLab implementation of pull request generator.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-10
/// </summary>
public sealed class GitLabMergeRequestGenerator : IPullRequestGenerator
{
public string ScmType => "gitlab";
public Task<PullRequestResult> CreatePullRequestAsync(
RemediationPlan plan,
CancellationToken cancellationToken = default)
{
if (!plan.PrReady)
{
return Task.FromResult(new PullRequestResult
{
PrId = $"mr-{Guid.NewGuid():N}",
PrNumber = 0,
Url = string.Empty,
BranchName = string.Empty,
Status = PullRequestStatus.Failed,
StatusMessage = plan.NotReadyReason ?? "Plan is not MR-ready",
CreatedAt = DateTime.UtcNow.ToString("O"),
UpdatedAt = DateTime.UtcNow.ToString("O")
});
}
var branchName = GenerateBranchName(plan);
var mrId = $"gl-mr-{Guid.NewGuid():N}";
var now = DateTime.UtcNow.ToString("O");
// In a real implementation, this would use GitLab API
return Task.FromResult(new PullRequestResult
{
PrId = mrId,
PrNumber = new Random().Next(1000, 9999),
Url = $"https://gitlab.com/{ExtractProjectPath(plan.Request.RepositoryUrl)}/-/merge_requests/{mrId}",
BranchName = branchName,
Status = PullRequestStatus.Creating,
StatusMessage = "Merge request is being created",
CreatedAt = now,
UpdatedAt = now
});
}
public Task<PullRequestResult> GetStatusAsync(
string prId,
CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow.ToString("O");
return Task.FromResult(new PullRequestResult
{
PrId = prId,
PrNumber = 0,
Url = string.Empty,
BranchName = string.Empty,
Status = PullRequestStatus.Open,
StatusMessage = "Waiting for pipeline",
CreatedAt = now,
UpdatedAt = now
});
}
public Task UpdateWithDeltaVerdictAsync(
string prId,
DeltaVerdictResult deltaVerdict,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task ClosePullRequestAsync(
string prId,
string reason,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
private static string GenerateBranchName(RemediationPlan plan)
{
var vulnId = plan.Request.VulnerabilityId.Replace(":", "-").ToLowerInvariant();
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd");
return $"stellaops/fix-{vulnId}-{timestamp}";
}
private static string ExtractProjectPath(string? repositoryUrl)
{
if (string.IsNullOrEmpty(repositoryUrl))
{
return "group/project";
}
var uri = new Uri(repositoryUrl);
var path = uri.AbsolutePath.Trim('/');
if (path.EndsWith(".git"))
{
path = path[..^4];
}
return path;
}
}

View File

@@ -0,0 +1,88 @@
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// Version resolution result.
/// </summary>
public sealed record VersionResolutionResult
{
/// <summary>
/// Current version.
/// </summary>
public required string CurrentVersion { get; init; }
/// <summary>
/// Recommended upgrade version.
/// </summary>
public required string RecommendedVersion { get; init; }
/// <summary>
/// Latest available version.
/// </summary>
public required string LatestVersion { get; init; }
/// <summary>
/// Whether upgrade path is safe.
/// </summary>
public required bool IsSafe { get; init; }
/// <summary>
/// Breaking changes detected.
/// </summary>
public required IReadOnlyList<string> BreakingChanges { get; init; }
/// <summary>
/// Vulnerabilities fixed by upgrade.
/// </summary>
public required IReadOnlyList<string> VulnerabilitiesFixed { get; init; }
/// <summary>
/// New vulnerabilities introduced (rare but possible).
/// </summary>
public required IReadOnlyList<string> NewVulnerabilities { get; init; }
/// <summary>
/// Upgrade type (patch, minor, major).
/// </summary>
public required string UpgradeType { get; init; }
/// <summary>
/// Confidence in the resolution (0.0-1.0).
/// </summary>
public required double Confidence { get; init; }
}
/// <summary>
/// Service for resolving package versions and validating upgrade paths.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-04
/// </summary>
public interface IPackageVersionResolver
{
/// <summary>
/// Resolve upgrade path for a package.
/// </summary>
/// <param name="purl">Package URL.</param>
/// <param name="targetVulnerability">Vulnerability to fix.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Version resolution result.</returns>
Task<VersionResolutionResult> ResolveUpgradePathAsync(
string purl,
string targetVulnerability,
CancellationToken cancellationToken = default);
/// <summary>
/// Check if a specific version is available.
/// </summary>
/// <param name="purl">Package URL with version.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if version exists.</returns>
Task<bool> IsVersionAvailableAsync(string purl, CancellationToken cancellationToken = default);
/// <summary>
/// Get all available versions for a package.
/// </summary>
/// <param name="purl">Package URL (without version).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of available versions.</returns>
Task<IReadOnlyList<string>> GetAvailableVersionsAsync(string purl, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,218 @@
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// Status of a pull request.
/// </summary>
public enum PullRequestStatus
{
/// <summary>
/// PR is being created.
/// </summary>
Creating,
/// <summary>
/// PR is open and waiting for review.
/// </summary>
Open,
/// <summary>
/// PR build is in progress.
/// </summary>
Building,
/// <summary>
/// PR build passed.
/// </summary>
BuildPassed,
/// <summary>
/// PR build failed.
/// </summary>
BuildFailed,
/// <summary>
/// PR tests are running.
/// </summary>
Testing,
/// <summary>
/// PR tests passed.
/// </summary>
TestsPassed,
/// <summary>
/// PR tests failed.
/// </summary>
TestsFailed,
/// <summary>
/// PR is merged.
/// </summary>
Merged,
/// <summary>
/// PR is closed without merge.
/// </summary>
Closed,
/// <summary>
/// PR creation failed.
/// </summary>
Failed
}
/// <summary>
/// Result of creating a pull request.
/// </summary>
public sealed record PullRequestResult
{
/// <summary>
/// Unique PR identifier.
/// </summary>
public required string PrId { get; init; }
/// <summary>
/// PR number in the SCM.
/// </summary>
public required int PrNumber { get; init; }
/// <summary>
/// URL to view the PR.
/// </summary>
public required string Url { get; init; }
/// <summary>
/// Branch name for the PR.
/// </summary>
public required string BranchName { get; init; }
/// <summary>
/// Current status.
/// </summary>
public required PullRequestStatus Status { get; init; }
/// <summary>
/// Status message.
/// </summary>
public string? StatusMessage { get; init; }
/// <summary>
/// Build result if available.
/// </summary>
public BuildResult? BuildResult { get; init; }
/// <summary>
/// Test result if available.
/// </summary>
public TestResult? TestResult { get; init; }
/// <summary>
/// Delta verdict if available.
/// </summary>
public DeltaVerdictResult? DeltaVerdict { get; init; }
/// <summary>
/// Created timestamp.
/// </summary>
public required string CreatedAt { get; init; }
/// <summary>
/// Last updated timestamp.
/// </summary>
public required string UpdatedAt { get; init; }
}
/// <summary>
/// Build result from CI pipeline.
/// </summary>
public sealed record BuildResult
{
public required bool Success { get; init; }
public required string BuildId { get; init; }
public string? BuildUrl { get; init; }
public string? ErrorMessage { get; init; }
public required string CompletedAt { get; init; }
}
/// <summary>
/// Test result from test suite.
/// </summary>
public sealed record TestResult
{
public required bool AllPassed { get; init; }
public required int TotalTests { get; init; }
public required int PassedTests { get; init; }
public required int FailedTests { get; init; }
public required int SkippedTests { get; init; }
public double Coverage { get; init; }
public IReadOnlyList<string> FailedTestNames { get; init; } = Array.Empty<string>();
public required string CompletedAt { get; init; }
}
/// <summary>
/// Delta verdict result.
/// </summary>
public sealed record DeltaVerdictResult
{
public required bool Improved { get; init; }
public required int VulnerabilitiesFixed { get; init; }
public required int VulnerabilitiesIntroduced { get; init; }
public required string VerdictId { get; init; }
public string? SignatureId { get; init; }
public required string ComputedAt { get; init; }
}
/// <summary>
/// Service for generating pull requests from remediation plans.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-08
/// </summary>
public interface IPullRequestGenerator
{
/// <summary>
/// SCM type supported by this generator.
/// </summary>
string ScmType { get; }
/// <summary>
/// Create a pull request for a remediation plan.
/// </summary>
/// <param name="plan">Remediation plan to apply.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Pull request result.</returns>
Task<PullRequestResult> CreatePullRequestAsync(
RemediationPlan plan,
CancellationToken cancellationToken = default);
/// <summary>
/// Get the status of a pull request.
/// </summary>
/// <param name="prId">PR identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Current PR status.</returns>
Task<PullRequestResult> GetStatusAsync(
string prId,
CancellationToken cancellationToken = default);
/// <summary>
/// Update PR description with delta verdict.
/// </summary>
/// <param name="prId">PR identifier.</param>
/// <param name="deltaVerdict">Delta verdict to include.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateWithDeltaVerdictAsync(
string prId,
DeltaVerdictResult deltaVerdict,
CancellationToken cancellationToken = default);
/// <summary>
/// Close a pull request.
/// </summary>
/// <param name="prId">PR identifier.</param>
/// <param name="reason">Reason for closing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task ClosePullRequestAsync(
string prId,
string reason,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,33 @@
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// Service for generating AI-powered remediation plans.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-02
/// </summary>
public interface IRemediationPlanner
{
/// <summary>
/// Generate a remediation plan for a finding.
/// </summary>
/// <param name="request">Remediation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Remediation plan with steps and risk assessment.</returns>
Task<RemediationPlan> GeneratePlanAsync(RemediationPlanRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Validate a remediation plan against current state.
/// </summary>
/// <param name="planId">Plan ID to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if plan is still valid.</returns>
Task<bool> ValidatePlanAsync(string planId, CancellationToken cancellationToken = default);
/// <summary>
/// Get a stored remediation plan.
/// </summary>
/// <param name="planId">Plan ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The plan, or null if not found.</returns>
Task<RemediationPlan?> GetPlanAsync(string planId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,224 @@
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// Authority level of the remediation plan.
/// </summary>
public enum RemediationAuthority
{
/// <summary>
/// Verified: build passed, tests passed, delta verified.
/// </summary>
Verified,
/// <summary>
/// Suggestion: requires human review (build/tests failed or not run).
/// </summary>
Suggestion,
/// <summary>
/// Draft: initial plan not yet verified.
/// </summary>
Draft
}
/// <summary>
/// Risk level of the remediation.
/// </summary>
public enum RemediationRisk
{
/// <summary>
/// Low risk: patch version bump.
/// </summary>
Low,
/// <summary>
/// Medium risk: minor version bump.
/// </summary>
Medium,
/// <summary>
/// High risk: major version bump or breaking changes.
/// </summary>
High,
/// <summary>
/// Unknown risk: unable to determine.
/// </summary>
Unknown
}
/// <summary>
/// A single step in a remediation plan.
/// </summary>
public sealed record RemediationStep
{
/// <summary>
/// Step number (1-based).
/// </summary>
public required int Order { get; init; }
/// <summary>
/// Type of action (update_package, update_lockfile, update_config, run_command, etc.).
/// </summary>
public required string ActionType { get; init; }
/// <summary>
/// File path affected.
/// </summary>
public required string FilePath { get; init; }
/// <summary>
/// Description of the change.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Previous value (for diff).
/// </summary>
public string? PreviousValue { get; init; }
/// <summary>
/// New value (for diff).
/// </summary>
public string? NewValue { get; init; }
/// <summary>
/// Whether this step is optional.
/// </summary>
public bool Optional { get; init; }
/// <summary>
/// Risk assessment for this step.
/// </summary>
public RemediationRisk Risk { get; init; } = RemediationRisk.Low;
}
/// <summary>
/// Expected SBOM delta after remediation.
/// </summary>
public sealed record ExpectedSbomDelta
{
/// <summary>
/// Components to be added.
/// </summary>
public required IReadOnlyList<string> Added { get; init; }
/// <summary>
/// Components to be removed.
/// </summary>
public required IReadOnlyList<string> Removed { get; init; }
/// <summary>
/// Components to be upgraded (old_purl → new_purl).
/// </summary>
public required IReadOnlyDictionary<string, string> Upgraded { get; init; }
/// <summary>
/// Net vulnerability change (negative = improvement).
/// </summary>
public required int NetVulnerabilityChange { get; init; }
}
/// <summary>
/// Test requirements for verifying remediation.
/// </summary>
public sealed record RemediationTestRequirements
{
/// <summary>
/// Required test suites to run.
/// </summary>
public required IReadOnlyList<string> TestSuites { get; init; }
/// <summary>
/// Minimum coverage required.
/// </summary>
public double MinCoverage { get; init; }
/// <summary>
/// Whether all tests must pass.
/// </summary>
public bool RequireAllPass { get; init; } = true;
/// <summary>
/// Timeout for test execution.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(30);
}
/// <summary>
/// A complete remediation plan.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-05
/// </summary>
public sealed record RemediationPlan
{
/// <summary>
/// Unique plan ID.
/// </summary>
public required string PlanId { get; init; }
/// <summary>
/// Original request.
/// </summary>
public required RemediationPlanRequest Request { get; init; }
/// <summary>
/// Remediation steps to apply.
/// </summary>
public required IReadOnlyList<RemediationStep> Steps { get; init; }
/// <summary>
/// Expected SBOM delta.
/// </summary>
public required ExpectedSbomDelta ExpectedDelta { get; init; }
/// <summary>
/// Overall risk assessment.
/// </summary>
public required RemediationRisk RiskAssessment { get; init; }
/// <summary>
/// Test requirements.
/// </summary>
public required RemediationTestRequirements TestRequirements { get; init; }
/// <summary>
/// Authority classification.
/// </summary>
public required RemediationAuthority Authority { get; init; }
/// <summary>
/// PR-ready flag (true if plan can be applied automatically).
/// </summary>
public required bool PrReady { get; init; }
/// <summary>
/// Reason if not PR-ready.
/// </summary>
public string? NotReadyReason { get; init; }
/// <summary>
/// Confidence score (0.0-1.0).
/// </summary>
public required double ConfidenceScore { get; init; }
/// <summary>
/// Model ID used for generation.
/// </summary>
public required string ModelId { get; init; }
/// <summary>
/// Generated timestamp (UTC ISO-8601).
/// </summary>
public required string GeneratedAt { get; init; }
/// <summary>
/// Input hashes for replay.
/// </summary>
public required IReadOnlyList<string> InputHashes { get; init; }
/// <summary>
/// Evidence refs used in planning.
/// </summary>
public required IReadOnlyList<string> EvidenceRefs { get; init; }
}

View File

@@ -0,0 +1,85 @@
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// Type of remediation to apply.
/// </summary>
public enum RemediationType
{
/// <summary>
/// Bump dependency to patched version.
/// </summary>
Bump,
/// <summary>
/// Upgrade base image to newer version.
/// </summary>
Upgrade,
/// <summary>
/// Apply configuration change to mitigate.
/// </summary>
Config,
/// <summary>
/// Apply backport patch.
/// </summary>
Backport,
/// <summary>
/// Auto-detect best remediation type.
/// </summary>
Auto
}
/// <summary>
/// Request for generating a remediation plan.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-01
/// </summary>
public sealed record RemediationPlanRequest
{
/// <summary>
/// Finding ID to remediate.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Artifact digest for context.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Vulnerability ID (CVE, GHSA, etc.).
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Affected component PURL.
/// </summary>
public required string ComponentPurl { get; init; }
/// <summary>
/// Type of remediation to apply.
/// </summary>
public RemediationType RemediationType { get; init; } = RemediationType.Auto;
/// <summary>
/// Repository URL for PR generation.
/// </summary>
public string? RepositoryUrl { get; init; }
/// <summary>
/// Target branch for PR (default: main).
/// </summary>
public string TargetBranch { get; init; } = "main";
/// <summary>
/// Whether to generate PR immediately.
/// </summary>
public bool AutoCreatePr { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; init; }
}