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

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

View File

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

View File

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

View File

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

View File

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