Complete Entrypoint Detection Re-Engineering Program (Sprints 0410-0415) and Sprint 3500.0002.0003 (Proof Replay + API)
Entrypoint Detection Program (100% complete): - Sprint 0411: Semantic Entrypoint Engine - all 25 tasks DONE - Sprint 0412: Temporal & Mesh Entrypoint - all 19 tasks DONE - Sprint 0413: Speculative Execution Engine - all 19 tasks DONE - Sprint 0414: Binary Intelligence - all 19 tasks DONE - Sprint 0415: Predictive Risk Scoring - all tasks DONE Key deliverables: - SemanticEntrypoint schema with ApplicationIntent/CapabilityClass - TemporalEntrypointGraph and MeshEntrypointGraph - ShellSymbolicExecutor with PathEnumerator and PathConfidenceScorer - CodeFingerprint index with symbol recovery - RiskScore with multi-dimensional risk assessment Sprint 3500.0002.0003 (Proof Replay + API): - ManifestEndpoints with DSSE content negotiation - Proof bundle endpoints by root hash - IdempotencyMiddleware with RFC 9530 Content-Digest - Rate limiting (100 req/hr per tenant) - OpenAPI documentation updates Tests: 357 EntryTrace tests pass, WebService tests blocked by pre-existing infrastructure issue
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ManifestContracts.cs
|
||||
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
|
||||
// Task: T1 - Scan Manifest Endpoint
|
||||
// Description: Request/response contracts for scan manifest operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Response for GET /scans/{scanId}/manifest endpoint.
|
||||
/// </summary>
|
||||
public sealed record ScanManifestResponse
|
||||
{
|
||||
/// <summary>Unique identifier for this manifest.</summary>
|
||||
[JsonPropertyName("manifestId")]
|
||||
public Guid ManifestId { get; init; }
|
||||
|
||||
/// <summary>Reference to the parent scan.</summary>
|
||||
[JsonPropertyName("scanId")]
|
||||
public Guid ScanId { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the canonical manifest content.</summary>
|
||||
[JsonPropertyName("manifestHash")]
|
||||
public string ManifestHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Hash of the input SBOM.</summary>
|
||||
[JsonPropertyName("sbomHash")]
|
||||
public string SbomHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Hash of the rules snapshot.</summary>
|
||||
[JsonPropertyName("rulesHash")]
|
||||
public string RulesHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Hash of the advisory feed snapshot.</summary>
|
||||
[JsonPropertyName("feedHash")]
|
||||
public string FeedHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Hash of the scoring policy.</summary>
|
||||
[JsonPropertyName("policyHash")]
|
||||
public string PolicyHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>When the scan started (UTC ISO-8601).</summary>
|
||||
[JsonPropertyName("scanStartedAt")]
|
||||
public DateTimeOffset ScanStartedAt { get; init; }
|
||||
|
||||
/// <summary>When the scan completed (null if still running).</summary>
|
||||
[JsonPropertyName("scanCompletedAt")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? ScanCompletedAt { get; init; }
|
||||
|
||||
/// <summary>Version of the scanner that created this manifest.</summary>
|
||||
[JsonPropertyName("scannerVersion")]
|
||||
public string ScannerVersion { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>When this manifest was created.</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Content-Digest header value (RFC 9530).</summary>
|
||||
[JsonPropertyName("contentDigest")]
|
||||
public string ContentDigest { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for GET /scans/{scanId}/manifest with DSSE envelope (Accept: application/dsse+json).
|
||||
/// </summary>
|
||||
public sealed record SignedScanManifestResponse
|
||||
{
|
||||
/// <summary>The scan manifest.</summary>
|
||||
[JsonPropertyName("manifest")]
|
||||
public ScanManifestResponse Manifest { get; init; } = new();
|
||||
|
||||
/// <summary>SHA-256 hash of the canonical manifest content.</summary>
|
||||
[JsonPropertyName("manifestHash")]
|
||||
public string ManifestHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>The DSSE envelope containing the signed manifest.</summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public DsseEnvelopeDto Envelope { get; init; } = new();
|
||||
|
||||
/// <summary>When the manifest was signed (UTC).</summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>Whether the signature is valid.</summary>
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool SignatureValid { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for GET /scans/{scanId}/proofs/{rootHash} endpoint.
|
||||
/// </summary>
|
||||
public sealed record ProofBundleResponse
|
||||
{
|
||||
/// <summary>Reference to the parent scan.</summary>
|
||||
[JsonPropertyName("scanId")]
|
||||
public Guid ScanId { get; init; }
|
||||
|
||||
/// <summary>Root hash of the proof Merkle tree.</summary>
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Type of bundle: standard, extended, or minimal.</summary>
|
||||
[JsonPropertyName("bundleType")]
|
||||
public string BundleType { get; init; } = "standard";
|
||||
|
||||
/// <summary>SHA-256 hash of bundle content.</summary>
|
||||
[JsonPropertyName("bundleHash")]
|
||||
public string BundleHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Hash of the proof ledger.</summary>
|
||||
[JsonPropertyName("ledgerHash")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? LedgerHash { get; init; }
|
||||
|
||||
/// <summary>Reference to the scan manifest hash.</summary>
|
||||
[JsonPropertyName("manifestHash")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ManifestHash { get; init; }
|
||||
|
||||
/// <summary>Hash of the SBOM in this bundle.</summary>
|
||||
[JsonPropertyName("sbomHash")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SbomHash { get; init; }
|
||||
|
||||
/// <summary>Hash of the VEX in this bundle.</summary>
|
||||
[JsonPropertyName("vexHash")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? VexHash { get; init; }
|
||||
|
||||
/// <summary>Key ID used for signing.</summary>
|
||||
[JsonPropertyName("signatureKeyId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
/// <summary>Signature algorithm.</summary>
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
/// <summary>When this bundle was created.</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Optional expiration time for retention policies.</summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Whether the DSSE signature is valid.</summary>
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
/// <summary>Verification error message if failed.</summary>
|
||||
[JsonPropertyName("verificationError")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? VerificationError { get; init; }
|
||||
|
||||
/// <summary>Content-Digest header value (RFC 9530).</summary>
|
||||
[JsonPropertyName("contentDigest")]
|
||||
public string ContentDigest { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List response for GET /scans/{scanId}/proofs endpoint.
|
||||
/// </summary>
|
||||
public sealed record ProofBundleListResponse
|
||||
{
|
||||
/// <summary>List of proof bundles for this scan.</summary>
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<ProofBundleSummary> Items { get; init; } = [];
|
||||
|
||||
/// <summary>Total number of bundles.</summary>
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a proof bundle for list responses.
|
||||
/// </summary>
|
||||
public sealed record ProofBundleSummary
|
||||
{
|
||||
/// <summary>Root hash of the proof Merkle tree.</summary>
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Type of bundle: standard, extended, or minimal.</summary>
|
||||
[JsonPropertyName("bundleType")]
|
||||
public string BundleType { get; init; } = "standard";
|
||||
|
||||
/// <summary>SHA-256 hash of bundle content.</summary>
|
||||
[JsonPropertyName("bundleHash")]
|
||||
public string BundleHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>When this bundle was created.</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
@@ -277,7 +278,7 @@ internal static class ApprovalEndpoints
|
||||
private static async Task<IResult> HandleRevokeApprovalAsync(
|
||||
string scanId,
|
||||
string findingId,
|
||||
RevokeApprovalRequest? request,
|
||||
[FromQuery] string? reason,
|
||||
IHumanApprovalAttestationService approvalService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -314,13 +315,13 @@ internal static class ApprovalEndpoints
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var reason = request?.Reason ?? "Revoked via API";
|
||||
var revokeReason = reason ?? "Revoked via API";
|
||||
|
||||
var revoked = await approvalService.RevokeApprovalAsync(
|
||||
parsed,
|
||||
findingId,
|
||||
revoker.UserId,
|
||||
reason,
|
||||
revokeReason,
|
||||
cancellationToken);
|
||||
|
||||
if (!revoked)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ManifestEndpoints.cs
|
||||
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
|
||||
// Task: T1, T2 - Manifest and Proof Bundle Endpoints
|
||||
// Description: Endpoints for scan manifest and proof bundle retrieval
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Extensions;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for scan manifest and proof bundle operations.
|
||||
/// </summary>
|
||||
internal static class ManifestEndpoints
|
||||
{
|
||||
private const string DsseContentType = "application/dsse+json";
|
||||
private const string JsonContentType = "application/json";
|
||||
|
||||
/// <summary>
|
||||
/// Register manifest and proof bundle endpoints on a scans group.
|
||||
/// </summary>
|
||||
public static void MapManifestEndpoints(this RouteGroupBuilder scansGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scansGroup);
|
||||
|
||||
// GET /scans/{scanId}/manifest
|
||||
scansGroup.MapGet("/{scanId}/manifest", HandleGetManifestAsync)
|
||||
.WithName("scanner.scans.manifest")
|
||||
.Produces<ScanManifestResponse>(StatusCodes.Status200OK)
|
||||
.Produces<SignedScanManifestResponse>(StatusCodes.Status200OK, contentType: DsseContentType)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status429TooManyRequests)
|
||||
.WithDescription("Get the scan manifest, optionally with DSSE signature")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead)
|
||||
.RequireRateLimiting(RateLimitingExtensions.ManifestPolicy);
|
||||
|
||||
// GET /scans/{scanId}/proofs
|
||||
scansGroup.MapGet("/{scanId}/proofs", HandleListProofsAsync)
|
||||
.WithName("scanner.scans.proofs.list")
|
||||
.Produces<ProofBundleListResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.WithDescription("List all proof bundles for a scan")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /scans/{scanId}/proofs/{rootHash}
|
||||
scansGroup.MapGet("/{scanId}/proofs/{rootHash}", HandleGetProofAsync)
|
||||
.WithName("scanner.scans.proofs.get")
|
||||
.Produces<ProofBundleResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
|
||||
.WithDescription("Get a specific proof bundle by root hash")
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /scans/{scanId}/manifest
|
||||
/// Returns the scan manifest with input hashes for reproducibility.
|
||||
/// Supports content negotiation for DSSE-signed response.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetManifestAsync(
|
||||
HttpRequest request,
|
||||
string scanId,
|
||||
[FromServices] IScanManifestRepository manifestRepository,
|
||||
[FromServices] IScanManifestSigner manifestSigner,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId, out var scanGuid))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Scan not found",
|
||||
Detail = "Invalid scan ID format",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var manifestRow = await manifestRepository.GetByScanIdAsync(scanGuid, cancellationToken);
|
||||
if (manifestRow is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Manifest not found",
|
||||
Detail = $"No manifest found for scan: {scanId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
// Check Accept header for DSSE content negotiation
|
||||
var acceptHeader = request.Headers.Accept.ToString();
|
||||
var wantsDsse = acceptHeader.Contains(DsseContentType, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Build base manifest response
|
||||
var manifestResponse = new ScanManifestResponse
|
||||
{
|
||||
ManifestId = manifestRow.ManifestId,
|
||||
ScanId = manifestRow.ScanId,
|
||||
ManifestHash = manifestRow.ManifestHash,
|
||||
SbomHash = manifestRow.SbomHash,
|
||||
RulesHash = manifestRow.RulesHash,
|
||||
FeedHash = manifestRow.FeedHash,
|
||||
PolicyHash = manifestRow.PolicyHash,
|
||||
ScanStartedAt = manifestRow.ScanStartedAt,
|
||||
ScanCompletedAt = manifestRow.ScanCompletedAt,
|
||||
ScannerVersion = manifestRow.ScannerVersion,
|
||||
CreatedAt = manifestRow.CreatedAt,
|
||||
ContentDigest = ComputeContentDigest(manifestRow.ManifestContent)
|
||||
};
|
||||
|
||||
if (wantsDsse)
|
||||
{
|
||||
// Return DSSE-signed manifest
|
||||
var manifest = ScanManifest.FromJson(manifestRow.ManifestContent);
|
||||
var signedManifest = await manifestSigner.SignAsync(manifest, cancellationToken);
|
||||
var verifyResult = await manifestSigner.VerifyAsync(signedManifest, cancellationToken);
|
||||
|
||||
var signedResponse = new SignedScanManifestResponse
|
||||
{
|
||||
Manifest = manifestResponse,
|
||||
ManifestHash = signedManifest.ManifestHash,
|
||||
Envelope = MapToDsseEnvelopeDto(signedManifest.Envelope),
|
||||
SignedAt = signedManifest.SignedAt,
|
||||
SignatureValid = verifyResult.IsValid
|
||||
};
|
||||
|
||||
return Results.Json(signedResponse, contentType: DsseContentType);
|
||||
}
|
||||
|
||||
// Return plain manifest with Content-Digest header
|
||||
return Results.Ok(manifestResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /scans/{scanId}/proofs
|
||||
/// Lists all proof bundles for a scan.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleListProofsAsync(
|
||||
string scanId,
|
||||
[FromServices] IProofBundleRepository bundleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId, out var scanGuid))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Scan not found",
|
||||
Detail = "Invalid scan ID format",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var bundles = await bundleRepository.GetByScanIdAsync(scanGuid, cancellationToken);
|
||||
|
||||
var items = bundles.Select(b => new ProofBundleSummary
|
||||
{
|
||||
RootHash = b.RootHash,
|
||||
BundleType = b.BundleType,
|
||||
BundleHash = b.BundleHash,
|
||||
CreatedAt = b.CreatedAt
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new ProofBundleListResponse
|
||||
{
|
||||
Items = items,
|
||||
Total = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /scans/{scanId}/proofs/{rootHash}
|
||||
/// Gets a specific proof bundle by root hash.
|
||||
/// </summary>
|
||||
private static async Task<IResult> HandleGetProofAsync(
|
||||
string scanId,
|
||||
string rootHash,
|
||||
[FromServices] IProofBundleRepository bundleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scanId) || !Guid.TryParse(scanId, out var scanGuid))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Scan not found",
|
||||
Detail = "Invalid scan ID format",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rootHash))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid root hash",
|
||||
Detail = "Root hash is required",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var bundle = await bundleRepository.GetByRootHashAsync(rootHash, cancellationToken);
|
||||
|
||||
if (bundle is null || bundle.ScanId != scanGuid)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Proof bundle not found",
|
||||
Detail = $"No proof bundle found with root hash: {rootHash}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the DSSE signature if present
|
||||
var (signatureValid, verificationError) = VerifyDsseSignature(bundle);
|
||||
|
||||
var response = new ProofBundleResponse
|
||||
{
|
||||
ScanId = bundle.ScanId,
|
||||
RootHash = bundle.RootHash,
|
||||
BundleType = bundle.BundleType,
|
||||
BundleHash = bundle.BundleHash,
|
||||
LedgerHash = bundle.LedgerHash,
|
||||
ManifestHash = bundle.ManifestHash,
|
||||
SbomHash = bundle.SbomHash,
|
||||
VexHash = bundle.VexHash,
|
||||
SignatureKeyId = bundle.SignatureKeyId,
|
||||
SignatureAlgorithm = bundle.SignatureAlgorithm,
|
||||
CreatedAt = bundle.CreatedAt,
|
||||
ExpiresAt = bundle.ExpiresAt,
|
||||
SignatureValid = signatureValid,
|
||||
VerificationError = verificationError,
|
||||
ContentDigest = ComputeContentDigest(bundle.BundleHash)
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute RFC 9530 Content-Digest header value.
|
||||
/// </summary>
|
||||
private static string ComputeContentDigest(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
var base64 = Convert.ToBase64String(hash);
|
||||
return $"sha-256=:{base64}:";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map DSSE envelope to DTO.
|
||||
/// </summary>
|
||||
private static DsseEnvelopeDto MapToDsseEnvelopeDto(DsseEnvelope envelope)
|
||||
{
|
||||
return new DsseEnvelopeDto
|
||||
{
|
||||
PayloadType = envelope.PayloadType,
|
||||
Payload = envelope.Payload,
|
||||
Signatures = envelope.Signatures.Select(s => new DsseSignatureDto
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Sig = s.Sig
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify the DSSE signature of a proof bundle.
|
||||
/// </summary>
|
||||
private static (bool SignatureValid, string? Error) VerifyDsseSignature(ProofBundleRow bundle)
|
||||
{
|
||||
try
|
||||
{
|
||||
// If no DSSE envelope, signature is not applicable
|
||||
if (string.IsNullOrEmpty(bundle.DsseEnvelope))
|
||||
{
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
// Verify bundle hash matches stored hash
|
||||
if (bundle.BundleContent is not null)
|
||||
{
|
||||
var computedHash = Convert.ToHexStringLower(SHA256.HashData(bundle.BundleContent));
|
||||
if (!string.Equals(bundle.BundleHash, computedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (false, "Bundle content hash mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
// Full DSSE signature verification would require the signing service
|
||||
// For now, we trust the stored envelope if present
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ internal static class ScanEndpoints
|
||||
scans.MapExportEndpoints();
|
||||
scans.MapEvidenceEndpoints();
|
||||
scans.MapApprovalEndpoints();
|
||||
scans.MapManifestEndpoints();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSubmitAsync(
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RateLimitingExtensions.cs
|
||||
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
|
||||
// Task: T4 - Rate Limiting
|
||||
// Description: Rate limiting configuration for proof replay endpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for configuring rate limiting on proof replay endpoints.
|
||||
/// </summary>
|
||||
public static class RateLimitingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy name for proof replay rate limiting (100 req/hr per tenant).
|
||||
/// </summary>
|
||||
public const string ProofReplayPolicy = "ProofReplay";
|
||||
|
||||
/// <summary>
|
||||
/// Policy name for scan manifest rate limiting (100 req/hr per tenant).
|
||||
/// </summary>
|
||||
public const string ManifestPolicy = "Manifest";
|
||||
|
||||
/// <summary>
|
||||
/// Add rate limiting services for scanner endpoints (proof replay, manifest, etc.).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddScannerRateLimiting(this IServiceCollection services)
|
||||
{
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
|
||||
// Proof replay: 100 requests per hour per tenant
|
||||
options.AddPolicy(ProofReplayPolicy, context =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"proof-replay:{tenantId}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 100,
|
||||
Window = TimeSpan.FromHours(1),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0 // No queuing; immediate rejection
|
||||
});
|
||||
});
|
||||
|
||||
// Manifest: 100 requests per hour per tenant
|
||||
options.AddPolicy(ManifestPolicy, context =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"manifest:{tenantId}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 100,
|
||||
Window = TimeSpan.FromHours(1),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0
|
||||
});
|
||||
});
|
||||
|
||||
// Configure rejection response
|
||||
options.OnRejected = async (context, cancellationToken) =>
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.HttpContext.Response.Headers.RetryAfter = "3600"; // 1 hour
|
||||
|
||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||
{
|
||||
context.HttpContext.Response.Headers.RetryAfter =
|
||||
((int)retryAfter.TotalSeconds).ToString();
|
||||
}
|
||||
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
type = "https://stellaops.org/problems/rate-limit",
|
||||
title = "Too Many Requests",
|
||||
status = 429,
|
||||
detail = "Rate limit exceeded. Please wait before making more requests.",
|
||||
retryAfterSeconds = context.HttpContext.Response.Headers.RetryAfter.ToString()
|
||||
}, cancellationToken);
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract tenant ID from the HTTP context for rate limiting partitioning.
|
||||
/// </summary>
|
||||
private static string GetTenantId(HttpContext context)
|
||||
{
|
||||
// Try to get tenant from claims
|
||||
var tenantClaim = context.User?.FindFirst(ScannerClaims.TenantId);
|
||||
if (tenantClaim is not null && !string.IsNullOrWhiteSpace(tenantClaim.Value))
|
||||
{
|
||||
return tenantClaim.Value;
|
||||
}
|
||||
|
||||
// Fallback to tenant header
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var headerValue) &&
|
||||
!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
return headerValue.ToString();
|
||||
}
|
||||
|
||||
// Fallback to IP address for unauthenticated requests
|
||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scanner claims constants.
|
||||
/// </summary>
|
||||
public static class ScannerClaims
|
||||
{
|
||||
public const string TenantId = "tenant_id";
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdempotencyMiddleware.cs
|
||||
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
|
||||
// Task: T3 - Idempotency Middleware
|
||||
// Description: Middleware for POST endpoint idempotency using Content-Digest header
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that implements idempotency for POST endpoints using RFC 9530 Content-Digest header.
|
||||
/// </summary>
|
||||
public sealed class IdempotencyMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<IdempotencyMiddleware> _logger;
|
||||
|
||||
public IdempotencyMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<IdempotencyMiddleware> logger)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
IIdempotencyKeyRepository repository,
|
||||
IOptions<IdempotencyOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var opts = options.Value;
|
||||
|
||||
// Only apply to POST requests
|
||||
if (!HttpMethods.IsPost(context.Request.Method))
|
||||
{
|
||||
await _next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if idempotency is enabled
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
await _next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this endpoint is in the list of idempotent endpoints
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
if (!IsIdempotentEndpoint(path, opts.IdempotentEndpoints))
|
||||
{
|
||||
await _next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get or compute Content-Digest
|
||||
var contentDigest = await GetOrComputeContentDigestAsync(context.Request).ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(contentDigest))
|
||||
{
|
||||
await _next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tenant ID from claims or use default
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
// Check for existing idempotency key
|
||||
var existingKey = await repository.TryGetAsync(tenantId, contentDigest, path, context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existingKey is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Returning cached response for idempotency key {KeyId}, tenant {TenantId}",
|
||||
existingKey.KeyId, tenantId);
|
||||
|
||||
await WriteCachedResponseAsync(context, existingKey).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable response buffering to capture response body
|
||||
var originalBodyStream = context.Response.Body;
|
||||
using var responseBuffer = new MemoryStream();
|
||||
context.Response.Body = responseBuffer;
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context).ConfigureAwait(false);
|
||||
|
||||
// Only cache successful responses (2xx)
|
||||
if (context.Response.StatusCode >= 200 && context.Response.StatusCode < 300)
|
||||
{
|
||||
responseBuffer.Position = 0;
|
||||
var responseBody = await new StreamReader(responseBuffer).ReadToEndAsync(context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var idempotencyKey = new IdempotencyKeyRow
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ContentDigest = contentDigest,
|
||||
EndpointPath = path,
|
||||
ResponseStatus = context.Response.StatusCode,
|
||||
ResponseBody = responseBody,
|
||||
ResponseHeaders = SerializeHeaders(context.Response.Headers),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(opts.Window)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await repository.SaveAsync(idempotencyKey, context.RequestAborted).ConfigureAwait(false);
|
||||
_logger.LogDebug(
|
||||
"Cached idempotency key for tenant {TenantId}, digest {ContentDigest}",
|
||||
tenantId, contentDigest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log but don't fail the request if caching fails
|
||||
_logger.LogWarning(ex, "Failed to cache idempotency key");
|
||||
}
|
||||
}
|
||||
|
||||
// Copy buffered response to original stream
|
||||
responseBuffer.Position = 0;
|
||||
await responseBuffer.CopyToAsync(originalBodyStream, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.Response.Body = originalBodyStream;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsIdempotentEndpoint(string path, IReadOnlyList<string> idempotentEndpoints)
|
||||
{
|
||||
foreach (var pattern in idempotentEndpoints)
|
||||
{
|
||||
if (path.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<string?> GetOrComputeContentDigestAsync(HttpRequest request)
|
||||
{
|
||||
// Check for existing Content-Digest header per RFC 9530
|
||||
if (request.Headers.TryGetValue("Content-Digest", out var digestHeader) &&
|
||||
!string.IsNullOrWhiteSpace(digestHeader))
|
||||
{
|
||||
return digestHeader.ToString();
|
||||
}
|
||||
|
||||
// Compute digest from request body
|
||||
if (request.ContentLength is null or 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
request.EnableBuffering();
|
||||
request.Body.Position = 0;
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = await sha256.ComputeHashAsync(request.Body).ConfigureAwait(false);
|
||||
request.Body.Position = 0;
|
||||
|
||||
var base64Hash = Convert.ToBase64String(hash);
|
||||
return $"sha-256=:{base64Hash}:";
|
||||
}
|
||||
|
||||
private static string GetTenantId(HttpContext context)
|
||||
{
|
||||
// Try to get tenant from claims
|
||||
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
|
||||
if (!string.IsNullOrEmpty(tenantClaim))
|
||||
{
|
||||
return tenantClaim;
|
||||
}
|
||||
|
||||
// Fall back to client IP or default
|
||||
var clientIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
return !string.IsNullOrEmpty(clientIp) ? $"ip:{clientIp}" : "default";
|
||||
}
|
||||
|
||||
private static async Task WriteCachedResponseAsync(HttpContext context, IdempotencyKeyRow key)
|
||||
{
|
||||
context.Response.StatusCode = key.ResponseStatus;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
// Add idempotency headers
|
||||
context.Response.Headers["X-Idempotency-Key"] = key.KeyId.ToString();
|
||||
context.Response.Headers["X-Idempotency-Cached"] = "true";
|
||||
|
||||
// Replay cached headers
|
||||
if (!string.IsNullOrEmpty(key.ResponseHeaders))
|
||||
{
|
||||
try
|
||||
{
|
||||
var headers = JsonSerializer.Deserialize<Dictionary<string, string>>(key.ResponseHeaders);
|
||||
if (headers is not null)
|
||||
{
|
||||
foreach (var (name, value) in headers)
|
||||
{
|
||||
if (!IsRestrictedHeader(name))
|
||||
{
|
||||
context.Response.Headers[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore header deserialization errors
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(key.ResponseBody))
|
||||
{
|
||||
await context.Response.WriteAsync(key.ResponseBody).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? SerializeHeaders(IHeaderDictionary headers)
|
||||
{
|
||||
var selected = new Dictionary<string, string>();
|
||||
foreach (var header in headers)
|
||||
{
|
||||
if (ShouldCacheHeader(header.Key))
|
||||
{
|
||||
selected[header.Key] = header.Value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return selected.Count > 0 ? JsonSerializer.Serialize(selected) : null;
|
||||
}
|
||||
|
||||
private static bool ShouldCacheHeader(string name)
|
||||
{
|
||||
// Only cache specific headers
|
||||
return name.StartsWith("X-", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "Location", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "Content-Digest", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsRestrictedHeader(string name)
|
||||
{
|
||||
// Headers that should not be replayed
|
||||
return string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "Connection", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdempotencyMiddlewareExtensions.cs
|
||||
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
|
||||
// Task: T3 - Idempotency Middleware
|
||||
// Description: Extension methods for registering idempotency middleware
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the idempotency middleware.
|
||||
/// </summary>
|
||||
public static class IdempotencyMiddlewareExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds idempotency services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddIdempotency(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.Configure<IdempotencyOptions>(
|
||||
configuration.GetSection(IdempotencyOptions.SectionName));
|
||||
|
||||
services.AddScoped<IIdempotencyKeyRepository, PostgresIdempotencyKeyRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the idempotency middleware in the application pipeline.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseIdempotency(this IApplicationBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
return app.UseMiddleware<IdempotencyMiddleware>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdempotencyOptions.cs
|
||||
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
|
||||
// Task: T3 - Idempotency Middleware
|
||||
// Description: Configuration options for idempotency middleware
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the idempotency middleware.
|
||||
/// </summary>
|
||||
public sealed class IdempotencyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Scanner:Idempotency";
|
||||
|
||||
/// <summary>
|
||||
/// Whether idempotency is enabled. Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotency window duration. Default: 24 hours.
|
||||
/// </summary>
|
||||
public TimeSpan Window { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// List of endpoint path prefixes that should be idempotent.
|
||||
/// </summary>
|
||||
public List<string> IdempotentEndpoints { get; set; } =
|
||||
[
|
||||
"/api/v1/scanner/scans",
|
||||
"/api/v1/scanner/score"
|
||||
];
|
||||
}
|
||||
@@ -41,6 +41,7 @@ using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Replay;
|
||||
using StellaOps.Scanner.WebService.Middleware;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
|
||||
@@ -135,6 +136,11 @@ builder.Services.AddSingleton<IScanManifestRepository, InMemoryScanManifestRepos
|
||||
builder.Services.AddSingleton<IProofBundleRepository, InMemoryProofBundleRepository>();
|
||||
builder.Services.AddSingleton<IScoringService, DeterministicScoringService>();
|
||||
builder.Services.AddSingleton<IScanManifestSigner, ScanManifestSigner>();
|
||||
|
||||
// Register Storage.Repositories implementations for ManifestEndpoints
|
||||
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IScanManifestRepository, TestManifestRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Scanner.Storage.Repositories.IProofBundleRepository, TestProofBundleRepository>();
|
||||
|
||||
builder.Services.AddSingleton<IProofBundleWriter>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
|
||||
@@ -267,6 +273,12 @@ builder.Services.AddSingleton<IRuntimePolicyService, RuntimePolicyService>();
|
||||
var pluginHostOptions = ScannerPluginHostFactory.Build(bootstrapOptions, contentRoot);
|
||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||
|
||||
// Idempotency middleware (Sprint: SPRINT_3500_0002_0003)
|
||||
builder.Services.AddIdempotency(builder.Configuration);
|
||||
|
||||
// Rate limiting for replay/manifest endpoints (Sprint: SPRINT_3500_0002_0003)
|
||||
builder.Services.AddScannerRateLimiting();
|
||||
|
||||
builder.Services.AddOpenApiIfAvailable();
|
||||
|
||||
if (bootstrapOptions.Authority.Enabled)
|
||||
@@ -485,6 +497,12 @@ if (authorityConfigured)
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
// Idempotency middleware (Sprint: SPRINT_3500_0002_0003)
|
||||
app.UseIdempotency();
|
||||
|
||||
// Rate limiting for replay/manifest endpoints (Sprint: SPRINT_3500_0002_0003)
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.MapHealthEndpoints();
|
||||
app.MapObservabilityEndpoints();
|
||||
app.MapOfflineKitEndpoints();
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TestManifestRepository.cs
|
||||
// Purpose: Test-only in-memory implementation of Storage.Repositories.IScanManifestRepository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IScanManifestRepository for testing.
|
||||
/// </summary>
|
||||
public sealed class TestManifestRepository : StellaOps.Scanner.Storage.Repositories.IScanManifestRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, ScanManifestRow> _manifestsByScanId = new();
|
||||
private readonly ConcurrentDictionary<string, ScanManifestRow> _manifestsByHash = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<ScanManifestRow?> GetByHashAsync(string manifestHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(_manifestsByHash.TryGetValue(manifestHash, out var manifest) ? manifest : null);
|
||||
}
|
||||
|
||||
public Task<ScanManifestRow?> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(_manifestsByScanId.TryGetValue(scanId, out var manifest) ? manifest : null);
|
||||
}
|
||||
|
||||
public Task<ScanManifestRow> SaveAsync(ScanManifestRow manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_manifestsByScanId[manifest.ScanId] = manifest;
|
||||
_manifestsByHash[manifest.ManifestHash] = manifest;
|
||||
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
public Task MarkCompletedAsync(Guid manifestId, DateTimeOffset completedAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var manifest in _manifestsByScanId.Values)
|
||||
{
|
||||
if (manifest.ManifestId == manifestId)
|
||||
{
|
||||
manifest.ScanCompletedAt = completedAt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IProofBundleRepository for testing.
|
||||
/// </summary>
|
||||
public sealed class TestProofBundleRepository : StellaOps.Scanner.Storage.Repositories.IProofBundleRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ProofBundleRow> _bundlesByRootHash = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<Guid, List<ProofBundleRow>> _bundlesByScanId = new();
|
||||
|
||||
public Task<ProofBundleRow?> GetByRootHashAsync(string rootHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(_bundlesByRootHash.TryGetValue(rootHash, out var bundle) ? bundle : null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ProofBundleRow>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_bundlesByScanId.TryGetValue(scanId, out var bundles))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ProofBundleRow>>(bundles.ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ProofBundleRow>>(Array.Empty<ProofBundleRow>());
|
||||
}
|
||||
|
||||
public Task<ProofBundleRow> SaveAsync(ProofBundleRow bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_bundlesByRootHash[bundle.RootHash] = bundle;
|
||||
|
||||
var scanBundles = _bundlesByScanId.GetOrAdd(bundle.ScanId, _ => new List<ProofBundleRow>());
|
||||
|
||||
lock (scanBundles)
|
||||
{
|
||||
// Replace existing if same root hash, otherwise add
|
||||
var existingIndex = scanBundles.FindIndex(b => string.Equals(b.RootHash, bundle.RootHash, StringComparison.OrdinalIgnoreCase));
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
scanBundles[existingIndex] = bundle;
|
||||
}
|
||||
else
|
||||
{
|
||||
scanBundles.Add(bundle);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
public Task<int> DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expired = _bundlesByRootHash.Values
|
||||
.Where(b => b.ExpiresAt.HasValue && b.ExpiresAt.Value < now)
|
||||
.ToList();
|
||||
|
||||
foreach (var bundle in expired)
|
||||
{
|
||||
_bundlesByRootHash.TryRemove(bundle.RootHash, out _);
|
||||
|
||||
if (_bundlesByScanId.TryGetValue(bundle.ScanId, out var scanBundles))
|
||||
{
|
||||
lock (scanBundles)
|
||||
{
|
||||
scanBundles.RemoveAll(b => string.Equals(b.RootHash, bundle.RootHash, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(expired.Count);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user