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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user