This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

View File

@@ -0,0 +1,78 @@
/**
* Lighthouse CI Configuration
*
* Quality gates aligned with StellaOps advisory:
* - Performance: >= 90
* - Accessibility: >= 95
* - Best Practices: >= 90
* - SEO: >= 90
*/
module.exports = {
ci: {
collect: {
staticDistDir: './dist/stella-ops-web/browser',
numberOfRuns: 3,
settings: {
// Mobile-first testing
formFactor: 'mobile',
throttling: {
rttMs: 150,
throughputKbps: 1638.4,
cpuSlowdownMultiplier: 4,
},
screenEmulation: {
mobile: true,
width: 375,
height: 667,
deviceScaleFactor: 2,
disabled: false,
},
// Skip audits that don't apply to SPAs
skipAudits: [
'uses-http2', // Often depends on server config
'redirects-http', // Handled by deployment
],
},
},
assert: {
preset: 'lighthouse:recommended',
assertions: {
// Performance budget
'categories:performance': ['warn', { minScore: 0.9 }],
'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['warn', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['warn', { maxNumericValue: 300 }],
// Accessibility (strict - these are blocking)
'categories:accessibility': ['error', { minScore: 0.95 }],
'color-contrast': 'error',
'document-title': 'error',
'html-has-lang': 'error',
'image-alt': 'error',
'label': 'error',
'link-name': 'error',
'meta-viewport': 'error',
'button-name': 'error',
'aria-roles': 'error',
'aria-valid-attr': 'error',
'aria-valid-attr-value': 'error',
// Best Practices
'categories:best-practices': ['warn', { minScore: 0.9 }],
'is-on-https': 'off', // Handled by deployment
'no-vulnerable-libraries': 'error',
// SEO
'categories:seo': ['warn', { minScore: 0.9 }],
'meta-description': 'warn',
'robots-txt': 'off', // Handled by deployment
},
},
upload: {
target: 'filesystem',
outputDir: './lighthouse-results',
reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%',
},
},
};

View File

@@ -47,3 +47,4 @@
| WEB-TRIAGE-0215-001 | DONE (2025-12-12) | Added triage TS models + web SDK clients (VEX decisions, audit bundles, vuln-scan attestation predicate) and fixed `scripts/chrome-path.js` so `npm test` runs on Windows Playwright Chromium. |
| UI-VEX-0215-A11Y | DONE (2025-12-12) | Added dialog semantics + focus trap for `VexDecisionModalComponent` and Playwright Axe coverage in `tests/e2e/a11y-smoke.spec.ts`. |
| UI-TRIAGE-0215-FIXTURES | DONE (2025-12-12) | Made quickstart mock fixtures deterministic for triage surfaces (VEX decisions, audit bundles, vulnerabilities) to support offline-kit hashing and stable tests. |
| UI-TRIAGE-4601-001 | DOING (2025-12-14) | Keyboard shortcuts for triage workspace (SPRINT_4601_0001_0001_keyboard_shortcuts.md). |

View File

