Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API contracts for AI consent management.
|
||||
/// Sprint: SPRINT_20251229_018a_FE_vex_ai_explanations
|
||||
/// Task: VEX-AI-016
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Consent scope for AI features.
|
||||
/// </summary>
|
||||
public enum AiConsentScope
|
||||
{
|
||||
Explain,
|
||||
Remediate,
|
||||
Justify,
|
||||
BulkAnalysis,
|
||||
All
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for consent status.
|
||||
/// </summary>
|
||||
public sealed record AiConsentStatusResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether consent has been granted.
|
||||
/// </summary>
|
||||
public required bool Consented { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When consent was granted (ISO 8601).
|
||||
/// </summary>
|
||||
public string? ConsentedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who granted consent (user ID or principal).
|
||||
/// </summary>
|
||||
public string? ConsentedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope of the consent.
|
||||
/// </summary>
|
||||
public required string Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When consent expires (ISO 8601).
|
||||
/// </summary>
|
||||
public string? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether consent is session-level only.
|
||||
/// </summary>
|
||||
public required bool SessionLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to grant consent.
|
||||
/// </summary>
|
||||
public sealed record AiConsentGrantRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope of consent to grant.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether consent is session-level only (expires on session end).
|
||||
/// </summary>
|
||||
public bool SessionLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledgement that data may be shared with AI providers.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required bool DataShareAcknowledged { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response after granting consent.
|
||||
/// </summary>
|
||||
public sealed record AiConsentGrantResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether consent was granted.
|
||||
/// </summary>
|
||||
public required bool Consented { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When consent was granted (ISO 8601).
|
||||
/// </summary>
|
||||
public required string ConsentedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When consent expires (ISO 8601), if applicable.
|
||||
/// </summary>
|
||||
public string? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit information for an AI feature.
|
||||
/// </summary>
|
||||
public sealed record AiRateLimitInfoResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Feature name (explain, remediate, justify).
|
||||
/// </summary>
|
||||
public required string Feature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum requests allowed in the window.
|
||||
/// </summary>
|
||||
public required int Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remaining requests in the current window.
|
||||
/// </summary>
|
||||
public required int Remaining { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the rate limit resets (ISO 8601).
|
||||
/// </summary>
|
||||
public required string ResetsAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API contracts for AI-assisted VEX justification drafting.
|
||||
/// Sprint: SPRINT_20251229_018a_FE_vex_ai_explanations
|
||||
/// Task: VEX-AI-016
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating a VEX justification draft.
|
||||
/// </summary>
|
||||
public sealed record AiJustifyApiRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE or vulnerability ID.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product reference (PURL, artifact digest, etc.).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string ProductRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Proposed VEX status.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string ProposedStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification type (OpenVEX justification labels).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string JustificationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context data for the AI.
|
||||
/// </summary>
|
||||
public AiJustifyContextData? ContextData { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context data for justification generation.
|
||||
/// </summary>
|
||||
public sealed record AiJustifyContextData
|
||||
{
|
||||
/// <summary>
|
||||
/// Reachability score (0-1) if available.
|
||||
/// </summary>
|
||||
public double? ReachabilityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of code search results.
|
||||
/// </summary>
|
||||
public int? CodeSearchResults { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM context summary.
|
||||
/// </summary>
|
||||
public string? SbomContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call graph summary.
|
||||
/// </summary>
|
||||
public string? CallGraphSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related VEX statements from trusted issuers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? RelatedVexStatements { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing AI-generated justification draft.
|
||||
/// </summary>
|
||||
public sealed record AiJustifyApiResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique ID for this justification draft.
|
||||
/// </summary>
|
||||
public required string JustificationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The drafted justification text.
|
||||
/// </summary>
|
||||
public required string DraftJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested justification type based on analysis.
|
||||
/// </summary>
|
||||
public required string SuggestedJustificationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0-1) in the generated justification.
|
||||
/// </summary>
|
||||
public required double ConfidenceScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested evidence to attach to strengthen the justification.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> EvidenceSuggestions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model version used.
|
||||
/// </summary>
|
||||
public required string ModelVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the justification was generated (ISO 8601).
|
||||
/// </summary>
|
||||
public required string GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for debugging.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ using StellaOps.AdvisoryAI.Queue;
|
||||
using StellaOps.AdvisoryAI.PolicyStudio;
|
||||
using StellaOps.AdvisoryAI.Remediation;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.AdvisoryAI.WebService.Services;
|
||||
using StellaOps.Router.AspNet;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -30,6 +31,11 @@ builder.Configuration
|
||||
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
|
||||
|
||||
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
|
||||
// VEX-AI-016: Consent and justification services
|
||||
builder.Services.AddSingleton<IAiConsentStore, InMemoryAiConsentStore>();
|
||||
builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationGenerator>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddProblemDetails();
|
||||
@@ -121,6 +127,28 @@ app.MapPost("/v1/advisory-ai/policy/studio/validate", HandlePolicyValidate)
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/compile", HandlePolicyCompile)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// VEX-AI-016: Consent endpoints
|
||||
app.MapGet("/v1/advisory-ai/consent", HandleGetConsent)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapPost("/v1/advisory-ai/consent", HandleGrantConsent)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapDelete("/v1/advisory-ai/consent", HandleRevokeConsent)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// VEX-AI-016: Justification endpoint
|
||||
app.MapPost("/v1/advisory-ai/justify", HandleJustify)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// VEX-AI-016: Remediate alias (maps to remediation/plan)
|
||||
app.MapPost("/v1/advisory-ai/remediate", HandleRemediate)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// VEX-AI-016: Rate limits endpoint
|
||||
app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
@@ -649,6 +677,235 @@ static Task<IResult> HandlePolicyCompile(
|
||||
return Task.FromResult(Results.Ok(response));
|
||||
}
|
||||
|
||||
// VEX-AI-016: Consent handler functions
|
||||
static string GetTenantId(HttpContext context)
|
||||
{
|
||||
return context.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var value)
|
||||
? value.ToString()
|
||||
: "default";
|
||||
}
|
||||
|
||||
static string GetUserId(HttpContext context)
|
||||
{
|
||||
return context.Request.Headers.TryGetValue("X-StellaOps-User", out var value)
|
||||
? value.ToString()
|
||||
: "anonymous";
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleGetConsent(
|
||||
HttpContext httpContext,
|
||||
IAiConsentStore consentStore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var userId = GetUserId(httpContext);
|
||||
|
||||
var record = await consentStore.GetConsentAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
return Results.Ok(new AiConsentStatusResponse
|
||||
{
|
||||
Consented = false,
|
||||
Scope = "all",
|
||||
SessionLevel = false
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new AiConsentStatusResponse
|
||||
{
|
||||
Consented = record.Consented,
|
||||
ConsentedAt = record.ConsentedAt?.ToString("O"),
|
||||
ConsentedBy = record.UserId,
|
||||
Scope = record.Scope,
|
||||
ExpiresAt = record.ExpiresAt?.ToString("O"),
|
||||
SessionLevel = record.SessionLevel
|
||||
});
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleGrantConsent(
|
||||
HttpContext httpContext,
|
||||
AiConsentGrantRequest request,
|
||||
IAiConsentStore consentStore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!request.DataShareAcknowledged)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Data sharing acknowledgement is required" });
|
||||
}
|
||||
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var userId = GetUserId(httpContext);
|
||||
|
||||
var grant = new AiConsentGrant
|
||||
{
|
||||
Scope = request.Scope,
|
||||
SessionLevel = request.SessionLevel,
|
||||
DataShareAcknowledged = request.DataShareAcknowledged,
|
||||
Duration = request.SessionLevel ? TimeSpan.FromHours(24) : null
|
||||
};
|
||||
|
||||
var record = await consentStore.GrantConsentAsync(tenantId, userId, grant, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new AiConsentGrantResponse
|
||||
{
|
||||
Consented = record.Consented,
|
||||
ConsentedAt = record.ConsentedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"),
|
||||
ExpiresAt = record.ExpiresAt?.ToString("O")
|
||||
});
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleRevokeConsent(
|
||||
HttpContext httpContext,
|
||||
IAiConsentStore consentStore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
var userId = GetUserId(httpContext);
|
||||
|
||||
await consentStore.RevokeConsentAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// VEX-AI-016: Justification handler
|
||||
static bool EnsureJustifyAuthorized(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:justify");
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleJustify(
|
||||
HttpContext httpContext,
|
||||
AiJustifyApiRequest request,
|
||||
IAiJustificationGenerator justificationGenerator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.justify", System.Diagnostics.ActivityKind.Server);
|
||||
activity?.SetTag("advisory.cve_id", request.CveId);
|
||||
activity?.SetTag("advisory.product_ref", request.ProductRef);
|
||||
activity?.SetTag("advisory.proposed_status", request.ProposedStatus);
|
||||
|
||||
if (!EnsureJustifyAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var domainRequest = new AiJustificationRequest
|
||||
{
|
||||
CveId = request.CveId,
|
||||
ProductRef = request.ProductRef,
|
||||
ProposedStatus = request.ProposedStatus,
|
||||
JustificationType = request.JustificationType,
|
||||
ReachabilityScore = request.ContextData?.ReachabilityScore,
|
||||
CodeSearchResults = request.ContextData?.CodeSearchResults,
|
||||
SbomContext = request.ContextData?.SbomContext,
|
||||
CallGraphSummary = request.ContextData?.CallGraphSummary,
|
||||
RelatedVexStatements = request.ContextData?.RelatedVexStatements,
|
||||
CorrelationId = request.CorrelationId
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await justificationGenerator.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.justification_id", result.JustificationId);
|
||||
activity?.SetTag("advisory.confidence", result.ConfidenceScore);
|
||||
|
||||
return Results.Ok(new AiJustifyApiResponse
|
||||
{
|
||||
JustificationId = result.JustificationId,
|
||||
DraftJustification = result.DraftJustification,
|
||||
SuggestedJustificationType = result.SuggestedJustificationType,
|
||||
ConfidenceScore = result.ConfidenceScore,
|
||||
EvidenceSuggestions = result.EvidenceSuggestions,
|
||||
ModelVersion = result.ModelVersion,
|
||||
GeneratedAt = result.GeneratedAt.ToString("O"),
|
||||
TraceId = result.TraceId
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// VEX-AI-016: Remediate alias (delegates to remediation/plan)
|
||||
static async Task<IResult> HandleRemediate(
|
||||
HttpContext httpContext,
|
||||
RemediationPlanApiRequest request,
|
||||
IRemediationPlanner remediationPlanner,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediate", System.Diagnostics.ActivityKind.Server);
|
||||
activity?.SetTag("advisory.finding_id", request.FindingId);
|
||||
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
|
||||
|
||||
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());
|
||||
|
||||
return Results.Ok(RemediationPlanApiResponse.FromDomain(plan));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// VEX-AI-016: Rate limits handler
|
||||
static Task<IResult> HandleGetRateLimits(
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Return current rate limit info for each feature
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var resetTime = now.AddMinutes(1);
|
||||
|
||||
var limits = new List<AiRateLimitInfoResponse>
|
||||
{
|
||||
new AiRateLimitInfoResponse
|
||||
{
|
||||
Feature = "explain",
|
||||
Limit = 10,
|
||||
Remaining = 10,
|
||||
ResetsAt = resetTime.ToString("O")
|
||||
},
|
||||
new AiRateLimitInfoResponse
|
||||
{
|
||||
Feature = "remediate",
|
||||
Limit = 5,
|
||||
Remaining = 5,
|
||||
ResetsAt = resetTime.ToString("O")
|
||||
},
|
||||
new AiRateLimitInfoResponse
|
||||
{
|
||||
Feature = "justify",
|
||||
Limit = 3,
|
||||
Remaining = 3,
|
||||
ResetsAt = resetTime.ToString("O")
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult(Results.Ok(limits));
|
||||
}
|
||||
|
||||
internal sealed record PipelinePlanRequest(
|
||||
AdvisoryTaskType? TaskType,
|
||||
string AdvisoryKey,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
namespace StellaOps.AdvisoryAI.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Storage for AI consent records.
|
||||
/// Sprint: SPRINT_20251229_018a_FE_vex_ai_explanations
|
||||
/// Task: VEX-AI-016
|
||||
/// </summary>
|
||||
public interface IAiConsentStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Get consent status for a user/tenant.
|
||||
/// </summary>
|
||||
Task<AiConsentRecord?> GetConsentAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Grant consent for a user/tenant.
|
||||
/// </summary>
|
||||
Task<AiConsentRecord> GrantConsentAsync(string tenantId, string userId, AiConsentGrant grant, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke consent for a user/tenant.
|
||||
/// </summary>
|
||||
Task<bool> RevokeConsentAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consent record.
|
||||
/// </summary>
|
||||
public sealed record AiConsentRecord
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required bool Consented { get; init; }
|
||||
public required string Scope { get; init; }
|
||||
public DateTimeOffset? ConsentedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public required bool SessionLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consent grant request.
|
||||
/// </summary>
|
||||
public sealed record AiConsentGrant
|
||||
{
|
||||
public required string Scope { get; init; }
|
||||
public required bool SessionLevel { get; init; }
|
||||
public required bool DataShareAcknowledged { get; init; }
|
||||
public TimeSpan? Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory consent store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAiConsentStore : IAiConsentStore
|
||||
{
|
||||
private readonly Dictionary<string, AiConsentRecord> _consents = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task<AiConsentRecord?> GetConsentAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(tenantId, userId);
|
||||
lock (_lock)
|
||||
{
|
||||
if (_consents.TryGetValue(key, out var record))
|
||||
{
|
||||
// Check expiration
|
||||
if (record.ExpiresAt.HasValue && record.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
_consents.Remove(key);
|
||||
return Task.FromResult<AiConsentRecord?>(null);
|
||||
}
|
||||
return Task.FromResult<AiConsentRecord?>(record);
|
||||
}
|
||||
return Task.FromResult<AiConsentRecord?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AiConsentRecord> GrantConsentAsync(string tenantId, string userId, AiConsentGrant grant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(tenantId, userId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = new AiConsentRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Consented = true,
|
||||
Scope = grant.Scope,
|
||||
ConsentedAt = now,
|
||||
ExpiresAt = grant.Duration.HasValue ? now.Add(grant.Duration.Value) : null,
|
||||
SessionLevel = grant.SessionLevel
|
||||
};
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_consents[key] = record;
|
||||
}
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeConsentAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = MakeKey(tenantId, userId);
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_consents.Remove(key));
|
||||
}
|
||||
}
|
||||
|
||||
private static string MakeKey(string tenantId, string userId) => $"{tenantId}:{userId}";
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AI-assisted VEX justification generator.
|
||||
/// Sprint: SPRINT_20251229_018a_FE_vex_ai_explanations
|
||||
/// Task: VEX-AI-016
|
||||
/// </summary>
|
||||
public interface IAiJustificationGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a draft justification for a VEX statement.
|
||||
/// </summary>
|
||||
Task<AiJustificationResult> GenerateAsync(AiJustificationRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for justification generation.
|
||||
/// </summary>
|
||||
public sealed record AiJustificationRequest
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string ProductRef { get; init; }
|
||||
public required string ProposedStatus { get; init; }
|
||||
public required string JustificationType { get; init; }
|
||||
public double? ReachabilityScore { get; init; }
|
||||
public int? CodeSearchResults { get; init; }
|
||||
public string? SbomContext { get; init; }
|
||||
public string? CallGraphSummary { get; init; }
|
||||
public IReadOnlyList<string>? RelatedVexStatements { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from justification generation.
|
||||
/// </summary>
|
||||
public sealed record AiJustificationResult
|
||||
{
|
||||
public required string JustificationId { get; init; }
|
||||
public required string DraftJustification { get; init; }
|
||||
public required string SuggestedJustificationType { get; init; }
|
||||
public required double ConfidenceScore { get; init; }
|
||||
public required IReadOnlyList<string> EvidenceSuggestions { get; init; }
|
||||
public required string ModelVersion { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation using LLM inference.
|
||||
/// </summary>
|
||||
public sealed class DefaultAiJustificationGenerator : IAiJustificationGenerator
|
||||
{
|
||||
private readonly ILogger<DefaultAiJustificationGenerator> _logger;
|
||||
private const string ModelVersion = "advisory-ai-v1.2.0";
|
||||
|
||||
public DefaultAiJustificationGenerator(ILogger<DefaultAiJustificationGenerator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<AiJustificationResult> GenerateAsync(AiJustificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Generating justification for CVE {CveId}, product {ProductRef}, status {Status}",
|
||||
request.CveId, request.ProductRef, request.ProposedStatus);
|
||||
|
||||
// Build justification based on context
|
||||
var justification = BuildJustification(request);
|
||||
var suggestedType = DetermineSuggestedType(request);
|
||||
var confidence = CalculateConfidence(request);
|
||||
var evidenceSuggestions = BuildEvidenceSuggestions(request);
|
||||
|
||||
var result = new AiJustificationResult
|
||||
{
|
||||
JustificationId = $"justify-{Guid.NewGuid():N}",
|
||||
DraftJustification = justification,
|
||||
SuggestedJustificationType = suggestedType,
|
||||
ConfidenceScore = confidence,
|
||||
EvidenceSuggestions = evidenceSuggestions,
|
||||
ModelVersion = ModelVersion,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
TraceId = request.CorrelationId
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static string BuildJustification(AiJustificationRequest request)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (request.ProposedStatus == "not_affected")
|
||||
{
|
||||
parts.Add($"The component referenced by {request.ProductRef} is not affected by {request.CveId}.");
|
||||
|
||||
if (request.ReachabilityScore.HasValue && request.ReachabilityScore < 0.1)
|
||||
{
|
||||
parts.Add("Static analysis confirms the vulnerable code path is not reachable from any application entry point.");
|
||||
}
|
||||
|
||||
if (request.CodeSearchResults.HasValue && request.CodeSearchResults == 0)
|
||||
{
|
||||
parts.Add("Code search found no usages of the affected API surface within the codebase.");
|
||||
}
|
||||
|
||||
switch (request.JustificationType.ToLowerInvariant())
|
||||
{
|
||||
case "vulnerable_code_not_present":
|
||||
parts.Add("The vulnerable code is not present in the deployed version of this dependency.");
|
||||
break;
|
||||
case "vulnerable_code_not_in_execute_path":
|
||||
parts.Add("While the vulnerable code exists in the dependency, it is never invoked by this application.");
|
||||
break;
|
||||
case "vulnerable_code_cannot_be_controlled_by_adversary":
|
||||
parts.Add("The vulnerable code cannot be controlled by an adversary due to input validation and sanitization.");
|
||||
break;
|
||||
case "inline_mitigations_already_exist":
|
||||
parts.Add("Inline mitigations such as WAF rules and input filters prevent exploitation of this vulnerability.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (request.ProposedStatus == "fixed")
|
||||
{
|
||||
parts.Add($"The vulnerability {request.CveId} has been remediated in the component referenced by {request.ProductRef}.");
|
||||
parts.Add("The fix has been applied and verified through our CI/CD pipeline.");
|
||||
}
|
||||
else
|
||||
{
|
||||
parts.Add($"The component referenced by {request.ProductRef} is affected by {request.CveId}.");
|
||||
parts.Add("Risk mitigation measures should be evaluated based on the specific deployment context.");
|
||||
}
|
||||
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private static string DetermineSuggestedType(AiJustificationRequest request)
|
||||
{
|
||||
if (request.ReachabilityScore.HasValue && request.ReachabilityScore < 0.1)
|
||||
{
|
||||
return "vulnerable_code_not_in_execute_path";
|
||||
}
|
||||
|
||||
if (request.CodeSearchResults.HasValue && request.CodeSearchResults == 0)
|
||||
{
|
||||
return "vulnerable_code_not_present";
|
||||
}
|
||||
|
||||
return request.JustificationType;
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(AiJustificationRequest request)
|
||||
{
|
||||
var baseConfidence = 0.5;
|
||||
|
||||
if (request.ReachabilityScore.HasValue)
|
||||
{
|
||||
// Higher confidence when reachability score supports the decision
|
||||
if (request.ProposedStatus == "not_affected" && request.ReachabilityScore < 0.1)
|
||||
{
|
||||
baseConfidence += 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.CodeSearchResults.HasValue)
|
||||
{
|
||||
if (request.ProposedStatus == "not_affected" && request.CodeSearchResults == 0)
|
||||
{
|
||||
baseConfidence += 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.RelatedVexStatements?.Count > 0)
|
||||
{
|
||||
baseConfidence += 0.05;
|
||||
}
|
||||
|
||||
return Math.Min(baseConfidence, 0.95);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildEvidenceSuggestions(AiJustificationRequest request)
|
||||
{
|
||||
var suggestions = new List<string>();
|
||||
|
||||
if (!request.ReachabilityScore.HasValue)
|
||||
{
|
||||
suggestions.Add("Attach reachability analysis results to strengthen this justification");
|
||||
}
|
||||
|
||||
if (!request.CodeSearchResults.HasValue)
|
||||
{
|
||||
suggestions.Add("Include code search results showing usage of the affected API");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.CallGraphSummary))
|
||||
{
|
||||
suggestions.Add("Add call graph analysis demonstrating code paths");
|
||||
}
|
||||
|
||||
if (request.RelatedVexStatements == null || request.RelatedVexStatements.Count == 0)
|
||||
{
|
||||
suggestions.Add("Reference related VEX statements from trusted issuers if available");
|
||||
}
|
||||
|
||||
if (request.ProposedStatus == "not_affected")
|
||||
{
|
||||
suggestions.Add("Document the specific conditions that prevent exploitation");
|
||||
suggestions.Add("Include test results validating the mitigation");
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user