This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -45,3 +45,5 @@
| UI-VEX-0215-001 | DONE (2025-12-12) | VEX-first triage modal with scope/validity/evidence/review sections and bulk apply; wired via `src/app/core/api/vex-decisions.client.ts`. |
| UI-AUDIT-0215-001 | DONE (2025-12-12) | Immutable audit bundle button + wizard/history views; download via `GET /v1/audit-bundles/{bundleId}` (`Accept: application/octet-stream`) using `src/app/core/api/audit-bundles.client.ts`. |
| 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. |

View File

@@ -116,15 +116,16 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
}
interface StoredAuditJob extends AuditBundleJobResponse {
readonly createdAtMs: number;
pollCount: number;
}
@Injectable({ providedIn: 'root' })
export class MockAuditBundlesClient implements AuditBundlesApi {
private static readonly BaseMs = Date.parse('2025-12-01T00:00:00Z');
private readonly store: StoredAuditJob[] = [];
listBundles(): Observable<AuditBundleListResponse> {
const traceId = generateTraceId();
const traceId = 'mock-trace-audit-list';
const items = [...this.store]
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : a.bundleId.localeCompare(b.bundleId)))
.map((job) => this.materialize(job));
@@ -133,17 +134,17 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
}
createBundle(request: AuditBundleCreateRequest, options: { traceId?: string } = {}): Observable<AuditBundleJobResponse> {
const traceId = options.traceId ?? generateTraceId();
const createdAt = new Date().toISOString();
const traceId = options.traceId ?? 'mock-trace-audit-create';
const bundleId = this.allocateId();
const createdAt = this.timestampForSeq(this.store.length + 1);
const job: StoredAuditJob = {
bundleId,
status: 'queued',
createdAt,
subject: request.subject,
createdAtMs: Date.now(),
traceId,
pollCount: 0,
};
this.store.push(job);
@@ -153,6 +154,7 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
getBundle(bundleId: string): Observable<AuditBundleJobResponse> {
const job = this.store.find((j) => j.bundleId === bundleId);
if (!job) return throwError(() => new Error('Bundle not found'));
job.pollCount += 1;
return of(this.materialize(job)).pipe(delay(150));
}
@@ -173,9 +175,18 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
}
private materialize(job: StoredAuditJob): AuditBundleJobResponse {
const elapsedMs = Date.now() - job.createdAtMs;
if (elapsedMs < 500) return job;
if (elapsedMs < 1500) return { ...job, status: 'processing' };
if (job.status === 'completed' || job.status === 'failed') {
return job;
}
if (job.pollCount <= 1) {
return job;
}
if (job.pollCount === 2) {
return { ...job, status: 'processing' };
}
return {
...job,
status: 'completed',
@@ -190,4 +201,8 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
const seq = this.store.length + 1;
return `bndl-${seq.toString().padStart(4, '0')}`;
}
private timestampForSeq(seq: number): string {
return new Date(MockAuditBundlesClient.BaseMs + seq * 60000).toISOString();
}
}

View File

