This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -0,0 +1,151 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration;
using StellaOps.ExportCenter.Client.Models;
namespace StellaOps.ExportCenter.WebService.AuditBundle;
/// <summary>
/// Extension methods for mapping audit bundle endpoints.
/// </summary>
public static class AuditBundleEndpoints
{
/// <summary>
/// Maps audit bundle endpoints to the application.
/// </summary>
public static WebApplication MapAuditBundleEndpoints(this WebApplication app)
{
var group = app.MapGroup("/v1/audit-bundles")
.WithTags("Audit Bundles")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator);
// POST /v1/audit-bundles - Create a new audit bundle
group.MapPost("", CreateAuditBundleAsync)
.WithName("CreateAuditBundle")
.WithSummary("Create an immutable audit bundle")
.WithDescription("Creates a new audit bundle containing vulnerability reports, VEX decisions, policy evaluations, and attestations for a subject artifact.")
.Produces<CreateAuditBundleResponse>(StatusCodes.Status202Accepted)
.Produces<ErrorEnvelope>(StatusCodes.Status400BadRequest);
// GET /v1/audit-bundles - List audit bundles
group.MapGet("", ListAuditBundlesAsync)
.WithName("ListAuditBundles")
.WithSummary("List audit bundles")
.WithDescription("Returns audit bundles, optionally filtered by subject or status.")
.Produces<AuditBundleListResponse>(StatusCodes.Status200OK);
// GET /v1/audit-bundles/{bundleId} - Get audit bundle status
group.MapGet("/{bundleId}", GetAuditBundleAsync)
.WithName("GetAuditBundle")
.WithSummary("Get audit bundle status")
.WithDescription("Returns the status and details of a specific audit bundle.")
.Produces<AuditBundleStatus>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /v1/audit-bundles/{bundleId}/download - Download audit bundle
group.MapGet("/{bundleId}/download", DownloadAuditBundleAsync)
.WithName("DownloadAuditBundle")
.WithSummary("Download audit bundle")
.WithDescription("Downloads the completed audit bundle as a ZIP file with integrity verification.")
.Produces(StatusCodes.Status200OK, contentType: "application/zip")
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
// GET /v1/audit-bundles/{bundleId}/index - Get audit bundle index
group.MapGet("/{bundleId}/index", GetAuditBundleIndexAsync)
.WithName("GetAuditBundleIndex")
.WithSummary("Get audit bundle index")
.WithDescription("Returns the index manifest of a completed audit bundle.")
.Produces<AuditBundleIndexDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
return app;
}
private static async Task<Results<Accepted<CreateAuditBundleResponse>, BadRequest<ErrorEnvelope>>> CreateAuditBundleAsync(
[FromBody] CreateAuditBundleRequest request,
[FromServices] IAuditBundleJobHandler handler,
HttpContext httpContext,
CancellationToken cancellationToken)
{
// Get actor from claims
var actorId = httpContext.User.FindFirst("sub")?.Value
?? httpContext.User.FindFirst("preferred_username")?.Value
?? "anonymous";
var actorName = httpContext.User.FindFirst("name")?.Value
?? httpContext.User.FindFirst("preferred_username")?.Value
?? "Anonymous User";
var result = await handler.CreateBundleAsync(request, actorId, actorName, cancellationToken);
if (result.Error is not null)
{
return TypedResults.BadRequest(new ErrorEnvelope(result.Error));
}
return TypedResults.Accepted($"/v1/audit-bundles/{result.Response!.BundleId}", result.Response);
}
private static async Task<Ok<AuditBundleListResponse>> ListAuditBundlesAsync(
[FromQuery] string? subjectName,
[FromQuery] string? status,
[FromQuery] int? limit,
[FromQuery] string? continuationToken,
[FromServices] IAuditBundleJobHandler handler,
CancellationToken cancellationToken)
{
var response = await handler.ListBundlesAsync(subjectName, status, limit ?? 50, continuationToken, cancellationToken);
return TypedResults.Ok(response);
}
private static async Task<Results<Ok<AuditBundleStatus>, NotFound>> GetAuditBundleAsync(
string bundleId,
[FromServices] IAuditBundleJobHandler handler,
CancellationToken cancellationToken)
{
var status = await handler.GetBundleStatusAsync(bundleId, cancellationToken);
if (status is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(status);
}
private static async Task<Results<FileContentHttpResult, NotFound, Conflict<string>>> DownloadAuditBundleAsync(
string bundleId,
[FromServices] IAuditBundleJobHandler handler,
CancellationToken cancellationToken)
{
var status = await handler.GetBundleStatusAsync(bundleId, cancellationToken);
if (status is null)
{
return TypedResults.NotFound();
}
if (status.Status != "Completed")
{
return TypedResults.Conflict($"Bundle is not ready for download. Current status: {status.Status}");
}
var content = await handler.GetBundleContentAsync(bundleId, cancellationToken);
if (content is null)
{
return TypedResults.NotFound();
}
return TypedResults.File(content, "application/zip", $"audit-bundle-{bundleId}.zip");
}
private static async Task<Results<Ok<AuditBundleIndexDto>, NotFound>> GetAuditBundleIndexAsync(
string bundleId,
[FromServices] IAuditBundleJobHandler handler,
CancellationToken cancellationToken)
{
var index = await handler.GetBundleIndexAsync(bundleId, cancellationToken);
if (index is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(index);
}
}

View File

@@ -0,0 +1,348 @@
using System.Collections.Concurrent;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.ExportCenter.Client.Models;
namespace StellaOps.ExportCenter.WebService.AuditBundle;
/// <summary>
/// In-memory implementation of audit bundle job handler for development/testing.
/// Production would use PostgreSQL repository and background job processing.
/// </summary>
public sealed class AuditBundleJobHandler : IAuditBundleJobHandler
{
private readonly ConcurrentDictionary<string, AuditBundleJob> _jobs = new();
private readonly ILogger<AuditBundleJobHandler> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public AuditBundleJobHandler(ILogger<AuditBundleJobHandler> logger)
{
_logger = logger;
}
public Task<AuditBundleCreateResult> CreateBundleAsync(
CreateAuditBundleRequest request,
string actorId,
string actorName,
CancellationToken cancellationToken)
{
// Validate request
if (request.Subject is null)
{
return Task.FromResult(new AuditBundleCreateResult(
null,
new ErrorDetail("INVALID_REQUEST", "Subject is required")));
}
if (string.IsNullOrWhiteSpace(request.Subject.Name))
{
return Task.FromResult(new AuditBundleCreateResult(
null,
new ErrorDetail("INVALID_REQUEST", "Subject name is required")));
}
var bundleId = $"bndl-{Guid.NewGuid():N}";
var now = DateTimeOffset.UtcNow;
var job = new AuditBundleJob
{
BundleId = bundleId,
Status = "Pending",
Progress = 0,
CreatedAt = now,
CreatedBy = new BundleActorRefDto(actorId, actorName),
Subject = request.Subject,
TimeWindow = request.TimeWindow,
IncludeContent = request.IncludeContent
};
_jobs[bundleId] = job;
_logger.LogInformation(
"Created audit bundle job {BundleId} for subject {SubjectName}",
bundleId, request.Subject.Name);
// In a real implementation, this would enqueue a background job
// For now, we'll process it synchronously in-memory
_ = Task.Run(async () => await ProcessBundleAsync(bundleId, cancellationToken), cancellationToken);
var response = new CreateAuditBundleResponse(
bundleId,
"Pending",
$"/v1/audit-bundles/{bundleId}",
EstimatedCompletionSeconds: 30);
return Task.FromResult(new AuditBundleCreateResult(response, null));
}
public Task<AuditBundleListResponse> ListBundlesAsync(
string? subjectName,
string? status,
int limit,
string? continuationToken,
CancellationToken cancellationToken)
{
IEnumerable<AuditBundleJob> query = _jobs.Values;
if (!string.IsNullOrWhiteSpace(subjectName))
{
query = query.Where(j => j.Subject.Name.Contains(subjectName, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(j => string.Equals(j.Status, status, StringComparison.OrdinalIgnoreCase));
}
// Deterministic ordering: createdAt desc
var sorted = query.OrderByDescending(j => j.CreatedAt).ToList();
var offset = 0;
if (!string.IsNullOrEmpty(continuationToken) && int.TryParse(continuationToken, out var parsedOffset))
{
offset = parsedOffset;
}
var page = sorted.Skip(offset).Take(limit).ToList();
var hasMore = offset + page.Count < sorted.Count;
var nextToken = hasMore ? (offset + page.Count).ToString() : null;
var bundles = page.Select(j => new AuditBundleSummary(
j.BundleId,
j.Subject,
j.Status,
j.CreatedAt,
j.CompletedAt,
j.BundleHash,
j.Index?.Artifacts.Count ?? 0,
j.Index?.VexDecisions?.Count ?? 0)).ToList();
return Task.FromResult(new AuditBundleListResponse(bundles, nextToken, hasMore));
}
public Task<AuditBundleStatus?> GetBundleStatusAsync(
string bundleId,
CancellationToken cancellationToken)
{
if (!_jobs.TryGetValue(bundleId, out var job))
{
return Task.FromResult<AuditBundleStatus?>(null);
}
return Task.FromResult<AuditBundleStatus?>(new AuditBundleStatus(
job.BundleId,
job.Status,
job.Progress,
job.CreatedAt,
job.CompletedAt,
job.BundleHash,
job.Status == "Completed" ? $"/v1/audit-bundles/{job.BundleId}/download" : null,
job.OciReference,
job.ErrorCode,
job.ErrorMessage));
}
public Task<byte[]?> GetBundleContentAsync(
string bundleId,
CancellationToken cancellationToken)
{
if (!_jobs.TryGetValue(bundleId, out var job) || job.Content is null)
{
return Task.FromResult<byte[]?>(null);
}
return Task.FromResult<byte[]?>(job.Content);
}
public Task<AuditBundleIndexDto?> GetBundleIndexAsync(
string bundleId,
CancellationToken cancellationToken)
{
if (!_jobs.TryGetValue(bundleId, out var job) || job.Index is null)
{
return Task.FromResult<AuditBundleIndexDto?>(null);
}
return Task.FromResult<AuditBundleIndexDto?>(job.Index);
}
private async Task ProcessBundleAsync(string bundleId, CancellationToken cancellationToken)
{
if (!_jobs.TryGetValue(bundleId, out var job))
{
return;
}
try
{
job.Status = "Running";
job.Progress = 10;
// Simulate gathering artifacts
await Task.Delay(500, cancellationToken);
job.Progress = 30;
// Create bundle content
using var memoryStream = new MemoryStream();
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
{
var artifacts = new List<BundleArtifactDto>();
// Add sample vulnerability report if requested
if (job.IncludeContent.VulnReports)
{
var vulnReport = CreateSampleVulnReport(job.Subject);
var vulnEntry = archive.CreateEntry("reports/vuln-report.json");
await using var vulnWriter = new StreamWriter(vulnEntry.Open());
await vulnWriter.WriteAsync(vulnReport);
var vulnDigest = ComputeHash(vulnReport);
artifacts.Add(new BundleArtifactDto(
"vuln-report-1",
"VULN_REPORT",
"StellaOps@1.0.0",
"reports/vuln-report.json",
"application/json",
new Dictionary<string, string> { ["sha256"] = vulnDigest },
null));
}
job.Progress = 50;
// Add sample SBOM if requested
if (job.IncludeContent.Sbom)
{
var sbom = CreateSampleSbom(job.Subject);
var sbomEntry = archive.CreateEntry("sbom/cyclonedx.json");
await using var sbomWriter = new StreamWriter(sbomEntry.Open());
await sbomWriter.WriteAsync(sbom);
var sbomDigest = ComputeHash(sbom);
artifacts.Add(new BundleArtifactDto(
"sbom-cyclonedx",
"SBOM",
"Syft@1.0.0",
"sbom/cyclonedx.json",
"application/vnd.cyclonedx+json",
new Dictionary<string, string> { ["sha256"] = sbomDigest },
null));
}
job.Progress = 70;
// Create the index
var vexDecisions = new List<BundleVexDecisionEntryDto>();
var integrity = new BundleIntegrityDto(
ComputeHash(string.Join(",", artifacts.Select(a => a.Digest["sha256"]))),
"sha256");
var index = new AuditBundleIndexDto(
"stella.ops/v1",
"AuditBundleIndex",
job.BundleId,
DateTimeOffset.UtcNow,
job.CreatedBy,
job.Subject,
job.TimeWindow,
artifacts,
vexDecisions,
integrity);
job.Index = index;
// Write index to archive
var indexJson = JsonSerializer.Serialize(index, JsonOptions);
var indexEntry = archive.CreateEntry("audit-bundle-index.json");
await using var indexWriter = new StreamWriter(indexEntry.Open());
await indexWriter.WriteAsync(indexJson);
}
job.Progress = 90;
// Get the content
job.Content = memoryStream.ToArray();
job.BundleHash = ComputeHash(job.Content);
job.Status = "Completed";
job.Progress = 100;
job.CompletedAt = DateTimeOffset.UtcNow;
_logger.LogInformation(
"Completed audit bundle {BundleId} with hash {BundleHash}",
bundleId, job.BundleHash);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to process audit bundle {BundleId}", bundleId);
job.Status = "Failed";
job.ErrorCode = "PROCESSING_ERROR";
job.ErrorMessage = ex.Message;
}
}
private static string CreateSampleVulnReport(BundleSubjectRefDto subject)
{
var report = new
{
subject = subject.Name,
scanDate = DateTimeOffset.UtcNow.ToString("O"),
findings = new[]
{
new { id = "CVE-2023-12345", severity = "HIGH", package = "sample-pkg", version = "1.0.0" }
}
};
return JsonSerializer.Serialize(report, JsonOptions);
}
private static string CreateSampleSbom(BundleSubjectRefDto subject)
{
var sbom = new
{
bomFormat = "CycloneDX",
specVersion = "1.6",
metadata = new
{
component = new { name = subject.Name, type = "container" }
},
components = Array.Empty<object>()
};
return JsonSerializer.Serialize(sbom, JsonOptions);
}
private static string ComputeHash(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
return ComputeHash(bytes);
}
private static string ComputeHash(byte[] bytes)
{
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private sealed class AuditBundleJob
{
public required string BundleId { get; init; }
public required string Status { get; set; }
public required int Progress { get; set; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? CompletedAt { get; set; }
public required BundleActorRefDto CreatedBy { get; init; }
public required BundleSubjectRefDto Subject { get; init; }
public BundleTimeWindowDto? TimeWindow { get; init; }
public required AuditBundleContentSelection IncludeContent { get; init; }
public string? BundleHash { get; set; }
public string? OciReference { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
public byte[]? Content { get; set; }
public AuditBundleIndexDto? Index { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.ExportCenter.WebService.AuditBundle;
/// <summary>
/// Extension methods for registering audit bundle services.
/// </summary>
public static class AuditBundleServiceCollectionExtensions
{
/// <summary>
/// Adds audit bundle job handler services.
/// </summary>
public static IServiceCollection AddAuditBundleJobHandler(this IServiceCollection services)
{
services.AddSingleton<IAuditBundleJobHandler, AuditBundleJobHandler>();
return services;
}
}

View File

@@ -0,0 +1,56 @@
using StellaOps.ExportCenter.Client.Models;
namespace StellaOps.ExportCenter.WebService.AuditBundle;
/// <summary>
/// Handles audit bundle job operations.
/// </summary>
public interface IAuditBundleJobHandler
{
/// <summary>
/// Creates a new audit bundle.
/// </summary>
Task<AuditBundleCreateResult> CreateBundleAsync(
CreateAuditBundleRequest request,
string actorId,
string actorName,
CancellationToken cancellationToken);
/// <summary>
/// Lists audit bundles with optional filtering.
/// </summary>
Task<AuditBundleListResponse> ListBundlesAsync(
string? subjectName,
string? status,
int limit,
string? continuationToken,
CancellationToken cancellationToken);
/// <summary>
/// Gets the status of a specific audit bundle.
/// </summary>
Task<AuditBundleStatus?> GetBundleStatusAsync(
string bundleId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the content of a completed audit bundle.
/// </summary>
Task<byte[]?> GetBundleContentAsync(
string bundleId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the index manifest of a completed audit bundle.
/// </summary>
Task<AuditBundleIndexDto?> GetBundleIndexAsync(
string bundleId,
CancellationToken cancellationToken);
}
/// <summary>
/// Result from audit bundle creation.
/// </summary>
public sealed record AuditBundleCreateResult(
CreateAuditBundleResponse? Response,
ErrorDetail? Error);

View File

@@ -12,6 +12,7 @@ using StellaOps.ExportCenter.WebService.Attestation;
using StellaOps.ExportCenter.WebService.Incident;
using StellaOps.ExportCenter.WebService.RiskBundle;
using StellaOps.ExportCenter.WebService.SimulationExport;
using StellaOps.ExportCenter.WebService.AuditBundle;
var builder = WebApplication.CreateBuilder(args);
@@ -72,6 +73,9 @@ builder.Services.AddRiskBundleJobHandler();
// Simulation export services
builder.Services.AddSimulationExport();
// Audit bundle job handler
builder.Services.AddAuditBundleJobHandler();
// Export API services (profiles, runs, artifacts)
builder.Services.AddExportApiServices(options =>
{
@@ -112,6 +116,9 @@ app.MapRiskBundleEndpoints();
// Simulation export endpoints
app.MapSimulationExportEndpoints();
// Audit bundle endpoints
app.MapAuditBundleEndpoints();
// Export API endpoints (profiles, runs, artifacts, SSE)
app.MapExportApiEndpoints();

View File

@@ -16,6 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj" />
<ProjectReference Include="..\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.ExportCenter.Client\StellaOps.ExportCenter.Client.csproj" />
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />