save progress

This commit is contained in:
StellaOps Bot
2026-01-03 15:27:15 +02:00
parent d486d41a48
commit bc4dd4f377
70 changed files with 8531 additions and 653 deletions

View File

@@ -455,6 +455,15 @@ export const routes: Routes = [
loadChildren: () =>
import('./features/unknowns-tracking/unknowns.routes').then((m) => m.unknownsRoutes),
},
// Analyze - Patch Map Explorer (SPRINT_20260103_003_FE_patch_map_explorer)
{
path: 'analyze/patch-map',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/binary-index/patch-map.component').then(
(m) => m.PatchMapComponent
),
},
// Fallback for unknown routes
{
path: '**',

View File

@@ -0,0 +1,129 @@
/**
* @file patch-coverage.client.ts
* @sprint SPRINT_20260103_003_FE_patch_map_explorer
* @description HTTP client for Patch Coverage API.
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map, catchError, throwError } from 'rxjs';
import {
PatchCoverageResult,
PatchCoverageDetails,
PatchMatchPage,
PatchCoverageQuery,
PatchMatchQuery,
} from './patch-coverage.models';
/**
* Patch Coverage API interface.
*/
export interface PatchCoverageApi {
/**
* Get aggregated patch coverage by CVE.
*/
getCoverage(query?: PatchCoverageQuery): Observable<PatchCoverageResult>;
/**
* Get detailed function-level coverage for a CVE.
*/
getCoverageDetails(cveId: string): Observable<PatchCoverageDetails>;
/**
* Get paginated list of matching images.
*/
getMatchingImages(query: PatchMatchQuery): Observable<PatchMatchPage>;
}
export const PATCH_COVERAGE_API = new InjectionToken<PatchCoverageApi>('PATCH_COVERAGE_API');
/**
* HTTP implementation of Patch Coverage API.
*/
@Injectable({ providedIn: 'root' })
export class PatchCoverageHttpClient implements PatchCoverageApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/stats/patch-coverage';
/**
* Get aggregated patch coverage by CVE.
*/
getCoverage(query?: PatchCoverageQuery): Observable<PatchCoverageResult> {
let params = new HttpParams();
if (query?.cve) {
params = params.set('cve', query.cve);
}
if (query?.package) {
params = params.set('package', query.package);
}
if (query?.limit !== undefined) {
params = params.set('limit', query.limit.toString());
}
if (query?.offset !== undefined) {
params = params.set('offset', query.offset.toString());
}
return this.http.get<PatchCoverageResult>(this.baseUrl, { params }).pipe(
catchError(err => {
console.error('Failed to fetch patch coverage', err);
return throwError(() => new Error('Failed to fetch patch coverage'));
})
);
}
/**
* Get detailed function-level coverage for a CVE.
*/
getCoverageDetails(cveId: string): Observable<PatchCoverageDetails> {
return this.http.get<PatchCoverageDetails>(
`${this.baseUrl}/${encodeURIComponent(cveId)}/details`
).pipe(
catchError(err => {
console.error(`Failed to fetch coverage details for ${cveId}`, err);
return throwError(() => new Error(`Failed to fetch coverage details for ${cveId}`));
})
);
}
/**
* Get paginated list of matching images.
*/
getMatchingImages(query: PatchMatchQuery): Observable<PatchMatchPage> {
let params = new HttpParams();
if (query.symbol) {
params = params.set('symbol', query.symbol);
}
if (query.state) {
params = params.set('state', query.state);
}
if (query.limit !== undefined) {
params = params.set('limit', query.limit.toString());
}
if (query.offset !== undefined) {
params = params.set('offset', query.offset.toString());
}
return this.http.get<PatchMatchPage>(
`${this.baseUrl}/${encodeURIComponent(query.cveId)}/matches`,
{ params }
).pipe(
catchError(err => {
console.error(`Failed to fetch matching images for ${query.cveId}`, err);
return throwError(() => new Error(`Failed to fetch matching images for ${query.cveId}`));
})
);
}
}
/**
* Provider configuration for Patch Coverage API.
*/
export function providePatchCoverageApi() {
return {
provide: PATCH_COVERAGE_API,
useClass: PatchCoverageHttpClient,
};
}

View File