@@ -134,6 +134,9 @@ export class VexDecisionsHttpClient implements VexDecisionsApi {
@Injectable({ providedIn: 'root' })
export class MockVexDecisionsClient implements VexDecisionsApi {
private static readonly BaseMs = Date.parse('2025-12-01T00:00:00Z');
private timestampSeq = 0;
private readonly store: VexDecision[] = [
{
id: '2f76d3d4-1c4f-4c0f-8b4d-b4bdbb7e2b11',
@@ -158,7 +161,7 @@ export class MockVexDecisionsClient implements VexDecisionsApi {
];
listDecisions(options: VexDecisionQueryOptions = {}): Observable<VexDecisionsResponse> {
const traceId = options.traceId ?? generateTraceId();
const traceId = options.traceId ?? 'mock-trace-vex-list';
let items = [...this.store];
if (options.vulnerabilityId) {
@@ -184,7 +187,7 @@ export class MockVexDecisionsClient implements VexDecisionsApi {
}
createDecision(request: VexDecisionCreateRequest, options: VexDecisionQueryOptions = {}): Observable<VexDecision> {
const createdAt = new Date().toISOString();
const createdAt = this.nextTimestampIso();
const decision: VexDecision = {
id: this.allocateId(),
vulnerabilityId: request.vulnerabilityId,
@@ -211,7 +214,7 @@ export class MockVexDecisionsClient implements VexDecisionsApi {
const updated: VexDecision = {
...existing,
...request,
updatedAt: new Date().toISOString(),
updatedAt: this.nextTimestampIso(),
};
const idx = this.store.findIndex((d) => d.id === decisionId);
@@ -223,5 +226,10 @@ export class MockVexDecisionsClient implements VexDecisionsApi {
const seq = this.store.length + 1;
return `00000000-0000-0000-0000-${seq.toString().padStart(12, '0')}`;
}
}
private nextTimestampIso(): string {
const next = new Date(MockVexDecisionsClient.BaseMs + (this.timestampSeq + 1) * 60000).toISOString();
this.timestampSeq += 1;
return next;
}
}

View File

@@ -294,8 +294,12 @@ function withReachability(vuln: Vulnerability): Vulnerability {
@Injectable({ providedIn: 'root' })
export class MockVulnerabilityApiService implements VulnerabilityApi {
private mockExports = new Map<string, VulnExportResponse>();
private mockExports = new Map<string, VulnExportResponse>();
private exportSeq = 0;
private static readonly FixedNowIso = '2025-12-01T00:00:00Z';
private static readonly FixedNowMs = Date.parse(MockVulnerabilityApiService.FixedNowIso);
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
let items = [...MOCK_VULNERABILITIES];
@@ -329,14 +333,14 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
items = items.slice(offset, offset + limit);
const traceId = options?.traceId ?? `mock-trace-${Date.now()}`;
const traceId = options?.traceId ?? 'mock-trace-vuln-list';
return of({
items: options?.includeReachability ? items.map(withReachability) : items,
total,
hasMore: offset + items.length < total,
etag: `"vuln-list-${Date.now()}"`,
etag: '"vuln-list-v1"',
traceId,
}).pipe(delay(200));
}
@@ -353,9 +357,9 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
}).pipe(delay(100));
}
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
const vulns = MOCK_VULNERABILITIES;
const stats: VulnerabilityStats = {
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
const vulns = MOCK_VULNERABILITIES;
const stats: VulnerabilityStats = {
total: vulns.length,
bySeverity: {
critical: vulns.filter((v) => v.severity === 'critical').length,
@@ -371,51 +375,51 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
excepted: vulns.filter((v) => v.status === 'excepted').length,
},
withExceptions: vulns.filter((v) => v.hasException).length,
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
computedAt: new Date().toISOString(),
traceId: `mock-stats-${Date.now()}`,
};
return of(stats).pipe(delay(150));
}
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse> {
const traceId = options?.traceId ?? `mock-trace-${Date.now()}`;
const correlationId = `mock-corr-${Date.now()}`;
return of({
status: 'accepted' as const,
ledgerEventId: `ledg-mock-${Date.now()}`,
etag: `"workflow-${request.findingId}-${Date.now()}"`,
traceId,
correlationId,
}).pipe(delay(300));
}
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
const exportId = `export-mock-${Date.now()}`;
const traceId = options?.traceId ?? `mock-trace-${Date.now()}`;
const exportResponse: VulnExportResponse = {
exportId,
status: 'completed',
downloadUrl: `https://mock.stellaops.local/exports/${exportId}.${request.format}`,
expiresAt: new Date(Date.now() + 3600000).toISOString(),
recordCount: MOCK_VULNERABILITIES.length,
fileSize: 1024 * (request.includeComponents ? 50 : 20),
traceId,
};
withExceptions: vulns.filter((v) => v.hasException).length,
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
computedAt: MockVulnerabilityApiService.FixedNowIso,
traceId: 'mock-trace-vuln-stats',
};
return of(stats).pipe(delay(150));
}
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse> {
const traceId = options?.traceId ?? 'mock-trace-vuln-workflow';
const correlationId = 'mock-corr-vuln-workflow';
return of({
status: 'accepted' as const,
ledgerEventId: 'ledg-mock-0001',
etag: `"workflow-${request.findingId}-v1"`,
traceId,
correlationId,
}).pipe(delay(300));
}
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
const exportId = `export-mock-${(++this.exportSeq).toString().padStart(4, '0')}`;
const traceId = options?.traceId ?? 'mock-trace-vuln-export';
const exportResponse: VulnExportResponse = {
exportId,
status: 'completed',
downloadUrl: `https://mock.stellaops.local/exports/${exportId}.${request.format}`,
expiresAt: new Date(MockVulnerabilityApiService.FixedNowMs + 3600000).toISOString(),
recordCount: MOCK_VULNERABILITIES.length,
fileSize: 1024 * (request.includeComponents ? 50 : 20),
traceId,
};
this.mockExports.set(exportId, exportResponse);
return of(exportResponse).pipe(delay(500));
}
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
const traceId = options?.traceId ?? `mock-trace-${Date.now()}`;
const existing = this.mockExports.get(exportId);
if (existing) {
return of(existing).pipe(delay(100));
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
const traceId = options?.traceId ?? 'mock-trace-vuln-export-status';
const existing = this.mockExports.get(exportId);
if (existing) {
return of(existing).pipe(delay(100));
}
return of({

View File

@@ -1,10 +1,18 @@
<div class="modal">
<div class="modal__backdrop" (click)="close()"></div>
<div class="modal__container" role="dialog" aria-modal="true" aria-label="VEX decision">
<div
class="modal__container"
role="dialog"
aria-modal="true"
aria-labelledby="vex-dialog-title"
aria-describedby="vex-dialog-description"
tabindex="-1"
(keydown)="onDialogKeydown($event)"
>
<header class="modal__header">
<div>
<h2>VEX decision</h2>
<p class="modal__subtitle">
<h2 id="vex-dialog-title">VEX decision</h2>
<p id="vex-dialog-description" class="modal__subtitle">
Subject: <code>{{ subject().name }}</code>
@if (isBulk()) {
&middot; applies to {{ vulnerabilityIds().length }} findings
@@ -13,7 +21,16 @@
}
</p>
</div>
<button type="button" class="modal__close" (click)="close()" [disabled]="loading()">Close</button>
<button
#closeButton
type="button"
class="modal__close"
aria-label="Close VEX decision dialog"
(click)="close()"
[disabled]="loading()"
>
Close
</button>
</header>
<div class="modal__body">
@@ -22,8 +39,8 @@
}
<section class="section">
<h3>Status</h3>
<div class="radio-grid">
<h3 id="vex-status-heading">Status</h3>
<div class="radio-grid" role="radiogroup" aria-labelledby="vex-status-heading">
@for (opt of statusOptions; track opt.value) {
<label class="radio">
<input type="radio" name="status" [checked]="status() === opt.value" (change)="status.set(opt.value)" />
@@ -110,7 +127,14 @@
<span class="field__label">URL</span>
<input class="field__control" placeholder="https://..." [value]="evidenceUrl()" (input)="evidenceUrl.set($any($event.target).value)" />
</label>
<button type="button" class="btn btn--secondary" (click)="addEvidence()">Add</button>
<button
type="button"
class="btn btn--secondary"
(click)="addEvidence()"
[disabled]="!evidenceUrl().trim()"
>
Add
</button>
</div>
<div class="field-row">
@@ -134,7 +158,14 @@
<li>
<code>{{ ref.type }}</code>
<a [href]="ref.url" target="_blank" rel="noopener noreferrer">{{ ref.title || ref.url }}</a>
<button type="button" class="link" (click)="removeEvidence(ref)">Remove</button>
<button
type="button"
class="link"
[attr.aria-label]="'Remove evidence ' + (ref.title || ref.url)"
(click)="removeEvidence(ref)"
>
Remove
</button>
</li>
}
</ul>
@@ -144,11 +175,17 @@
<section class="section">
<h3>Review</h3>
<p class="hint">Will generate signed attestation on save.</p>
<button type="button" class="btn btn--secondary" (click)="viewRawJson.set(!viewRawJson())">
<button
type="button"
class="btn btn--secondary"
(click)="viewRawJson.set(!viewRawJson())"
[attr.aria-controls]="'vex-raw-json'"
[attr.aria-expanded]="viewRawJson()"
>
{{ viewRawJson() ? 'Hide raw JSON' : 'View raw JSON' }}
</button>
@if (viewRawJson()) {
<pre class="json-preview">{{ requestPreview() | json }}</pre>
<pre id="vex-raw-json" class="json-preview">{{ requestPreview() | json }}</pre>
}
</section>
</div>

View File

@@ -1,13 +1,17 @@
import { CommonModule } from '@angular/common';
import { CommonModule, DOCUMENT } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
input,
OnDestroy,
output,
signal,
ViewChild,
} from '@angular/core';
import { forkJoin, of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
@@ -70,6 +74,12 @@ function fromLocalDateTimeValue(value: string): string | undefined {
})
export class VexDecisionModalComponent {
private readonly api = inject<VexDecisionsApi>(VEX_DECISIONS_API);
private readonly document = inject(DOCUMENT);
@ViewChild('closeButton', { static: true })
private readonly closeButton?: ElementRef<HTMLButtonElement>;
private previouslyFocused: HTMLElement | null = null;
readonly subject = input.required<VexSubjectRef>();
readonly vulnerabilityIds = input.required<readonly string[]>();
@@ -162,6 +172,34 @@ export class VexDecisionModalComponent {
});
}
ngAfterViewInit(): void {
this.previouslyFocused = this.document.activeElement instanceof HTMLElement ? this.document.activeElement : null;
queueMicrotask(() => this.closeButton?.nativeElement.focus());
}
ngOnDestroy(): void {
try {
this.previouslyFocused?.focus();
} catch {
// best-effort restore focus only
}
}
onDialogKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.close();
return;
}
if (event.key !== 'Tab') {
return;
}
this.trapFocus(event);
}
close(): void {
if (!this.loading()) this.closed.emit();
}
@@ -245,6 +283,33 @@ export class VexDecisionModalComponent {
});
}
private trapFocus(event: KeyboardEvent): void {
const root = this.closeButton?.nativeElement.closest<HTMLElement>('.modal__container');
if (!root) return;
const focusable = Array.from(
root.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex=\"-1\"])'
)
).filter((el) => el.offsetParent !== null);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = this.document.activeElement instanceof HTMLElement ? this.document.activeElement : null;
if (event.shiftKey) {
if (active === first || (active && !root.contains(active))) {
event.preventDefault();
last.focus();
}
} else if (active === last) {
event.preventDefault();
first.focus();
}
}
private splitCsv(text: string): string[] {
return text
.split(',')

View File

@@ -87,4 +87,29 @@ test.describe('a11y-smoke', () => {
description: `${violations.length} violations (/graph)`,
});
});
test('triage VEX modal', async ({ page }, testInfo) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: 'VEX' }).first().click();
await expect(page.getByRole('dialog', { name: 'VEX decision' })).toBeVisible({ timeout: 10000 });
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.include('.modal__container')
.analyze();
const violations = [...results.violations].sort((a, b) => a.id.localeCompare(b.id));
await writeReport('a11y-triage_vex_modal.json', { url: page.url(), violations });
if (shouldFail) {
expect(violations).toEqual([]);
}
testInfo.annotations.push({
type: 'a11y',
description: `${violations.length} violations (/triage VEX modal)`,
});
});
});