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:
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user