feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
using StellaOps.VexHub.WebService.Models;
|
||||
|
||||
namespace StellaOps.VexHub.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for mapping VexHub API endpoints.
|
||||
/// </summary>
|
||||
public static class VexHubEndpointExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps all VexHub API endpoints.
|
||||
/// </summary>
|
||||
public static WebApplication MapVexHubEndpoints(this WebApplication app)
|
||||
{
|
||||
var vexGroup = app.MapGroup("/api/v1/vex")
|
||||
.WithTags("VEX");
|
||||
|
||||
// GET /api/v1/vex/cve/{cve-id}
|
||||
vexGroup.MapGet("/cve/{cveId}", GetByCve)
|
||||
.WithName("GetVexByCve")
|
||||
.WithDescription("Get VEX statements for a CVE ID")
|
||||
.Produces<VexStatementsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/vex/package/{purl}
|
||||
vexGroup.MapGet("/package/{purl}", GetByPackage)
|
||||
.WithName("GetVexByPackage")
|
||||
.WithDescription("Get VEX statements for a package PURL")
|
||||
.Produces<VexStatementsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/vex/source/{source-id}
|
||||
vexGroup.MapGet("/source/{sourceId}", GetBySource)
|
||||
.WithName("GetVexBySource")
|
||||
.WithDescription("Get VEX statements from a specific source")
|
||||
.Produces<VexStatementsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/vex/statement/{id}
|
||||
vexGroup.MapGet("/statement/{id:guid}", GetById)
|
||||
.WithName("GetVexStatement")
|
||||
.WithDescription("Get a specific VEX statement by ID")
|
||||
.Produces<AggregatedVexStatement>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/vex/search
|
||||
vexGroup.MapGet("/search", Search)
|
||||
.WithName("SearchVex")
|
||||
.WithDescription("Search VEX statements with filters")
|
||||
.Produces<VexSearchResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// GET /api/v1/vex/stats
|
||||
vexGroup.MapGet("/stats", GetStats)
|
||||
.WithName("GetVexStats")
|
||||
.WithDescription("Get VEX hub statistics")
|
||||
.Produces<VexHubStats>(StatusCodes.Status200OK);
|
||||
|
||||
// GET /api/v1/vex/index
|
||||
vexGroup.MapGet("/index", GetIndex)
|
||||
.WithName("GetVexIndex")
|
||||
.WithDescription("Get VEX hub index manifest for tool integration")
|
||||
.Produces<VexIndexManifest>(StatusCodes.Status200OK);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetByCve(
|
||||
string cveId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statements = await repository.GetByCveAsync(cveId, limit ?? 100, offset ?? 0, cancellationToken);
|
||||
|
||||
if (statements.Count == 0)
|
||||
return Results.NotFound(new { Message = $"No VEX statements found for CVE {cveId}" });
|
||||
|
||||
return Results.Ok(new VexStatementsResponse
|
||||
{
|
||||
Statements = statements,
|
||||
TotalCount = statements.Count,
|
||||
QueryType = "cve",
|
||||
QueryValue = cveId
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetByPackage(
|
||||
string purl,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// URL decode the PURL
|
||||
var decodedPurl = Uri.UnescapeDataString(purl);
|
||||
var statements = await repository.GetByPackageAsync(decodedPurl, limit ?? 100, offset ?? 0, cancellationToken);
|
||||
|
||||
if (statements.Count == 0)
|
||||
return Results.NotFound(new { Message = $"No VEX statements found for package {decodedPurl}" });
|
||||
|
||||
return Results.Ok(new VexStatementsResponse
|
||||
{
|
||||
Statements = statements,
|
||||
TotalCount = statements.Count,
|
||||
QueryType = "package",
|
||||
QueryValue = decodedPurl
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBySource(
|
||||
string sourceId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statements = await repository.GetBySourceAsync(sourceId, limit ?? 100, offset ?? 0, cancellationToken);
|
||||
|
||||
if (statements.Count == 0)
|
||||
return Results.NotFound(new { Message = $"No VEX statements found for source {sourceId}" });
|
||||
|
||||
return Results.Ok(new VexStatementsResponse
|
||||
{
|
||||
Statements = statements,
|
||||
TotalCount = statements.Count,
|
||||
QueryType = "source",
|
||||
QueryValue = sourceId
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetById(
|
||||
Guid id,
|
||||
IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statement = await repository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
if (statement is null)
|
||||
return Results.NotFound(new { Message = $"VEX statement {id} not found" });
|
||||
|
||||
return Results.Ok(statement);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Search(
|
||||
[FromQuery] string? sourceId,
|
||||
[FromQuery] string? vulnerabilityId,
|
||||
[FromQuery] string? productKey,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] bool? isFlagged,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = new VexStatementFilter
|
||||
{
|
||||
SourceId = sourceId,
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
ProductKey = productKey,
|
||||
IsFlagged = isFlagged
|
||||
};
|
||||
|
||||
var statements = await repository.SearchAsync(filter, limit ?? 100, offset ?? 0, cancellationToken);
|
||||
var totalCount = await repository.GetCountAsync(filter, cancellationToken);
|
||||
|
||||
return Results.Ok(new VexSearchResponse
|
||||
{
|
||||
Statements = statements,
|
||||
TotalCount = totalCount,
|
||||
Limit = limit ?? 100,
|
||||
Offset = offset ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStats(
|
||||
IVexStatementRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var totalCount = await repository.GetCountAsync(cancellationToken: cancellationToken);
|
||||
var verifiedCount = await repository.GetCountAsync(
|
||||
new VexStatementFilter { VerificationStatus = VerificationStatus.Verified },
|
||||
cancellationToken);
|
||||
var flaggedCount = await repository.GetCountAsync(
|
||||
new VexStatementFilter { IsFlagged = true },
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new VexHubStats
|
||||
{
|
||||
TotalStatements = totalCount,
|
||||
VerifiedStatements = verifiedCount,
|
||||
FlaggedStatements = flaggedCount,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetIndex()
|
||||
{
|
||||
return Results.Ok(new VexIndexManifest
|
||||
{
|
||||
Version = "1.0",
|
||||
LastUpdated = DateTimeOffset.UtcNow,
|
||||
Endpoints = new VexIndexEndpoints
|
||||
{
|
||||
ByCve = "/api/v1/vex/cve/{cve}",
|
||||
ByPackage = "/api/v1/vex/package/{purl}",
|
||||
BySource = "/api/v1/vex/source/{source-id}",
|
||||
Search = "/api/v1/vex/search",
|
||||
Stats = "/api/v1/vex/stats"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.VexHub.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for VexHub web service.
|
||||
/// </summary>
|
||||
public static class VexHubWebServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds VexHub web service dependencies.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddVexHubWebService(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddControllers();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.VexHub.WebService.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication handler for API key authentication.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
|
||||
{
|
||||
private const string ApiKeyHeaderName = "X-Api-Key";
|
||||
private const string ApiKeyQueryParamName = "api_key";
|
||||
|
||||
public ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Try to get API key from header first
|
||||
if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyHeader))
|
||||
{
|
||||
// Fall back to query parameter
|
||||
if (!Request.Query.TryGetValue(ApiKeyQueryParamName, out var apiKeyQuery))
|
||||
{
|
||||
// No API key provided - allow anonymous access for public endpoints
|
||||
if (Options.AllowAnonymous)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Fail("API key is required"));
|
||||
}
|
||||
|
||||
apiKeyHeader = apiKeyQuery;
|
||||
}
|
||||
|
||||
var providedApiKey = apiKeyHeader.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providedApiKey))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.Fail("API key is empty"));
|
||||
}
|
||||
|
||||
// Validate the API key
|
||||
var apiKeyInfo = ValidateApiKey(providedApiKey);
|
||||
if (apiKeyInfo is null)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.Fail("Invalid API key"));
|
||||
}
|
||||
|
||||
// Create claims identity
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, apiKeyInfo.ClientId),
|
||||
new(ClaimTypes.Name, apiKeyInfo.ClientName),
|
||||
new("api_key_id", apiKeyInfo.KeyId)
|
||||
};
|
||||
|
||||
foreach (var scope in apiKeyInfo.Scopes)
|
||||
{
|
||||
claims.Add(new Claim("scope", scope));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
|
||||
private ApiKeyInfo? ValidateApiKey(string apiKey)
|
||||
{
|
||||
// Check against configured API keys
|
||||
if (Options.ApiKeys.TryGetValue(apiKey, out var keyInfo))
|
||||
{
|
||||
return keyInfo;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for API key authentication.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Dictionary of valid API keys to their info.
|
||||
/// </summary>
|
||||
public Dictionary<string, ApiKeyInfo> ApiKeys { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow anonymous access when no API key is provided.
|
||||
/// </summary>
|
||||
public bool AllowAnonymous { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an API key.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this key.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Client identifier.
|
||||
/// </summary>
|
||||
public required string ClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable client name.
|
||||
/// </summary>
|
||||
public required string ClientName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scopes granted to this key.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit override for this key (requests per minute).
|
||||
/// </summary>
|
||||
public int? RateLimitPerMinute { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
|
||||
namespace StellaOps.VexHub.WebService.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware for rate limiting API requests using a sliding window algorithm.
|
||||
/// </summary>
|
||||
public sealed class RateLimitingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<RateLimitingMiddleware> _logger;
|
||||
private readonly VexHubOptions _options;
|
||||
private readonly ConcurrentDictionary<string, RateLimitEntry> _rateLimits = new();
|
||||
private readonly Timer _cleanupTimer;
|
||||
|
||||
public RateLimitingMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<VexHubOptions> options,
|
||||
ILogger<RateLimitingMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
|
||||
// Clean up old entries every minute
|
||||
_cleanupTimer = new Timer(CleanupOldEntries, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Skip rate limiting for health checks
|
||||
if (context.Request.Path.StartsWithSegments("/health"))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var clientId = GetClientIdentifier(context);
|
||||
var rateLimit = GetRateLimitForClient(context);
|
||||
|
||||
if (rateLimit <= 0)
|
||||
{
|
||||
// Rate limiting disabled
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = _rateLimits.GetOrAdd(clientId, _ => new RateLimitEntry());
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Clean old requests outside the window
|
||||
entry.CleanOldRequests(now);
|
||||
|
||||
// Check if rate limit exceeded
|
||||
if (entry.RequestCount >= rateLimit)
|
||||
{
|
||||
var resetTime = entry.GetResetTime();
|
||||
var retryAfter = (int)Math.Ceiling((resetTime - now).TotalSeconds);
|
||||
|
||||
context.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
|
||||
context.Response.Headers["Retry-After"] = retryAfter.ToString();
|
||||
context.Response.Headers["X-RateLimit-Limit"] = rateLimit.ToString();
|
||||
context.Response.Headers["X-RateLimit-Remaining"] = "0";
|
||||
context.Response.Headers["X-RateLimit-Reset"] = resetTime.ToUnixTimeSeconds().ToString();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rate limit exceeded for client {ClientId}. Limit: {Limit}, Retry after: {RetryAfter}s",
|
||||
clientId,
|
||||
rateLimit,
|
||||
retryAfter);
|
||||
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "rate_limit_exceeded",
|
||||
message = $"Rate limit exceeded. Try again in {retryAfter} seconds.",
|
||||
retryAfter
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Record this request
|
||||
entry.RecordRequest(now);
|
||||
|
||||
// Add rate limit headers
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
var remaining = Math.Max(0, rateLimit - entry.RequestCount);
|
||||
context.Response.Headers["X-RateLimit-Limit"] = rateLimit.ToString();
|
||||
context.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
|
||||
context.Response.Headers["X-RateLimit-Reset"] = entry.GetResetTime().ToUnixTimeSeconds().ToString();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private string GetClientIdentifier(HttpContext context)
|
||||
{
|
||||
// First try to get from authenticated user
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var clientId = context.User.FindFirst("api_key_id")?.Value;
|
||||
if (!string.IsNullOrEmpty(clientId))
|
||||
{
|
||||
return $"key:{clientId}";
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to IP address
|
||||
var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
// Check for forwarded IP
|
||||
if (context.Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
|
||||
{
|
||||
var firstIp = forwardedFor.ToString().Split(',').FirstOrDefault()?.Trim();
|
||||
if (!string.IsNullOrEmpty(firstIp))
|
||||
{
|
||||
ipAddress = firstIp;
|
||||
}
|
||||
}
|
||||
|
||||
return $"ip:{ipAddress}";
|
||||
}
|
||||
|
||||
private int GetRateLimitForClient(HttpContext context)
|
||||
{
|
||||
// Check for API key with custom rate limit
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
// API key authenticated clients could have higher limits
|
||||
// This would be retrieved from the API key info
|
||||
// For now, use 2x the default rate limit for authenticated clients
|
||||
return _options.Distribution.RateLimitPerMinute * 2;
|
||||
}
|
||||
|
||||
return _options.Distribution.RateLimitPerMinute;
|
||||
}
|
||||
|
||||
private void CleanupOldEntries(object? state)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiredKeys = _rateLimits
|
||||
.Where(kvp => (now - kvp.Value.LastRequestTime).TotalMinutes > 5)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_rateLimits.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
if (expiredKeys.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Cleaned up {Count} expired rate limit entries", expiredKeys.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks request counts for rate limiting using a sliding window.
|
||||
/// </summary>
|
||||
internal sealed class RateLimitEntry
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Queue<DateTimeOffset> _requestTimes = new();
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||
|
||||
public int RequestCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _requestTimes.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset LastRequestTime { get; private set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public void RecordRequest(DateTimeOffset time)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_requestTimes.Enqueue(time);
|
||||
LastRequestTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
public void CleanOldRequests(DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var windowStart = now - Window;
|
||||
while (_requestTimes.Count > 0 && _requestTimes.Peek() < windowStart)
|
||||
{
|
||||
_requestTimes.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset GetResetTime()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_requestTimes.Count == 0)
|
||||
{
|
||||
return DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
return _requestTimes.Peek() + Window;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for adding rate limiting to the application.
|
||||
/// </summary>
|
||||
public static class RateLimitingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the rate limiting middleware to the pipeline.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseVexHubRateLimiting(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<RateLimitingMiddleware>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
|
||||
namespace StellaOps.VexHub.WebService.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Response containing VEX statements.
|
||||
/// </summary>
|
||||
public sealed class VexStatementsResponse
|
||||
{
|
||||
public required IReadOnlyList<AggregatedVexStatement> Statements { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public required string QueryType { get; init; }
|
||||
public required string QueryValue { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for VEX search queries.
|
||||
/// </summary>
|
||||
public sealed class VexSearchResponse
|
||||
{
|
||||
public required IReadOnlyList<AggregatedVexStatement> Statements { get; init; }
|
||||
public required long TotalCount { get; init; }
|
||||
public required int Limit { get; init; }
|
||||
public required int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX Hub statistics.
|
||||
/// </summary>
|
||||
public sealed class VexHubStats
|
||||
{
|
||||
public required long TotalStatements { get; init; }
|
||||
public required long VerifiedStatements { get; init; }
|
||||
public required long FlaggedStatements { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX Hub index manifest for tool integration.
|
||||
/// </summary>
|
||||
public sealed class VexIndexManifest
|
||||
{
|
||||
public required string Version { get; init; }
|
||||
public required DateTimeOffset LastUpdated { get; init; }
|
||||
public required VexIndexEndpoints Endpoints { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX Hub API endpoints.
|
||||
/// </summary>
|
||||
public sealed class VexIndexEndpoints
|
||||
{
|
||||
public required string ByCve { get; init; }
|
||||
public required string ByPackage { get; init; }
|
||||
public required string BySource { get; init; }
|
||||
public required string Search { get; init; }
|
||||
public required string Stats { get; init; }
|
||||
}
|
||||
73
src/VexHub/StellaOps.VexHub.WebService/Program.cs
Normal file
73
src/VexHub/StellaOps.VexHub.WebService/Program.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Serilog;
|
||||
using StellaOps.VexHub.Core.Extensions;
|
||||
using StellaOps.VexHub.Storage.Postgres.Extensions;
|
||||
using StellaOps.VexHub.WebService.Extensions;
|
||||
using StellaOps.VexHub.WebService.Middleware;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Serilog
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.CreateLogger();
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// Add services to the container
|
||||
builder.Services.AddVexHubCore(builder.Configuration);
|
||||
builder.Services.AddVexHubPostgres(builder.Configuration);
|
||||
builder.Services.AddVexHubWebService(builder.Configuration);
|
||||
|
||||
// Add authentication
|
||||
builder.Services.AddAuthentication("ApiKey")
|
||||
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", options =>
|
||||
{
|
||||
options.AllowAnonymous = true; // Allow anonymous for public read endpoints
|
||||
// API keys can be configured via configuration
|
||||
var apiKeysSection = builder.Configuration.GetSection("VexHub:ApiKeys");
|
||||
foreach (var keySection in apiKeysSection.GetChildren())
|
||||
{
|
||||
var key = keySection.Key;
|
||||
options.ApiKeys[key] = new ApiKeyInfo
|
||||
{
|
||||
KeyId = keySection["KeyId"] ?? key,
|
||||
ClientId = keySection["ClientId"] ?? "unknown",
|
||||
ClientName = keySection["ClientName"] ?? "Unknown Client",
|
||||
Scopes = keySection.GetSection("Scopes").Get<string[]>() ?? Array.Empty<string>(),
|
||||
RateLimitPerMinute = keySection.GetValue<int?>("RateLimitPerMinute")
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
// Add rate limiting middleware
|
||||
app.UseVexHubRateLimiting();
|
||||
|
||||
// Add authentication and authorization
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Map API endpoints
|
||||
app.MapVexHubEndpoints();
|
||||
|
||||
// Health check
|
||||
app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Service = "VexHub" }))
|
||||
.WithName("HealthCheck")
|
||||
.WithTags("Health");
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.VexHub.WebService</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.VexHub.Core/StellaOps.VexHub.Core.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.VexHub.Storage.Postgres/StellaOps.VexHub.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../VexLens/StellaOps.VexLens/StellaOps.VexLens.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/VexHub/StellaOps.VexHub.WebService/appsettings.json
Normal file
54
src/VexHub/StellaOps.VexHub.WebService/appsettings.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
|
||||
},
|
||||
"VexHub": {
|
||||
"DefaultPollingIntervalSeconds": 3600,
|
||||
"MaxConcurrentPolls": 4,
|
||||
"StaleStatementAgeDays": 365,
|
||||
"AutoResolveLowSeverityConflicts": true,
|
||||
"StoreRawStatements": true,
|
||||
"MaxApiPageSize": 1000,
|
||||
"DefaultApiPageSize": 100,
|
||||
"EnableSignatureVerification": true,
|
||||
"Ingestion": {
|
||||
"EnableDeduplication": true,
|
||||
"EnableConflictDetection": true,
|
||||
"BatchSize": 500,
|
||||
"FetchTimeoutSeconds": 300,
|
||||
"MaxRetries": 3
|
||||
},
|
||||
"Distribution": {
|
||||
"EnableBulkExport": true,
|
||||
"EnableWebhooks": true,
|
||||
"CacheDurationSeconds": 300,
|
||||
"RateLimitPerMinute": 60
|
||||
}
|
||||
},
|
||||
"Postgres": {
|
||||
"ConnectionString": "Host=localhost;Port=5432;Database=stellaops;Username=postgres;Password=postgres",
|
||||
"SchemaName": "vexhub"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user