diff --git a/src/DevSecOpsSentinel.Api/Endpoints/CatalogueEndpoints.cs b/src/DevSecOpsSentinel.Api/Endpoints/CatalogueEndpoints.cs
new file mode 100644
index 0000000..afd1414
--- /dev/null
+++ b/src/DevSecOpsSentinel.Api/Endpoints/CatalogueEndpoints.cs
@@ -0,0 +1,61 @@
+using System.Text.Json;
+using DevSecOpsSentinel.Api.Security;
+using DevSecOpsSentinel.Application;
+using DevSecOpsSentinel.Domain;
+using DevSecOpsSentinel.Infrastructure.Ai;
+using DevSecOpsSentinel.Infrastructure.GitHub;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DevSecOpsSentinel.Api.Endpoints;
+
+///
+/// The rule catalogue and the bundled scenarios: what this tool looks for, and the
+/// worked examples of each. Neither depends on configuration.
+///
+/// Extracted from Program.cs, which had grown to 944 lines holding the composition root,
+/// the middleware pipeline and every handler body at once.
+///
+public static class CatalogueEndpoints
+{
+ public static WebApplication MapCatalogueEndpoints(this WebApplication app)
+ {
+ app.MapGet(
+ "/api/rules",
+ (IEnumerable rules) =>
+ Results.Ok(
+ rules.Select(rule => new
+ {
+ rule.RuleId,
+ rule.Title,
+ severity = rule.Severity.ToString()
+ })
+ .OrderBy(rule => rule.RuleId)))
+ .CacheOutput(policy =>
+ policy.Expire(TimeSpan.FromMinutes(5)));
+
+ app.MapGet(
+ "/api/scenarios",
+ (IScenarioStore store) =>
+ Results.Ok(store.GetAll()))
+ .CacheOutput(policy =>
+ policy.Expire(TimeSpan.FromMinutes(5)));
+
+ app.MapGet(
+ "/api/scenarios/{id}",
+ (string id, IScenarioStore store) =>
+ {
+ ScenarioDetail? scenario = store.GetById(id);
+
+ return scenario is null
+ ? Results.NotFound(new ProblemDetails
+ {
+ Title = "Scenario not found",
+ Detail = $"No scenario with id '{id}' exists.",
+ Status = StatusCodes.Status404NotFound
+ })
+ : Results.Ok(scenario);
+ });
+
+ return app;
+ }
+}
diff --git a/src/DevSecOpsSentinel.Api/Endpoints/GitHubEndpoints.cs b/src/DevSecOpsSentinel.Api/Endpoints/GitHubEndpoints.cs
new file mode 100644
index 0000000..e0e224a
--- /dev/null
+++ b/src/DevSecOpsSentinel.Api/Endpoints/GitHubEndpoints.cs
@@ -0,0 +1,243 @@
+using System.Text.Json;
+using DevSecOpsSentinel.Api.Security;
+using DevSecOpsSentinel.Application;
+using DevSecOpsSentinel.Domain;
+using DevSecOpsSentinel.Infrastructure.Ai;
+using DevSecOpsSentinel.Infrastructure.GitHub;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DevSecOpsSentinel.Api.Endpoints;
+
+///
+/// Repository, workflow and analysis endpoints backed by the read-only GitHub App.
+/// ADR-004 keeps the installation read-only, so nothing here writes.
+///
+/// Extracted from Program.cs, which had grown to 944 lines holding the composition root,
+/// the middleware pipeline and every handler body at once.
+///
+public static class GitHubEndpoints
+{
+ public static WebApplication MapGitHubEndpoints(this WebApplication app, GitHubOptions gitHubOptions)
+ {
+ app.MapGet(
+ "/api/github/status",
+ async (
+ IGitHubRepositoryReader reader,
+ ILogger logger,
+ CancellationToken cancellationToken) =>
+ {
+ if (!gitHubOptions.Enabled)
+ {
+ return Results.Ok(new GitHubConnectionStatus(
+ false,
+ false,
+ false,
+ "ReadOnly",
+ gitHubOptions.AllowedRepositories.Length,
+ "GitHub integration is disabled."));
+ }
+
+ if (!gitHubOptions.IsConfigured)
+ {
+ return Results.Ok(new GitHubConnectionStatus(
+ true,
+ false,
+ false,
+ "ReadOnly",
+ gitHubOptions.AllowedRepositories.Length,
+ "GitHub App configuration is incomplete."));
+ }
+
+ try
+ {
+ IReadOnlyList repositories =
+ await reader.GetRepositoriesAsync(cancellationToken);
+
+ return Results.Ok(new GitHubConnectionStatus(
+ true,
+ true,
+ true,
+ "ReadOnly",
+ repositories.Count,
+ "Connected using a short-lived GitHub App " +
+ "installation token."));
+ }
+ catch (Exception exception)
+ {
+ logger.LogWarning(
+ exception,
+ "GitHub status check failed.");
+
+ return Results.Ok(new GitHubConnectionStatus(
+ true,
+ true,
+ false,
+ "ReadOnly",
+ gitHubOptions.AllowedRepositories.Length,
+ "GitHub could not be reached or authentication failed."));
+ }
+ });
+
+ app.MapGet(
+ "/api/github/repositories",
+ async (
+ IGitHubRepositoryReader reader,
+ CancellationToken cancellationToken) =>
+ {
+ if (!gitHubOptions.IsConfigured)
+ {
+ return Results.Problem(
+ title: "GitHub integration is not configured",
+ statusCode:
+ StatusCodes.Status503ServiceUnavailable);
+ }
+
+ return Results.Ok(
+ await reader.GetRepositoriesAsync(cancellationToken));
+ })
+ .RequireRateLimiting("github-read");
+
+ app.MapGet(
+ "/api/github/repositories/{owner}/{repository}/workflows",
+ async (
+ string owner,
+ string repository,
+ IGitHubRepositoryReader reader,
+ CancellationToken cancellationToken) =>
+ {
+ if (!gitHubOptions.IsAllowed(owner, repository))
+ {
+ return Results.Problem(
+ statusCode: StatusCodes.Status403Forbidden,
+ title: "Repository access denied",
+ detail:
+ "The requested repository is not included in " +
+ "the configured allowlist.");
+ }
+
+ return Results.Ok(
+ await reader.GetWorkflowsAsync(
+ owner,
+ repository,
+ cancellationToken));
+ })
+ .RequireRateLimiting("github-read");
+
+ app.MapGet(
+ "/api/github/repositories/{owner}/{repository}/workflows/content",
+ async (
+ string owner,
+ string repository,
+ string path,
+ string? reference,
+ IGitHubRepositoryReader reader,
+ CancellationToken cancellationToken) =>
+ {
+ if (!gitHubOptions.IsAllowed(owner, repository))
+ {
+ return Results.Problem(
+ statusCode: StatusCodes.Status403Forbidden,
+ title: "Repository access denied",
+ detail:
+ "The requested repository is not included in " +
+ "the configured allowlist.");
+ }
+
+ GitHubWorkflowFile? workflow =
+ await reader.GetWorkflowAsync(
+ owner,
+ repository,
+ path,
+ reference,
+ cancellationToken);
+
+ return workflow is null
+ ? Results.NotFound()
+ : Results.Ok(workflow);
+ })
+ .RequireRateLimiting("github-read");
+
+ app.MapPost(
+ "/api/github/repositories/{owner}/{repository}/analyze",
+ async (
+ string owner,
+ string repository,
+ AnalyzeGitHubWorkflowRequest? request,
+ IGitHubRepositoryReader reader,
+ IWorkflowAnalysisService analysisService,
+ IWorkflowExplanationService explanationService,
+ CancellationToken cancellationToken) =>
+ {
+ if (!gitHubOptions.IsAllowed(owner, repository))
+ {
+ return Results.Problem(
+ statusCode: StatusCodes.Status403Forbidden,
+ title: "Repository access denied",
+ detail:
+ "The requested repository is not included in " +
+ "the configured allowlist.");
+ }
+
+ if (request is null ||
+ string.IsNullOrWhiteSpace(request.Path))
+ {
+ return Results.BadRequest(new ProblemDetails
+ {
+ Title = "Invalid GitHub workflow request",
+ Detail = "A workflow path is required.",
+ Status = StatusCodes.Status400BadRequest
+ });
+ }
+
+ GitHubWorkflowFile? workflow =
+ await reader.GetWorkflowAsync(
+ owner,
+ repository,
+ request.Path,
+ request.Reference,
+ cancellationToken);
+
+ if (workflow is null)
+ {
+ return Results.NotFound();
+ }
+
+ WorkflowDocument document = new(
+ Path.GetFileName(workflow.Path),
+ workflow.Content);
+
+ if (request.UseAi)
+ {
+ // Reaching this endpoint at all requires the key - /api/github is
+ // privileged in every mode - so the caller is identified and the
+ // configured provider applies.
+ WorkflowExplanationResult explained =
+ await explanationService.ExplainAsync(
+ document,
+ true,
+ AiCallerAccess.Configured,
+ cancellationToken);
+
+ return Results.Ok(new
+ {
+ source = workflow,
+ result = explained
+ });
+ }
+
+ WorkflowAnalysisResult analyzed =
+ await analysisService.AnalyzeAsync(
+ document,
+ cancellationToken);
+
+ return Results.Ok(new
+ {
+ source = workflow,
+ result = analyzed
+ });
+ })
+ .RequireRateLimiting("workflow-analysis");
+
+ return app;
+ }
+}
diff --git a/src/DevSecOpsSentinel.Api/Endpoints/StatusEndpoints.cs b/src/DevSecOpsSentinel.Api/Endpoints/StatusEndpoints.cs
new file mode 100644
index 0000000..691d9bc
--- /dev/null
+++ b/src/DevSecOpsSentinel.Api/Endpoints/StatusEndpoints.cs
@@ -0,0 +1,152 @@
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+using DevSecOpsSentinel.Api.Security;
+using DevSecOpsSentinel.Application;
+using DevSecOpsSentinel.Domain;
+using DevSecOpsSentinel.Infrastructure.Ai;
+using DevSecOpsSentinel.Infrastructure.GitHub;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DevSecOpsSentinel.Api.Endpoints;
+
+///
+/// Root, health, security status, AI status.
+/// Liveness and readiness are deliberately distinct: liveness answers as soon as the
+/// process is listening, readiness only once the app can serve. A deploy that waited on
+/// the wrong one ran its smoke test against a half-started app.
+///
+/// Extracted from Program.cs, which had grown to 944 lines holding the composition root,
+/// the middleware pipeline and every handler body at once.
+///
+public static class StatusEndpoints
+{
+ public static WebApplication MapStatusEndpoints(this WebApplication app, OpenAiOptions openAiOptions, GitHubOptions gitHubOptions)
+ {
+ app.MapGet("/", () => Results.Ok(new
+ {
+ status = "Running",
+ application = ProductInfo.Name,
+ version = ProductInfo.Version,
+ message =
+ "Open /scalar for API documentation or " +
+ "http://localhost:5173 for the React application."
+ }));
+
+ app.MapGet("/api/health", () => Results.Ok(new
+ {
+ status = "Healthy",
+ application = ProductInfo.Name,
+ version = ProductInfo.Version
+ }));
+
+ app.MapGet(
+ "/api/security/status",
+ (IOptionsMonitor optionsMonitor) =>
+ {
+ ApiSecurityOptions security =
+ optionsMonitor.CurrentValue;
+
+ return Results.Ok(new
+ {
+ // Whether a key is needed to use the API at all. False in Public
+ // mode, where the scanner is open and the key only unlocks more.
+ required = security.IsRequired,
+ headerName = security.HeaderName,
+ sessionOnlyBrowserKey = true,
+ mode = security.Mode,
+
+ // So the client can offer the key as an upgrade rather than a gate,
+ // and say what it is for.
+ keyUnlocksGitHub = security.UsesApiKey,
+ keyUnlocksLiveAi = security.UsesApiKey
+ });
+ });
+
+ app.MapGet("/api/health/live", () => Results.Ok(new
+ {
+ status = "Healthy",
+ check = "Liveness",
+ timestampUtc = DateTimeOffset.UtcNow
+ }))
+ .CacheOutput(policy =>
+ policy.Expire(TimeSpan.FromSeconds(10)));
+
+ /*
+ * Readiness answers one question: can this instance serve requests?
+ *
+ * Deterministic analysis is the product and depends on nothing external, so the
+ * answer is yes whenever the process started. GitHub and OpenAI are optional
+ * integrations, and reporting the whole application as unready because one of
+ * them is misconfigured would take a working instance out of rotation over a
+ * feature most requests never touch.
+ *
+ * Their state is reported here so a misconfiguration is visible, and separately
+ * on /api/github/status and /api/ai/status, but a degraded integration does not
+ * make the application unready. What it must never do is silently present
+ * simulated results as real ones — an integration configured for live use and
+ * unable to reach its service reports exactly that.
+ */
+ app.MapGet("/api/health/ready", (IGitHubPrivateKeySource privateKeySource) =>
+ {
+ bool gitHubDegraded =
+ gitHubOptions.Enabled &&
+ (!gitHubOptions.IsConfigured || !privateKeySource.IsAvailable);
+
+ bool openAiDegraded =
+ string.Equals(openAiOptions.Mode, "Live", StringComparison.OrdinalIgnoreCase) &&
+ string.IsNullOrWhiteSpace(openAiOptions.ApiKey);
+
+ return Results.Ok(new
+ {
+ status = "Ready",
+ deterministicAnalysis = "Available",
+ gitHub = new
+ {
+ state = !gitHubOptions.Enabled
+ ? "Disabled"
+ : gitHubDegraded ? "Unavailable" : "ReadOnly",
+ detail = !gitHubOptions.Enabled
+ ? "GitHub integration is disabled."
+ : gitHubDegraded
+ ? "GitHub is enabled but its configuration or private key is incomplete."
+ : $"Connected using a private key supplied by {privateKeySource.Description}.",
+ },
+ ai = new
+ {
+ state = openAiDegraded ? "Unavailable" : openAiOptions.Mode,
+ detail = openAiDegraded
+ ? "OpenAI is configured for live mode but no API key is available. Explanations fall back to deterministic text and are labelled as such."
+ : $"OpenAI is in {openAiOptions.Mode} mode."
+ },
+ timestampUtc = DateTimeOffset.UtcNow
+ });
+ });
+
+ app.MapGet("/api/ai/status", () =>
+ {
+ bool configured =
+ !string.IsNullOrWhiteSpace(openAiOptions.ApiKey);
+
+ return Results.Ok(new
+ {
+ enabled = !string.Equals(
+ openAiOptions.Mode,
+ "Disabled",
+ StringComparison.OrdinalIgnoreCase),
+
+ configured,
+ provider = "OpenAI",
+ mode = openAiOptions.Mode,
+ model = openAiOptions.Model,
+
+ costProtection = new
+ {
+ explicitRequestOnly = true,
+ mockModeConsumesCredits = false
+ }
+ });
+ });
+
+ return app;
+ }
+}
diff --git a/src/DevSecOpsSentinel.Api/Endpoints/WorkflowEndpoints.cs b/src/DevSecOpsSentinel.Api/Endpoints/WorkflowEndpoints.cs
new file mode 100644
index 0000000..4fa5205
--- /dev/null
+++ b/src/DevSecOpsSentinel.Api/Endpoints/WorkflowEndpoints.cs
@@ -0,0 +1,264 @@
+using System.Text.Json;
+using DevSecOpsSentinel.Api.Security;
+using DevSecOpsSentinel.Application;
+using DevSecOpsSentinel.Domain;
+using DevSecOpsSentinel.Infrastructure.Ai;
+using DevSecOpsSentinel.Infrastructure.GitHub;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DevSecOpsSentinel.Api.Endpoints;
+
+///
+/// Analysis, remediation preview and AI explanation for a workflow supplied in the
+/// request. ADR-006 keeps remediation preview-only — nothing here writes to a repository.
+///
+/// Extracted from Program.cs, which had grown to 944 lines holding the composition root,
+/// the middleware pipeline and every handler body at once.
+///
+public static class WorkflowEndpoints
+{
+ public static WebApplication MapWorkflowEndpoints(this WebApplication app, int maximumWorkflowCharacters)
+ {
+ app.MapPost(
+ "/api/workflows/analyze",
+ async (
+ AnalyzeWorkflowRequest? request,
+ IWorkflowAnalysisService service,
+ CancellationToken cancellationToken) =>
+ {
+ IResult? validationFailure =
+ ValidateWorkflowRequest(
+ request,
+ maximumWorkflowCharacters);
+
+ if (validationFailure is not null)
+ {
+ return validationFailure;
+ }
+
+ WorkflowAnalysisResult result =
+ await service.AnalyzeAsync(
+ new WorkflowDocument(
+ request!.FileName,
+ request.Content),
+ cancellationToken);
+
+ return !result.IsValid
+ ? Results.Problem(
+ title: "Workflow YAML could not be parsed",
+ detail: string.Join(
+ " ",
+ result.ValidationErrors),
+ statusCode:
+ StatusCodes.Status422UnprocessableEntity)
+ : Results.Ok(result);
+ })
+ .Accepts("application/json")
+ .Produces(
+ StatusCodes.Status200OK)
+ .ProducesProblem(StatusCodes.Status400BadRequest)
+ .ProducesProblem(StatusCodes.Status413PayloadTooLarge)
+ .ProducesProblem(StatusCodes.Status415UnsupportedMediaType)
+ .ProducesProblem(
+ StatusCodes.Status422UnprocessableEntity)
+ .RequireRateLimiting("workflow-analysis");
+
+ app.MapPost(
+ "/api/workflows/remediation",
+ async (
+ AnalyzeWorkflowRequest? request,
+ IRemediationReportService service,
+ CancellationToken cancellationToken) =>
+ {
+ IResult? validationFailure =
+ ValidateWorkflowRequest(
+ request,
+ maximumWorkflowCharacters);
+
+ if (validationFailure is not null)
+ {
+ return validationFailure;
+ }
+
+ RemediationReport report =
+ await service.BuildAsync(
+ new WorkflowDocument(
+ request!.FileName,
+ request.Content),
+ cancellationToken);
+
+ return !report.OriginalAnalysis.IsValid
+ ? Results.Problem(
+ title: "Workflow YAML could not be parsed",
+ detail: string.Join(
+ " ",
+ report.OriginalAnalysis.ValidationErrors),
+ statusCode:
+ StatusCodes.Status422UnprocessableEntity)
+ : Results.Ok(report);
+ })
+ .RequireRateLimiting("workflow-analysis");
+
+ app.MapPost(
+ "/api/workflows/remediation/export/{format}",
+ async (
+ string format,
+ AnalyzeWorkflowRequest? request,
+ IRemediationReportService service,
+ CancellationToken cancellationToken) =>
+ {
+ IResult? validationFailure =
+ ValidateWorkflowRequest(
+ request,
+ maximumWorkflowCharacters);
+
+ if (validationFailure is not null)
+ {
+ return validationFailure;
+ }
+
+ RemediationReport report =
+ await service.BuildAsync(
+ new WorkflowDocument(
+ request!.FileName,
+ request.Content),
+ cancellationToken);
+
+ string safeName =
+ Path.GetFileNameWithoutExtension(request.FileName);
+
+ return format.ToLowerInvariant() switch
+ {
+ "markdown" or "md" =>
+ Results.File(
+ System.Text.Encoding.UTF8.GetBytes(
+ RemediationExports.Markdown(report)),
+ "text/markdown",
+ $"{safeName}-remediation.md"),
+
+ "html" =>
+ Results.File(
+ System.Text.Encoding.UTF8.GetBytes(
+ RemediationExports.Html(report)),
+ "text/html",
+ $"{safeName}-remediation.html"),
+
+ "sarif" =>
+ Results.Json(
+ RemediationExports.Sarif(report),
+ contentType: "application/sarif+json"),
+
+ "json" =>
+ Results.File(
+ System.Text.Encoding.UTF8.GetBytes(
+ RemediationExports.Json(report)),
+ "application/json",
+ $"{safeName}-remediation.json"),
+
+ "diff" or "patch" =>
+ Results.File(
+ System.Text.Encoding.UTF8.GetBytes(
+ string.Join(
+ "\n",
+ report.UnifiedDiff)),
+ "text/x-diff",
+ $"{safeName}.patch"),
+
+ _ =>
+ Results.Problem(
+ statusCode:
+ StatusCodes.Status400BadRequest,
+ title: "Unsupported export format",
+ detail:
+ "Supported formats: markdown, html, " +
+ "sarif, json, diff.")
+ };
+ })
+ .RequireRateLimiting("workflow-analysis");
+
+ app.MapPost(
+ "/api/workflows/explain",
+ async (
+ ExplainWorkflowRequest? request,
+ IWorkflowExplanationService service,
+ CallerAuthentication caller,
+ CancellationToken cancellationToken) =>
+ {
+ IResult? validationFailure =
+ ValidateWorkflowRequest(
+ request is null
+ ? null
+ : new AnalyzeWorkflowRequest(
+ request.FileName,
+ request.Content),
+ maximumWorkflowCharacters);
+
+ if (validationFailure is not null)
+ {
+ return validationFailure;
+ }
+
+ WorkflowExplanationResult result =
+ await service.ExplainAsync(
+ new WorkflowDocument(
+ request!.FileName,
+ request.Content),
+ request.UseAi,
+ caller.AiAccess == AiAccess.Full
+ ? AiCallerAccess.Configured
+ : AiCallerAccess.MockOnly,
+ cancellationToken);
+
+ return !result.Analysis.IsValid
+ ? Results.Problem(
+ title: "Workflow YAML could not be parsed",
+ detail: string.Join(
+ " ",
+ result.Analysis.ValidationErrors),
+ statusCode:
+ StatusCodes.Status422UnprocessableEntity)
+ : Results.Ok(result);
+ })
+ .Accepts("application/json")
+ .Produces(
+ StatusCodes.Status200OK)
+ .ProducesProblem(StatusCodes.Status400BadRequest)
+ .ProducesProblem(StatusCodes.Status413PayloadTooLarge)
+ .ProducesProblem(StatusCodes.Status415UnsupportedMediaType)
+ .ProducesProblem(
+ StatusCodes.Status422UnprocessableEntity)
+ .RequireRateLimiting("workflow-analysis");
+
+ return app;
+ }
+
+ private static IResult? ValidateWorkflowRequest(
+ AnalyzeWorkflowRequest? request,
+ int maximumCharacters)
+ {
+ if (request is null ||
+ string.IsNullOrWhiteSpace(request.FileName) ||
+ string.IsNullOrWhiteSpace(request.Content))
+ {
+ return Results.BadRequest(new ProblemDetails
+ {
+ Title = "Invalid workflow request",
+ Detail = "Both fileName and content are required.",
+ Status = StatusCodes.Status400BadRequest
+ });
+ }
+
+ if (request.Content.Length > maximumCharacters)
+ {
+ return Results.Problem(
+ title: "Workflow is too large",
+ detail:
+ $"Workflow content cannot exceed " +
+ $"{maximumCharacters:N0} characters.",
+ statusCode:
+ StatusCodes.Status413PayloadTooLarge);
+ }
+
+ return null;
+ }
+}
diff --git a/src/DevSecOpsSentinel.Api/Program.cs b/src/DevSecOpsSentinel.Api/Program.cs
index 2218f0b..eca9ade 100644
--- a/src/DevSecOpsSentinel.Api/Program.cs
+++ b/src/DevSecOpsSentinel.Api/Program.cs
@@ -4,6 +4,7 @@
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using DevSecOpsSentinel.Api;
+using DevSecOpsSentinel.Api.Endpoints;
using DevSecOpsSentinel.Api.Operational;
using DevSecOpsSentinel.Api.Security;
using DevSecOpsSentinel.Application;
@@ -115,17 +116,13 @@
"Scenarios");
builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
+// Discovered, not listed. A rule added to Infrastructure and forgotten here would never
+// run, and nothing would report it — the failure is silence, which is why this is not a
+// hand-maintained list. RuleDiscovery is the single source the tests and the eval use too.
+foreach (IWorkflowSecurityRule rule in RuleDiscovery.All())
+{
+ builder.Services.AddSingleton(rule);
+}
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -279,596 +276,10 @@
app.UseRateLimiter();
app.UseOutputCache();
-app.MapGet("/", () => Results.Ok(new
-{
- status = "Running",
- application = ProductInfo.Name,
- version = ProductInfo.Version,
- message =
- "Open /scalar for API documentation or " +
- "http://localhost:5173 for the React application."
-}));
-
-app.MapGet("/api/health", () => Results.Ok(new
-{
- status = "Healthy",
- application = ProductInfo.Name,
- version = ProductInfo.Version
-}));
-
-app.MapGet(
- "/api/security/status",
- (IOptionsMonitor optionsMonitor) =>
- {
- ApiSecurityOptions security =
- optionsMonitor.CurrentValue;
-
- return Results.Ok(new
- {
- // Whether a key is needed to use the API at all. False in Public
- // mode, where the scanner is open and the key only unlocks more.
- required = security.IsRequired,
- headerName = security.HeaderName,
- sessionOnlyBrowserKey = true,
- mode = security.Mode,
-
- // So the client can offer the key as an upgrade rather than a gate,
- // and say what it is for.
- keyUnlocksGitHub = security.UsesApiKey,
- keyUnlocksLiveAi = security.UsesApiKey
- });
- });
-
-app.MapGet("/api/health/live", () => Results.Ok(new
-{
- status = "Healthy",
- check = "Liveness",
- timestampUtc = DateTimeOffset.UtcNow
-}))
-.CacheOutput(policy =>
- policy.Expire(TimeSpan.FromSeconds(10)));
-
-/*
- * Readiness answers one question: can this instance serve requests?
- *
- * Deterministic analysis is the product and depends on nothing external, so the
- * answer is yes whenever the process started. GitHub and OpenAI are optional
- * integrations, and reporting the whole application as unready because one of
- * them is misconfigured would take a working instance out of rotation over a
- * feature most requests never touch.
- *
- * Their state is reported here so a misconfiguration is visible, and separately
- * on /api/github/status and /api/ai/status, but a degraded integration does not
- * make the application unready. What it must never do is silently present
- * simulated results as real ones — an integration configured for live use and
- * unable to reach its service reports exactly that.
- */
-app.MapGet("/api/health/ready", (IGitHubPrivateKeySource privateKeySource) =>
-{
- bool gitHubDegraded =
- gitHubOptions.Enabled &&
- (!gitHubOptions.IsConfigured || !privateKeySource.IsAvailable);
-
- bool openAiDegraded =
- string.Equals(openAiOptions.Mode, "Live", StringComparison.OrdinalIgnoreCase) &&
- string.IsNullOrWhiteSpace(openAiOptions.ApiKey);
-
- return Results.Ok(new
- {
- status = "Ready",
- deterministicAnalysis = "Available",
- gitHub = new
- {
- state = !gitHubOptions.Enabled
- ? "Disabled"
- : gitHubDegraded ? "Unavailable" : "ReadOnly",
- detail = !gitHubOptions.Enabled
- ? "GitHub integration is disabled."
- : gitHubDegraded
- ? "GitHub is enabled but its configuration or private key is incomplete."
- : $"Connected using a private key supplied by {privateKeySource.Description}.",
- },
- ai = new
- {
- state = openAiDegraded ? "Unavailable" : openAiOptions.Mode,
- detail = openAiDegraded
- ? "OpenAI is configured for live mode but no API key is available. Explanations fall back to deterministic text and are labelled as such."
- : $"OpenAI is in {openAiOptions.Mode} mode."
- },
- timestampUtc = DateTimeOffset.UtcNow
- });
-});
-
-app.MapGet("/api/ai/status", () =>
-{
- bool configured =
- !string.IsNullOrWhiteSpace(openAiOptions.ApiKey);
-
- return Results.Ok(new
- {
- enabled = !string.Equals(
- openAiOptions.Mode,
- "Disabled",
- StringComparison.OrdinalIgnoreCase),
-
- configured,
- provider = "OpenAI",
- mode = openAiOptions.Mode,
- model = openAiOptions.Model,
-
- costProtection = new
- {
- explicitRequestOnly = true,
- mockModeConsumesCredits = false
- }
- });
-});
-
-app.MapGet(
- "/api/github/status",
- async (
- IGitHubRepositoryReader reader,
- ILogger logger,
- CancellationToken cancellationToken) =>
- {
- if (!gitHubOptions.Enabled)
- {
- return Results.Ok(new GitHubConnectionStatus(
- false,
- false,
- false,
- "ReadOnly",
- gitHubOptions.AllowedRepositories.Length,
- "GitHub integration is disabled."));
- }
-
- if (!gitHubOptions.IsConfigured)
- {
- return Results.Ok(new GitHubConnectionStatus(
- true,
- false,
- false,
- "ReadOnly",
- gitHubOptions.AllowedRepositories.Length,
- "GitHub App configuration is incomplete."));
- }
-
- try
- {
- IReadOnlyList repositories =
- await reader.GetRepositoriesAsync(cancellationToken);
-
- return Results.Ok(new GitHubConnectionStatus(
- true,
- true,
- true,
- "ReadOnly",
- repositories.Count,
- "Connected using a short-lived GitHub App " +
- "installation token."));
- }
- catch (Exception exception)
- {
- logger.LogWarning(
- exception,
- "GitHub status check failed.");
-
- return Results.Ok(new GitHubConnectionStatus(
- true,
- true,
- false,
- "ReadOnly",
- gitHubOptions.AllowedRepositories.Length,
- "GitHub could not be reached or authentication failed."));
- }
- });
-
-app.MapGet(
- "/api/github/repositories",
- async (
- IGitHubRepositoryReader reader,
- CancellationToken cancellationToken) =>
- {
- if (!gitHubOptions.IsConfigured)
- {
- return Results.Problem(
- title: "GitHub integration is not configured",
- statusCode:
- StatusCodes.Status503ServiceUnavailable);
- }
-
- return Results.Ok(
- await reader.GetRepositoriesAsync(cancellationToken));
- })
- .RequireRateLimiting("github-read");
-
-app.MapGet(
- "/api/github/repositories/{owner}/{repository}/workflows",
- async (
- string owner,
- string repository,
- IGitHubRepositoryReader reader,
- CancellationToken cancellationToken) =>
- {
- if (!gitHubOptions.IsAllowed(owner, repository))
- {
- return Results.Problem(
- statusCode: StatusCodes.Status403Forbidden,
- title: "Repository access denied",
- detail:
- "The requested repository is not included in " +
- "the configured allowlist.");
- }
-
- return Results.Ok(
- await reader.GetWorkflowsAsync(
- owner,
- repository,
- cancellationToken));
- })
- .RequireRateLimiting("github-read");
-
-app.MapGet(
- "/api/github/repositories/{owner}/{repository}/workflows/content",
- async (
- string owner,
- string repository,
- string path,
- string? reference,
- IGitHubRepositoryReader reader,
- CancellationToken cancellationToken) =>
- {
- if (!gitHubOptions.IsAllowed(owner, repository))
- {
- return Results.Problem(
- statusCode: StatusCodes.Status403Forbidden,
- title: "Repository access denied",
- detail:
- "The requested repository is not included in " +
- "the configured allowlist.");
- }
-
- GitHubWorkflowFile? workflow =
- await reader.GetWorkflowAsync(
- owner,
- repository,
- path,
- reference,
- cancellationToken);
-
- return workflow is null
- ? Results.NotFound()
- : Results.Ok(workflow);
- })
- .RequireRateLimiting("github-read");
-
-app.MapPost(
- "/api/github/repositories/{owner}/{repository}/analyze",
- async (
- string owner,
- string repository,
- AnalyzeGitHubWorkflowRequest? request,
- IGitHubRepositoryReader reader,
- IWorkflowAnalysisService analysisService,
- IWorkflowExplanationService explanationService,
- CancellationToken cancellationToken) =>
- {
- if (!gitHubOptions.IsAllowed(owner, repository))
- {
- return Results.Problem(
- statusCode: StatusCodes.Status403Forbidden,
- title: "Repository access denied",
- detail:
- "The requested repository is not included in " +
- "the configured allowlist.");
- }
-
- if (request is null ||
- string.IsNullOrWhiteSpace(request.Path))
- {
- return Results.BadRequest(new ProblemDetails
- {
- Title = "Invalid GitHub workflow request",
- Detail = "A workflow path is required.",
- Status = StatusCodes.Status400BadRequest
- });
- }
-
- GitHubWorkflowFile? workflow =
- await reader.GetWorkflowAsync(
- owner,
- repository,
- request.Path,
- request.Reference,
- cancellationToken);
-
- if (workflow is null)
- {
- return Results.NotFound();
- }
-
- WorkflowDocument document = new(
- Path.GetFileName(workflow.Path),
- workflow.Content);
-
- if (request.UseAi)
- {
- // Reaching this endpoint at all requires the key - /api/github is
- // privileged in every mode - so the caller is identified and the
- // configured provider applies.
- WorkflowExplanationResult explained =
- await explanationService.ExplainAsync(
- document,
- true,
- AiCallerAccess.Configured,
- cancellationToken);
-
- return Results.Ok(new
- {
- source = workflow,
- result = explained
- });
- }
-
- WorkflowAnalysisResult analyzed =
- await analysisService.AnalyzeAsync(
- document,
- cancellationToken);
-
- return Results.Ok(new
- {
- source = workflow,
- result = analyzed
- });
- })
- .RequireRateLimiting("workflow-analysis");
-
-app.MapGet(
- "/api/rules",
- (IEnumerable rules) =>
- Results.Ok(
- rules.Select(rule => new
- {
- rule.RuleId,
- rule.Title,
- severity = rule.Severity.ToString()
- })
- .OrderBy(rule => rule.RuleId)))
- .CacheOutput(policy =>
- policy.Expire(TimeSpan.FromMinutes(5)));
-
-app.MapGet(
- "/api/scenarios",
- (IScenarioStore store) =>
- Results.Ok(store.GetAll()))
- .CacheOutput(policy =>
- policy.Expire(TimeSpan.FromMinutes(5)));
-
-app.MapGet(
- "/api/scenarios/{id}",
- (string id, IScenarioStore store) =>
- {
- ScenarioDetail? scenario = store.GetById(id);
-
- return scenario is null
- ? Results.NotFound(new ProblemDetails
- {
- Title = "Scenario not found",
- Detail = $"No scenario with id '{id}' exists.",
- Status = StatusCodes.Status404NotFound
- })
- : Results.Ok(scenario);
- });
-
-app.MapPost(
- "/api/workflows/analyze",
- async (
- AnalyzeWorkflowRequest? request,
- IWorkflowAnalysisService service,
- CancellationToken cancellationToken) =>
- {
- IResult? validationFailure =
- ValidateWorkflowRequest(
- request,
- maximumWorkflowCharacters);
-
- if (validationFailure is not null)
- {
- return validationFailure;
- }
-
- WorkflowAnalysisResult result =
- await service.AnalyzeAsync(
- new WorkflowDocument(
- request!.FileName,
- request.Content),
- cancellationToken);
-
- return !result.IsValid
- ? Results.Problem(
- title: "Workflow YAML could not be parsed",
- detail: string.Join(
- " ",
- result.ValidationErrors),
- statusCode:
- StatusCodes.Status422UnprocessableEntity)
- : Results.Ok(result);
- })
- .Accepts("application/json")
- .Produces(
- StatusCodes.Status200OK)
- .ProducesProblem(StatusCodes.Status400BadRequest)
- .ProducesProblem(StatusCodes.Status413PayloadTooLarge)
- .ProducesProblem(StatusCodes.Status415UnsupportedMediaType)
- .ProducesProblem(
- StatusCodes.Status422UnprocessableEntity)
- .RequireRateLimiting("workflow-analysis");
-
-app.MapPost(
- "/api/workflows/remediation",
- async (
- AnalyzeWorkflowRequest? request,
- IRemediationReportService service,
- CancellationToken cancellationToken) =>
- {
- IResult? validationFailure =
- ValidateWorkflowRequest(
- request,
- maximumWorkflowCharacters);
-
- if (validationFailure is not null)
- {
- return validationFailure;
- }
-
- RemediationReport report =
- await service.BuildAsync(
- new WorkflowDocument(
- request!.FileName,
- request.Content),
- cancellationToken);
-
- return !report.OriginalAnalysis.IsValid
- ? Results.Problem(
- title: "Workflow YAML could not be parsed",
- detail: string.Join(
- " ",
- report.OriginalAnalysis.ValidationErrors),
- statusCode:
- StatusCodes.Status422UnprocessableEntity)
- : Results.Ok(report);
- })
- .RequireRateLimiting("workflow-analysis");
-
-app.MapPost(
- "/api/workflows/remediation/export/{format}",
- async (
- string format,
- AnalyzeWorkflowRequest? request,
- IRemediationReportService service,
- CancellationToken cancellationToken) =>
- {
- IResult? validationFailure =
- ValidateWorkflowRequest(
- request,
- maximumWorkflowCharacters);
-
- if (validationFailure is not null)
- {
- return validationFailure;
- }
-
- RemediationReport report =
- await service.BuildAsync(
- new WorkflowDocument(
- request!.FileName,
- request.Content),
- cancellationToken);
-
- string safeName =
- Path.GetFileNameWithoutExtension(request.FileName);
-
- return format.ToLowerInvariant() switch
- {
- "markdown" or "md" =>
- Results.File(
- System.Text.Encoding.UTF8.GetBytes(
- RemediationExports.Markdown(report)),
- "text/markdown",
- $"{safeName}-remediation.md"),
-
- "html" =>
- Results.File(
- System.Text.Encoding.UTF8.GetBytes(
- RemediationExports.Html(report)),
- "text/html",
- $"{safeName}-remediation.html"),
-
- "sarif" =>
- Results.Json(
- RemediationExports.Sarif(report),
- contentType: "application/sarif+json"),
-
- "json" =>
- Results.File(
- System.Text.Encoding.UTF8.GetBytes(
- RemediationExports.Json(report)),
- "application/json",
- $"{safeName}-remediation.json"),
-
- "diff" or "patch" =>
- Results.File(
- System.Text.Encoding.UTF8.GetBytes(
- string.Join(
- "\n",
- report.UnifiedDiff)),
- "text/x-diff",
- $"{safeName}.patch"),
-
- _ =>
- Results.Problem(
- statusCode:
- StatusCodes.Status400BadRequest,
- title: "Unsupported export format",
- detail:
- "Supported formats: markdown, html, " +
- "sarif, json, diff.")
- };
- })
- .RequireRateLimiting("workflow-analysis");
-
-app.MapPost(
- "/api/workflows/explain",
- async (
- ExplainWorkflowRequest? request,
- IWorkflowExplanationService service,
- CallerAuthentication caller,
- CancellationToken cancellationToken) =>
- {
- IResult? validationFailure =
- ValidateWorkflowRequest(
- request is null
- ? null
- : new AnalyzeWorkflowRequest(
- request.FileName,
- request.Content),
- maximumWorkflowCharacters);
-
- if (validationFailure is not null)
- {
- return validationFailure;
- }
-
- WorkflowExplanationResult result =
- await service.ExplainAsync(
- new WorkflowDocument(
- request!.FileName,
- request.Content),
- request.UseAi,
- caller.AiAccess == AiAccess.Full
- ? AiCallerAccess.Configured
- : AiCallerAccess.MockOnly,
- cancellationToken);
-
- return !result.Analysis.IsValid
- ? Results.Problem(
- title: "Workflow YAML could not be parsed",
- detail: string.Join(
- " ",
- result.Analysis.ValidationErrors),
- statusCode:
- StatusCodes.Status422UnprocessableEntity)
- : Results.Ok(result);
- })
- .Accepts("application/json")
- .Produces(
- StatusCodes.Status200OK)
- .ProducesProblem(StatusCodes.Status400BadRequest)
- .ProducesProblem(StatusCodes.Status413PayloadTooLarge)
- .ProducesProblem(StatusCodes.Status415UnsupportedMediaType)
- .ProducesProblem(
- StatusCodes.Status422UnprocessableEntity)
- .RequireRateLimiting("workflow-analysis");
+app.MapStatusEndpoints(openAiOptions, gitHubOptions)
+ .MapGitHubEndpoints(gitHubOptions)
+ .MapCatalogueEndpoints()
+ .MapWorkflowEndpoints(maximumWorkflowCharacters);
app.Run();
@@ -915,34 +326,4 @@ static string GetRateLimitPartitionKey(
$"ip:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}";
}
-static IResult? ValidateWorkflowRequest(
- AnalyzeWorkflowRequest? request,
- int maximumCharacters)
-{
- if (request is null ||
- string.IsNullOrWhiteSpace(request.FileName) ||
- string.IsNullOrWhiteSpace(request.Content))
- {
- return Results.BadRequest(new ProblemDetails
- {
- Title = "Invalid workflow request",
- Detail = "Both fileName and content are required.",
- Status = StatusCodes.Status400BadRequest
- });
- }
-
- if (request.Content.Length > maximumCharacters)
- {
- return Results.Problem(
- title: "Workflow is too large",
- detail:
- $"Workflow content cannot exceed " +
- $"{maximumCharacters:N0} characters.",
- statusCode:
- StatusCodes.Status413PayloadTooLarge);
- }
-
- return null;
-}
-
public partial class Program;
diff --git a/src/DevSecOpsSentinel.Application/GitHubContracts.cs b/src/DevSecOpsSentinel.Application/GitHubContracts.cs
index 6359a04..e9c56f8 100644
--- a/src/DevSecOpsSentinel.Application/GitHubContracts.cs
+++ b/src/DevSecOpsSentinel.Application/GitHubContracts.cs
@@ -82,3 +82,35 @@ Task ResolveAsync(
string actionReference,
CancellationToken cancellationToken);
}
+
+// Moved here from Infrastructure. The Api layer injects this into the readiness endpoint, so
+// leaving it beside its implementation had the outer layer depending on an abstraction the
+// outer layer also owned — the one place in this project where that was true. Every other
+// contract is declared by the layer that needs it and implemented further out; this one now
+// matches.
+
+///
+/// Supplies the GitHub App private key, from configuration or from a file.
+///
+/// A file path is workable on a developer machine and unworkable on a hosted
+/// platform: App Service application settings and Key Vault references deliver a
+/// value, not a file. Reading the key only from disk is what stopped this
+/// application being deployable.
+///
+/// Configuration wins when both are present, so a deployment cannot be
+/// accidentally served by a stale file left on the host.
+///
+public interface IGitHubPrivateKeySource
+{
+ ///
+ /// True when a key can be obtained. Answers the readiness probe without
+ /// throwing, and without holding key material to find out.
+ ///
+ bool IsAvailable { get; }
+
+ /// Describes where the key comes from. Contains no key material.
+ string Description { get; }
+
+ /// The PEM text. Throws when no key is configured.
+ string ReadPem();
+}
diff --git a/src/DevSecOpsSentinel.Infrastructure/GitHub/GitHubAppJwtFactory.cs b/src/DevSecOpsSentinel.Infrastructure/GitHub/GitHubAppJwtFactory.cs
index 9c57629..b00901c 100644
--- a/src/DevSecOpsSentinel.Infrastructure/GitHub/GitHubAppJwtFactory.cs
+++ b/src/DevSecOpsSentinel.Infrastructure/GitHub/GitHubAppJwtFactory.cs
@@ -1,3 +1,4 @@
+using DevSecOpsSentinel.Application;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
diff --git a/src/DevSecOpsSentinel.Infrastructure/GitHub/GitHubPrivateKeySource.cs b/src/DevSecOpsSentinel.Infrastructure/GitHub/GitHubPrivateKeySource.cs
index 0a4003f..47e8a4b 100644
--- a/src/DevSecOpsSentinel.Infrastructure/GitHub/GitHubPrivateKeySource.cs
+++ b/src/DevSecOpsSentinel.Infrastructure/GitHub/GitHubPrivateKeySource.cs
@@ -1,33 +1,8 @@
using System.Text;
+using DevSecOpsSentinel.Application;
namespace DevSecOpsSentinel.Infrastructure.GitHub;
-///
-/// Supplies the GitHub App private key, from configuration or from a file.
-///
-/// A file path is workable on a developer machine and unworkable on a hosted
-/// platform: App Service application settings and Key Vault references deliver a
-/// value, not a file. Reading the key only from disk is what stopped this
-/// application being deployable.
-///
-/// Configuration wins when both are present, so a deployment cannot be
-/// accidentally served by a stale file left on the host.
-///
-public interface IGitHubPrivateKeySource
-{
- ///
- /// True when a key can be obtained. Answers the readiness probe without
- /// throwing, and without holding key material to find out.
- ///
- bool IsAvailable { get; }
-
- /// Describes where the key comes from. Contains no key material.
- string Description { get; }
-
- /// The PEM text. Throws when no key is configured.
- string ReadPem();
-}
-
public sealed class GitHubPrivateKeySource(GitHubOptions options)
: IGitHubPrivateKeySource
{
diff --git a/src/DevSecOpsSentinel.Infrastructure/Rules/RuleDiscovery.cs b/src/DevSecOpsSentinel.Infrastructure/Rules/RuleDiscovery.cs
new file mode 100644
index 0000000..73cc7b0
--- /dev/null
+++ b/src/DevSecOpsSentinel.Infrastructure/Rules/RuleDiscovery.cs
@@ -0,0 +1,36 @@
+using DevSecOpsSentinel.Application;
+
+namespace DevSecOpsSentinel.Infrastructure.Rules;
+
+///
+/// Every security rule in this assembly, found rather than listed.
+///
+/// The list used to be written out three times — once in the composition root, once in the
+/// tests, once in the eval — and a rule added to two of them would simply never run in the
+/// third, with nothing to say so. A hand-maintained registry is the one thing certain to
+/// drift, because forgetting it produces no error, only silence.
+///
+/// Ordered by rule id so registration order is the order a reader expects (GHA001 first) and
+/// does not depend on the order the runtime happens to return types in.
+///
+public static class RuleDiscovery
+{
+ ///
+ /// A fresh instance per call. Rules hold no state between evaluations, but handing out a
+ /// shared array would let a caller's edit reach every other caller.
+ ///
+ public static IReadOnlyList All() =>
+ [
+ .. typeof(RuleDiscovery).Assembly
+ .GetTypes()
+ .Where(type => typeof(IWorkflowSecurityRule).IsAssignableFrom(type))
+ .Where(type => type is { IsAbstract: false, IsInterface: false })
+ // A rule needing constructor arguments cannot be discovered this way. There is no
+ // such rule today; if one is added, it needs registering explicitly and this
+ // filter keeps it from being silently skipped as an activation failure.
+ .Where(type => type.GetConstructor(Type.EmptyTypes) is not null)
+ .Select(Activator.CreateInstance)
+ .Cast()
+ .OrderBy(rule => rule.RuleId, StringComparer.Ordinal)
+ ];
+}
diff --git a/tests/DevSecOpsSentinel.Evals/CorpusEval.cs b/tests/DevSecOpsSentinel.Evals/CorpusEval.cs
index 524a28b..ab304b6 100644
--- a/tests/DevSecOpsSentinel.Evals/CorpusEval.cs
+++ b/tests/DevSecOpsSentinel.Evals/CorpusEval.cs
@@ -1,7 +1,7 @@
-using System.Reflection;
using DevSecOpsSentinel.Application;
using DevSecOpsSentinel.Domain;
using DevSecOpsSentinel.Infrastructure;
+using DevSecOpsSentinel.Infrastructure.Rules;
namespace DevSecOpsSentinel.Evals;
@@ -21,21 +21,10 @@ public sealed class CorpusEval
private static readonly WorkflowParser Parser = new();
///
- /// Discovered from the assembly rather than listed here. A hand-maintained list is the
- /// one thing guaranteed to drift: a rule added to Infrastructure and forgotten here would
- /// simply never be measured, and nothing would say so.
+ /// The same discovery the composition root registers from, so the eval scores the rules
+ /// the application actually runs rather than a second opinion about what they are.
///
- private static readonly IReadOnlyList AllRules =
- [
- .. typeof(WorkflowParser).Assembly
- .GetTypes()
- .Where(type => typeof(IWorkflowSecurityRule).IsAssignableFrom(type))
- .Where(type => type is { IsAbstract: false, IsInterface: false })
- .Where(type => type.GetConstructor(Type.EmptyTypes) is not null)
- .Select(Activator.CreateInstance)
- .Cast()
- .OrderBy(rule => rule.RuleId, StringComparer.Ordinal)
- ];
+ private static readonly IReadOnlyList AllRules = RuleDiscovery.All();
public static TheoryData CorpusFiles()
{
diff --git a/tests/DevSecOpsSentinel.Infrastructure.Tests/RuleCatalogue.cs b/tests/DevSecOpsSentinel.Infrastructure.Tests/RuleCatalogue.cs
index ebaf42b..587a5bd 100644
--- a/tests/DevSecOpsSentinel.Infrastructure.Tests/RuleCatalogue.cs
+++ b/tests/DevSecOpsSentinel.Infrastructure.Tests/RuleCatalogue.cs
@@ -8,21 +8,12 @@ namespace DevSecOpsSentinel.Infrastructure.Tests;
///
/// Duplicated lists drift: a rule added to the application and not to a test's
/// private copy is simply never exercised, and nothing says so.
+///
+/// That copy is now gone. This delegates to the same discovery the composition
+/// root uses, so "every rule the API registers" is true by construction rather
+/// than by remembering.
///
internal static class RuleCatalogue
{
- public static IReadOnlyList All() =>
- [
- new UnpinnedActionRule(),
- new ExcessivePermissionsRule(),
- new MissingTimeoutRule(),
- new UnsafePullRequestTargetRule(),
- new ScriptInjectionRule(),
- new PersistedCredentialsRule(),
- new UntrustedCheckoutRule(),
- new InheritedSecretsRule(),
- new UndeclaredPermissionsRule(),
- new SelfHostedRunnerRule(),
- new ArtifactPoisoningRule()
- ];
+ public static IReadOnlyList All() => RuleDiscovery.All();
}