@@ -0,0 +1,212 @@
/**
* @file patch-coverage.models.ts
* @sprint SPRINT_20260103_003_FE_patch_map_explorer
* @description TypeScript models for Patch Map Explorer.
*/
/**
* State of a binary match.
*/
export type MatchState = 'vulnerable' | 'patched' | 'unknown';
/**
* Paginated result of patch coverage by CVE.
*/
export interface PatchCoverageResult {
/** Coverage entries by CVE. */
entries: PatchCoverageEntry[];
/** Total CVEs matching filter. */
totalCount: number;
/** Pagination offset. */
offset: number;
/** Pagination limit. */
limit: number;
}
/**
* Patch coverage summary for a single CVE.
*/
export interface PatchCoverageEntry {
/** CVE identifier. */
cveId: string;
/** Primary package name. */
packageName: string;
/** Number of vulnerable matches. */
vulnerableCount: number;
/** Number of patched matches. */
patchedCount: number;
/** Number of unknown matches. */
unknownCount: number;
/** Number of distinct symbols. */
symbolCount: number;
/** Patch coverage percentage (0-100). */
coveragePercent: number;
/** When signatures were last updated. */
lastUpdatedAt: string;
}
/**
* Detailed patch coverage with function-level breakdown.
*/
export interface PatchCoverageDetails {
/** CVE identifier. */
cveId: string;
/** Primary package name. */
packageName: string;
/** Function-level breakdown. */
functions: FunctionCoverageEntry[];
/** Summary statistics. */
summary: PatchCoverageSummary;
}
/**
* Coverage for a single function/symbol.
*/
export interface FunctionCoverageEntry {
/** Symbol/function name. */
symbolName: string;
/** Shared object name. */
soname?: string;
/** Vulnerable match count. */
vulnerableCount: number;
/** Patched match count. */
patchedCount: number;
/** Unknown match count. */
unknownCount: number;
/** Whether vulnerable and patched signatures exist. */
hasDelta: boolean;
}
/**
* Summary statistics for patch coverage.
*/
export interface PatchCoverageSummary {
/** Total images analyzed. */
totalImages: number;
/** Vulnerable images. */
vulnerableImages: number;
/** Patched images. */
patchedImages: number;
/** Unknown images. */
unknownImages: number;
/** Overall coverage percentage (0-100). */
overallCoverage: number;
/** Number of distinct symbols. */
symbolCount: number;
/** Number of symbols with delta pairs. */
deltaPairCount: number;
}
/**
* Paginated list of matching images.
*/
export interface PatchMatchPage {
/** Match entries. */
matches: PatchMatchEntry[];
/** Total matches. */
totalCount: number;
/** Offset used. */
offset: number;
/** Limit used. */
limit: number;
}
/**
* Single image match entry.
*/
export interface PatchMatchEntry {
/** Match ID. */
matchId: string;
/** Binary key (image digest or path). */
binaryKey: string;
/** Binary SHA-256 hash. */
binarySha256?: string;
/** Matched symbol name. */
symbolName: string;
/** Match state. */
matchState: MatchState;
/** Confidence (0-1). */
confidence: number;
/** Scan ID. */
scanId?: string;
/** Scan timestamp. */
scannedAt: string;
}
/**
* Query options for patch coverage list.
*/
export interface PatchCoverageQuery {
/** CVE IDs to filter (comma-separated). */
cve?: string;
/** Package name filter. */
package?: string;
/** Maximum entries. */
limit?: number;
/** Pagination offset. */
offset?: number;
}
/**
* Query options for matching images.
*/
export interface PatchMatchQuery {
/** CVE ID (required). */
cveId: string;
/** Symbol name filter. */
symbol?: string;
/** State filter. */
state?: MatchState;
/** Maximum entries. */
limit?: number;
/** Pagination offset. */
offset?: number;
}
/**
* Heatmap cell data for visualization.
*/
export interface HeatmapCell {
/** CVE ID. */
cveId: string;
/** Package name. */
packageName: string;
/** Coverage percentage (0-100). */
coverage: number;
/** Color intensity based on coverage. */
colorClass: 'critical' | 'high' | 'medium' | 'low' | 'safe';
/** Vulnerable count. */
vulnerable: number;
/** Patched count. */
patched: number;
/** Unknown count. */
unknown: number;
}
/**
* Compute color class from coverage percentage.
*/
export function getCoverageColorClass(
coverage: number
): HeatmapCell['colorClass'] {
if (coverage >= 90) return 'safe';
if (coverage >= 70) return 'low';
if (coverage >= 50) return 'medium';
if (coverage >= 25) return 'high';
return 'critical';
}
/**
* Transform coverage entry to heatmap cell.
*/
export function toHeatmapCell(entry: PatchCoverageEntry): HeatmapCell {
return {
cveId: entry.cveId,
packageName: entry.packageName,
coverage: entry.coveragePercent,
colorClass: getCoverageColorClass(entry.coveragePercent),
vulnerable: entry.vulnerableCount,
patched: entry.patchedCount,
unknown: entry.unknownCount,
};
}

View File

