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(); }