fix tests. new product advisories enhancements

This commit is contained in:
master
2026-01-25 19:11:36 +02:00
parent c70e83719e
commit 6e687b523a
504 changed files with 40610 additions and 3785 deletions

View File

@@ -243,7 +243,7 @@ public static class DeltasEndpoints
deltas.MapGet("/{deltaId}/attestation", async Task<IResult>(
string deltaId,
IMemoryCache cache,
IDeltaVerdictAttestor? attestor,
[FromServices] IDeltaVerdictAttestor? attestor,
ILogger<DeltaComputer> logger,
CancellationToken cancellationToken) =>
{

View File

@@ -166,6 +166,12 @@ public static class GatesEndpoints
HttpContext httpContext,
CancellationToken ct)
{
// Validate required fields
if (string.IsNullOrWhiteSpace(request.Justification))
{
return Results.BadRequest(new { error = "Justification is required" });
}
var decodedBomRef = Uri.UnescapeDataString(bomRef);
var requestedBy = httpContext.User.Identity?.Name ?? "anonymous";
@@ -176,13 +182,22 @@ public static class GatesEndpoints
requestedBy,
ct);
var response = new GateExceptionResponse
var now = DateTimeOffset.UtcNow;
var exceptionId = result.ExceptionRef ?? $"EXC-{Guid.NewGuid():N}"[..20];
var response = new GateExceptionCreatedResponse
{
ExceptionId = exceptionId,
Status = result.Granted ? "active" : "denied",
CreatedAt = now,
UpdatedAt = now,
Granted = result.Granted,
ExceptionRef = result.ExceptionRef,
DenialReason = result.DenialReason,
ExpiresAt = result.ExpiresAt,
RequestedAt = DateTimeOffset.UtcNow
ExpiresAt = result.ExpiresAt ?? now.AddDays(30),
RequesterId = requestedBy,
Rationale = request.Justification ?? "",
Scope = new GateExceptionScope { Type = "component", Target = decodedBomRef }
};
return result.Granted
@@ -698,7 +713,7 @@ public sealed record ExceptionRequest
/// <summary>Justification for bypass.</summary>
[JsonPropertyName("justification")]
public required string Justification { get; init; }
public string? Justification { get; init; }
}
/// <summary>
@@ -727,6 +742,103 @@ public sealed record GateExceptionResponse
public DateTimeOffset RequestedAt { get; init; }
}
/// <summary>
/// Response for gate exception creation.
/// Uses camelCase property names to match ExceptionResponse contract.
/// </summary>
public sealed record GateExceptionCreatedResponse
{
/// <summary>Exception identifier.</summary>
[JsonPropertyName("exceptionId")]
public required string ExceptionId { get; init; }
/// <summary>Current status of the exception.</summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>When the exception was created.</summary>
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Whether exception was granted.</summary>
[JsonPropertyName("granted")]
public bool Granted { get; init; }
/// <summary>Exception reference.</summary>
[JsonPropertyName("exceptionRef")]
public string? ExceptionRef { get; init; }
/// <summary>Denial reason if not granted.</summary>
[JsonPropertyName("denialReason")]
public string? DenialReason { get; init; }
/// <summary>When exception expires.</summary>
[JsonPropertyName("expiresAt")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>Version for optimistic concurrency.</summary>
[JsonPropertyName("version")]
public int Version { get; init; } = 1;
/// <summary>Exception type.</summary>
[JsonPropertyName("type")]
public string Type { get; init; } = "gate_bypass";
/// <summary>Owner ID.</summary>
[JsonPropertyName("ownerId")]
public string OwnerId { get; init; } = "system";
/// <summary>Requester ID.</summary>
[JsonPropertyName("requesterId")]
public string RequesterId { get; init; } = "anonymous";
/// <summary>Approver IDs.</summary>
[JsonPropertyName("approverIds")]
public IReadOnlyList<string> ApproverIds { get; init; } = [];
/// <summary>Last updated timestamp.</summary>
[JsonPropertyName("updatedAt")]
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>Reason code.</summary>
[JsonPropertyName("reasonCode")]
public string ReasonCode { get; init; } = "gate_bypass";
/// <summary>Rationale.</summary>
[JsonPropertyName("rationale")]
public string Rationale { get; init; } = "";
/// <summary>Evidence references.</summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<string> EvidenceRefs { get; init; } = [];
/// <summary>Compensating controls.</summary>
[JsonPropertyName("compensatingControls")]
public IReadOnlyList<string> CompensatingControls { get; init; } = [];
/// <summary>Exception scope.</summary>
[JsonPropertyName("scope")]
public GateExceptionScope Scope { get; init; } = new();
/// <summary>Metadata.</summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
}
/// <summary>
/// Scope for gate exception.
/// </summary>
public sealed record GateExceptionScope
{
/// <summary>Scope type.</summary>
[JsonPropertyName("type")]
public string Type { get; init; } = "component";
/// <summary>Scope target.</summary>
[JsonPropertyName("target")]
public string Target { get; init; } = "";
}
#endregion
#region Gate Decision History DTOs

View File

@@ -104,12 +104,23 @@ public static class GovernanceEndpoints
[FromServices] TimeProvider timeProvider,
[FromQuery] string? tenantId)
{
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
var tenant = tenantId ?? GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenant))
{
return Task.FromResult(Results.BadRequest(new ProblemDetails
{
Title = "Tenant ID required",
Status = 400,
Detail = "Provide tenant via X-StellaOps-Tenant header or tenantId query parameter"
}));
}
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
var response = new SealedModeStatusResponse
{
IsSealed = state.IsSealed,
TenantId = tenant,
SealedAt = state.SealedAt,
SealedBy = state.SealedBy,
Reason = state.Reason,
@@ -137,7 +148,7 @@ public static class GovernanceEndpoints
.Select(MapOverrideToResponse)
.ToList();
return Task.FromResult(Results.Ok(overrides));
return Task.FromResult(Results.Ok(new { overrides }));
}
private static Task<IResult> ToggleSealedModeAsync(
@@ -181,6 +192,7 @@ public static class GovernanceEndpoints
var response = new SealedModeStatusResponse
{
IsSealed = state.IsSealed,
TenantId = tenant,
SealedAt = state.SealedAt,
SealedBy = state.SealedBy,
Reason = state.Reason,
@@ -200,20 +212,25 @@ public static class GovernanceEndpoints
SealedModeOverrideRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var actor = request.Actor ?? GetActorId(httpContext) ?? "system";
var now = timeProvider.GetUtcNow();
var overrideId = $"override-{Guid.NewGuid():N}";
var overrideType = request.OverrideType ?? request.Type ?? "general";
var durationHours = request.DurationMinutes > 0
? (double)request.DurationMinutes / 60.0
: request.DurationHours > 0 ? request.DurationHours : 24;
var entity = new SealedModeOverrideEntity
{
Id = overrideId,
TenantId = tenant,
Type = request.Type,
Target = request.Target,
Reason = request.Reason,
Type = overrideType,
Target = request.Target ?? "system",
Reason = request.Reason ?? "",
ApprovalId = $"approval-{Guid.NewGuid():N}",
ApprovedBy = [actor],
ExpiresAt = now.AddHours(request.DurationHours).ToString("O", CultureInfo.InvariantCulture),
ExpiresAt = now.AddHours(durationHours).ToString("O", CultureInfo.InvariantCulture),
CreatedAt = now.ToString("O", CultureInfo.InvariantCulture),
Active = true
};
@@ -221,9 +238,19 @@ public static class GovernanceEndpoints
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_created", overrideId, "sealed_mode_override",
$"Created override for {request.Target}: {request.Reason}", timeProvider);
$"Created override for {entity.Target}: {entity.Reason}", timeProvider);
return Task.FromResult(Results.Ok(MapOverrideToResponse(entity)));
return Task.FromResult(Results.Created(
$"/api/v1/governance/sealed-mode/overrides/{overrideId}",
new SealedModeOverrideCreatedResponse
{
OverrideId = overrideId,
OverrideType = overrideType,
Reason = entity.Reason,
ExpiresAt = entity.ExpiresAt,
CreatedAt = entity.CreatedAt,
Active = entity.Active
}));
}
private static Task<IResult> RevokeSealedModeOverrideAsync(
@@ -248,7 +275,7 @@ public static class GovernanceEndpoints
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_revoked", overrideId, "sealed_mode_override",
$"Revoked override: {request.Reason}", timeProvider);
$"Revoked override: {request.Reason ?? "no reason provided"}", timeProvider);
return Task.FromResult(Results.NoContent());
}
@@ -270,7 +297,7 @@ public static class GovernanceEndpoints
.Select(MapProfileToResponse)
.ToList();
return Task.FromResult(Results.Ok(profiles));
return Task.FromResult(Results.Ok(new { profiles }));
}
private static Task<IResult> GetRiskProfileAsync(
@@ -301,7 +328,7 @@ public static class GovernanceEndpoints
var actor = GetActorId(httpContext) ?? "system";
var now = timeProvider.GetUtcNow();
var profileId = $"profile-{Guid.NewGuid():N}";
var profileId = request.ProfileId ?? $"profile-{Guid.NewGuid():N}";
var entity = new RiskProfileEntity
{
Id = profileId,
@@ -446,13 +473,13 @@ public static class GovernanceEndpoints
Status = "deprecated",
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
ModifiedBy = actor,
DeprecationReason = request.Reason
DeprecationReason = request.Reason ?? "deprecated"
};
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_deprecated", profileId, "risk_profile",
$"Deprecated risk profile: {entity.Name} - {request.Reason}", timeProvider);
$"Deprecated risk profile: {entity.Name} - {request.Reason ?? "no reason"}", timeProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
@@ -484,7 +511,7 @@ public static class GovernanceEndpoints
var response = new RiskProfileValidationResponse
{
Valid = errors.Count == 0,
IsValid = errors.Count == 0,
Errors = errors,
Warnings = warnings
};
@@ -502,7 +529,16 @@ public static class GovernanceEndpoints
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
var tenant = tenantId ?? GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenant))
{
return Task.FromResult(Results.BadRequest(new ProblemDetails
{
Title = "Tenant ID required",
Status = 400,
Detail = "Provide tenant via X-StellaOps-Tenant header or tenantId query parameter"
}));
}
var events = AuditEntries.Values
.Where(e => e.TenantId == tenant)
@@ -629,7 +665,7 @@ public static class GovernanceEndpoints
{
return new RiskProfileResponse
{
Id = entity.Id,
ProfileId = entity.Id,
Version = entity.Version,
Name = entity.Name,
Description = entity.Description,
@@ -737,20 +773,34 @@ public sealed record SealedModeToggleRequest
public sealed record SealedModeOverrideRequest
{
public required string Type { get; init; }
public required string Target { get; init; }
public required string Reason { get; init; }
public int DurationHours { get; init; } = 24;
public string? Type { get; init; }
public string? OverrideType { get; init; }
public string? Target { get; init; }
public string? Reason { get; init; }
public int DurationHours { get; init; }
public int DurationMinutes { get; init; }
public string? Actor { get; init; }
}
public sealed record SealedModeOverrideCreatedResponse
{
public required string OverrideId { get; init; }
public required string OverrideType { get; init; }
public string? Reason { get; init; }
public required string ExpiresAt { get; init; }
public required string CreatedAt { get; init; }
public required bool Active { get; init; }
}
public sealed record RevokeOverrideRequest
{
public required string Reason { get; init; }
public string? Reason { get; init; }
}
public sealed record SealedModeStatusResponse
{
public required bool IsSealed { get; init; }
public string? TenantId { get; init; }
public string? SealedAt { get; init; }
public string? SealedBy { get; init; }
public string? Reason { get; init; }
@@ -776,6 +826,7 @@ public sealed record SealedModeOverrideResponse
public sealed record CreateRiskProfileRequest
{
public string? ProfileId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public string? ExtendsProfile { get; init; }
@@ -795,7 +846,7 @@ public sealed record UpdateRiskProfileRequest
public sealed record DeprecateProfileRequest
{
public required string Reason { get; init; }
public string? Reason { get; init; }
}
public sealed record ValidateRiskProfileRequest
@@ -806,7 +857,7 @@ public sealed record ValidateRiskProfileRequest
public sealed record RiskProfileResponse
{
public required string Id { get; init; }
public required string ProfileId { get; init; }
public required string Version { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
@@ -823,7 +874,7 @@ public sealed record RiskProfileResponse
public sealed record RiskProfileValidationResponse
{
public required bool Valid { get; init; }
public required bool IsValid { get; init; }
public required List<ValidationError> Errors { get; init; }
public required List<ValidationWarning> Warnings { get; init; }
}

View File

@@ -30,13 +30,13 @@ public static class ScoreGateEndpoints
// POST /api/v1/gate/evaluate - Evaluate score-based gate for a finding
gates.MapPost("/evaluate", async Task<IResult>(
HttpContext httpContext,
ScoreGateEvaluateRequest request,
ScoreGateEvaluateRequest? request,
IEvidenceWeightedScoreCalculator ewsCalculator,
IVerdictBundleBuilder verdictBuilder,
IVerdictSigningService signingService,
IVerdictRekorAnchorService anchorService,
[FromServices] TimeProvider timeProvider,
ILogger logger,
[FromServices] ILogger<ScoreGateLog> logger,
CancellationToken cancellationToken) =>
{
if (request is null)
@@ -166,7 +166,7 @@ public static class ScoreGateEndpoints
IVerdictSigningService signingService,
IVerdictRekorAnchorService anchorService,
[FromServices] TimeProvider timeProvider,
ILogger logger,
[FromServices] ILogger<ScoreGateLog> logger,
CancellationToken cancellationToken) =>
{
if (request is null || request.Findings is null || request.Findings.Count == 0)
@@ -285,7 +285,16 @@ public static class ScoreGateEndpoints
var tasks = findings.Select(async finding =>
{
await semaphore.WaitAsync(linkedCts.Token);
try
{
await semaphore.WaitAsync(linkedCts.Token);
}
catch (OperationCanceledException)
{
// Fail-fast triggered; skip remaining findings
return null;
}
try
{
if (linkedCts.Token.IsCancellationRequested)
@@ -308,11 +317,16 @@ public static class ScoreGateEndpoints
// Check fail-fast
if (options.FailFast && decision.Action == ScoreGateActions.Block)
{
failFastToken.Cancel();
await failFastToken.CancelAsync();
}
return decision;
}
catch (OperationCanceledException)
{
// Fail-fast triggered during evaluation; skip this finding
return null;
}
finally
{
semaphore.Release();
@@ -437,7 +451,8 @@ public static class ScoreGateEndpoints
"function" or "function_level" => 0.7,
"package" or "package_level" => 0.3,
"none" => 0.0,
_ => 0.0
// Unknown/unspecified: use conservative default assuming partial reachability
_ => 0.5
};
}
@@ -531,3 +546,5 @@ public static class ScoreGateEndpoints
}
}
internal sealed class ScoreGateLog;

View File

@@ -27,6 +27,7 @@ using StellaOps.Policy.Persistence.Postgres;
using Polly;
using Polly.Extensions.Http;
using StellaOps.AirGap.Policy;
using StellaOps.Determinism;
var builder = WebApplication.CreateBuilder(args);
@@ -122,6 +123,7 @@ builder.Services.AddOptions<ToolLatticeOptions>()
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSystemGuidProvider();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
@@ -156,6 +158,10 @@ builder.Services.AddSingleton<InMemoryGateEvaluationQueue>();
builder.Services.AddSingleton<IGateEvaluationQueue>(sp => sp.GetRequiredService<InMemoryGateEvaluationQueue>());
builder.Services.AddHostedService<GateEvaluationWorker>();
// Unknowns gate services (Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement)
builder.Services.Configure<StellaOps.Policy.Gates.UnknownsGateOptions>(_ => { });
builder.Services.AddHttpClient<StellaOps.Policy.Gates.IUnknownsGateChecker, StellaOps.Policy.Gates.UnknownsGateChecker>();
// Gate bypass audit services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration, Task: CICD-GATE-06)
builder.Services.AddSingleton<StellaOps.Policy.Audit.IGateBypassAuditRepository,
StellaOps.Policy.Audit.InMemoryGateBypassAuditRepository>();
@@ -560,6 +566,9 @@ app.MapDeltasEndpoints();
// Gate evaluation endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
app.MapGateEndpoints();
// Unknowns gate endpoints (Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api)
app.MapGatesEndpoints();
// Score-based gate evaluation endpoints (Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api)
app.MapScoreGateEndpoints();