@@ -80,6 +80,13 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
icon: 'help-circle',
tooltip: 'Track and identify unknown components',
},
{
id: 'patch-map',
label: 'Patch Map',
route: '/analyze/patch-map',
icon: 'grid',
tooltip: 'Fleet-wide binary patch coverage heatmap',
},
],
},

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ import {
output,
signal,
} from '@angular/core';
import { RouterModule } from '@angular/router';
import {
BinaryEvidence,
@@ -33,7 +34,7 @@ import {
@Component({
selector: 'app-binary-evidence-panel',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, RouterModule],
template: `
<div class="binary-evidence-panel" [class.has-findings]="hasBinaries()">
<!-- Summary Header -->
@@ -62,6 +63,15 @@ import {
</span>
}
</div>
<!-- Link to Patch Map Explorer -->
<a
routerLink="/analyze/patch-map"
class="patch-map-link"
title="View fleet-wide patch coverage heatmap"
>
<span class="link-icon">&#9633;</span>
Patch Map
</a>
</div>
<!-- Distribution Info -->
@@ -294,6 +304,35 @@ import {
font-weight: 700;
}
.patch-map-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
font-size: 0.8125rem;
font-weight: 500;
color: #3b82f6;
text-decoration: none;
transition: all 0.15s;
&:hover {
background: #eff6ff;
border-color: #93c5fd;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
.link-icon {
font-size: 0.875rem;
}
.distro-info {
padding: 0.5rem 1rem;
background: #f9fafb;

View File

@@ -0,0 +1,432 @@
/**
* @file filter-preset-pills.component.ts
* @sprint SPRINT_20260103_001_FE_filter_preset_pills
* @description Always-visible preset pills bar for quick filter selection.
*
* Features:
* - Horizontal scrollable preset pills
* - Active preset highlighting
* - Custom filter indicator with count
* - Copy shareable URL button
* - Responsive with horizontal scroll on mobile
*/
import {
Component,
ChangeDetectionStrategy,
inject,
signal,
computed,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterUrlSyncService } from '../../services/filter-url-sync.service';
import {
FilterPreset,
getPresetsOrdered,
} from './filter-preset.models';
/**
* Icon mapping for preset icons (using simple text/unicode for accessibility).
* Can be replaced with icon component references if needed.
*/
const ICON_MAP: Record<string, string> = {
target: 'O',
'alert-circle': '!',
eye: '*',
'check-circle': 'v',
list: '=',
activity: '~',
'shield-check': '#',
};
/**
* Always-visible filter preset pills component.
*
* Displays a horizontal row of clickable pills that apply common filter
* configurations with one click. Supports URL sync for shareable links.
*/
@Component({
selector: 'app-filter-preset-pills',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="preset-pills-container" role="toolbar" aria-label="Filter presets">
<!-- Pills scroll container -->
<div class="pills-scroll" role="group">
@for (preset of presets(); track preset.id) {
<button
type="button"
class="preset-pill"
[class.active]="activePresetId() === preset.id"
[class.noise-gating]="preset.category === 'noise-gating'"
[attr.aria-pressed]="activePresetId() === preset.id"
[title]="preset.description"
(click)="onPresetClick(preset)"
>
<span class="pill-icon" aria-hidden="true">{{ getIcon(preset.icon) }}</span>
<span class="pill-label">{{ preset.name }}</span>
</button>
}
<!-- Custom filter indicator (when no preset matches) -->
@if (!hasActivePreset() && filterCount() > 0) {
<span class="custom-filter-badge" aria-label="Custom filters applied">
<span class="badge-icon" aria-hidden="true">*</span>
<span class="badge-label">Custom</span>
<span class="badge-count">{{ filterCount() }}</span>
</span>
}
</div>
<!-- Actions -->
<div class="pills-actions">
<!-- Copy URL button -->
<button
type="button"
class="action-btn copy-btn"
[class.copied]="justCopied()"
[attr.aria-label]="justCopied() ? 'Link copied!' : 'Copy shareable link'"
[title]="justCopied() ? 'Copied!' : 'Copy shareable link'"
(click)="onCopyUrl()"
>
<span class="action-icon" aria-hidden="true">{{ justCopied() ? 'v' : '@' }}</span>
@if (justCopied()) {
<span class="copy-feedback">Copied!</span>
}
</button>
<!-- Reset button (when not on default) -->
@if (filterCount() > 0 || activePresetId() !== 'actionable') {
<button
type="button"
class="action-btn reset-btn"
aria-label="Reset to defaults"
title="Reset to defaults"
(click)="onReset()"
>
<span class="action-icon" aria-hidden="true">x</span>
</button>
}
</div>
</div>
`,
styles: [`
.preset-pills-container {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--pills-bg, #f8f9fa);
border-radius: 8px;
border: 1px solid var(--pills-border, #e9ecef);
}
/* Dark mode support */
:host-context(.dark-mode) .preset-pills-container {
--pills-bg: #2d2d2d;
--pills-border: #404040;
}
.pills-scroll {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
flex: 1;
min-width: 0;
padding: 4px 0;
}
.pills-scroll::-webkit-scrollbar {
height: 4px;
}
.pills-scroll::-webkit-scrollbar-track {
background: transparent;
}
.pills-scroll::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 2px;
}
/* Preset pill button */
.preset-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--pill-bg, white);
border: 1px solid var(--pill-border, #dee2e6);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: var(--pill-color, #495057);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
}
:host-context(.dark-mode) .preset-pill {
--pill-bg: #1e1e1e;
--pill-border: #404040;
--pill-color: #e0e0e0;
}
.preset-pill:hover {
border-color: var(--pill-hover-border, #007bff);
color: var(--pill-hover-color, #007bff);
}
.preset-pill:focus-visible {
outline: 2px solid var(--focus-ring, #007bff);
outline-offset: 2px;
}
/* Active preset */
.preset-pill.active {
background: var(--pill-active-bg, #e7f3ff);
border-color: var(--pill-active-border, #007bff);
color: var(--pill-active-color, #0056b3);
}
:host-context(.dark-mode) .preset-pill.active {
--pill-active-bg: #1a3a5c;
--pill-active-border: #4dabf7;
--pill-active-color: #74c0fc;
}
/* Noise-gating presets have distinct styling */
.preset-pill.noise-gating {
border-style: dashed;
}
.preset-pill.noise-gating.active {
background: var(--pill-ng-active-bg, #e8f5e9);
border-color: var(--pill-ng-active-border, #2e7d32);
border-style: solid;
color: var(--pill-ng-active-color, #1b5e20);
}
:host-context(.dark-mode) .preset-pill.noise-gating.active {
--pill-ng-active-bg: #1b3d1e;
--pill-ng-active-border: #4caf50;
--pill-ng-active-color: #81c784;
}
.pill-icon {
font-size: 14px;
line-height: 1;
}
.pill-label {
line-height: 1.2;
}
/* Custom filter badge */
.custom-filter-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: var(--badge-bg, #fff3cd);
border: 1px solid var(--badge-border, #ffc107);
border-radius: 20px;
font-size: 12px;
font-weight: 500;
color: var(--badge-color, #856404);
white-space: nowrap;
flex-shrink: 0;
}
:host-context(.dark-mode) .custom-filter-badge {
--badge-bg: #3d3200;
--badge-border: #ffc107;
--badge-color: #ffd54f;
}
.badge-count {
background: var(--badge-count-bg, #ffc107);
color: var(--badge-count-color, #1a1a1a);
padding: 1px 6px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
/* Actions */
.pills-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
padding-left: 8px;
border-left: 1px solid var(--divider, #dee2e6);
}
:host-context(.dark-mode) .pills-actions {
--divider: #404040;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--action-color, #6c757d);
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn:hover {
background: var(--action-hover-bg, #e9ecef);
color: var(--action-hover-color, #495057);
}
:host-context(.dark-mode) .action-btn:hover {
--action-hover-bg: #383838;
--action-hover-color: #e0e0e0;
}
.action-btn:focus-visible {
outline: 2px solid var(--focus-ring, #007bff);
outline-offset: 2px;
}
.action-icon {
font-size: 14px;
font-weight: bold;
line-height: 1;
}
/* Copy button states */
.copy-btn.copied {
width: auto;
padding: 0 8px;
background: var(--success-bg, #d4edda);
border-color: var(--success-border, #28a745);
color: var(--success-color, #155724);
}
:host-context(.dark-mode) .copy-btn.copied {
--success-bg: #1b3d1e;
--success-border: #4caf50;
--success-color: #81c784;
}
.copy-feedback {
font-size: 11px;
font-weight: 500;
}
/* Reset button */
.reset-btn:hover {
background: var(--reset-hover-bg, #f8d7da);
color: var(--reset-hover-color, #721c24);
}
:host-context(.dark-mode) .reset-btn:hover {
--reset-hover-bg: #3d1e1e;
--reset-hover-color: #f8d7da;
}
/* Mobile responsive */
@media (max-width: 600px) {
.preset-pills-container {
padding: 6px 12px;
}
.preset-pill {
padding: 5px 10px;
font-size: 12px;
}
.pill-icon {
font-size: 12px;
}
.pills-actions {
padding-left: 6px;
}
}
`]
})
export class FilterPresetPillsComponent {
private readonly urlSyncService = inject(FilterUrlSyncService);
// -------------------------------------------------------------------------
// Signals from service
// -------------------------------------------------------------------------
readonly presets = signal(getPresetsOrdered());
readonly activePresetId = this.urlSyncService.activePresetId;
readonly hasActivePreset = this.urlSyncService.hasActivePreset;
readonly filterCount = this.urlSyncService.activeFilterCount;
// -------------------------------------------------------------------------
// Local state
// -------------------------------------------------------------------------
readonly justCopied = signal(false);
// -------------------------------------------------------------------------
// Outputs
// -------------------------------------------------------------------------
/** Emitted when a preset is clicked */
readonly presetSelected = output<FilterPreset>();
/** Emitted when filters are reset */
readonly filtersReset = output<void>();
// -------------------------------------------------------------------------
// Methods
// -------------------------------------------------------------------------
/**
* Get icon character for preset.
*/
getIcon(iconName: string): string {
return ICON_MAP[iconName] ?? '?';
}
/**
* Handle preset pill click.
*/
onPresetClick(preset: FilterPreset): void {
this.urlSyncService.applyPreset(preset.id);
this.presetSelected.emit(preset);
}
/**
* Handle copy URL click.
*/
async onCopyUrl(): Promise<void> {
const success = await this.urlSyncService.copyShareableUrl();
if (success) {
this.justCopied.set(true);
setTimeout(() => this.justCopied.set(false), 2000);
}
}
/**
* Handle reset click.
*/
onReset(): void {
this.urlSyncService.resetToDefaults();
this.filtersReset.emit();
}
}

View File

@@ -0,0 +1,404 @@
/**
* @file filter-preset.models.ts
* @sprint SPRINT_20260103_001_FE_filter_preset_pills
* @description Filter preset interfaces and standard presets for triage workflows.
*
* Presets provide one-click filter configurations for common triage scenarios.
* The pill bar surfaces these as always-visible clickable chips with URL sync.
*/
import {
TriageFilters,
TriageEnvironment,
DEFAULT_TRIAGE_FILTERS,
} from '../../models/evidence-subgraph.models';
/**
* Category grouping for presets in the UI.
*/
export type PresetCategory = 'standard' | 'noise-gating' | 'custom';
/**
* Filter preset configuration.
*/
export interface FilterPreset {
/** Unique preset identifier for URL sync. */
id: string;
/** Display name shown on the pill. */
name: string;
/** Tooltip description. */
description: string;
/** Visual icon (kept as text for accessibility). */
icon: string;
/** Category for grouping in expanded view. */
category: PresetCategory;
/** Partial filter overrides applied when preset is selected. */
filters: Partial<TriageFilters>;
/** Whether this is a system preset (cannot be deleted). */
isSystem: boolean;
/** Sort order within category. */
order: number;
}
/**
* URL query parameter keys for filter serialization.
*/
export const FILTER_QUERY_PARAMS = {
preset: 'preset',
reachability: 'reach',
patchStatus: 'patch',
vexStatus: 'vex',
severity: 'sev',
showSuppressed: 'supp',
runtimeExecuted: 'runtime',
environment: 'env',
backportProved: 'backport',
semverMismatch: 'semver',
} as const;
/**
* Standard presets for common triage workflows.
*
* Order:
* 1. actionable (default)
* 2. prod-runtime (new noise-gating)
* 3. backport-verified (new noise-gating)
* 4. critical-only
* 5. needs-review
* 6. vex-applied
* 7. all-findings
*/
export const FILTER_PRESETS: FilterPreset[] = [
// Standard presets
{
id: 'actionable',
name: 'Actionable',
description: 'Reachable, unpatched, critical/high severity - the default quiet view',
icon: 'target',
category: 'standard',
filters: DEFAULT_TRIAGE_FILTERS,
isSystem: true,
order: 1,
},
{
id: 'critical-only',
name: 'Critical Only',
description: 'Focus on critical vulnerabilities only',
icon: 'alert-circle',
category: 'standard',
filters: {
...DEFAULT_TRIAGE_FILTERS,
severity: ['critical'],
},
isSystem: true,
order: 4,
},
{
id: 'needs-review',
name: 'Needs Review',
description: 'Items awaiting triage decision',
icon: 'eye',
category: 'standard',
filters: {
reachability: 'Reachable',
patchStatus: 'Unpatched',
vexStatus: 'Unvexed',
severity: ['critical', 'high', 'medium'],
showSuppressed: false,
},
isSystem: true,
order: 5,
},
{
id: 'vex-applied',
name: 'VEX Applied',
description: 'Show findings with VEX statements applied',
icon: 'check-circle',
category: 'standard',
filters: {
reachability: 'All',
patchStatus: 'All',
vexStatus: 'Vexed',
severity: ['critical', 'high', 'medium', 'low'],
showSuppressed: false,
},
isSystem: true,
order: 6,
},
{
id: 'all-findings',
name: 'All Findings',
description: 'Show everything including suppressed items',
icon: 'list',
category: 'standard',
filters: {
reachability: 'All',
patchStatus: 'All',
vexStatus: 'All',
severity: ['critical', 'high', 'medium', 'low'],
showSuppressed: true,
},
isSystem: true,
order: 7,
},
// Noise-gating presets (new)
{
id: 'prod-runtime',
name: 'Prod Runtime',
description: 'Only vulnerabilities in code paths observed executing in production',
icon: 'activity',
category: 'noise-gating',
filters: {
...DEFAULT_TRIAGE_FILTERS,
runtimeExecuted: true,
environment: 'prod',
},
isSystem: true,
order: 2,
},
{
id: 'backport-verified',
name: 'Backport Verified',
description: 'Packages where binary analysis proves patch is applied despite semver mismatch',
icon: 'shield-check',
category: 'noise-gating',
filters: {
reachability: 'All',
patchStatus: 'All',
vexStatus: 'All',
severity: ['critical', 'high', 'medium', 'low'],
showSuppressed: false,
backportProved: true,
semverMismatch: true,
},
isSystem: true,
order: 3,
},
];
/**
* Get preset by ID.
*/
export function getPresetById(id: string): FilterPreset | undefined {
return FILTER_PRESETS.find(p => p.id === id);
}
/**
* Get presets sorted by order within categories.
*/
export function getPresetsOrdered(): FilterPreset[] {
return [...FILTER_PRESETS].sort((a, b) => a.order - b.order);
}
/**
* Get presets grouped by category.
*/
export function getPresetsByCategory(): Map<PresetCategory, FilterPreset[]> {
const grouped = new Map<PresetCategory, FilterPreset[]>();
for (const preset of FILTER_PRESETS) {
const list = grouped.get(preset.category) ?? [];
list.push(preset);
grouped.set(preset.category, list);
}
// Sort each category by order
for (const [category, presets] of grouped) {
grouped.set(category, presets.sort((a, b) => a.order - b.order));
}
return grouped;
}
/**
* Check if current filters match a preset.
*/
export function matchesPreset(
filters: TriageFilters,
preset: FilterPreset
): boolean {
const pf: TriageFilters = {
...DEFAULT_TRIAGE_FILTERS,
...preset.filters,
};
// Compare each filter field
if (filters.reachability !== pf.reachability) return false;
if (filters.patchStatus !== pf.patchStatus) return false;
if (filters.vexStatus !== pf.vexStatus) return false;
if (filters.showSuppressed !== pf.showSuppressed) return false;
// Compare severity arrays (order-independent)
const aSev = [...filters.severity].sort();
const bSev = [...pf.severity].sort();
if (aSev.length !== bSev.length || !aSev.every((v, i) => v === bSev[i])) {
return false;
}
// Compare noise-gating fields (only if preset specifies them)
if (preset.filters.runtimeExecuted !== undefined) {
if (filters.runtimeExecuted !== pf.runtimeExecuted) return false;
}
if (preset.filters.environment !== undefined) {
if (filters.environment !== pf.environment) return false;
}
if (preset.filters.backportProved !== undefined) {
if (filters.backportProved !== pf.backportProved) return false;
}
if (preset.filters.semverMismatch !== undefined) {
if (filters.semverMismatch !== pf.semverMismatch) return false;
}
return true;
}
/**
* Find matching preset for current filters.
*/
export function findMatchingPreset(filters: TriageFilters): FilterPreset | null {
for (const preset of FILTER_PRESETS) {
if (matchesPreset(filters, preset)) {
return preset;
}
}
return null;
}
/**
* Serialize filters to URL query string.
*/
export function serializeFiltersToQuery(filters: TriageFilters): string {
const params = new URLSearchParams();
// Check if matches a preset first - use preset param for cleaner URLs
const matchingPreset = findMatchingPreset(filters);
if (matchingPreset) {
params.set(FILTER_QUERY_PARAMS.preset, matchingPreset.id);
return params.toString();
}
// Otherwise serialize individual filters
if (filters.reachability !== 'Reachable') {
params.set(FILTER_QUERY_PARAMS.reachability, filters.reachability);
}
if (filters.patchStatus !== 'Unpatched') {
params.set(FILTER_QUERY_PARAMS.patchStatus, filters.patchStatus);
}
if (filters.vexStatus !== 'Unvexed') {
params.set(FILTER_QUERY_PARAMS.vexStatus, filters.vexStatus);
}
if (filters.severity.length > 0) {
params.set(FILTER_QUERY_PARAMS.severity, filters.severity.join(','));
}
if (filters.showSuppressed) {
params.set(FILTER_QUERY_PARAMS.showSuppressed, 'true');
}
// Noise-gating params (only if non-default)
if (filters.runtimeExecuted) {
params.set(FILTER_QUERY_PARAMS.runtimeExecuted, 'true');
}
if (filters.environment && filters.environment !== 'all') {
params.set(FILTER_QUERY_PARAMS.environment, filters.environment);
}
if (filters.backportProved) {
params.set(FILTER_QUERY_PARAMS.backportProved, 'true');
}
if (filters.semverMismatch) {
params.set(FILTER_QUERY_PARAMS.semverMismatch, 'true');
}
return params.toString();
}
/**
* Parse filters from URL query string.
*/
export function parseFiltersFromQuery(queryString: string): TriageFilters {
const params = new URLSearchParams(queryString);
// Check for preset param first
const presetId = params.get(FILTER_QUERY_PARAMS.preset);
if (presetId) {
const preset = getPresetById(presetId);
if (preset) {
return {
...DEFAULT_TRIAGE_FILTERS,
...preset.filters,
};
}
}
// Parse individual filters
const filters: TriageFilters = { ...DEFAULT_TRIAGE_FILTERS };
const reachability = params.get(FILTER_QUERY_PARAMS.reachability);
if (reachability && isValidReachability(reachability)) {
filters.reachability = reachability;
}
const patchStatus = params.get(FILTER_QUERY_PARAMS.patchStatus);
if (patchStatus && isValidPatchStatus(patchStatus)) {
filters.patchStatus = patchStatus;
}
const vexStatus = params.get(FILTER_QUERY_PARAMS.vexStatus);
if (vexStatus && isValidVexStatus(vexStatus)) {
filters.vexStatus = vexStatus;
}
const severity = params.get(FILTER_QUERY_PARAMS.severity);
if (severity) {
filters.severity = severity.split(',').filter(s => isValidSeverity(s));
}
const showSuppressed = params.get(FILTER_QUERY_PARAMS.showSuppressed);
if (showSuppressed === 'true') {
filters.showSuppressed = true;
}
// Noise-gating params
const runtimeExecuted = params.get(FILTER_QUERY_PARAMS.runtimeExecuted);
if (runtimeExecuted === 'true') {
filters.runtimeExecuted = true;
}
const environment = params.get(FILTER_QUERY_PARAMS.environment);
if (environment && isValidEnvironment(environment)) {
filters.environment = environment;
}
const backportProved = params.get(FILTER_QUERY_PARAMS.backportProved);
if (backportProved === 'true') {
filters.backportProved = true;
}
const semverMismatch = params.get(FILTER_QUERY_PARAMS.semverMismatch);
if (semverMismatch === 'true') {
filters.semverMismatch = true;
}
return filters;
}
// Type guards for parsing validation
function isValidReachability(value: string): value is TriageFilters['reachability'] {
return ['All', 'Reachable', 'Unreachable', 'Unknown'].includes(value);
}
function isValidPatchStatus(value: string): value is TriageFilters['patchStatus'] {
return ['All', 'Patched', 'Unpatched'].includes(value);
}
function isValidVexStatus(value: string): value is TriageFilters['vexStatus'] {
return ['All', 'Vexed', 'Unvexed', 'Conflicting'].includes(value);
}
function isValidSeverity(value: string): boolean {
return ['critical', 'high', 'medium', 'low'].includes(value.toLowerCase());
}
function isValidEnvironment(value: string): value is TriageEnvironment {
return ['all', 'prod', 'staging', 'dev'].includes(value);
}

View File

@@ -148,14 +148,60 @@ export interface ExecuteTriageActionResponse {
}
/**
* Triage filters.
* Triage severity level.
*/
export type TriageSeverity = 'Critical' | 'High' | 'Medium' | 'Low';
/**
* Environment filter for runtime-executed filtering.
*/
export type TriageEnvironment = 'all' | 'prod' | 'staging' | 'dev';
/**
* Triage filters with noise-gating capabilities.
*
* @sprint SPRINT_20260103_001_FE_filter_preset_pills
*/
export interface TriageFilters {
/** Reachability status filter. */
reachability: 'All' | 'Reachable' | 'Unreachable' | 'Unknown';
/** Patch status filter. */
patchStatus: 'All' | 'Patched' | 'Unpatched';
/** VEX status filter. */
vexStatus: 'All' | 'Vexed' | 'Unvexed' | 'Conflicting';
/** Severity levels to include. */
severity: string[];
/** Whether to show suppressed findings. */
showSuppressed: boolean;
// Noise-gating fields for advanced filtering
/**
* Filter to runtime-executed code paths only.
* When true, shows only vulnerabilities in code paths observed
* executing in the specified environment.
*/
runtimeExecuted?: boolean;
/**
* Environment filter for runtime execution.
* Used with runtimeExecuted to filter by deployment environment.
*/
environment?: TriageEnvironment;
/**
* Filter to backport-verified findings only.
* When true, shows only packages where binary patch signature
* proves the fix is applied.
*/
backportProved?: boolean;
/**
* Filter to semver-mismatch packages.
* When true, shows packages where version string looks vulnerable
* but binary analysis proves it's patched (backport detection).
*/
semverMismatch?: boolean;
}
/**

View File

@@ -0,0 +1,334 @@
/**
* @file filter-url-sync.service.ts
* @sprint SPRINT_20260103_001_FE_filter_preset_pills
* @description Service for synchronizing triage filters with URL query parameters.
*
* Enables shareable filter states via URLs like:
* - /triage?preset=actionable
* - /triage?reach=Reachable&sev=critical,high&runtime=true&env=prod
*/
import { Injectable, inject, signal, computed, effect, DestroyRef } from '@angular/core';
import { Router, ActivatedRoute, NavigationEnd, Params } from '@angular/router';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { filter, map, distinctUntilChanged, skip } from 'rxjs/operators';
import { Subject } from 'rxjs';
import {
TriageFilters,
DEFAULT_TRIAGE_FILTERS,
} from '../models/evidence-subgraph.models';
import {
FilterPreset,
FILTER_PRESETS,
serializeFiltersToQuery,
parseFiltersFromQuery,
findMatchingPreset,
getPresetById,
FILTER_QUERY_PARAMS,
} from '../components/filter-preset-pills/filter-preset.models';
/**
* Service for bidirectional sync between filter state and URL query params.
*
* Features:
* - Parses filters from URL on initial load
* - Updates URL when filters change (debounced)
* - Provides reactive signals for components
* - Supports preset shortcuts for cleaner URLs
* - Copy-to-clipboard for shareable links
*/
@Injectable({ providedIn: 'root' })
export class FilterUrlSyncService {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
// -------------------------------------------------------------------------
// State Signals
// -------------------------------------------------------------------------
/** Current filter state (source of truth) */
private readonly _filters = signal<TriageFilters>({ ...DEFAULT_TRIAGE_FILTERS });
/** Whether URL sync is currently updating (prevents infinite loops) */
private readonly _isUpdatingUrl = signal(false);
/** Whether service has been initialized from URL */
private readonly _initialized = signal(false);
/** Last URL that was synced (prevents duplicate updates) */
private readonly _lastSyncedQuery = signal<string>('');
// -------------------------------------------------------------------------
// Public Signals
// -------------------------------------------------------------------------
/** Current filters (read-only) */
readonly filters = this._filters.asReadonly();
/** Whether service has initialized from URL */
readonly initialized = this._initialized.asReadonly();
/** Currently active preset (computed from filters) */
readonly activePreset = computed<FilterPreset | null>(() => {
return findMatchingPreset(this._filters());
});
/** Whether current filters match any preset */
readonly hasActivePreset = computed(() => this.activePreset() !== null);
/** Active preset ID (for simple comparisons) */
readonly activePresetId = computed(() => this.activePreset()?.id ?? null);
/** Count of active filter modifications from default */
readonly activeFilterCount = computed(() => {
const filters = this._filters();
let count = 0;
if (filters.reachability !== DEFAULT_TRIAGE_FILTERS.reachability) count++;
if (filters.patchStatus !== DEFAULT_TRIAGE_FILTERS.patchStatus) count++;
if (filters.vexStatus !== DEFAULT_TRIAGE_FILTERS.vexStatus) count++;
if (filters.showSuppressed !== DEFAULT_TRIAGE_FILTERS.showSuppressed) count++;
// Severity comparison
const defaultSev = [...DEFAULT_TRIAGE_FILTERS.severity].sort();
const currentSev = [...filters.severity].sort();
if (defaultSev.length !== currentSev.length ||
!defaultSev.every((v, i) => v === currentSev[i])) {
count++;
}
// Noise-gating fields
if (filters.runtimeExecuted) count++;
if (filters.environment && filters.environment !== 'all') count++;
if (filters.backportProved) count++;
if (filters.semverMismatch) count++;
return count;
});
/** Human-readable summary of active filters */
readonly filterSummary = computed(() => {
const filters = this._filters();
const parts: string[] = [];
if (filters.reachability !== 'All') {
parts.push(filters.reachability);
}
if (filters.patchStatus !== 'All') {
parts.push(filters.patchStatus);
}
if (filters.vexStatus !== 'All') {
parts.push(filters.vexStatus);
}
// Severity (if not all)
if (filters.severity.length < 4 && filters.severity.length > 0) {
parts.push(filters.severity.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('/'));
}
// Noise-gating indicators
if (filters.runtimeExecuted) {
parts.push(`Runtime${filters.environment && filters.environment !== 'all' ? ':' + filters.environment : ''}`);
}
if (filters.backportProved) {
parts.push('Backport-proven');
}
return parts.length > 0 ? parts.join(' + ') : 'No filters';
});
/** Current shareable URL */
readonly shareableUrl = computed(() => {
const query = serializeFiltersToQuery(this._filters());
const base = window.location.origin + window.location.pathname;
return query ? `${base}?${query}` : base;
});
// -------------------------------------------------------------------------
// Event Subjects
// -------------------------------------------------------------------------
/** Emits when filters change */
private readonly filtersChanged$ = new Subject<TriageFilters>();
constructor() {
this.initializeFromUrl();
this.setupUrlSync();
}
// -------------------------------------------------------------------------
// Public Methods
// -------------------------------------------------------------------------
/**
* Update filters and sync to URL.
*/
setFilters(filters: TriageFilters): void {
this._filters.set(filters);
this.syncToUrl(filters);
}
/**
* Apply a partial filter update.
*/
updateFilters(partial: Partial<TriageFilters>): void {
const newFilters = {
...this._filters(),
...partial,
};
this.setFilters(newFilters);
}
/**
* Apply a preset by ID.
*/
applyPreset(presetId: string): void {
const preset = getPresetById(presetId);
if (preset) {
const newFilters: TriageFilters = {
...DEFAULT_TRIAGE_FILTERS,
...preset.filters,
};
this.setFilters(newFilters);
}
}
/**
* Reset to default filters.
*/
resetToDefaults(): void {
this.setFilters({ ...DEFAULT_TRIAGE_FILTERS });
}
/**
* Clear all filters (show everything).
*/
clearAllFilters(): void {
this.applyPreset('all-findings');
}
/**
* Copy shareable URL to clipboard.
* Returns true if successful.
*/
async copyShareableUrl(): Promise<boolean> {
try {
await navigator.clipboard.writeText(this.shareableUrl());
return true;
} catch {
// Fallback for older browsers or permission denied
return this.fallbackCopyToClipboard(this.shareableUrl());
}
}
/**
* Get observable of filter changes.
*/
get filtersChanged() {
return this.filtersChanged$.asObservable();
}
// -------------------------------------------------------------------------
// Private Methods
// -------------------------------------------------------------------------
/**
* Initialize filters from current URL on service creation.
*/
private initializeFromUrl(): void {
// Get initial query params
const queryString = window.location.search.slice(1);
if (queryString) {
const filters = parseFiltersFromQuery(queryString);
this._filters.set(filters);
this._lastSyncedQuery.set(queryString);
}
this._initialized.set(true);
}
/**
* Setup subscription to sync URL changes back to filter state.
*/
private setupUrlSync(): void {
// Watch for external navigation changes
this.router.events.pipe(
filter(event => event instanceof NavigationEnd),
// Skip the first emission (we already initialized)
skip(1),
map(() => window.location.search.slice(1)),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
).subscribe(queryString => {
// Only update if not caused by our own sync
if (!this._isUpdatingUrl() && queryString !== this._lastSyncedQuery()) {
const filters = parseFiltersFromQuery(queryString);
this._filters.set(filters);
this._lastSyncedQuery.set(queryString);
this.filtersChanged$.next(filters);
}
});
}
/**
* Sync current filters to URL query params.
*/
private syncToUrl(filters: TriageFilters): void {
const queryString = serializeFiltersToQuery(filters);
// Skip if already synced
if (queryString === this._lastSyncedQuery()) {
this.filtersChanged$.next(filters);
return;
}
this._isUpdatingUrl.set(true);
this._lastSyncedQuery.set(queryString);
// Update URL without triggering navigation
const queryParams: Params = {};
if (queryString) {
const params = new URLSearchParams(queryString);
params.forEach((value, key) => {
queryParams[key] = value;
});
}
this.router.navigate([], {
relativeTo: this.route,
queryParams,
queryParamsHandling: '',
replaceUrl: true, // Don't add to browser history for filter changes
}).then(() => {
this._isUpdatingUrl.set(false);
this.filtersChanged$.next(filters);
});
}
/**
* Fallback clipboard copy for older browsers.
*/
private fallbackCopyToClipboard(text: string): boolean {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const success = document.execCommand('copy');
document.body.removeChild(textArea);
return success;
} catch {
document.body.removeChild(textArea);
return false;
}
}
}