new advisories work and features gaps work
This commit is contained in:
@@ -285,6 +285,9 @@ public static class EvidencePackEndpoints
|
||||
"html" => EvidencePackExportFormat.Html,
|
||||
"pdf" => EvidencePackExportFormat.Pdf,
|
||||
"signedjson" => EvidencePackExportFormat.SignedJson,
|
||||
// Sprint: SPRINT_20260112_005_BE_evidence_card_api (EVPCARD-BE-001)
|
||||
"evidencecard" or "evidence-card" or "card" => EvidencePackExportFormat.EvidenceCard,
|
||||
"evidencecardcompact" or "card-compact" => EvidencePackExportFormat.EvidenceCardCompact,
|
||||
_ => EvidencePackExportFormat.Json
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// <copyright file="PrTemplateBuilder.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_007_BE_remediation_pr_generator (REMEDY-BE-001)
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Remediation;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic PR.md templates for remediation pull requests.
|
||||
/// </summary>
|
||||
public sealed class PrTemplateBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a PR description from a remediation plan.
|
||||
/// </summary>
|
||||
public string BuildPrBody(RemediationPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header section
|
||||
sb.AppendLine("## Security Remediation");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Plan ID:** `{plan.PlanId}`");
|
||||
sb.AppendLine($"**Authority:** {plan.Authority}");
|
||||
sb.AppendLine($"**Risk Level:** {plan.RiskAssessment}");
|
||||
sb.AppendLine($"**Confidence:** {plan.ConfidenceScore:P0}");
|
||||
sb.AppendLine($"**Generated:** {plan.GeneratedAt}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Summary section
|
||||
AppendSummary(sb, plan);
|
||||
|
||||
// Steps section
|
||||
AppendSteps(sb, plan);
|
||||
|
||||
// Expected changes section
|
||||
AppendExpectedChanges(sb, plan);
|
||||
|
||||
// Test requirements section
|
||||
AppendTestRequirements(sb, plan);
|
||||
|
||||
// Rollback section
|
||||
AppendRollbackSteps(sb, plan);
|
||||
|
||||
// VEX claim section
|
||||
AppendVexClaim(sb, plan);
|
||||
|
||||
// Evidence section
|
||||
AppendEvidence(sb, plan);
|
||||
|
||||
// Footer
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine($"*Generated by StellaOps AdvisoryAI ({plan.ModelId})*");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PR title from a remediation plan.
|
||||
/// </summary>
|
||||
public string BuildPrTitle(RemediationPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var riskEmoji = plan.RiskAssessment switch
|
||||
{
|
||||
RemediationRisk.Low => "[LOW]",
|
||||
RemediationRisk.Medium => "[MEDIUM]",
|
||||
RemediationRisk.High => "[HIGH]",
|
||||
_ => "[UNKNOWN]"
|
||||
};
|
||||
|
||||
return $"{riskEmoji} Security fix: {plan.Request.VulnerabilityId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a branch name from a remediation plan.
|
||||
/// </summary>
|
||||
public string BuildBranchName(RemediationPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var sanitizedPlanId = plan.PlanId
|
||||
.ToLowerInvariant()
|
||||
.Replace(" ", "-")
|
||||
.Replace("_", "-");
|
||||
|
||||
return $"stellaops/security-fix/{sanitizedPlanId}";
|
||||
}
|
||||
|
||||
private static void AppendSummary(StringBuilder sb, RemediationPlan plan)
|
||||
{
|
||||
sb.AppendLine("### Summary");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"This PR remediates vulnerability **{plan.Request.VulnerabilityId}** in component **{plan.Request.ComponentPurl}**.");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("**Vulnerability addressed:**");
|
||||
sb.AppendLine($"- `{plan.Request.VulnerabilityId}`");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void AppendSteps(StringBuilder sb, RemediationPlan plan)
|
||||
{
|
||||
sb.AppendLine("### Remediation Steps");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var step in plan.Steps.OrderBy(s => s.Order))
|
||||
{
|
||||
var optionalTag = step.Optional ? " *(optional)*" : "";
|
||||
var riskTag = step.Risk != RemediationRisk.Low ? $" [{step.Risk}]" : "";
|
||||
|
||||
sb.AppendLine($"{step.Order}. **{step.ActionType}**{riskTag}{optionalTag}");
|
||||
sb.AppendLine($" - File: `{step.FilePath}`");
|
||||
sb.AppendLine($" - {step.Description}");
|
||||
|
||||
if (!string.IsNullOrEmpty(step.PreviousValue) && !string.IsNullOrEmpty(step.NewValue))
|
||||
{
|
||||
sb.AppendLine($" - Change: `{step.PreviousValue}` -> `{step.NewValue}`");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendExpectedChanges(StringBuilder sb, RemediationPlan plan)
|
||||
{
|
||||
sb.AppendLine("### Expected SBOM Changes");
|
||||
sb.AppendLine();
|
||||
|
||||
var delta = plan.ExpectedDelta;
|
||||
|
||||
if (delta.Upgraded.Count > 0)
|
||||
{
|
||||
sb.AppendLine("**Upgrades:**");
|
||||
foreach (var (oldPurl, newPurl) in delta.Upgraded.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine($"- `{oldPurl}` -> `{newPurl}`");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (delta.Added.Count > 0)
|
||||
{
|
||||
sb.AppendLine("**Added:**");
|
||||
foreach (var purl in delta.Added.OrderBy(p => p, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine($"- `{purl}`");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (delta.Removed.Count > 0)
|
||||
{
|
||||
sb.AppendLine("**Removed:**");
|
||||
foreach (var purl in delta.Removed.OrderBy(p => p, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine($"- `{purl}`");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
var changeSign = delta.NetVulnerabilityChange <= 0 ? "" : "+";
|
||||
sb.AppendLine($"**Net vulnerability change:** {changeSign}{delta.NetVulnerabilityChange}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void AppendTestRequirements(StringBuilder sb, RemediationPlan plan)
|
||||
{
|
||||
sb.AppendLine("### Test Requirements");
|
||||
sb.AppendLine();
|
||||
|
||||
var tests = plan.TestRequirements;
|
||||
|
||||
if (tests.TestSuites.Count > 0)
|
||||
{
|
||||
sb.AppendLine("**Required test suites:**");
|
||||
foreach (var suite in tests.TestSuites.OrderBy(s => s, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine($"- `{suite}`");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine($"- Minimum coverage: {tests.MinCoverage:P0}");
|
||||
sb.AppendLine($"- Require all pass: {(tests.RequireAllPass ? "Yes" : "No")}");
|
||||
sb.AppendLine($"- Timeout: {tests.Timeout.TotalMinutes:F0} minutes");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void AppendRollbackSteps(StringBuilder sb, RemediationPlan plan)
|
||||
{
|
||||
sb.AppendLine("### Rollback Steps");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("If this remediation causes issues, rollback using:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```bash");
|
||||
sb.AppendLine("# Revert this PR");
|
||||
sb.AppendLine($"git revert <commit-sha>");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("# Or restore previous versions:");
|
||||
|
||||
foreach (var step in plan.Steps.Where(s => !string.IsNullOrEmpty(s.PreviousValue)).OrderBy(s => s.Order))
|
||||
{
|
||||
sb.AppendLine($"# {step.FilePath}: restore '{step.PreviousValue}'");
|
||||
}
|
||||
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void AppendVexClaim(StringBuilder sb, RemediationPlan plan)
|
||||
{
|
||||
sb.AppendLine("### VEX Claim");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Upon merge, the following VEX statements will be generated:");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine($"- `{plan.Request.VulnerabilityId}`: status=`fixed`, justification=`vulnerable_code_not_present`");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("These VEX statements will be signed and attached to the evidence pack.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void AppendEvidence(StringBuilder sb, RemediationPlan plan)
|
||||
{
|
||||
if (plan.EvidenceRefs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sb.AppendLine("### Evidence");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**Evidence references:**");
|
||||
foreach (var evidenceRef in plan.EvidenceRefs.OrderBy(e => e, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine($"- `{evidenceRef}`");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
if (plan.InputHashes.Count > 0)
|
||||
{
|
||||
sb.AppendLine("**Input hashes (for replay):**");
|
||||
sb.AppendLine("```");
|
||||
foreach (var hash in plan.InputHashes.OrderBy(h => h, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine(hash);
|
||||
}
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rollback step for a remediation.
|
||||
/// </summary>
|
||||
public sealed record RollbackStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Step order.
|
||||
/// </summary>
|
||||
public required int Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File to restore.
|
||||
/// </summary>
|
||||
public required string FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Command or action to execute.
|
||||
/// </summary>
|
||||
public required string Command { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generated PR metadata.
|
||||
/// </summary>
|
||||
public sealed record PrMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// PR title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Branch name.
|
||||
/// </summary>
|
||||
public required string BranchName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR body (Markdown).
|
||||
/// </summary>
|
||||
public required string Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Labels to apply.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Labels { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reviewers to request.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Reviewers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether auto-merge should be enabled.
|
||||
/// </summary>
|
||||
public bool EnableAutoMerge { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Draft status.
|
||||
/// </summary>
|
||||
public bool IsDraft { get; init; }
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Bench.AdvisoryAI" />
|
||||
<InternalsVisibleTo Include="StellaOps.AdvisoryAI.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
|
||||
Reference in New Issue
Block a user