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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
<!--
Explicitly pins the transitive OpenAPI.NET dependency above the patched 2.x floor
for GHSA-v5pm-xwqc-g5wc. No project directly references this package.
Expand Down
74 changes: 74 additions & 0 deletions src/DevSecOpsSentinel.Api/Endpoints/PublicScanEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using DevSecOpsSentinel.Application;
using Microsoft.AspNetCore.Mvc;

namespace DevSecOpsSentinel.Api.Endpoints;

/// <summary>
/// Scan any public repository's workflows by name, anonymously.
///
/// This deliberately amends the middleware's "no outbound call" justification for
/// anonymous access: this endpoint does call out, and the amended boundary is
/// stated rather than implied — api.github.com and raw.githubusercontent.com
/// only, no credential attached, read-only by construction, results cached so a
/// visitor cannot spend the unauthenticated GitHub quota one refresh at a time.
/// Private repositories cannot appear here: an anonymous request cannot see them,
/// which is why this path needs no allowlist while /api/github does.
/// </summary>
public static class PublicScanEndpoints
{
public static WebApplication MapPublicScanEndpoints(this WebApplication app)
{
app.MapGet(
"/api/public-scan/{owner}/{repository}",
async (
string owner,
string repository,
IPublicRepositoryScanner scanner,
CancellationToken cancellationToken) =>
{
PublicScanResult result =
await scanner.ScanAsync(owner, repository, cancellationToken);

return result.Status switch
{
PublicScanStatus.Completed or PublicScanStatus.NoWorkflows =>
Results.Ok(result),

PublicScanStatus.InvalidName => Results.BadRequest(new ProblemDetails
{
Title = "Invalid repository name",
Detail = result.Detail,
Status = StatusCodes.Status400BadRequest
}),

PublicScanStatus.RepositoryNotFound => Results.NotFound(new ProblemDetails
{
Title = "Repository not found",
Detail = result.Detail,
Status = StatusCodes.Status404NotFound
}),

PublicScanStatus.QuotaExhausted => Results.Json(
new ProblemDetails
{
Title = "GitHub quota exhausted",
Detail = result.Detail,
Status = StatusCodes.Status503ServiceUnavailable
},
statusCode: StatusCodes.Status503ServiceUnavailable),

_ => Results.Json(
new ProblemDetails
{
Title = "GitHub unavailable",
Detail = result.Detail,
Status = StatusCodes.Status502BadGateway
},
statusCode: StatusCodes.Status502BadGateway)
};
})
.RequireRateLimiting("workflow-analysis");

return app;
}
}
16 changes: 16 additions & 0 deletions src/DevSecOpsSentinel.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@
client.Timeout = TimeSpan.FromSeconds(30);
});

// The anonymous public-repository scanner. A separate named client from "GitHub" on
// purpose: that one carries an installation token, this one must never carry anything.
// GitHub rejects requests without a User-Agent, and asking for the JSON media type keeps
// the contents API's answers stable.
builder.Services.AddHttpClient("GitHubPublic", client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd("DevSecOpsSentinel");
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
});

builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IPublicRepositoryScanner, PublicRepositoryScanner>();

builder.Services.AddSingleton<
IGitHubPrivateKeySource,
GitHubPrivateKeySource>();
Expand Down Expand Up @@ -279,6 +294,7 @@
app.MapStatusEndpoints(openAiOptions, gitHubOptions)
.MapGitHubEndpoints(gitHubOptions)
.MapCatalogueEndpoints()
.MapPublicScanEndpoints()
.MapWorkflowEndpoints(maximumWorkflowCharacters);

app.Run();
Expand Down
69 changes: 69 additions & 0 deletions src/DevSecOpsSentinel.Application/PublicScanContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using DevSecOpsSentinel.Domain;

namespace DevSecOpsSentinel.Application;

/// <summary>
/// Scan of a public repository's workflows, requested by name alone.
///
/// This is the one feature that makes an outbound call on behalf of an anonymous
/// visitor, so its boundaries are the point: only api.github.com and
/// raw.githubusercontent.com are contacted, no credential is attached, nothing is
/// written, and results are cached so a popular repository costs one fetch rather
/// than one per visitor. Private repositories are invisible to it by construction —
/// an unauthenticated request cannot see them, which is the whole reason this needs
/// no allowlist while the GitHub App integration does.
/// </summary>
public interface IPublicRepositoryScanner
{
Task<PublicScanResult> ScanAsync(
string owner,
string repository,
CancellationToken cancellationToken);
}

public enum PublicScanStatus
{
/// <summary>Workflows were fetched and analysed.</summary>
Completed,

/// <summary>The repository does not exist, or is private — indistinguishable
/// without credentials, and deliberately reported the same way.</summary>
RepositoryNotFound,

/// <summary>The repository exists but has no workflow files.</summary>
NoWorkflows,

/// <summary>The owner or repository name is not a name GitHub could accept.</summary>
InvalidName,

/// <summary>GitHub's unauthenticated quota for this host is exhausted.</summary>
QuotaExhausted,

/// <summary>GitHub answered with something unexpected.</summary>
GitHubUnavailable
}

/// <summary>One workflow file and what the deterministic rules made of it.</summary>
public sealed record PublicScanFile(
string FileName,
string HtmlUrl,
WorkflowAnalysisResult Analysis);

public sealed record PublicScanResult(
string Owner,
string Repository,
PublicScanStatus Status,
string? Detail,
IReadOnlyList<PublicScanFile> Files,
int SkippedFiles,
DateTimeOffset FetchedAtUtc,
bool FromCache)
{
public static PublicScanResult Failure(
string owner,
string repository,
PublicScanStatus status,
string detail,
DateTimeOffset atUtc) =>
new(owner, repository, status, detail, [], 0, atUtc, FromCache: false);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="OpenAI" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
Expand Down
Loading