feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration

- Add RateLimitConfig for configuration management with YAML binding support.
- Introduce RateLimitDecision to encapsulate the result of rate limit checks.
- Implement RateLimitMetrics for OpenTelemetry metrics tracking.
- Create RateLimitMiddleware for enforcing rate limits on incoming requests.
- Develop RateLimitService to orchestrate instance and environment rate limit checks.
- Add RateLimitServiceCollectionExtensions for dependency injection registration.
This commit is contained in:
master
2025-12-17 18:02:37 +02:00
parent 394b57f6bf
commit 8bbfe4d2d2
211 changed files with 47179 additions and 1590 deletions

View File

@@ -0,0 +1,174 @@
<!--
SPDX-License-Identifier: AGPL-3.0-or-later
Sprint: SPRINT_3600_0002_0001
Task: UNK-RANK-012 - Wire unknowns list to UI with score-based sort
-->
<div class="unknowns-list">
<!-- Header with band stats -->
<div class="unknowns-header">
<h2>Unknowns Queue</h2>
<div class="band-stats">
<button
class="band-chip band-hot"
[class.active]="bandFilter() === 'HOT'"
(click)="setBandFilter(bandFilter() === 'HOT' ? null : 'HOT')">
🔥 HOT ({{ hotCount() }})
</button>
<button
class="band-chip band-warm"
[class.active]="bandFilter() === 'WARM'"
(click)="setBandFilter(bandFilter() === 'WARM' ? null : 'WARM')">
🌡️ WARM ({{ warmCount() }})
</button>
<button
class="band-chip band-cold"
[class.active]="bandFilter() === 'COLD'"
(click)="setBandFilter(bandFilter() === 'COLD' ? null : 'COLD')">
❄️ COLD ({{ coldCount() }})
</button>
</div>
</div>
<!-- Loading state -->
@if (loading()) {
<div class="loading-overlay">
<div class="spinner"></div>
<span>Loading unknowns...</span>
</div>
}
<!-- Error state -->
@if (error()) {
<div class="error-banner">
<span class="error-icon">⚠️</span>
<span>{{ error() }}</span>
<button class="retry-btn" (click)="loadUnknowns()">Retry</button>
</div>
}
<!-- Empty state -->
@if (!loading() && unknowns().length === 0) {
<div class="empty-state">
<span class="empty-icon"></span>
<h3>No unknowns in queue</h3>
<p>All findings have been triaged or no unknowns match your filters.</p>
</div>
}
<!-- Unknowns table -->
@if (unknowns().length > 0) {
<table class="unknowns-table">
<thead>
<tr>
<th class="col-band">Band</th>
<th class="col-cve">CVE</th>
<th class="col-package">Package</th>
<th
class="col-score sortable"
[class.sorted]="sortBy() === 'score'"
(click)="setSortBy('score')">
Score
@if (sortBy() === 'score') {
<span class="sort-icon">{{ sortOrder() === 'desc' ? '▼' : '▲' }}</span>
}
</th>
<th
class="col-epss sortable"
[class.sorted]="sortBy() === 'epss'"
(click)="setSortBy('epss')">
EPSS
@if (sortBy() === 'epss') {
<span class="sort-icon">{{ sortOrder() === 'desc' ? '▼' : '▲' }}</span>
}
</th>
<th class="col-blast">Blast Radius</th>
<th class="col-containment">Containment</th>
<th class="col-reason">Reason</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (item of unknowns(); track trackByUnknownId($index, item)) {
<tr class="unknown-row" [class]="getBandClass(item.band)">
<td class="col-band">
<span class="band-badge" [class]="getBandClass(item.band)">
{{ item.band }}
</span>
</td>
<td class="col-cve">
<a [href]="'https://nvd.nist.gov/vuln/detail/' + item.cveId" target="_blank" rel="noopener">
{{ item.cveId }}
</a>
@if (item.kev) {
<span class="kev-badge" title="Known Exploited Vulnerability">KEV</span>
}
</td>
<td class="col-package">
<span class="package-name">{{ item.packageName }}</span>
<span class="package-version">{{ item.version }}</span>
</td>
<td class="col-score">
<span class="score-value" [class]="getScoreClass(item.score)">
{{ formatScore(item.score) }}
</span>
</td>
<td class="col-epss">
<span class="epss-value">{{ formatEpss(item.epss) }}</span>
</td>
<td class="col-blast" [title]="getBlastRadiusTooltip(item)">
@if (item.blastRadius) {
<span class="blast-dependents">{{ item.blastRadius.dependents ?? '-' }}</span>
@if (item.blastRadius.netFacing) {
<span class="net-facing-badge" title="Network-facing">🌐</span>
}
} @else {
<span class="no-data">-</span>
}
</td>
<td class="col-containment">
<span class="containment-icon" [title]="item.containmentSignals?.seccomp ?? 'No containment'">
{{ getContainmentIcon(item) }}
</span>
</td>
<td class="col-reason">
<span class="reason-text">{{ item.reason }}</span>
</td>
<td class="col-actions">
<button class="action-btn primary" title="Investigate">
🔍
</button>
<button class="action-btn" title="VEX Decision">
📝
</button>
</td>
</tr>
}
</tbody>
</table>
<!-- Pagination -->
<div class="pagination">
<span class="pagination-info">
Showing {{ (currentPage() - 1) * pageSize() + 1 }} -
{{ Math.min(currentPage() * pageSize(), totalCount()) }}
of {{ totalCount() }}
</span>
<div class="pagination-controls">
<button
class="page-btn"
[disabled]="!hasPrevPage()"
(click)="prevPage()">
← Previous
</button>
<span class="page-number">Page {{ currentPage() }} of {{ totalPages() }}</span>
<button
class="page-btn"
[disabled]="!hasNextPage()"
(click)="nextPage()">
Next →
</button>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,378 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_3600_0002_0001
// Task: UNK-RANK-012 - Wire unknowns list to UI with score-based sort
.unknowns-list {
padding: var(--spacing-lg);
background: var(--surface-background);
border-radius: var(--border-radius-lg);
}
// Header
.unknowns-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
h2 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
}
}
.band-stats {
display: flex;
gap: var(--spacing-sm);
}
.band-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--border-radius-md);
border: 2px solid transparent;
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
transition: all 0.2s ease;
&.band-hot {
background: rgba(239, 68, 68, 0.1);
color: var(--color-danger);
&:hover, &.active {
background: rgba(239, 68, 68, 0.2);
border-color: var(--color-danger);
}
}
&.band-warm {
background: rgba(245, 158, 11, 0.1);
color: var(--color-warning);
&:hover, &.active {
background: rgba(245, 158, 11, 0.2);
border-color: var(--color-warning);
}
}
&.band-cold {
background: rgba(59, 130, 246, 0.1);
color: var(--color-info);
&:hover, &.active {
background: rgba(59, 130, 246, 0.2);
border-color: var(--color-info);
}
}
}
// Loading state
.loading-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
gap: var(--spacing-md);
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
span {
color: var(--text-secondary);
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
// Error state
.error-banner {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--color-danger);
border-radius: var(--border-radius-md);
color: var(--color-danger);
.retry-btn {
margin-left: auto;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--color-danger);
color: white;
border: none;
border-radius: var(--border-radius-sm);
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
}
// Empty state
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
text-align: center;
.empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-md);
}
h3 {
margin: 0 0 var(--spacing-sm);
color: var(--text-primary);
}
p {
margin: 0;
color: var(--text-secondary);
}
}
// Table
.unknowns-table {
width: 100%;
border-collapse: collapse;
th, td {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: 600;
color: var(--text-secondary);
background: var(--surface-elevated);
&.sortable {
cursor: pointer;
user-select: none;
&:hover {
background: var(--surface-hover);
}
&.sorted {
color: var(--color-primary);
}
}
.sort-icon {
margin-left: var(--spacing-xs);
font-size: var(--font-size-xs);
}
}
tbody tr {
transition: background 0.2s ease;
&:hover {
background: var(--surface-hover);
}
&.band-hot {
border-left: 3px solid var(--color-danger);
}
&.band-warm {
border-left: 3px solid var(--color-warning);
}
&.band-cold {
border-left: 3px solid var(--color-info);
}
}
}
.band-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--border-radius-sm);
font-size: var(--font-size-xs);
font-weight: 600;
&.band-hot {
background: rgba(239, 68, 68, 0.1);
color: var(--color-danger);
}
&.band-warm {
background: rgba(245, 158, 11, 0.1);
color: var(--color-warning);
}
&.band-cold {
background: rgba(59, 130, 246, 0.1);
color: var(--color-info);
}
}
.kev-badge {
display: inline-block;
margin-left: var(--spacing-xs);
padding: 1px 4px;
background: var(--color-danger);
color: white;
font-size: 10px;
font-weight: 700;
border-radius: 3px;
}
.package-name {
font-weight: 500;
color: var(--text-primary);
}
.package-version {
margin-left: var(--spacing-xs);
color: var(--text-secondary);
font-size: var(--font-size-sm);
&::before {
content: '@';
}
}
.score-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
&.score-high {
color: var(--color-danger);
}
&.score-medium {
color: var(--color-warning);
}
&.score-low {
color: var(--color-success);
}
}
.epss-value {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.blast-dependents {
font-weight: 500;
}
.net-facing-badge {
margin-left: var(--spacing-xs);
}
.containment-icon {
font-size: var(--font-size-lg);
}
.reason-text {
font-size: var(--font-size-sm);
color: var(--text-secondary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-btn {
padding: var(--spacing-xs);
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--surface-hover);
border-color: var(--color-primary);
}
&.primary {
background: var(--color-primary);
border-color: var(--color-primary);
&:hover {
opacity: 0.9;
}
}
}
// Pagination
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--spacing-lg);
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-color);
}
.pagination-info {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.pagination-controls {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.page-btn {
padding: var(--spacing-xs) var(--spacing-md);
background: var(--surface-elevated);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--color-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.page-number {
padding: 0 var(--spacing-sm);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
// Column widths
.col-band { width: 80px; }
.col-cve { width: 140px; }
.col-package { width: 180px; }
.col-score { width: 80px; text-align: right; }
.col-epss { width: 80px; text-align: right; }
.col-blast { width: 100px; }
.col-containment { width: 80px; text-align: center; }
.col-reason { width: 200px; }
.col-actions { width: 100px; }

View File

@@ -0,0 +1,196 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_3600_0002_0001
// Task: UNK-RANK-012 - Wire unknowns list to UI with score-based sort
import { Component, OnInit, OnDestroy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, takeUntil } from 'rxjs';
import { UnknownsService, UnknownItem, UnknownsListResponse, UnknownsFilter } from '../services/unknowns.service';
/**
* Unknowns List Component
*
* Displays prioritized unknown findings with score-based sorting.
* Features:
* - Band-based color coding (HOT/WARM/COLD)
* - Score breakdown tooltip
* - Containment signals display
* - Filter by artifact, reason, band
* - Pagination
*/
@Component({
selector: 'app-unknowns-list',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './unknowns-list.component.html',
styleUrls: ['./unknowns-list.component.scss']
})
export class UnknownsListComponent implements OnInit, OnDestroy {
private readonly destroy$ = new Subject<void>();
// State signals
readonly unknowns = signal<UnknownItem[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly totalCount = signal(0);
readonly currentPage = signal(1);
readonly pageSize = signal(25);
// Filter state
readonly bandFilter = signal<'HOT' | 'WARM' | 'COLD' | null>(null);
readonly reasonFilter = signal<string | null>(null);
readonly artifactFilter = signal<string | null>(null);
readonly sortBy = signal<'score' | 'created_at' | 'epss'>('score');
readonly sortOrder = signal<'asc' | 'desc'>('desc');
// Computed values
readonly totalPages = computed(() => Math.ceil(this.totalCount() / this.pageSize()));
readonly hasNextPage = computed(() => this.currentPage() < this.totalPages());
readonly hasPrevPage = computed(() => this.currentPage() > 1);
// Band statistics
readonly hotCount = computed(() => this.unknowns().filter(u => u.band === 'HOT').length);
readonly warmCount = computed(() => this.unknowns().filter(u => u.band === 'WARM').length);
readonly coldCount = computed(() => this.unknowns().filter(u => u.band === 'COLD').length);
constructor(private readonly unknownsService: UnknownsService) {}
ngOnInit(): void {
this.loadUnknowns();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadUnknowns(): void {
this.loading.set(true);
this.error.set(null);
const filter: UnknownsFilter = {
page: this.currentPage(),
pageSize: this.pageSize(),
sortBy: this.sortBy(),
sortOrder: this.sortOrder(),
band: this.bandFilter() ?? undefined,
reason: this.reasonFilter() ?? undefined,
artifactId: this.artifactFilter() ?? undefined
};
this.unknownsService.listUnknowns(filter)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: UnknownsListResponse) => {
this.unknowns.set(response.items);
this.totalCount.set(response.totalCount);
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load unknowns: ' + (err.message || 'Unknown error'));
this.loading.set(false);
}
});
}
// Navigation
goToPage(page: number): void {
if (page >= 1 && page <= this.totalPages()) {
this.currentPage.set(page);
this.loadUnknowns();
}
}
nextPage(): void {
if (this.hasNextPage()) {
this.goToPage(this.currentPage() + 1);
}
}
prevPage(): void {
if (this.hasPrevPage()) {
this.goToPage(this.currentPage() - 1);
}
}
// Filtering
setBandFilter(band: 'HOT' | 'WARM' | 'COLD' | null): void {
this.bandFilter.set(band);
this.currentPage.set(1);
this.loadUnknowns();
}
setReasonFilter(reason: string | null): void {
this.reasonFilter.set(reason);
this.currentPage.set(1);
this.loadUnknowns();
}
// Sorting
setSortBy(field: 'score' | 'created_at' | 'epss'): void {
if (this.sortBy() === field) {
// Toggle order if same field
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
} else {
this.sortBy.set(field);
this.sortOrder.set('desc');
}
this.loadUnknowns();
}
// Helpers
getBandClass(band: string): string {
switch (band) {
case 'HOT': return 'band-hot';
case 'WARM': return 'band-warm';
case 'COLD': return 'band-cold';
default: return 'band-unknown';
}
}
getScoreClass(score: number): string {
if (score >= 0.7) return 'score-high';
if (score >= 0.4) return 'score-medium';
return 'score-low';
}
formatScore(score: number): string {
return (score * 100).toFixed(1) + '%';
}
formatEpss(epss: number | null): string {
if (epss === null) return 'N/A';
return (epss * 100).toFixed(2) + '%';
}
getContainmentIcon(item: UnknownItem): string {
const signals = item.containmentSignals;
if (!signals) return '🔓';
const hasSeccomp = signals.seccomp === 'strict' || signals.seccomp === 'enabled';
const hasReadOnlyFs = signals.fsMode === 'read-only';
if (hasSeccomp && hasReadOnlyFs) return '🔒';
if (hasSeccomp || hasReadOnlyFs) return '🔐';
return '🔓';
}
getBlastRadiusTooltip(item: UnknownItem): string {
const br = item.blastRadius;
if (!br) return 'No blast radius data';
const parts = [
`Dependents: ${br.dependents ?? 'N/A'}`,
`Network-facing: ${br.netFacing ? 'Yes' : 'No'}`,
`Privilege: ${br.privilege ?? 'N/A'}`
];
return parts.join('\n');
}
trackByUnknownId(_index: number, item: UnknownItem): string {
return item.id;
}
}

View File

@@ -0,0 +1,135 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_3600_0002_0001
// Task: UNK-RANK-012 - Wire unknowns list to UI with score-based sort
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../../../environments/environment';
/**
* Unknown item from the ranking API.
*/
export interface UnknownItem {
id: string;
cveId: string;
packageName: string;
version: string;
score: number;
band: 'HOT' | 'WARM' | 'COLD';
reason: string;
epss: number | null;
kev: boolean;
blastRadius: BlastRadius | null;
containmentSignals: ContainmentSignals | null;
artifactId: string;
createdAt: string;
proofRef: string | null;
}
export interface BlastRadius {
dependents: number | null;
netFacing: boolean;
privilege: string | null;
}
export interface ContainmentSignals {
seccomp: 'strict' | 'enabled' | 'disabled' | null;
fsMode: 'read-only' | 'read-write' | null;
}
export interface UnknownsListResponse {
items: UnknownItem[];
totalCount: number;
page: number;
pageSize: number;
}
export interface UnknownsFilter {
page?: number;
pageSize?: number;
sortBy?: 'score' | 'created_at' | 'epss';
sortOrder?: 'asc' | 'desc';
band?: 'HOT' | 'WARM' | 'COLD';
reason?: string;
artifactId?: string;
}
/**
* Service for interacting with the Unknowns Ranking API.
*/
@Injectable({
providedIn: 'root'
})
export class UnknownsService {
private readonly baseUrl = `${environment.apiUrl}/unknowns`;
constructor(private readonly http: HttpClient) {}
/**
* List unknowns with optional filters and pagination.
*/
listUnknowns(filter?: UnknownsFilter): Observable<UnknownsListResponse> {
let params = new HttpParams();
if (filter) {
if (filter.page) params = params.set('page', filter.page.toString());
if (filter.pageSize) params = params.set('pageSize', filter.pageSize.toString());
if (filter.sortBy) params = params.set('sortBy', filter.sortBy);
if (filter.sortOrder) params = params.set('sortOrder', filter.sortOrder);
if (filter.band) params = params.set('band', filter.band);
if (filter.reason) params = params.set('reason', filter.reason);
if (filter.artifactId) params = params.set('artifactId', filter.artifactId);
}
return this.http.get<UnknownsListResponse>(this.baseUrl, { params });
}
/**
* Get a single unknown by ID.
*/
getUnknown(id: string): Observable<UnknownItem> {
return this.http.get<UnknownItem>(`${this.baseUrl}/${id}`);
}
/**
* Get unknowns for a specific artifact.
*/
getUnknownsForArtifact(artifactId: string, filter?: UnknownsFilter): Observable<UnknownsListResponse> {
const fullFilter: UnknownsFilter = {
...filter,
artifactId
};
return this.listUnknowns(fullFilter);
}
/**
* Get unknowns statistics (counts by band).
*/
getUnknownsStats(): Observable<UnknownsStats> {
return this.http.get<UnknownsStats>(`${this.baseUrl}/stats`);
}
/**
* Trigger a rescan for unknowns that have been in queue for a while.
*/
triggerRescan(unknownIds: string[]): Observable<RescanResponse> {
return this.http.post<RescanResponse>(`${this.baseUrl}/rescan`, { ids: unknownIds });
}
}
export interface UnknownsStats {
totalCount: number;
hotCount: number;
warmCount: number;
coldCount: number;
avgScore: number;
oldestAge: number; // days
}
export interface RescanResponse {
scheduled: number;
failed: number;
errors: string[];
}