@@ -0,0 +1,216 @@
import { CommonModule, DOCUMENT } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
inject,
output,
ViewChild,
} from '@angular/core';
import { KeyboardShortcutsService, type KeyboardShortcut, type KeyboardShortcutCategory } from '../../services/keyboard-shortcuts.service';
@Component({
selector: 'app-keyboard-help',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="overlay" (click)="closed.emit()" role="presentation">
<section
class="modal"
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
(click)="$event.stopPropagation()"
>
<header class="header">
<h2 class="title">Keyboard shortcuts</h2>
<button #closeButton type="button" class="close" (click)="closed.emit()" aria-label="Close keyboard shortcuts">
Close
</button>
</header>
@for (category of categories; track category.key) {
<section class="category">
<h3 class="category__title">{{ category.title }}</h3>
<div class="grid">
@for (shortcut of shortcutsByCategory(category.key); track shortcut.description + ':' + shortcut.key) {
<div class="row">
<kbd>{{ formatShortcut(shortcut) }}</kbd>
<span>{{ shortcut.description }}</span>
</div>
}
</div>
</section>
}
<footer class="footer">
<span>Press <kbd>?</kbd> to toggle this help.</span>
</footer>
</section>
</div>
`,
styles: [
`
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 220;
padding: 1rem;
}
.modal {
width: min(720px, 100%);
max-height: min(80vh, 720px);
overflow: auto;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: #0b1224;
color: #e5e7eb;
padding: 1.1rem 1.25rem;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.title {
margin: 0;
font-size: 1.1rem;
}
.close {
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.65);
color: #e5e7eb;
padding: 0.35rem 0.6rem;
border-radius: 10px;
cursor: pointer;
}
.category {
margin-top: 1rem;
}
.category__title {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.grid {
display: grid;
gap: 0.55rem;
}
.row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 0.75rem;
align-items: center;
}
kbd {
display: inline-flex;
justify-content: center;
align-items: center;
width: fit-content;
min-width: 68px;
padding: 0.25rem 0.5rem;
border-radius: 10px;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.25);
font-family: ui-monospace, monospace;
font-size: 0.8rem;
color: #e2e8f0;
}
.footer {
margin-top: 1.1rem;
color: #94a3b8;
font-size: 0.85rem;
text-align: center;
}
`,
],
})
export class KeyboardHelpComponent implements AfterViewInit, OnDestroy {
private readonly keyboard = inject(KeyboardShortcutsService);
private readonly document = inject(DOCUMENT);
private readonly previouslyFocused =
this.document.activeElement instanceof HTMLElement ? this.document.activeElement : null;
@ViewChild('closeButton', { static: true })
private readonly closeButton?: ElementRef<HTMLButtonElement>;
readonly closed = output<void>();
readonly categories: readonly { key: KeyboardShortcutCategory; title: string }[] = [
{ key: 'navigation', title: 'Navigation' },
{ key: 'decision', title: 'Decision' },
{ key: 'utility', title: 'Utility' },
];
ngAfterViewInit(): void {
queueMicrotask(() => {
try {
this.closeButton?.nativeElement.focus();
} catch {
// best-effort focus only
}
});
}
ngOnDestroy(): void {
try {
this.previouslyFocused?.focus();
} catch {
// best-effort restore focus only
}
}
shortcutsByCategory(category: KeyboardShortcutCategory): KeyboardShortcut[] {
return this.keyboard.getByCategory(category);
}
formatShortcut(shortcut: KeyboardShortcut): string {
const combo = this.keyboard.toComboLabel(shortcut);
return combo
.split('+')
.map((part) => this.formatKeyPart(part))
.join('+');
}
private formatKeyPart(value: string): string {
const key = value.toLowerCase();
const map: Record<string, string> = {
ctrl: 'Ctrl',
alt: 'Alt',
meta: 'Meta',
arrowdown: '↓',
arrowup: '↑',
arrowleft: '←',
arrowright: '→',
escape: 'Esc',
enter: 'Enter',
};
if (map[key]) return map[key];
if (key.length === 1) return key.toUpperCase();
return value;
}
}

View File

@@ -0,0 +1,129 @@
import { DOCUMENT } from '@angular/common';
import { DestroyRef, Injectable, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, Subject, fromEvent } from 'rxjs';
import { filter } from 'rxjs/operators';
export type KeyboardShortcutCategory = 'navigation' | 'decision' | 'utility';
export type KeyboardShortcutModifier = 'ctrl' | 'alt' | 'meta';
export interface KeyboardShortcut {
readonly key: string;
readonly description: string;
readonly category: KeyboardShortcutCategory;
readonly action: (event: KeyboardEvent) => void;
readonly requiresModifier?: KeyboardShortcutModifier;
readonly enabled?: () => boolean;
}
const CATEGORY_ORDER: Record<KeyboardShortcutCategory, number> = {
navigation: 0,
decision: 1,
utility: 2,
};
@Injectable({ providedIn: 'root' })
export class KeyboardShortcutsService {
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
private readonly shortcuts = new Map<string, KeyboardShortcut>();
private readonly keyPress$ = new Subject<KeyboardEvent>();
private enabled = true;
constructor() {
fromEvent<KeyboardEvent>(this.document, 'keydown')
.pipe(
takeUntilDestroyed(this.destroyRef),
filter(() => this.enabled),
filter((event) => !event.repeat),
filter((event) => !event.defaultPrevented),
filter((event) => !this.isEditableTarget(event.target))
)
.subscribe((event) => this.handleKeyPress(event));
}
register(shortcut: KeyboardShortcut): () => void {
const storageKey = this.toStorageKey(shortcut);
this.shortcuts.set(storageKey, shortcut);
return () => this.shortcuts.delete(storageKey);
}
unregister(combo: string): void {
this.shortcuts.delete(combo.toLowerCase());
}
getAll(): KeyboardShortcut[] {
return this.sorted([...this.shortcuts.values()]);
}
getByCategory(category: KeyboardShortcutCategory): KeyboardShortcut[] {
return this.getAll().filter((s) => s.category === category);
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
onKeyPress(): Observable<KeyboardEvent> {
return this.keyPress$.asObservable();
}
toComboLabel(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
if (shortcut.requiresModifier) parts.push(shortcut.requiresModifier);
parts.push(shortcut.key.toLowerCase());
return parts.join('+');
}
private handleKeyPress(event: KeyboardEvent): void {
const key = this.normalizeKey(event);
const shortcut = this.shortcuts.get(key);
if (!shortcut) return;
if (shortcut.enabled && !shortcut.enabled()) return;
event.preventDefault();
shortcut.action(event);
this.keyPress$.next(event);
}
private normalizeKey(event: KeyboardEvent): string {
const parts: string[] = [];
if (event.ctrlKey) parts.push('ctrl');
if (event.altKey) parts.push('alt');
if (event.metaKey) parts.push('meta');
parts.push((event.key || '').toLowerCase());
return parts.join('+');
}
private toStorageKey(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
if (shortcut.requiresModifier) parts.push(shortcut.requiresModifier);
parts.push(shortcut.key.toLowerCase());
return parts.join('+');
}
private isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
const tagName = target.tagName.toLowerCase();
if (['input', 'textarea', 'select'].includes(tagName)) return true;
if (target.getAttribute('contenteditable') === 'true') return true;
return target.closest('[contenteditable="true"]') !== null;
}
private sorted(list: KeyboardShortcut[]): KeyboardShortcut[] {
return [...list].sort((a, b) => {
const cat = CATEGORY_ORDER[a.category] - CATEGORY_ORDER[b.category];
if (cat !== 0) return cat;
const aKey = this.toComboLabel(a);
const bKey = this.toComboLabel(b);
const keyCmp = aKey.localeCompare(bKey);
if (keyCmp !== 0) return keyCmp;
return a.description.localeCompare(b.description);
});
}
}

View File

@@ -0,0 +1,167 @@
import { Injectable, inject } from '@angular/core';
import { KeyboardShortcutsService } from './keyboard-shortcuts.service';
export type TriageQuickVexStatus = 'AFFECTED' | 'NOT_AFFECTED' | 'UNDER_INVESTIGATION';
export interface TriageShortcutsActions {
readonly canRunWorkspaceShortcuts: () => boolean;
readonly canRunGlobalShortcuts: () => boolean;
readonly hasSelection: () => boolean;
readonly jumpToIncompleteEvidence: () => void;
readonly focusGraphSearch: () => void;
readonly toggleReachabilityView: () => void;
readonly toggleDeterministicSort: () => void;
readonly openQuickVex: (status: TriageQuickVexStatus) => void;
readonly copyDsseAttestation: () => void | Promise<void>;
readonly toggleKeyboardHelp: () => void;
readonly selectNextFinding: () => void;
readonly selectPreviousFinding: () => void;
readonly selectNextTab: () => void;
readonly selectPreviousTab: () => void;
readonly openSelected: () => void;
readonly closeOverlays: () => void;
}
@Injectable()
export class TriageShortcutsService {
private readonly keyboard = inject(KeyboardShortcutsService);
private cleanup: Array<() => void> = [];
private initialized = false;
initialize(actions: TriageShortcutsActions): void {
if (this.initialized) return;
this.initialized = true;
const globalEnabled = () => actions.canRunGlobalShortcuts();
const workspaceEnabled = () => actions.canRunWorkspaceShortcuts();
const selectionEnabled = () => actions.canRunWorkspaceShortcuts() && actions.hasSelection();
this.cleanup.push(
// Navigation
this.keyboard.register({
key: 'j',
description: 'Jump to first incomplete evidence pane',
category: 'navigation',
action: () => actions.jumpToIncompleteEvidence(),
enabled: selectionEnabled,
}),
this.keyboard.register({
key: '/',
description: 'Search within reachability graph',
category: 'navigation',
action: () => actions.focusGraphSearch(),
enabled: selectionEnabled,
}),
this.keyboard.register({
key: 'r',
description: 'Toggle reachability view',
category: 'navigation',
action: () => actions.toggleReachabilityView(),
enabled: selectionEnabled,
}),
this.keyboard.register({
key: 's',
description: 'Toggle deterministic sort',
category: 'navigation',
action: () => actions.toggleDeterministicSort(),
enabled: workspaceEnabled,
}),
// Decision
this.keyboard.register({
key: 'a',
description: 'Quick VEX: Affected',
category: 'decision',
action: () => actions.openQuickVex('AFFECTED'),
enabled: selectionEnabled,
}),
this.keyboard.register({
key: 'n',
description: 'Quick VEX: Not affected',
category: 'decision',
action: () => actions.openQuickVex('NOT_AFFECTED'),
enabled: selectionEnabled,
}),
this.keyboard.register({
key: 'u',
description: 'Quick VEX: Under investigation',
category: 'decision',
action: () => actions.openQuickVex('UNDER_INVESTIGATION'),
enabled: selectionEnabled,
}),
// Utility
this.keyboard.register({
key: 'y',
description: 'Copy DSSE attestation',
category: 'utility',
action: () => void actions.copyDsseAttestation(),
enabled: globalEnabled,
}),
this.keyboard.register({
key: '?',
description: 'Toggle keyboard help',
category: 'utility',
action: () => actions.toggleKeyboardHelp(),
enabled: globalEnabled,
}),
// Arrow navigation
this.keyboard.register({
key: 'arrowdown',
description: 'Select next finding',
category: 'navigation',
action: () => actions.selectNextFinding(),
enabled: workspaceEnabled,
}),
this.keyboard.register({
key: 'arrowup',
description: 'Select previous finding',
category: 'navigation',
action: () => actions.selectPreviousFinding(),
enabled: workspaceEnabled,
}),
this.keyboard.register({
key: 'arrowright',
description: 'Next evidence tab',
category: 'navigation',
action: () => actions.selectNextTab(),
enabled: workspaceEnabled,
}),
this.keyboard.register({
key: 'arrowleft',
description: 'Previous evidence tab',
category: 'navigation',
action: () => actions.selectPreviousTab(),
enabled: workspaceEnabled,
}),
this.keyboard.register({
key: 'enter',
description: 'Open selected finding',
category: 'navigation',
action: () => actions.openSelected(),
enabled: selectionEnabled,
}),
this.keyboard.register({
key: 'escape',
description: 'Close overlay',
category: 'navigation',
action: () => actions.closeOverlays(),
enabled: globalEnabled,
})
);
}
destroy(): void {
for (const unregister of this.cleanup) unregister();
this.cleanup = [];
this.initialized = false;
}
}

View File

@@ -1,8 +1,11 @@
import { CommonModule } from '@angular/common';
import { CommonModule, DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
computed,
inject,
signal,
@@ -13,9 +16,11 @@ import { firstValueFrom } from 'rxjs';
import type { SeverityCounts, VulnScanAttestation } from '../../core/api/attestation-vuln-scan.models';
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
import type { AffectedComponent, Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models';
import type { VexDecision } from '../../core/api/evidence.models';
import type { VexDecision, VexStatus } from '../../core/api/evidence.models';
import { VEX_DECISIONS_API, type VexDecisionsApi } from '../../core/api/vex-decisions.client';
import { ReachabilityWhyDrawerComponent } from '../reachability/reachability-why-drawer.component';
import { KeyboardHelpComponent } from './components/keyboard-help/keyboard-help.component';
import { type TriageQuickVexStatus, TriageShortcutsService } from './services/triage-shortcuts.service';
import { VexDecisionModalComponent } from './vex-decision-modal.component';
import {
TriageAttestationDetailModalComponent,
@@ -57,18 +62,25 @@ interface PolicyGateCell {
CommonModule,
RouterLink,
ReachabilityWhyDrawerComponent,
KeyboardHelpComponent,
VexDecisionModalComponent,
TriageAttestationDetailModalComponent,
],
providers: [TriageShortcutsService],
templateUrl: './triage-workspace.component.html',
styleUrls: ['./triage-workspace.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TriageWorkspaceComponent implements OnInit {
export class TriageWorkspaceComponent implements OnInit, OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly vexApi = inject<VexDecisionsApi>(VEX_DECISIONS_API);
private readonly shortcuts = inject(TriageShortcutsService);
@ViewChild('reachabilitySearchInput')
private readonly reachabilitySearchInput?: ElementRef<HTMLInputElement>;
readonly artifactId = signal<string>('');
readonly loading = signal(false);
@@ -83,11 +95,19 @@ export class TriageWorkspaceComponent implements OnInit {
readonly showVexModal = signal(false);
readonly vexTargetVulnerabilityIds = signal<readonly string[]>([]);
readonly vexModalInitialStatus = signal<VexStatus | null>(null);
readonly showReachabilityDrawer = signal(false);
readonly reachabilityComponent = signal<string | null>(null);
readonly attestationModal = signal<TriageAttestationDetail | null>(null);
readonly showKeyboardHelp = signal(false);
readonly reachabilityView = signal<'path-list' | 'compact-graph' | 'textual-proof'>('path-list');
readonly reachabilitySearch = signal('');
readonly findingsSort = signal<'default' | 'deterministic'>('default');
readonly keyboardStatus = signal<string | null>(null);
readonly selectedVuln = computed(() => {
const id = this.selectedVulnId();
@@ -105,10 +125,24 @@ export class TriageWorkspaceComponent implements OnInit {
})
.filter((v): v is FindingCardModel => v !== null);
// deterministic ordering: severity, status, cveId
const sortMode = this.findingsSort();
relevant.sort((a, b) => {
if (sortMode === 'deterministic') {
const reach = this.compareReachability(a.vuln, b.vuln);
if (reach !== 0) return reach;
}
const sev = SEVERITY_ORDER[a.vuln.severity] - SEVERITY_ORDER[b.vuln.severity];
if (sev !== 0) return sev;
if (sortMode === 'deterministic') {
const age = this.compareAge(a.vuln, b.vuln);
if (age !== 0) return age;
const component = (a.component?.purl ?? '').localeCompare(b.component?.purl ?? '');
if (component !== 0) return component;
}
const cve = a.vuln.cveId.localeCompare(b.vuln.cveId);
if (cve !== 0) return cve;
return a.vuln.vulnId.localeCompare(b.vuln.vulnId);
@@ -194,6 +228,28 @@ export class TriageWorkspaceComponent implements OnInit {
);
async ngOnInit(): Promise<void> {
this.shortcuts.initialize({
canRunWorkspaceShortcuts: () => !this.isShortcutOverlayOpen(),
canRunGlobalShortcuts: () => !this.showVexModal(),
hasSelection: () => typeof this.selectedVulnId() === 'string' && this.selectedVulnId()!.length > 0,
jumpToIncompleteEvidence: () => this.jumpToIncompleteEvidencePane(),
focusGraphSearch: () => this.focusReachabilitySearch(),
toggleReachabilityView: () => this.cycleReachabilityView(),
toggleDeterministicSort: () => this.toggleFindingsSort(),
openQuickVex: (status) => this.openQuickVex(status),
copyDsseAttestation: () => void this.copyDsseAttestation(),
toggleKeyboardHelp: () => this.toggleKeyboardHelp(),
selectNextFinding: () => this.selectRelativeFinding(1),
selectPreviousFinding: () => this.selectRelativeFinding(-1),
selectNextTab: () => this.selectRelativeTab(1),
selectPreviousTab: () => this.selectRelativeTab(-1),
openSelected: () => this.openVexForSelected(),
closeOverlays: () => this.closeOverlays(),
});
const artifactId = this.route.snapshot.paramMap.get('artifactId') ?? '';
this.artifactId.set(artifactId);
await this.load();
@@ -203,6 +259,10 @@ export class TriageWorkspaceComponent implements OnInit {
this.selectedVulnId.set(first);
}
ngOnDestroy(): void {
this.shortcuts.destroy();
}
async load(): Promise<void> {
this.loading.set(true);
this.error.set(null);
@@ -249,6 +309,7 @@ export class TriageWorkspaceComponent implements OnInit {
}
openVexForFinding(vulnId: string): void {
this.vexModalInitialStatus.set(null);
const selected = this.findings().find((f) => f.vuln.vulnId === vulnId);
if (!selected) return;
this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]);
@@ -256,6 +317,7 @@ export class TriageWorkspaceComponent implements OnInit {
}
openBulkVex(): void {
this.vexModalInitialStatus.set(null);
const selectedIds = this.selectedForBulk();
if (selectedIds.length === 0) return;
const cves = this.findings()
@@ -270,6 +332,7 @@ export class TriageWorkspaceComponent implements OnInit {
closeVexModal(): void {
this.showVexModal.set(false);
this.vexTargetVulnerabilityIds.set([]);
this.vexModalInitialStatus.set(null);
}
onVexSaved(decisions: readonly VexDecision[]): void {