- Added IsolatedReplayContext class to provide an isolated environment for replaying audit bundles without external calls. - Introduced methods for initializing the context, verifying input digests, and extracting inputs for policy evaluation. - Created supporting interfaces and options for context configuration. feat: Create ReplayExecutor for executing policy re-evaluation and verdict comparison - Developed ReplayExecutor class to handle the execution of replay processes, including input verification and verdict comparison. - Implemented detailed drift detection and error handling during replay execution. - Added interfaces for policy evaluation and replay execution options. feat: Add ScanSnapshotFetcher for fetching scan data and snapshots - Introduced ScanSnapshotFetcher class to retrieve necessary scan data and snapshots for audit bundle creation. - Implemented methods to fetch scan metadata, advisory feeds, policy snapshots, and VEX statements. - Created supporting interfaces for scan data, feed snapshots, and policy snapshots.
374 lines
14 KiB
C#
374 lines
14 KiB
C#
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Sprint: SPRINT_4100_0004_0001 - Security State Delta & Verdict
|
|
// Task: T6 - Add Delta API endpoints
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using StellaOps.Policy.Deltas;
|
|
using StellaOps.Policy.Gateway.Contracts;
|
|
|
|
namespace StellaOps.Policy.Gateway.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Delta API endpoints for Policy Gateway.
|
|
/// </summary>
|
|
public static class DeltasEndpoints
|
|
{
|
|
private const string DeltaCachePrefix = "delta:";
|
|
private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30);
|
|
|
|
/// <summary>
|
|
/// Maps delta endpoints to the application.
|
|
/// </summary>
|
|
public static void MapDeltasEndpoints(this WebApplication app)
|
|
{
|
|
var deltas = app.MapGroup("/api/policy/deltas")
|
|
.WithTags("Deltas");
|
|
|
|
// POST /api/policy/deltas/compute - Compute a security state delta
|
|
deltas.MapPost("/compute", async Task<IResult>(
|
|
ComputeDeltaRequest request,
|
|
IDeltaComputer deltaComputer,
|
|
IBaselineSelector baselineSelector,
|
|
IMemoryCache cache,
|
|
ILogger<DeltaComputer> logger,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Request body required",
|
|
Status = 400
|
|
});
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Artifact digest required",
|
|
Status = 400
|
|
});
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.TargetSnapshotId))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Target snapshot ID required",
|
|
Status = 400
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
// Select baseline
|
|
BaselineSelectionResult baselineResult;
|
|
if (!string.IsNullOrWhiteSpace(request.BaselineSnapshotId))
|
|
{
|
|
baselineResult = await baselineSelector.SelectExplicitAsync(
|
|
request.BaselineSnapshotId,
|
|
cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
var strategy = ParseStrategy(request.BaselineStrategy);
|
|
baselineResult = await baselineSelector.SelectBaselineAsync(
|
|
request.ArtifactDigest,
|
|
strategy,
|
|
cancellationToken);
|
|
}
|
|
|
|
if (!baselineResult.IsFound)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Baseline not found",
|
|
Status = 404,
|
|
Detail = baselineResult.Error
|
|
});
|
|
}
|
|
|
|
// Compute delta
|
|
var delta = await deltaComputer.ComputeDeltaAsync(
|
|
baselineResult.Snapshot!.SnapshotId,
|
|
request.TargetSnapshotId,
|
|
new ArtifactRef(
|
|
request.ArtifactDigest,
|
|
request.ArtifactName,
|
|
request.ArtifactTag),
|
|
cancellationToken);
|
|
|
|
// Cache the delta for subsequent retrieval
|
|
cache.Set(
|
|
DeltaCachePrefix + delta.DeltaId,
|
|
delta,
|
|
DeltaCacheDuration);
|
|
|
|
logger.LogInformation(
|
|
"Computed delta {DeltaId} between {Baseline} and {Target}",
|
|
delta.DeltaId, delta.BaselineSnapshotId, delta.TargetSnapshotId);
|
|
|
|
return Results.Ok(new ComputeDeltaResponse
|
|
{
|
|
DeltaId = delta.DeltaId,
|
|
BaselineSnapshotId = delta.BaselineSnapshotId,
|
|
TargetSnapshotId = delta.TargetSnapshotId,
|
|
ComputedAt = delta.ComputedAt,
|
|
Summary = DeltaSummaryDto.FromModel(delta.Summary),
|
|
DriverCount = delta.Drivers.Count
|
|
});
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Snapshot not found",
|
|
Status = 404,
|
|
Detail = ex.Message
|
|
});
|
|
}
|
|
})
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
|
|
|
|
// GET /api/policy/deltas/{deltaId} - Get a delta by ID
|
|
deltas.MapGet("/{deltaId}", async Task<IResult>(
|
|
string deltaId,
|
|
IMemoryCache cache,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(deltaId))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Delta ID required",
|
|
Status = 400
|
|
});
|
|
}
|
|
|
|
// Try to retrieve from cache
|
|
if (!cache.TryGetValue(DeltaCachePrefix + deltaId, out SecurityStateDelta? delta) || delta is null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Delta not found",
|
|
Status = 404,
|
|
Detail = $"No delta found with ID: {deltaId}. Deltas are cached for {DeltaCacheDuration.TotalMinutes} minutes after computation."
|
|
});
|
|
}
|
|
|
|
return Results.Ok(DeltaResponse.FromModel(delta));
|
|
})
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
|
|
|
// POST /api/policy/deltas/{deltaId}/evaluate - Evaluate delta and get verdict
|
|
deltas.MapPost("/{deltaId}/evaluate", async Task<IResult>(
|
|
string deltaId,
|
|
EvaluateDeltaRequest? request,
|
|
IMemoryCache cache,
|
|
ILogger<DeltaComputer> logger,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(deltaId))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Delta ID required",
|
|
Status = 400
|
|
});
|
|
}
|
|
|
|
// Try to retrieve delta from cache
|
|
if (!cache.TryGetValue(DeltaCachePrefix + deltaId, out SecurityStateDelta? delta) || delta is null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Delta not found",
|
|
Status = 404,
|
|
Detail = $"No delta found with ID: {deltaId}"
|
|
});
|
|
}
|
|
|
|
// Build verdict from delta drivers
|
|
var builder = new DeltaVerdictBuilder();
|
|
|
|
// Apply risk points based on summary
|
|
builder.WithRiskPoints((int)delta.Summary.RiskScore);
|
|
|
|
// Categorize drivers as blocking or warning
|
|
foreach (var driver in delta.Drivers)
|
|
{
|
|
if (IsBlockingDriver(driver))
|
|
{
|
|
builder.AddBlockingDriver(driver);
|
|
}
|
|
else if (driver.Severity >= DeltaDriverSeverity.Medium)
|
|
{
|
|
builder.AddWarningDriver(driver);
|
|
}
|
|
}
|
|
|
|
// Apply exceptions if provided
|
|
if (request?.Exceptions is not null)
|
|
{
|
|
foreach (var exceptionId in request.Exceptions)
|
|
{
|
|
builder.AddException(exceptionId);
|
|
}
|
|
}
|
|
|
|
// Add recommendations based on drivers
|
|
AddRecommendations(builder, delta.Drivers);
|
|
|
|
var verdict = builder.Build(deltaId);
|
|
|
|
// Cache the verdict
|
|
cache.Set(
|
|
DeltaCachePrefix + deltaId + ":verdict",
|
|
verdict,
|
|
DeltaCacheDuration);
|
|
|
|
logger.LogInformation(
|
|
"Evaluated delta {DeltaId}: status={Status}, gate={Gate}",
|
|
deltaId, verdict.Status, verdict.RecommendedGate);
|
|
|
|
return Results.Ok(DeltaVerdictResponse.FromModel(verdict));
|
|
})
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
|
|
|
|
// GET /api/policy/deltas/{deltaId}/attestation - Get signed attestation
|
|
deltas.MapGet("/{deltaId}/attestation", async Task<IResult>(
|
|
string deltaId,
|
|
IMemoryCache cache,
|
|
IDeltaVerdictAttestor? attestor,
|
|
ILogger<DeltaComputer> logger,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(deltaId))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Delta ID required",
|
|
Status = 400
|
|
});
|
|
}
|
|
|
|
// Try to retrieve delta from cache
|
|
if (!cache.TryGetValue(DeltaCachePrefix + deltaId, out SecurityStateDelta? delta) || delta is null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Delta not found",
|
|
Status = 404,
|
|
Detail = $"No delta found with ID: {deltaId}"
|
|
});
|
|
}
|
|
|
|
// Try to retrieve verdict from cache
|
|
if (!cache.TryGetValue(DeltaCachePrefix + deltaId + ":verdict", out DeltaVerdict? verdict) || verdict is null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Verdict not found",
|
|
Status = 404,
|
|
Detail = "Delta must be evaluated before attestation can be generated. Call POST /evaluate first."
|
|
});
|
|
}
|
|
|
|
if (attestor is null)
|
|
{
|
|
return Results.Problem(new ProblemDetails
|
|
{
|
|
Title = "Attestor not configured",
|
|
Status = 501,
|
|
Detail = "Delta verdict attestation requires a signer to be configured"
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var envelope = await attestor.AttestAsync(delta, verdict, cancellationToken);
|
|
|
|
logger.LogInformation(
|
|
"Created attestation for delta {DeltaId} verdict {VerdictId}",
|
|
deltaId, verdict.VerdictId);
|
|
|
|
return Results.Ok(envelope);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to create attestation for delta {DeltaId}", deltaId);
|
|
return Results.Problem(new ProblemDetails
|
|
{
|
|
Title = "Attestation failed",
|
|
Status = 500,
|
|
Detail = "Failed to create signed attestation"
|
|
});
|
|
}
|
|
})
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
|
|
}
|
|
|
|
private static BaselineSelectionStrategy ParseStrategy(string? strategy)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(strategy))
|
|
return BaselineSelectionStrategy.LastApproved;
|
|
|
|
return strategy.ToLowerInvariant() switch
|
|
{
|
|
"previousbuild" or "previous_build" or "previous-build" => BaselineSelectionStrategy.PreviousBuild,
|
|
"lastapproved" or "last_approved" or "last-approved" => BaselineSelectionStrategy.LastApproved,
|
|
"productiondeployed" or "production_deployed" or "production-deployed" or "production" => BaselineSelectionStrategy.ProductionDeployed,
|
|
"branchbase" or "branch_base" or "branch-base" => BaselineSelectionStrategy.BranchBase,
|
|
_ => BaselineSelectionStrategy.LastApproved
|
|
};
|
|
}
|
|
|
|
private static bool IsBlockingDriver(DeltaDriver driver)
|
|
{
|
|
// Block on critical/high severity negative drivers
|
|
if (driver.Severity is DeltaDriverSeverity.Critical or DeltaDriverSeverity.High)
|
|
{
|
|
// These types indicate risk increase
|
|
return driver.Type is
|
|
"new-reachable-cve" or
|
|
"lost-vex-coverage" or
|
|
"vex-status-downgrade" or
|
|
"new-policy-violation";
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static void AddRecommendations(DeltaVerdictBuilder builder, IReadOnlyList<DeltaDriver> drivers)
|
|
{
|
|
var hasReachableCve = drivers.Any(d => d.Type == "new-reachable-cve");
|
|
var hasLostVex = drivers.Any(d => d.Type == "lost-vex-coverage");
|
|
var hasNewViolation = drivers.Any(d => d.Type == "new-policy-violation");
|
|
var hasNewUnknowns = drivers.Any(d => d.Type == "new-unknowns");
|
|
|
|
if (hasReachableCve)
|
|
{
|
|
builder.AddRecommendation("Review new reachable CVEs and apply VEX statements or patches");
|
|
}
|
|
|
|
if (hasLostVex)
|
|
{
|
|
builder.AddRecommendation("Investigate lost VEX coverage - statements may have expired or been revoked");
|
|
}
|
|
|
|
if (hasNewViolation)
|
|
{
|
|
builder.AddRecommendation("Address policy violations or request exceptions");
|
|
}
|
|
|
|
if (hasNewUnknowns)
|
|
{
|
|
builder.AddRecommendation("Investigate new unknown packages - consider adding SBOM metadata");
|
|
}
|
|
}
|
|
}
|