477 lines
19 KiB
C#
477 lines
19 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using System.Globalization;
|
|
|
|
namespace StellaOps.Platform.WebService.Endpoints;
|
|
|
|
public static class AocCompatibilityEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapAocCompatibilityEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/api/v1/aoc")
|
|
.WithTags("AOC Compatibility")
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AocVerify))
|
|
.RequireTenant();
|
|
|
|
group.MapGet("/metrics", (
|
|
HttpContext httpContext,
|
|
[FromQuery] string? tenantId,
|
|
[FromQuery] int? windowMinutes,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var tenant = ResolveTenant(httpContext, tenantId);
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant_required" });
|
|
}
|
|
|
|
var effectiveWindowMinutes = windowMinutes is > 0 ? windowMinutes.Value : 1440;
|
|
var now = timeProvider.GetUtcNow();
|
|
|
|
return Results.Ok(new
|
|
{
|
|
passCount = 12847,
|
|
failCount = 23,
|
|
totalCount = 12870,
|
|
passRate = 0.9982,
|
|
recentViolations = BuildViolationSummaries(now),
|
|
ingestThroughput = new
|
|
{
|
|
docsPerMinute = 8.9,
|
|
avgLatencyMs = 145,
|
|
p95LatencyMs = 312,
|
|
queueDepth = 3,
|
|
errorRate = 0.18
|
|
},
|
|
timeWindow = new
|
|
{
|
|
start = now.AddMinutes(-effectiveWindowMinutes).ToString("O", CultureInfo.InvariantCulture),
|
|
end = now.ToString("O", CultureInfo.InvariantCulture),
|
|
durationMinutes = effectiveWindowMinutes
|
|
}
|
|
});
|
|
})
|
|
.WithName("AocCompatibility.GetMetrics");
|
|
|
|
group.MapPost("/verify", (
|
|
HttpContext httpContext,
|
|
AocVerifyRequest request,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var tenant = ResolveTenant(httpContext, request.TenantId);
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant_required" });
|
|
}
|
|
|
|
var now = timeProvider.GetUtcNow();
|
|
return Results.Ok(new
|
|
{
|
|
verificationId = $"verify-{tenant}-{now:yyyyMMddHHmmss}",
|
|
status = "partial",
|
|
checkedCount = Math.Clamp(request.Limit ?? 250, 1, 1000),
|
|
passedCount = 247,
|
|
failedCount = 3,
|
|
violations = new[]
|
|
{
|
|
new
|
|
{
|
|
documentId = "sbom-nginx-prod",
|
|
violationCode = "AOC-PROV-001",
|
|
field = "provenance.digest",
|
|
expected = "signed",
|
|
actual = "missing",
|
|
provenance = new
|
|
{
|
|
sourceId = "docker-hub",
|
|
ingestedAt = now.AddMinutes(-20).ToString("O", CultureInfo.InvariantCulture),
|
|
digest = "sha256:4fdb5e6a31a80f0d",
|
|
sourceType = "registry",
|
|
sourceUrl = "docker.io/library/nginx:1.27.4",
|
|
submitter = "scanner-agent-01"
|
|
}
|
|
}
|
|
},
|
|
completedAt = now.ToString("O", CultureInfo.InvariantCulture)
|
|
});
|
|
})
|
|
.WithName("AocCompatibility.Verify");
|
|
|
|
group.MapGet("/compliance/dashboard", (
|
|
HttpContext httpContext,
|
|
[FromQuery] string? tenantId,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var tenant = ResolveTenant(httpContext, tenantId);
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant_required" });
|
|
}
|
|
|
|
var now = timeProvider.GetUtcNow();
|
|
return Results.Ok(new
|
|
{
|
|
metrics = new
|
|
{
|
|
guardViolations = new
|
|
{
|
|
count = 23,
|
|
percentage = 0.18,
|
|
byReason = new Dictionary<string, int>(StringComparer.Ordinal)
|
|
{
|
|
["schema_invalid"] = 9,
|
|
["missing_required_fields"] = 8,
|
|
["hash_mismatch"] = 6
|
|
},
|
|
trend = "stable"
|
|
},
|
|
provenanceCompleteness = new
|
|
{
|
|
percentage = 99.1,
|
|
recordsWithValidHash = 12755,
|
|
totalRecords = 12870,
|
|
trend = "up"
|
|
},
|
|
deduplicationRate = new
|
|
{
|
|
percentage = 12.4,
|
|
duplicatesDetected = 1595,
|
|
totalIngested = 12870,
|
|
trend = "stable"
|
|
},
|
|
ingestionLatency = new
|
|
{
|
|
p50Ms = 122,
|
|
p95Ms = 301,
|
|
p99Ms = 410,
|
|
meetsSla = true,
|
|
slaTargetP95Ms = 500
|
|
},
|
|
supersedesDepth = new
|
|
{
|
|
maxDepth = 4,
|
|
avgDepth = 1.6,
|
|
distribution = new[]
|
|
{
|
|
new { depth = 1, count = 1180 },
|
|
new { depth = 2, count = 242 },
|
|
new { depth = 3, count = 44 },
|
|
new { depth = 4, count = 6 }
|
|
}
|
|
},
|
|
periodStart = now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
|
|
periodEnd = now.ToString("O", CultureInfo.InvariantCulture)
|
|
},
|
|
recentViolations = BuildGuardViolations(now, page: 1, pageSize: 5).Items,
|
|
ingestionFlow = BuildIngestionFlow(now)
|
|
});
|
|
})
|
|
.WithName("AocCompatibility.GetComplianceDashboard");
|
|
|
|
group.MapGet("/compliance/violations", (
|
|
[FromQuery] int? page,
|
|
[FromQuery] int? pageSize,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var effectivePage = page is > 0 ? page.Value : 1;
|
|
var effectivePageSize = pageSize is > 0 ? pageSize.Value : 20;
|
|
var response = BuildGuardViolations(timeProvider.GetUtcNow(), effectivePage, effectivePageSize);
|
|
return Results.Ok(response);
|
|
})
|
|
.WithName("AocCompatibility.GetViolations");
|
|
|
|
group.MapPost("/compliance/violations/{violationId}/retry", (string violationId) =>
|
|
Results.Ok(new
|
|
{
|
|
success = true,
|
|
message = $"Retry scheduled for {violationId}."
|
|
}))
|
|
.WithName("AocCompatibility.RetryViolation");
|
|
|
|
group.MapGet("/ingestion/flow", ([FromServices] TimeProvider timeProvider) =>
|
|
Results.Ok(BuildIngestionFlow(timeProvider.GetUtcNow())))
|
|
.WithName("AocCompatibility.GetIngestionFlow");
|
|
|
|
group.MapPost("/provenance/validate", (
|
|
AocProvenanceValidateRequest request,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var now = timeProvider.GetUtcNow();
|
|
var inputType = string.IsNullOrWhiteSpace(request.InputType) ? "finding_id" : request.InputType!;
|
|
var inputValue = string.IsNullOrWhiteSpace(request.InputValue) ? "finding-001" : request.InputValue!;
|
|
return Results.Ok(new
|
|
{
|
|
inputType,
|
|
inputValue,
|
|
steps = new[]
|
|
{
|
|
new AocProvenanceStep(
|
|
"source",
|
|
"Registry intake",
|
|
now.AddMinutes(-40).ToString("O", CultureInfo.InvariantCulture),
|
|
"sha256:1f7d98a2bf54c390",
|
|
null,
|
|
"valid",
|
|
new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "docker-hub",
|
|
["artifact"] = "nginx:1.27.4"
|
|
}),
|
|
new AocProvenanceStep(
|
|
"normalized",
|
|
"Concelier normalization",
|
|
now.AddMinutes(-33).ToString("O", CultureInfo.InvariantCulture),
|
|
"sha256:4fdb5e6a31a80f0d",
|
|
"sha256:1f7d98a2bf54c390",
|
|
"valid",
|
|
new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["pipeline"] = "concelier",
|
|
["tenant"] = "demo-prod"
|
|
}),
|
|
new AocProvenanceStep(
|
|
"finding",
|
|
"Finding materialized",
|
|
now.AddMinutes(-22).ToString("O", CultureInfo.InvariantCulture),
|
|
"sha256:0a52b69d9f9c72c0",
|
|
"sha256:4fdb5e6a31a80f0d",
|
|
"valid",
|
|
new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["findingId"] = inputValue,
|
|
["decision"] = "warn"
|
|
})
|
|
},
|
|
isComplete = true,
|
|
validationErrors = Array.Empty<string>(),
|
|
validatedAt = now.ToString("O", CultureInfo.InvariantCulture)
|
|
});
|
|
})
|
|
.WithName("AocCompatibility.ValidateProvenance");
|
|
|
|
group.MapPost("/compliance/reports", (
|
|
AocComplianceReportRequest request,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var now = timeProvider.GetUtcNow();
|
|
return Results.Ok(new
|
|
{
|
|
reportId = $"aoc-report-{now:yyyyMMddHHmmss}",
|
|
generatedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
|
period = new
|
|
{
|
|
start = request.StartDate ?? now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
|
|
end = request.EndDate ?? now.ToString("O", CultureInfo.InvariantCulture)
|
|
},
|
|
guardViolationSummary = new
|
|
{
|
|
total = 23,
|
|
bySource = new Dictionary<string, int>(StringComparer.Ordinal)
|
|
{
|
|
["docker-hub"] = 12,
|
|
["github-packages"] = 11
|
|
},
|
|
byReason = new Dictionary<string, int>(StringComparer.Ordinal)
|
|
{
|
|
["schema_invalid"] = 9,
|
|
["missing_required_fields"] = 8,
|
|
["hash_mismatch"] = 6
|
|
}
|
|
},
|
|
provenanceCompliance = new
|
|
{
|
|
percentage = 99.1,
|
|
bySource = new Dictionary<string, double>(StringComparer.Ordinal)
|
|
{
|
|
["docker-hub"] = 99.5,
|
|
["github-packages"] = 98.7
|
|
}
|
|
},
|
|
deduplicationMetrics = new
|
|
{
|
|
rate = 12.4,
|
|
bySource = new Dictionary<string, double>(StringComparer.Ordinal)
|
|
{
|
|
["docker-hub"] = 10.8,
|
|
["github-packages"] = 14.1
|
|
}
|
|
},
|
|
latencyMetrics = new
|
|
{
|
|
p50Ms = 122,
|
|
p95Ms = 301,
|
|
p99Ms = 410,
|
|
bySource = new Dictionary<string, object>(StringComparer.Ordinal)
|
|
{
|
|
["docker-hub"] = new { p50 = 118, p95 = 292, p99 = 401 },
|
|
["github-packages"] = new { p50 = 126, p95 = 312, p99 = 420 }
|
|
}
|
|
}
|
|
});
|
|
})
|
|
.WithName("AocCompatibility.GenerateComplianceReport");
|
|
|
|
return app;
|
|
}
|
|
|
|
private static object[] BuildViolationSummaries(DateTimeOffset now) =>
|
|
[
|
|
new
|
|
{
|
|
code = "AOC-PROV-001",
|
|
description = "Missing provenance attestation",
|
|
count = 12,
|
|
severity = "high",
|
|
lastSeen = now.AddMinutes(-15).ToString("O", CultureInfo.InvariantCulture)
|
|
},
|
|
new
|
|
{
|
|
code = "AOC-DIGEST-002",
|
|
description = "Digest mismatch in manifest",
|
|
count = 7,
|
|
severity = "critical",
|
|
lastSeen = now.AddMinutes(-42).ToString("O", CultureInfo.InvariantCulture)
|
|
},
|
|
new
|
|
{
|
|
code = "AOC-SCHEMA-003",
|
|
description = "Schema validation failed",
|
|
count = 4,
|
|
severity = "medium",
|
|
lastSeen = now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture)
|
|
}
|
|
];
|
|
|
|
private static object BuildIngestionFlow(DateTimeOffset now) => new
|
|
{
|
|
sources = new[]
|
|
{
|
|
new
|
|
{
|
|
sourceId = "docker-hub",
|
|
sourceName = "Docker Hub",
|
|
module = "concelier",
|
|
throughputPerMinute = 5.2,
|
|
latencyP50Ms = 112,
|
|
latencyP95Ms = 284,
|
|
latencyP99Ms = 365,
|
|
errorRate = 0.12,
|
|
backlogDepth = 2,
|
|
lastIngestionAt = now.AddMinutes(-3).ToString("O", CultureInfo.InvariantCulture),
|
|
status = "healthy"
|
|
},
|
|
new
|
|
{
|
|
sourceId = "github-packages",
|
|
sourceName = "GitHub Packages",
|
|
module = "excititor",
|
|
throughputPerMinute = 3.7,
|
|
latencyP50Ms = 133,
|
|
latencyP95Ms = 318,
|
|
latencyP99Ms = 411,
|
|
errorRate = 0.24,
|
|
backlogDepth = 1,
|
|
lastIngestionAt = now.AddMinutes(-6).ToString("O", CultureInfo.InvariantCulture),
|
|
status = "degraded"
|
|
}
|
|
},
|
|
totalThroughput = 8.9,
|
|
avgLatencyP95Ms = 301,
|
|
overallErrorRate = 0.18,
|
|
lastUpdatedAt = now.ToString("O", CultureInfo.InvariantCulture)
|
|
};
|
|
|
|
private static AocGuardViolationResponse BuildGuardViolations(DateTimeOffset now, int page, int pageSize)
|
|
{
|
|
var all = new[]
|
|
{
|
|
new AocGuardViolation(
|
|
"viol-001",
|
|
now.AddMinutes(-16).ToString("O", CultureInfo.InvariantCulture),
|
|
"docker-hub",
|
|
"missing_required_fields",
|
|
"Provenance digest missing from normalized advisory.",
|
|
"{\"digest\":null}",
|
|
"concelier",
|
|
true),
|
|
new AocGuardViolation(
|
|
"viol-002",
|
|
now.AddMinutes(-44).ToString("O", CultureInfo.InvariantCulture),
|
|
"github-packages",
|
|
"hash_mismatch",
|
|
"Manifest digest did not match DSSE payload.",
|
|
"{\"expected\":\"sha256:a\",\"actual\":\"sha256:b\"}",
|
|
"excititor",
|
|
true),
|
|
new AocGuardViolation(
|
|
"viol-003",
|
|
now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture),
|
|
"docker-hub",
|
|
"schema_invalid",
|
|
"Document failed schema validation for SPDX 2.3.",
|
|
"{\"schema\":\"spdx-2.3\"}",
|
|
"concelier",
|
|
false)
|
|
};
|
|
|
|
var effectivePage = page > 0 ? page : 1;
|
|
var effectivePageSize = pageSize > 0 ? pageSize : 20;
|
|
var skip = (effectivePage - 1) * effectivePageSize;
|
|
var items = all.Skip(skip).Take(effectivePageSize).ToArray();
|
|
|
|
return new AocGuardViolationResponse(
|
|
items,
|
|
all.Length,
|
|
effectivePage,
|
|
effectivePageSize,
|
|
skip + items.Length < all.Length);
|
|
}
|
|
|
|
private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
|
|
=> tenantId?.Trim()
|
|
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
|
|
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
|
|
?? httpContext.User.Claims.FirstOrDefault(static claim =>
|
|
claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
|
|
|
|
private sealed record AocVerifyRequest(string? TenantId, string? Since, int? Limit);
|
|
|
|
private sealed record AocProvenanceValidateRequest(string? InputType, string? InputValue);
|
|
|
|
private sealed record AocComplianceReportRequest(
|
|
string? StartDate,
|
|
string? EndDate,
|
|
IReadOnlyList<string>? Sources,
|
|
string? Format,
|
|
bool IncludeViolationDetails);
|
|
|
|
private sealed record AocGuardViolation(
|
|
string Id,
|
|
string Timestamp,
|
|
string Source,
|
|
string Reason,
|
|
string Message,
|
|
string PayloadSample,
|
|
string Module,
|
|
bool CanRetry);
|
|
|
|
private sealed record AocGuardViolationResponse(
|
|
IReadOnlyList<AocGuardViolation> Items,
|
|
int TotalCount,
|
|
int Page,
|
|
int PageSize,
|
|
bool HasMore);
|
|
|
|
private sealed record AocProvenanceStep(
|
|
string StepType,
|
|
string Label,
|
|
string Timestamp,
|
|
string Hash,
|
|
string? LinkedFromHash,
|
|
string Status,
|
|
IReadOnlyDictionary<string, object?> Details);
|
|
}
|