From d74f70cc16834df151ca98dd8bccb73b962d6c84 Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:15:15 -0500 Subject: [PATCH 1/2] refactor: discover the rules, and let Application own every contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the architecture nearly had. The rule list was written out three times: eleven registrations in the composition root, a copy in RuleCatalogue, and reflection in the eval. A rule added to two of them would never run in the third and nothing would report it, because forgetting a registry produces silence rather than an error — the exact failure RuleCatalogue's own comment warned about while duplicating the list. RuleDiscovery finds them in the assembly, ordered by rule id so GHA001 still registers first, and all three call sites now use it. IGitHubPrivateKeySource was declared in Infrastructure beside its implementation, but Program.cs injects it into the readiness endpoint. That had the outer layer depending on an abstraction the outer layer also owned — the one place in the project where that was true. Moved to Application, where every other contract already lives. No interfaces remain in Infrastructure. 204 tests pass. Application and Domain still reference nothing outward. Co-Authored-By: Claude Opus 5 --- src/DevSecOpsSentinel.Api/Program.cs | 18 ++++------ .../GitHubContracts.cs | 32 +++++++++++++++++ .../GitHub/GitHubAppJwtFactory.cs | 1 + .../GitHub/GitHubPrivateKeySource.cs | 27 +------------- .../Rules/RuleDiscovery.cs | 36 +++++++++++++++++++ tests/DevSecOpsSentinel.Evals/CorpusEval.cs | 19 +++------- .../RuleCatalogue.cs | 19 +++------- 7 files changed, 86 insertions(+), 66 deletions(-) create mode 100644 src/DevSecOpsSentinel.Infrastructure/Rules/RuleDiscovery.cs diff --git a/src/DevSecOpsSentinel.Api/Program.cs b/src/DevSecOpsSentinel.Api/Program.cs index 2218f0b..9be12e9 100644 --- a/src/DevSecOpsSentinel.Api/Program.cs +++ b/src/DevSecOpsSentinel.Api/Program.cs @@ -115,17 +115,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(); 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(); } From 3eca271b77ad6895e308465d9ef6d65ee2a8f64d Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:19:42 -0500 Subject: [PATCH 2/2] refactor: split Program.cs into a composition root and four endpoint groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 944 lines held the composition root, the middleware pipeline and every handler body at once — the first file a reviewer opens, and the least disciplined thing in an otherwise disciplined codebase. Program.cs is now 329 lines: configuration, DI, the pipeline, and four calls. The handlers move to Api/Endpoints, grouped by what they serve rather than by verb: Status (root, health, security and AI status), GitHub (the read-only App), Catalogue (rules and scenarios), Workflow (analysis, remediation, explanation). Nothing closed over disappeared. Each group takes what it needs as a parameter — openAiOptions and gitHubOptions for Status, gitHubOptions for GitHub, maximumWorkflowCharacters for Workflow, nothing for Catalogue — so the dependency is stated in the signature rather than captured from a file-scoped local. ValidateWorkflowRequest moves in with its only caller. Mechanical: no handler body changed. The route surface is identical, verified by diffing all 18 routes against main. 204 tests pass including all 61 integration tests, which exercise these endpoints end to end. Co-Authored-By: Claude Opus 5 --- .../Endpoints/CatalogueEndpoints.cs | 61 ++ .../Endpoints/GitHubEndpoints.cs | 243 +++++++ .../Endpoints/StatusEndpoints.cs | 152 +++++ .../Endpoints/WorkflowEndpoints.cs | 264 ++++++++ src/DevSecOpsSentinel.Api/Program.cs | 625 +----------------- 5 files changed, 725 insertions(+), 620 deletions(-) create mode 100644 src/DevSecOpsSentinel.Api/Endpoints/CatalogueEndpoints.cs create mode 100644 src/DevSecOpsSentinel.Api/Endpoints/GitHubEndpoints.cs create mode 100644 src/DevSecOpsSentinel.Api/Endpoints/StatusEndpoints.cs create mode 100644 src/DevSecOpsSentinel.Api/Endpoints/WorkflowEndpoints.cs 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 9be12e9..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; @@ -275,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(); @@ -911,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;