Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/DevSecOpsSentinel.Api/Endpoints/GitHubEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ namespace DevSecOpsSentinel.Api.Endpoints;
/// </summary>
public static class GitHubEndpoints
{
public static WebApplication MapGitHubEndpoints(this WebApplication app, GitHubOptions gitHubOptions)
// Options come from DI per request rather than a parameter captured at map time. The
// captured copy was read from configuration before Build(), which made it a second
// source of truth — one a test host provably could not influence, and one that would
// disagree with the container's copy if registration ever changed. One source now.
public static WebApplication MapGitHubEndpoints(this WebApplication app)
{
app.MapGet(
"/api/github/status",
async (
GitHubOptions gitHubOptions,
IGitHubRepositoryReader reader,
ILogger<Program> logger,
CancellationToken cancellationToken) =>
Expand Down Expand Up @@ -81,6 +86,7 @@ public static WebApplication MapGitHubEndpoints(this WebApplication app, GitHubO
app.MapGet(
"/api/github/repositories",
async (
GitHubOptions gitHubOptions,
IGitHubRepositoryReader reader,
CancellationToken cancellationToken) =>
{
Expand All @@ -102,6 +108,7 @@ public static WebApplication MapGitHubEndpoints(this WebApplication app, GitHubO
async (
string owner,
string repository,
GitHubOptions gitHubOptions,
IGitHubRepositoryReader reader,
CancellationToken cancellationToken) =>
{
Expand Down Expand Up @@ -130,6 +137,7 @@ await reader.GetWorkflowsAsync(
string repository,
string path,
string? reference,
GitHubOptions gitHubOptions,
IGitHubRepositoryReader reader,
CancellationToken cancellationToken) =>
{
Expand Down Expand Up @@ -163,6 +171,7 @@ await reader.GetWorkflowAsync(
string owner,
string repository,
AnalyzeGitHubWorkflowRequest? request,
GitHubOptions gitHubOptions,
IGitHubRepositoryReader reader,
IWorkflowAnalysisService analysisService,
IWorkflowExplanationService explanationService,
Expand Down
11 changes: 8 additions & 3 deletions src/DevSecOpsSentinel.Api/Endpoints/StatusEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ namespace DevSecOpsSentinel.Api.Endpoints;
/// </summary>
public static class StatusEndpoints
{
public static WebApplication MapStatusEndpoints(this WebApplication app, OpenAiOptions openAiOptions, GitHubOptions gitHubOptions)
// Same rule as GitHubEndpoints: options resolve from DI at request time, not from a
// pre-Build snapshot captured into the closure.
public static WebApplication MapStatusEndpoints(this WebApplication app)
{
app.MapGet("/", () => Results.Ok(new
{
Expand Down Expand Up @@ -86,7 +88,10 @@ public static WebApplication MapStatusEndpoints(this WebApplication app, OpenAiO
* 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) =>
app.MapGet("/api/health/ready", (
OpenAiOptions openAiOptions,
GitHubOptions gitHubOptions,
IGitHubPrivateKeySource privateKeySource) =>
{
bool gitHubDegraded =
gitHubOptions.Enabled &&
Expand Down Expand Up @@ -122,7 +127,7 @@ public static WebApplication MapStatusEndpoints(this WebApplication app, OpenAiO
});
});

app.MapGet("/api/ai/status", () =>
app.MapGet("/api/ai/status", (OpenAiOptions openAiOptions) =>
{
bool configured =
!string.IsNullOrWhiteSpace(openAiOptions.ApiKey);
Expand Down
4 changes: 2 additions & 2 deletions src/DevSecOpsSentinel.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,8 @@
app.UseRateLimiter();
app.UseOutputCache();

app.MapStatusEndpoints(openAiOptions, gitHubOptions)
.MapGitHubEndpoints(gitHubOptions)
app.MapStatusEndpoints()
.MapGitHubEndpoints()
.MapCatalogueEndpoints()
.MapPublicScanEndpoints()
.MapWorkflowEndpoints(maximumWorkflowCharacters);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,25 @@ namespace DevSecOpsSentinel.Infrastructure.Ai;

public sealed class OpenAiWorkflowAiProvider : IWorkflowAiProvider
{
/// <summary>
/// The one call that leaves the process, as a seam.
///
/// Everything around it — prompt assembly, the timeout envelope, payload parsing, the
/// containment gate, every fallback — was unreachable offline while the provider built
/// its ChatClient internally, which meant the pipeline the replay corpus exists to
/// exercise could only be proven up to the gate, never through it. The delegate carries
/// the request the production path would send; tests substitute the transport and
/// nothing else.
/// </summary>
internal delegate Task<string> CompleteChat(
IReadOnlyList<ChatMessage> messages,
ChatCompletionOptions options,
CancellationToken cancellationToken);

private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly OpenAiOptions _options;
private readonly ILogger<OpenAiWorkflowAiProvider> _logger;
private readonly ChatClient? _client;
private readonly CompleteChat? _completeChat;

public OpenAiWorkflowAiProvider(
OpenAiOptions options,
Expand All @@ -21,10 +36,28 @@ public OpenAiWorkflowAiProvider(
_logger = logger;
if (!string.IsNullOrWhiteSpace(options.ApiKey))
{
_client = new ChatClient(options.Model, options.ApiKey);
ChatClient client = new(options.Model, options.ApiKey);
_completeChat = async (messages, completionOptions, cancellationToken) =>
{
ChatCompletion completion = await client.CompleteChatAsync(
[.. messages],
completionOptions,
cancellationToken);
return completion.Content[0].Text;
};
}
}

internal OpenAiWorkflowAiProvider(
OpenAiOptions options,
ILogger<OpenAiWorkflowAiProvider> logger,
CompleteChat completeChat)
{
_options = options;
_logger = logger;
_completeChat = completeChat;
}

public async Task<WorkflowAiExplanation> ExplainAsync(
WorkflowAnalysisResult analysis,
string sanitizedContent,
Expand All @@ -36,7 +69,7 @@ public async Task<WorkflowAiExplanation> ExplainAsync(
// character is enough to matter; none survive this.
string safeFileName = new([.. analysis.FileName.Where(c => !char.IsControl(c))]);

if (_client is null)
if (_completeChat is null)
{
return AiExplanationFactory.CreateFallback(
analysis,
Expand Down Expand Up @@ -86,12 +119,7 @@ public async Task<WorkflowAiExplanation> ExplainAsync(
jsonSchemaIsStrict: true)
};

ChatCompletion completion = await _client.CompleteChatAsync(
messages,
completionOptions,
timeout.Token);

string json = completion.Content[0].Text;
string json = await _completeChat(messages, completionOptions, timeout.Token);
OpenAiExplanationPayload? payload = JsonSerializer.Deserialize<OpenAiExplanationPayload>(json, JsonOptions);
if (payload is null || !IsValid(payload, analysis))
{
Expand Down
137 changes: 137 additions & 0 deletions src/devsecops-sentinel-web/src/UnlockAndExport.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App';
import { downloadRemediationExport } from './api';

/**
* The two flows the other suites leave untouched: buying live access with the
* key, and taking a remediation out of the app as a file. The unlock flow is
* security-relevant — a wrong key must not be stored as though it worked,
* which is a regression this app has actually had.
*/
const scenario = { id: 'sample', name: 'Sample', description: 'Sample', fileName: 'sample.yml' };

const publicMode = {
required: false,
headerName: 'X-API-Key',
sessionOnlyBrowserKey: true,
mode: 'Public',
keyUnlocksGitHub: true,
keyUnlocksLiveAi: true,
};

function respond(overrides: Record<string, (init?: RequestInit) => Response> = {}) {
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = String(input);
for (const [prefix, handler] of Object.entries(overrides)) {
if (url.includes(prefix)) return handler(init);
}
if (url === '/api/security/status') return Response.json(publicMode);
if (url === '/api/scenarios') return Response.json([scenario]);
if (url === '/api/scenarios/sample') return Response.json({ ...scenario, content: 'name: Sample\non:\n push:\n' });
if (url === '/api/ai/status') return Response.json({ enabled: true, configured: false, provider: 'OpenAI', mode: 'Mock', model: 'gpt-5-mini', costProtection: { explicitRequestOnly: true, mockModeConsumesCredits: false } });
if (url === '/api/github/status') return Response.json({ enabled: false, configured: false, connected: false, mode: 'ReadOnly', allowedRepositoryCount: 0, message: 'Not configured.' });
return new Response('{}', { status: 404 });
};
}

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
sessionStorage.clear();
});

describe('Unlocking live access', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(respond()));
});

it('accepts a key the API verifies and switches the header to locked mode', async () => {
render(<App />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Unlock live AI and GitHub' })).toBeInTheDocument());

fireEvent.click(screen.getByRole('button', { name: 'Unlock live AI and GitHub' }));
fireEvent.change(screen.getByLabelText('X-API-Key'), { target: { value: 'the-key' } });
fireEvent.click(screen.getByRole('button', { name: 'Unlock' }));

await waitFor(() => expect(screen.getByRole('button', { name: 'Lock API' })).toBeInTheDocument());
expect(sessionStorage.getItem('devsecops-sentinel-api-key')).toBe('the-key');
});

it('rejects a key the API refuses and does not keep it', async () => {
// The regression this pins: an unverified key was stored, the header said
// "Lock API" as though it had worked, and the only symptom was GitHub
// quietly staying unavailable.
// The app verifies a candidate key by probing /api/github/status with it.
vi.stubGlobal('fetch', vi.fn(respond({
'/api/github/status': (init) => {
const headers = new Headers(init?.headers);
return headers.get('X-API-Key')
? new Response('{"title":"Invalid key"}', { status: 401 })
: Response.json({ enabled: false, configured: false, connected: false, mode: 'ReadOnly', allowedRepositoryCount: 0, message: 'Not configured.' });
},
})));

render(<App />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Unlock live AI and GitHub' })).toBeInTheDocument());

fireEvent.click(screen.getByRole('button', { name: 'Unlock live AI and GitHub' }));
fireEvent.change(screen.getByLabelText('X-API-Key'), { target: { value: 'wrong' } });
fireEvent.click(screen.getByRole('button', { name: 'Unlock' }));

await waitFor(() => expect(screen.getAllByText('That key was not accepted. Check it and try again.').length).toBeGreaterThan(0));
expect(sessionStorage.getItem('devsecops-sentinel-api-key')).toBeNull();
expect(screen.queryByRole('button', { name: 'Lock API' })).not.toBeInTheDocument();
});

it('locking again clears the stored key', async () => {
render(<App />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Unlock live AI and GitHub' })).toBeInTheDocument());

fireEvent.click(screen.getByRole('button', { name: 'Unlock live AI and GitHub' }));
fireEvent.change(screen.getByLabelText('X-API-Key'), { target: { value: 'the-key' } });
fireEvent.click(screen.getByRole('button', { name: 'Unlock' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Lock API' })).toBeInTheDocument());

fireEvent.click(screen.getByRole('button', { name: 'Lock API' }));

await waitFor(() => expect(screen.getByRole('button', { name: 'Unlock live AI and GitHub' })).toBeInTheDocument());
expect(sessionStorage.getItem('devsecops-sentinel-api-key')).toBeNull();
});
});

describe('Remediation export', () => {
it('downloads the export under the server-supplied file name', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/workflows/remediation/export/markdown')) {
return new Response(new Blob(['# report']), {
status: 200,
headers: { 'content-disposition': 'attachment; filename="sample-remediation.md"' },
});
}
return new Response('{}', { status: 404 });
}));
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => 'blob:url'),
revokeObjectURL: vi.fn(),
});
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});

await downloadRemediationExport('sample.yml', 'name: x', 'markdown');

expect(click).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:url');
});

it('a failed export throws with the status instead of downloading', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 500 })));
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});

await expect(downloadRemediationExport('sample.yml', 'name: x', 'sarif'))
.rejects.toThrow('Export failed (500)');
expect(click).not.toHaveBeenCalled();
});
});
Loading