Repair triage artifact scope and evidence contracts
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-triage-artifacts-scope-compat.json');
|
||||
const authStatePath = path.join(outputDir, 'live-triage-artifacts-scope-compat.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-triage-artifacts-scope-compat.auth.json');
|
||||
const routePath = '/triage/artifacts';
|
||||
|
||||
function scopedUrl(route = routePath) {
|
||||
return `https://stella-ops.local${route}`;
|
||||
}
|
||||
|
||||
async function settle(page, timeoutMs = 1_500) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
|
||||
await page.waitForTimeout(timeoutMs);
|
||||
}
|
||||
|
||||
async function waitForArtifactsSurface(page) {
|
||||
await Promise.race([
|
||||
page.locator('tbody tr').first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}),
|
||||
page.getByText('Unable to load artifacts', { exact: true }).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}),
|
||||
page.getByText('No artifacts match the current lane and filters.', { exact: true }).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}),
|
||||
page.waitForTimeout(20_000),
|
||||
]);
|
||||
}
|
||||
|
||||
async function waitForWorkspaceSurface(page) {
|
||||
await Promise.race([
|
||||
page.locator('[data-finding-card]').first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}),
|
||||
page.getByText('Unable to load findings', { exact: true }).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}),
|
||||
page.getByText('No findings for this artifact.', { exact: true }).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}),
|
||||
page.waitForTimeout(20_000),
|
||||
]);
|
||||
}
|
||||
|
||||
async function assert(condition, message, details = {}) {
|
||||
if (!condition) {
|
||||
throw new Error(`${message}${Object.keys(details).length ? ` ${JSON.stringify(details)}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const authReport = await authenticateFrontdoor({
|
||||
statePath: authStatePath,
|
||||
reportPath: authReportPath,
|
||||
headless: true,
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath });
|
||||
const page = await context.newPage();
|
||||
const consoleErrors = [];
|
||||
const responseErrors = [];
|
||||
const requestFailures = [];
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes('/connect/authorize')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status() >= 400) {
|
||||
responseErrors.push({
|
||||
status: response.status(),
|
||||
method: response.request().method(),
|
||||
url,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
const failure = request.failure()?.errorText ?? 'unknown';
|
||||
if (failure === 'net::ERR_ABORTED') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestFailures.push({
|
||||
method: request.method(),
|
||||
url,
|
||||
error: failure,
|
||||
});
|
||||
});
|
||||
|
||||
const checks = [];
|
||||
|
||||
try {
|
||||
await page.goto(scopedUrl(), { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await waitForArtifactsSurface(page);
|
||||
await settle(page);
|
||||
|
||||
const heading = (await page.locator('h1').first().textContent())?.trim() ?? '';
|
||||
await assert(heading === 'Artifact workspace', 'Unexpected artifact workspace heading.', { heading });
|
||||
checks.push({ name: 'artifacts-heading', ok: true, heading });
|
||||
|
||||
const loadErrorVisible = await page.getByText('Unable to load artifacts', { exact: true }).isVisible().catch(() => false);
|
||||
await assert(!loadErrorVisible, 'Artifact workspace still rendered the load error state.');
|
||||
checks.push({ name: 'artifacts-no-error-state', ok: true });
|
||||
|
||||
const initialRowCount = await page.locator('tbody tr').count();
|
||||
await assert(initialRowCount > 0, 'Artifact workspace did not render any rows.', { initialRowCount });
|
||||
checks.push({ name: 'artifacts-row-count', ok: true, initialRowCount });
|
||||
|
||||
await page.getByRole('button', { name: 'Needs Review' }).click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
await page.getByPlaceholder('Search artifacts or environments...').fill('asset-web-prod');
|
||||
await settle(page);
|
||||
|
||||
const filteredRowCount = await page.locator('tbody tr').count();
|
||||
await assert(filteredRowCount === 1, 'Needs Review lane search did not isolate asset-web-prod.', { filteredRowCount });
|
||||
checks.push({ name: 'artifacts-lane-and-search', ok: true, filteredRowCount });
|
||||
|
||||
await page.getByRole('button', { name: 'Open workspace' }).first().click({ timeout: 10_000 });
|
||||
await waitForWorkspaceSurface(page);
|
||||
await settle(page);
|
||||
|
||||
const detailHeading = (await page.locator('h1').first().textContent())?.trim() ?? '';
|
||||
const detailSubtitle = ((await page.locator('.subtitle').first().textContent()) || '').trim().replace(/\s+/g, ' ');
|
||||
await assert(detailHeading === 'Artifact triage', 'Unexpected workspace heading after opening an artifact.', {
|
||||
detailHeading,
|
||||
url: page.url(),
|
||||
});
|
||||
await assert(detailSubtitle.includes('asset-web-prod'), 'Workspace subtitle did not identify the selected artifact.', {
|
||||
detailSubtitle,
|
||||
});
|
||||
checks.push({ name: 'workspace-route-and-heading', ok: true, detailHeading, detailSubtitle, url: page.url() });
|
||||
|
||||
const detailErrorVisible = await page.getByText('Unable to load findings', { exact: true }).isVisible().catch(() => false);
|
||||
await assert(!detailErrorVisible, 'Artifact workspace still rendered the findings error state.');
|
||||
const findingCardCount = await page.locator('[data-finding-card]').count();
|
||||
await assert(findingCardCount > 0, 'Artifact workspace did not render any finding cards.', { findingCardCount });
|
||||
checks.push({ name: 'workspace-finding-cards', ok: true, findingCardCount });
|
||||
|
||||
await page.getByRole('tab', { name: 'Attestations' }).click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
await assert(page.url().includes('tab=attestations'), 'Workspace tab action did not update the route state.', {
|
||||
url: page.url(),
|
||||
});
|
||||
checks.push({ name: 'workspace-tab-action', ok: true, url: page.url() });
|
||||
|
||||
await page.getByRole('link', { name: /Back to artifacts/i }).click({ timeout: 10_000 });
|
||||
await waitForArtifactsSurface(page);
|
||||
await settle(page);
|
||||
await assert(page.url().includes('/triage/artifacts'), 'Workspace back navigation did not return to the artifacts route.', {
|
||||
url: page.url(),
|
||||
});
|
||||
checks.push({ name: 'workspace-back-navigation', ok: true, url: page.url() });
|
||||
|
||||
const runtimeIssues = {
|
||||
consoleErrors,
|
||||
responseErrors,
|
||||
requestFailures,
|
||||
};
|
||||
const summary = {
|
||||
checkedAtUtc: new Date().toISOString(),
|
||||
routePath,
|
||||
checks,
|
||||
runtimeIssues,
|
||||
failedCheckCount: checks.filter((check) => !check.ok).length,
|
||||
runtimeIssueCount:
|
||||
runtimeIssues.consoleErrors.length
|
||||
+ runtimeIssues.responseErrors.length
|
||||
+ runtimeIssues.requestFailures.length,
|
||||
};
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (summary.failedCheckCount > 0 || summary.runtimeIssueCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
const summary = {
|
||||
checkedAtUtc: new Date().toISOString(),
|
||||
routePath,
|
||||
checks,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
consoleErrors,
|
||||
responseErrors,
|
||||
requestFailures,
|
||||
};
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await context.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await run();
|
||||
13
src/Web/StellaOps.Web/src/app/app.config-paths.spec.ts
Normal file
13
src/Web/StellaOps.Web/src/app/app.config-paths.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { resolveApiRootUrl } from './app.config';
|
||||
|
||||
describe('app config url helpers', () => {
|
||||
it('preserves service roots while trimming a trailing slash', () => {
|
||||
expect(resolveApiRootUrl('/authority/')).toBe('/authority');
|
||||
expect(resolveApiRootUrl('https://api.example.local/authority/')).toBe('https://api.example.local/authority');
|
||||
});
|
||||
|
||||
it('returns an empty string when the configured root is blank', () => {
|
||||
expect(resolveApiRootUrl(undefined)).toBe('');
|
||||
expect(resolveApiRootUrl(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -22,14 +22,18 @@ import {
|
||||
NotifyApiHttpClient,
|
||||
MockNotifyClient,
|
||||
} from './core/api/notify.client';
|
||||
import {
|
||||
EXCEPTION_API,
|
||||
EXCEPTION_API_BASE_URL,
|
||||
ExceptionApiHttpClient,
|
||||
MockExceptionApiService,
|
||||
} from './core/api/exception.client';
|
||||
import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client';
|
||||
import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/vulnerability-http.client';
|
||||
import {
|
||||
EXCEPTION_API,
|
||||
EXCEPTION_API_BASE_URL,
|
||||
ExceptionApiHttpClient,
|
||||
MockExceptionApiService,
|
||||
} from './core/api/exception.client';
|
||||
import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client';
|
||||
import {
|
||||
VULNERABILITY_API_BASE_URL,
|
||||
VULNERABILITY_QUERY_API_BASE_URL,
|
||||
VulnerabilityHttpClient,
|
||||
} from './core/api/vulnerability-http.client';
|
||||
import { RISK_API, MockRiskApi } from './core/api/risk.client';
|
||||
import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
@@ -262,8 +266,8 @@ import {
|
||||
MockIdentityProviderClient,
|
||||
} from './core/api/identity-provider.client';
|
||||
|
||||
function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const normalizedBase = (baseUrl ?? '').trim();
|
||||
function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
const normalizedBase = (baseUrl ?? '').trim();
|
||||
|
||||
if (!normalizedBase) {
|
||||
return path;
|
||||
@@ -281,8 +285,19 @@ function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||
: normalizedBase;
|
||||
|
||||
return `${baseWithoutTrailingSlash}/${path.replace(/^\/+/, '')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveApiRootUrl(baseUrl: string | undefined): string {
|
||||
const normalizedBase = (baseUrl ?? '').trim();
|
||||
if (!normalizedBase) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalizedBase.endsWith('/')
|
||||
? normalizedBase.slice(0, -1)
|
||||
: normalizedBase;
|
||||
}
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -352,21 +367,21 @@ export const appConfig: ApplicationConfig = {
|
||||
})(inject(AuthSessionStore), inject(TenantActivationService));
|
||||
return initializerFn();
|
||||
}),
|
||||
{
|
||||
provide: RISK_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const authorityBase = config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/risk', authorityBase).toString();
|
||||
} catch {
|
||||
const normalized = authorityBase.endsWith('/')
|
||||
? authorityBase.slice(0, -1)
|
||||
: authorityBase;
|
||||
return `${normalized}/risk`;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RISK_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const authorityBase = config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/risk', authorityBase).toString();
|
||||
} catch {
|
||||
const normalized = authorityBase.endsWith('/')
|
||||
? authorityBase.slice(0, -1)
|
||||
: authorityBase;
|
||||
return `${normalized}/risk`;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AUTH_SERVICE,
|
||||
useExisting: AuthorityAuthAdapterService,
|
||||
@@ -390,21 +405,23 @@ export const appConfig: ApplicationConfig = {
|
||||
provide: RISK_API,
|
||||
useExisting: RiskHttpClient,
|
||||
},
|
||||
{
|
||||
provide: VULNERABILITY_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const authorityBase = config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/vuln', authorityBase).toString();
|
||||
} catch {
|
||||
const normalized = authorityBase.endsWith('/')
|
||||
? authorityBase.slice(0, -1)
|
||||
: authorityBase;
|
||||
return `${normalized}/vuln`;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: VULNERABILITY_QUERY_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const gatewayBase = config.config.apiBaseUrls.gateway
|
||||
?? config.config.apiBaseUrls.scanner
|
||||
?? config.config.apiBaseUrls.authority;
|
||||
return resolveApiBaseUrl(gatewayBase, '/api/v1/vulnerabilities');
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: VULNERABILITY_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
return resolveApiRootUrl(config.config.apiBaseUrls.authority);
|
||||
},
|
||||
},
|
||||
VulnerabilityHttpClient,
|
||||
MockVulnerabilityApiService,
|
||||
{
|
||||
|
||||
@@ -3,7 +3,11 @@ import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { VulnerabilityHttpClient, VULNERABILITY_API_BASE_URL } from './vulnerability-http.client';
|
||||
import {
|
||||
VulnerabilityHttpClient,
|
||||
VULNERABILITY_API_BASE_URL,
|
||||
VULNERABILITY_QUERY_API_BASE_URL,
|
||||
} from './vulnerability-http.client';
|
||||
import { VulnerabilitiesResponse } from './vulnerability.models';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
@@ -30,6 +34,7 @@ describe('VulnerabilityHttpClient', () => {
|
||||
imports: [],
|
||||
providers: [
|
||||
VulnerabilityHttpClient,
|
||||
{ provide: VULNERABILITY_QUERY_API_BASE_URL, useValue: 'https://query.example.local/api/v1/vulnerabilities' },
|
||||
{ provide: VULNERABILITY_API_BASE_URL, useValue: 'https://api.example.local' },
|
||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||
{ provide: TenantActivationService, useValue: tenantServiceStub },
|
||||
@@ -51,7 +56,7 @@ describe('VulnerabilityHttpClient', () => {
|
||||
expect(resp.page).toBe(1);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('https://api.example.local/vuln?page=1&pageSize=5');
|
||||
const req = httpMock.expectOne('https://query.example.local/api/v1/vulnerabilities?page=1&pageSize=5');
|
||||
expect(req.request.headers.get('X-Stella-Ops-Tenant')).toBe('tenant-dev');
|
||||
expect(req.request.headers.has('X-Stella-Ops-Trace-Id')).toBeTrue();
|
||||
req.flush(stub);
|
||||
@@ -60,7 +65,7 @@ describe('VulnerabilityHttpClient', () => {
|
||||
it('adds project header when provided', () => {
|
||||
client.listVulnerabilities({ page: 1, projectId: 'proj-ops' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne('https://api.example.local/vuln?page=1');
|
||||
const req = httpMock.expectOne('https://query.example.local/api/v1/vulnerabilities?page=1');
|
||||
expect(req.request.headers.get('X-Stella-Ops-Project')).toBe('proj-ops');
|
||||
req.flush({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import { VulnerabilityApi } from './vulnerability.client';
|
||||
|
||||
export const VULNERABILITY_API_BASE_URL = new InjectionToken<string>('VULNERABILITY_API_BASE_URL');
|
||||
export const VULNERABILITY_QUERY_API_BASE_URL = new InjectionToken<string>('VULNERABILITY_QUERY_API_BASE_URL');
|
||||
|
||||
/**
|
||||
* HTTP client for vulnerability API with tenant scoping, RBAC/ABAC, and request logging.
|
||||
@@ -36,7 +37,8 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
@Inject(VULNERABILITY_API_BASE_URL) private readonly baseUrl: string
|
||||
@Inject(VULNERABILITY_QUERY_API_BASE_URL) private readonly queryBaseUrl: string,
|
||||
@Inject(VULNERABILITY_API_BASE_URL) private readonly legacyBaseUrl: string
|
||||
) {}
|
||||
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
|
||||
@@ -62,7 +64,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
if (options?.includeReachability) params = params.set('includeReachability', 'true');
|
||||
|
||||
return this.http
|
||||
.get<VulnerabilitiesResponse>(`${this.baseUrl}/vuln`, { headers, params, observe: 'response' })
|
||||
.get<VulnerabilitiesResponse>(this.queryBaseUrl, { headers, params, observe: 'response' })
|
||||
.pipe(
|
||||
map((resp: HttpResponse<VulnerabilitiesResponse>) => ({
|
||||
...resp.body!,
|
||||
@@ -77,7 +79,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
tenantId: tenant,
|
||||
projectId: options?.projectId,
|
||||
operation: 'listVulnerabilities',
|
||||
path: '/vuln',
|
||||
path: '/api/v1/vulnerabilities',
|
||||
method: 'GET',
|
||||
timestamp: new Date().toISOString(),
|
||||
durationMs: Date.now() - startTime,
|
||||
@@ -90,7 +92,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
tenantId: tenant,
|
||||
projectId: options?.projectId,
|
||||
operation: 'listVulnerabilities',
|
||||
path: '/vuln',
|
||||
path: '/api/v1/vulnerabilities',
|
||||
method: 'GET',
|
||||
timestamp: new Date().toISOString(),
|
||||
durationMs: Date.now() - startTime,
|
||||
@@ -113,10 +115,10 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
}
|
||||
|
||||
const headers = this.buildHeaders(tenant, options?.projectId, traceId, requestId);
|
||||
const path = `/vuln/${encodeURIComponent(vulnId)}`;
|
||||
const path = `${this.queryBaseUrl}/${encodeURIComponent(vulnId)}`;
|
||||
|
||||
return this.http
|
||||
.get<Vulnerability>(`${this.baseUrl}${path}`, { headers, observe: 'response' })
|
||||
.get<Vulnerability>(path, { headers, observe: 'response' })
|
||||
.pipe(
|
||||
map((resp: HttpResponse<Vulnerability>) => ({
|
||||
...resp.body!,
|
||||
@@ -166,7 +168,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
const headers = this.buildHeaders(tenant, options?.projectId, traceId, requestId);
|
||||
|
||||
return this.http
|
||||
.get<VulnerabilityStats>(`${this.baseUrl}/vuln/status`, { headers })
|
||||
.get<VulnerabilityStats>(`${this.queryBaseUrl}/status`, { headers })
|
||||
.pipe(
|
||||
map((stats) => ({ ...stats, traceId })),
|
||||
tap(() => this.logRequest({
|
||||
@@ -175,7 +177,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
tenantId: tenant,
|
||||
projectId: options?.projectId,
|
||||
operation: 'getStats',
|
||||
path: '/vuln/status',
|
||||
path: '/api/v1/vulnerabilities/status',
|
||||
method: 'GET',
|
||||
timestamp: new Date().toISOString(),
|
||||
durationMs: Date.now() - startTime,
|
||||
@@ -188,7 +190,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
tenantId: tenant,
|
||||
projectId: options?.projectId,
|
||||
operation: 'getStats',
|
||||
path: '/vuln/status',
|
||||
path: '/api/v1/vulnerabilities/status',
|
||||
method: 'GET',
|
||||
timestamp: new Date().toISOString(),
|
||||
durationMs: Date.now() - startTime,
|
||||
@@ -219,7 +221,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
const path = `/ledger/findings/${encodeURIComponent(request.findingId)}/actions`;
|
||||
|
||||
return this.http
|
||||
.post<VulnWorkflowResponse>(`${this.baseUrl}${path}`, request, { headers, observe: 'response' })
|
||||
.post<VulnWorkflowResponse>(`${this.legacyBaseUrl}${path}`, request, { headers, observe: 'response' })
|
||||
.pipe(
|
||||
map((resp: HttpResponse<VulnWorkflowResponse>) => ({
|
||||
...resp.body!,
|
||||
@@ -273,7 +275,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
const path = '/vuln/export';
|
||||
|
||||
return this.http
|
||||
.post<VulnExportResponse>(`${this.baseUrl}${path}`, request, { headers })
|
||||
.post<VulnExportResponse>(`${this.legacyBaseUrl}${path}`, request, { headers })
|
||||
.pipe(
|
||||
map((resp) => ({ ...resp, traceId })),
|
||||
tap(() => this.logRequest({
|
||||
@@ -321,7 +323,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
const path = `/vuln/export/${encodeURIComponent(exportId)}`;
|
||||
|
||||
return this.http
|
||||
.get<VulnExportResponse>(`${this.baseUrl}${path}`, { headers })
|
||||
.get<VulnExportResponse>(`${this.legacyBaseUrl}${path}`, { headers })
|
||||
.pipe(
|
||||
map((resp) => ({ ...resp, traceId })),
|
||||
tap(() => this.logRequest({
|
||||
@@ -427,4 +429,3 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABI
|
||||
const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
{
|
||||
vulnId: 'vuln-001',
|
||||
findingId: '11111111-1111-1111-1111-111111111111',
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
|
||||
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
@@ -67,6 +68,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-002',
|
||||
findingId: '22222222-2222-2222-2222-222222222222',
|
||||
cveId: 'CVE-2021-45046',
|
||||
title: 'Log4j2 Thread Context Message Pattern DoS',
|
||||
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
|
||||
@@ -90,6 +92,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-003',
|
||||
findingId: '33333333-3333-3333-3333-333333333333',
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'HTTP/2 Rapid Reset Attack',
|
||||
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
|
||||
@@ -119,6 +122,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-004',
|
||||
findingId: '44444444-4444-4444-4444-444444444444',
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'runc container escape vulnerability',
|
||||
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
|
||||
@@ -141,6 +145,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-005',
|
||||
findingId: '55555555-5555-5555-5555-555555555555',
|
||||
cveId: 'CVE-2023-38545',
|
||||
title: 'curl SOCKS5 heap buffer overflow',
|
||||
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
|
||||
@@ -163,6 +168,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-006',
|
||||
findingId: '66666666-6666-6666-6666-666666666666',
|
||||
cveId: 'CVE-2022-22965',
|
||||
title: 'Spring4Shell - Spring Framework RCE',
|
||||
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
|
||||
@@ -185,6 +191,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-007',
|
||||
findingId: '77777777-7777-7777-7777-777777777777',
|
||||
cveId: 'CVE-2023-45853',
|
||||
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
|
||||
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
|
||||
@@ -205,6 +212,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-008',
|
||||
findingId: '88888888-8888-8888-8888-888888888888',
|
||||
cveId: 'CVE-2024-0567',
|
||||
title: 'GnuTLS certificate verification bypass',
|
||||
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
|
||||
@@ -225,6 +233,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-009',
|
||||
findingId: '99999999-9999-9999-9999-999999999999',
|
||||
cveId: 'CVE-2023-5363',
|
||||
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
|
||||
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
|
||||
@@ -245,6 +254,7 @@ const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-010',
|
||||
findingId: 'aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa',
|
||||
cveId: 'CVE-2024-24790',
|
||||
title: 'Go net/netip ParseAddr stack exhaustion',
|
||||
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
|
||||
|
||||
@@ -13,6 +13,7 @@ export type VulnActorType = 'user' | 'service' | 'automation';
|
||||
|
||||
export interface Vulnerability {
|
||||
readonly vulnId: string;
|
||||
readonly findingId?: string;
|
||||
readonly cveId: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import type { AuthSession } from './auth-session.model';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
import { TenantActivationService } from './tenant-activation.service';
|
||||
import { ConsoleSessionStore } from '../console/console-session.store';
|
||||
|
||||
function buildSession(scopes: readonly string[]): AuthSession {
|
||||
const now = Date.now();
|
||||
return {
|
||||
tokens: {
|
||||
accessToken: 'token',
|
||||
tokenType: 'Bearer',
|
||||
scope: scopes.join(' '),
|
||||
expiresAtEpochMs: now + 60_000,
|
||||
},
|
||||
identity: {
|
||||
subject: 'user-1',
|
||||
roles: ['admin'],
|
||||
},
|
||||
dpopKeyThumbprint: 'thumbprint',
|
||||
issuedAtEpochMs: now,
|
||||
tenantId: 'demo-prod',
|
||||
scopes: [...scopes],
|
||||
audiences: [],
|
||||
authenticationTimeEpochMs: now,
|
||||
freshAuthActive: false,
|
||||
freshAuthExpiresAtEpochMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TenantActivationService', () => {
|
||||
let service: TenantActivationService;
|
||||
let authStore: AuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [TenantActivationService, AuthSessionStore, ConsoleSessionStore],
|
||||
});
|
||||
|
||||
service = TestBed.inject(TenantActivationService);
|
||||
authStore = TestBed.inject(AuthSessionStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authStore.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('accepts vuln:view for legacy vuln:read checks', () => {
|
||||
authStore.setSession(buildSession(['vuln:view']));
|
||||
|
||||
expect(service.authorize('vulnerability', 'read', ['vuln:read'])).toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts vuln:audit for legacy vuln:export checks', () => {
|
||||
authStore.setSession(buildSession(['vuln:audit']));
|
||||
|
||||
expect(service.authorize('vulnerability', 'export', ['vuln:export'])).toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts legacy vuln:write for modern vuln:operate checks', () => {
|
||||
authStore.setSession(buildSession(['vuln:write']));
|
||||
|
||||
expect(service.authorize('vulnerability', 'operate', ['vuln:operate'])).toBeTrue();
|
||||
});
|
||||
|
||||
it('does not let vuln:investigate satisfy vuln:operate', () => {
|
||||
authStore.setSession(buildSession(['vuln:investigate']));
|
||||
|
||||
expect(service.authorize('vulnerability', 'operate', ['vuln:operate'])).toBeFalse();
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,16 @@ export interface JwtClaims {
|
||||
auth_time?: number;
|
||||
}
|
||||
|
||||
const SCOPE_COMPATIBILITY_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
||||
'vuln:read': ['vuln:view'],
|
||||
'vuln:view': ['vuln:read'],
|
||||
'vuln:write': ['vuln:investigate', 'vuln:operate'],
|
||||
'vuln:investigate': ['vuln:write'],
|
||||
'vuln:operate': ['vuln:write'],
|
||||
'vuln:export': ['vuln:audit'],
|
||||
'vuln:audit': ['vuln:export'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Service for tenant activation, JWT verification, scope matching, and decision audit.
|
||||
* Implements WEB-TEN-47-001.
|
||||
@@ -484,6 +494,11 @@ export class TenantActivationService {
|
||||
return true;
|
||||
}
|
||||
|
||||
const compatibleScopes = SCOPE_COMPATIBILITY_ALIASES[required];
|
||||
if (compatibleScopes?.some((scope) => granted.has(scope))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hierarchical match: admin includes write includes read
|
||||
const [resource, permission] = required.split(':');
|
||||
if (permission === 'read') {
|
||||
|
||||
@@ -62,6 +62,19 @@ export class GatingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gated buckets summary for an artifact workspace.
|
||||
*/
|
||||
getArtifactGatedBucketsSummary(artifactId: string): Observable<GatedBucketsSummary | null> {
|
||||
return this.http.get<GatedBucketsSummary>(`${this.baseUrl}/artifacts/${artifactId}/gated-buckets`)
|
||||
.pipe(
|
||||
catchError(err => {
|
||||
console.error(`Failed to get gated buckets for artifact ${artifactId}:`, err);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unified evidence for a finding.
|
||||
*/
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { TelemetryClient } from '../../../core/telemetry/telemetry.client';
|
||||
import { EvidenceBitset } from '../models/evidence.model';
|
||||
import { TtfsTelemetryService } from './ttfs-telemetry.service';
|
||||
|
||||
describe('TtfsTelemetryService', () => {
|
||||
let service: TtfsTelemetryService;
|
||||
let httpMock: HttpTestingController;
|
||||
let telemetry: jasmine.SpyObj<TelemetryClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
telemetry = jasmine.createSpyObj('TelemetryClient', ['emit']) as jasmine.SpyObj<TelemetryClient>;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
TtfsTelemetryService,
|
||||
{ provide: TelemetryClient, useValue: telemetry },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(TtfsTelemetryService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('flushes batched events on decision and clears timing state', fakeAsync(() => {
|
||||
it('emits TTFS lifecycle and budget violations through the shared telemetry client', () => {
|
||||
const times = [0, 300, 600, 1400, 1500, 1700, 2000];
|
||||
spyOn(performance, 'now').and.callFake(() => times.shift() ?? 0);
|
||||
|
||||
@@ -31,47 +33,50 @@ describe('TtfsTelemetryService', () => {
|
||||
service.recordInteraction('alert-1', 'click');
|
||||
service.recordDecision('alert-1', 'accepted');
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/telemetry/ttfs');
|
||||
expect(req.request.method).toBe('POST');
|
||||
|
||||
const body = req.request.body as { events: Array<Record<string, unknown>> };
|
||||
expect(Array.isArray(body.events)).toBeTrue();
|
||||
|
||||
const eventTypes = body.events.map((e) => e['event_type']);
|
||||
const eventTypes = telemetry.emit.calls.allArgs().map(([type]) => type as string);
|
||||
expect(eventTypes).toContain('ttfs.start');
|
||||
expect(eventTypes).toContain('ttfs.skeleton');
|
||||
expect(eventTypes).toContain('ttfs.first_evidence');
|
||||
expect(eventTypes).toContain('ttfs.full_evidence');
|
||||
expect(eventTypes).toContain('triage.interaction');
|
||||
expect(eventTypes).toContain('decision.recorded');
|
||||
expect(eventTypes.filter((type) => type === 'budget.violation').length).toBe(2);
|
||||
|
||||
expect(body.events.filter((e) => e['event_type'] === 'budget.violation').length).toBe(2);
|
||||
const firstEvidencePayload = telemetry.emit.calls
|
||||
.allArgs()
|
||||
.find(([type]) => type === 'ttfs.first_evidence')?.[1] as Record<string, unknown>;
|
||||
expect(firstEvidencePayload['alert_id']).toBe('alert-1');
|
||||
expect(firstEvidencePayload['evidence_type']).toBe('reachability');
|
||||
expect(firstEvidencePayload['duration_ms']).toBe(600);
|
||||
|
||||
const firstEvidence = body.events.find((e) => e['event_type'] === 'ttfs.first_evidence') as Record<string, unknown>;
|
||||
expect(firstEvidence['evidence_type']).toBe('reachability');
|
||||
|
||||
const decision = body.events.find((e) => e['event_type'] === 'decision.recorded') as Record<string, unknown>;
|
||||
expect(decision['decision_status']).toBe('accepted');
|
||||
expect(decision['click_count']).toBe(2);
|
||||
|
||||
req.flush({});
|
||||
tick();
|
||||
const decisionPayload = telemetry.emit.calls
|
||||
.allArgs()
|
||||
.find(([type]) => type === 'decision.recorded')?.[1] as Record<string, unknown>;
|
||||
expect(decisionPayload['decision_status']).toBe('accepted');
|
||||
expect(decisionPayload['click_count']).toBe(2);
|
||||
|
||||
expect(service.getTimings('alert-1')).toBeUndefined();
|
||||
}));
|
||||
});
|
||||
|
||||
it('flushes queued events after the timeout', fakeAsync(() => {
|
||||
spyOn(performance, 'now').and.returnValue(0);
|
||||
it('records a clicks-to-closure budget violation when the interaction budget is exceeded', () => {
|
||||
const times = [0, 100, 101, 102, 103, 104, 105, 106, 200];
|
||||
spyOn(performance, 'now').and.callFake(() => times.shift() ?? 0);
|
||||
|
||||
service.startTracking('alert-1', new Date('2025-12-15T00:00:00.000Z'));
|
||||
service.startTracking('alert-2', new Date('2025-12-15T00:00:00.000Z'));
|
||||
Array.from({ length: 7 }).forEach(() => service.recordInteraction('alert-2', 'click'));
|
||||
service.recordDecision('alert-2', 'accepted');
|
||||
|
||||
tick(4999);
|
||||
httpMock.expectNone('/api/v1/telemetry/ttfs');
|
||||
const budgetViolationPayload = telemetry.emit.calls
|
||||
.allArgs()
|
||||
.find(
|
||||
([type, payload]) =>
|
||||
type === 'budget.violation'
|
||||
&& (payload as Record<string, unknown>)['phase'] === 'clicks_to_closure',
|
||||
)?.[1] as Record<string, unknown>;
|
||||
|
||||
tick(1);
|
||||
const req = httpMock.expectOne('/api/v1/telemetry/ttfs');
|
||||
expect((req.request.body as { events: unknown[] }).events.length).toBe(1);
|
||||
req.flush({});
|
||||
tick();
|
||||
}));
|
||||
expect(budgetViolationPayload['alert_id']).toBe('alert-2');
|
||||
expect(budgetViolationPayload['duration_ms']).toBe(7);
|
||||
expect(budgetViolationPayload['budget']).toBe(6);
|
||||
expect(service.getClickCount('alert-2')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DestroyRef, Injectable, NgZone, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { DestroyRef, Injectable, inject } from '@angular/core';
|
||||
|
||||
import { TelemetryClient } from '../../../core/telemetry/telemetry.client';
|
||||
import { EvidenceBitset } from '../models/evidence.model';
|
||||
|
||||
/**
|
||||
@@ -17,23 +18,6 @@ export interface TtfsTimings {
|
||||
evidenceBitset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TTFS event for backend ingestion.
|
||||
*/
|
||||
interface TtfsEvent {
|
||||
event_type: string;
|
||||
alert_id: string;
|
||||
duration_ms: number;
|
||||
evidence_type?: string;
|
||||
completeness_score?: number;
|
||||
click_count?: number;
|
||||
decision_status?: string;
|
||||
phase?: string;
|
||||
budget?: number;
|
||||
evidence_bitset?: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance budgets in milliseconds.
|
||||
*/
|
||||
@@ -44,26 +28,14 @@ const BUDGETS = {
|
||||
clicksToClosure: 6,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Service for tracking Time-to-First-Signal (TTFS) telemetry.
|
||||
* Measures time from alert creation to first evidence render.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TtfsTelemetryService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly zone = inject(NgZone);
|
||||
private readonly telemetry = inject(TelemetryClient);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly activeTimings = new Map<string, TtfsTimings>();
|
||||
private readonly pendingEvents: TtfsEvent[] = [];
|
||||
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
if (this.flushTimeout) {
|
||||
clearTimeout(this.flushTimeout);
|
||||
this.flushTimeout = null;
|
||||
}
|
||||
this.pendingEvents.length = 0;
|
||||
this.activeTimings.clear();
|
||||
});
|
||||
}
|
||||
@@ -82,11 +54,8 @@ export class TtfsTelemetryService {
|
||||
|
||||
this.activeTimings.set(alertId, timing);
|
||||
|
||||
this.queueEvent({
|
||||
event_type: 'ttfs.start',
|
||||
alert_id: alertId,
|
||||
this.emit('ttfs.start', alertId, {
|
||||
duration_ms: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,14 +69,10 @@ export class TtfsTelemetryService {
|
||||
timing.skeletonRenderedAt = performance.now();
|
||||
const duration = timing.skeletonRenderedAt - timing.ttfsStartAt;
|
||||
|
||||
this.queueEvent({
|
||||
event_type: 'ttfs.skeleton',
|
||||
alert_id: alertId,
|
||||
this.emit('ttfs.skeleton', alertId, {
|
||||
duration_ms: duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Check against budget
|
||||
if (duration > BUDGETS.skeleton) {
|
||||
this.recordBudgetViolation(alertId, 'skeleton', duration, BUDGETS.skeleton);
|
||||
}
|
||||
@@ -123,15 +88,11 @@ export class TtfsTelemetryService {
|
||||
timing.firstEvidenceAt = performance.now();
|
||||
const duration = timing.firstEvidenceAt - timing.ttfsStartAt;
|
||||
|
||||
this.queueEvent({
|
||||
event_type: 'ttfs.first_evidence',
|
||||
alert_id: alertId,
|
||||
this.emit('ttfs.first_evidence', alertId, {
|
||||
duration_ms: duration,
|
||||
evidence_type: evidenceType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Check against budget
|
||||
if (duration > BUDGETS.firstEvidence) {
|
||||
this.recordBudgetViolation(alertId, 'first_evidence', duration, BUDGETS.firstEvidence);
|
||||
}
|
||||
@@ -149,16 +110,12 @@ export class TtfsTelemetryService {
|
||||
|
||||
const duration = timing.fullEvidenceAt - timing.ttfsStartAt;
|
||||
|
||||
this.queueEvent({
|
||||
event_type: 'ttfs.full_evidence',
|
||||
alert_id: alertId,
|
||||
this.emit('ttfs.full_evidence', alertId, {
|
||||
duration_ms: duration,
|
||||
completeness_score: bitset.completenessScore,
|
||||
evidence_bitset: bitset.value,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Check against budget
|
||||
if (duration > BUDGETS.fullEvidence) {
|
||||
this.recordBudgetViolation(alertId, 'full_evidence', duration, BUDGETS.fullEvidence);
|
||||
}
|
||||
@@ -173,13 +130,10 @@ export class TtfsTelemetryService {
|
||||
|
||||
timing.clickCount++;
|
||||
|
||||
this.queueEvent({
|
||||
event_type: 'triage.interaction',
|
||||
alert_id: alertId,
|
||||
this.emit('triage.interaction', alertId, {
|
||||
duration_ms: performance.now() - timing.ttfsStartAt,
|
||||
evidence_type: interactionType,
|
||||
click_count: timing.clickCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,27 +147,19 @@ export class TtfsTelemetryService {
|
||||
timing.decisionRecordedAt = performance.now();
|
||||
const totalDuration = timing.decisionRecordedAt - timing.ttfsStartAt;
|
||||
|
||||
this.queueEvent({
|
||||
event_type: 'decision.recorded',
|
||||
alert_id: alertId,
|
||||
this.emit('decision.recorded', alertId, {
|
||||
duration_ms: totalDuration,
|
||||
click_count: timing.clickCount,
|
||||
decision_status: decisionStatus,
|
||||
evidence_bitset: timing.evidenceBitset,
|
||||
completeness_score: new EvidenceBitset(timing.evidenceBitset).completenessScore,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Check clicks-to-closure budget
|
||||
if (timing.clickCount > BUDGETS.clicksToClosure) {
|
||||
this.recordBudgetViolation(alertId, 'clicks_to_closure', timing.clickCount, BUDGETS.clicksToClosure);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
this.activeTimings.delete(alertId);
|
||||
|
||||
// Flush events after decision
|
||||
this.flushEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,53 +184,18 @@ export class TtfsTelemetryService {
|
||||
}
|
||||
|
||||
private recordBudgetViolation(alertId: string, phase: string, actual: number, budget: number): void {
|
||||
this.queueEvent({
|
||||
event_type: 'budget.violation',
|
||||
alert_id: alertId,
|
||||
this.emit('budget.violation', alertId, {
|
||||
duration_ms: actual,
|
||||
phase,
|
||||
budget,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private queueEvent(event: TtfsEvent): void {
|
||||
this.pendingEvents.push(event);
|
||||
|
||||
// Schedule flush if not already scheduled
|
||||
if (!this.flushTimeout) {
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.flushTimeout = setTimeout(() => {
|
||||
this.zone.run(() => this.flushEvents());
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Flush immediately if we have too many events
|
||||
if (this.pendingEvents.length >= 20) {
|
||||
this.flushEvents();
|
||||
}
|
||||
}
|
||||
|
||||
private flushEvents(): void {
|
||||
if (this.flushTimeout) {
|
||||
clearTimeout(this.flushTimeout);
|
||||
this.flushTimeout = null;
|
||||
}
|
||||
|
||||
if (this.pendingEvents.length === 0) return;
|
||||
|
||||
const events = [...this.pendingEvents];
|
||||
this.pendingEvents.length = 0;
|
||||
|
||||
// Send to backend
|
||||
this.http
|
||||
.post('/api/v1/telemetry/ttfs', { events })
|
||||
.subscribe({
|
||||
error: (err) => {
|
||||
// Log but don't fail - telemetry should be non-blocking
|
||||
console.warn('Failed to send TTFS telemetry:', err);
|
||||
},
|
||||
});
|
||||
private emit(eventType: string, alertId: string, payload: Record<string, unknown>): void {
|
||||
this.telemetry.emit(eventType, {
|
||||
alert_id: alertId,
|
||||
timestamp: new Date().toISOString(),
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentFixture, TestBed, fakeAsync, flush, flushMicrotasks } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
|
||||
@@ -16,19 +16,28 @@ describe('TriageWorkspaceComponent', () => {
|
||||
let vexApi: jasmine.SpyObj<VexDecisionsApi>;
|
||||
let gatingService: jasmine.SpyObj<GatingService>;
|
||||
|
||||
async function initializeComponent(): Promise<TriageWorkspaceComponent> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
return fixture.componentInstance;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vulnApi = jasmine.createSpyObj<VulnerabilityApi>('VulnerabilityApi', ['listVulnerabilities']);
|
||||
vexApi = jasmine.createSpyObj<VexDecisionsApi>('VexDecisionsApi', ['listDecisions']);
|
||||
gatingService = jasmine.createSpyObj<GatingService>('GatingService', [
|
||||
vulnApi = jasmine.createSpyObj('VulnerabilityApi', ['listVulnerabilities']) as jasmine.SpyObj<VulnerabilityApi>;
|
||||
vexApi = jasmine.createSpyObj('VexDecisionsApi', ['listDecisions']) as jasmine.SpyObj<VexDecisionsApi>;
|
||||
gatingService = jasmine.createSpyObj('GatingService', [
|
||||
'getArtifactGatedBucketsSummary',
|
||||
'getGatedBucketsSummary',
|
||||
'getGatingStatus',
|
||||
'getUnifiedEvidence',
|
||||
'getReplayCommand',
|
||||
]);
|
||||
]) as jasmine.SpyObj<GatingService>;
|
||||
|
||||
const vulns: Vulnerability[] = [
|
||||
{
|
||||
vulnId: 'v-1',
|
||||
findingId: '11111111-1111-1111-1111-111111111111',
|
||||
cveId: 'CVE-2024-0001',
|
||||
title: 'Test',
|
||||
severity: 'high',
|
||||
@@ -41,6 +50,7 @@ describe('TriageWorkspaceComponent', () => {
|
||||
},
|
||||
{
|
||||
vulnId: 'v-2',
|
||||
findingId: '22222222-2222-2222-2222-222222222222',
|
||||
cveId: 'CVE-2024-0002',
|
||||
title: 'Second',
|
||||
severity: 'high',
|
||||
@@ -53,6 +63,7 @@ describe('TriageWorkspaceComponent', () => {
|
||||
},
|
||||
{
|
||||
vulnId: 'v-3',
|
||||
findingId: '33333333-3333-3333-3333-333333333333',
|
||||
cveId: 'CVE-2024-0003',
|
||||
title: 'Other asset',
|
||||
severity: 'high',
|
||||
@@ -65,6 +76,7 @@ describe('TriageWorkspaceComponent', () => {
|
||||
|
||||
vulnApi.listVulnerabilities.and.returnValue(of({ items: vulns, total: vulns.length }));
|
||||
vexApi.listDecisions.and.returnValue(of({ items: [], count: 0, continuationToken: null }));
|
||||
gatingService.getArtifactGatedBucketsSummary.and.returnValue(of(null));
|
||||
gatingService.getGatedBucketsSummary.and.returnValue(of(null));
|
||||
gatingService.getGatingStatus.and.returnValue(of(null));
|
||||
gatingService.getReplayCommand.and.returnValue(of(null));
|
||||
@@ -77,7 +89,16 @@ describe('TriageWorkspaceComponent', () => {
|
||||
{ provide: VULNERABILITY_API, useValue: vulnApi },
|
||||
{ provide: VEX_DECISIONS_API, useValue: vexApi },
|
||||
{ provide: GatingService, useValue: gatingService },
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: new Map([['artifactId', 'asset-web-prod']]) } } },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ artifactId: 'asset-web-prod' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -88,20 +109,14 @@ describe('TriageWorkspaceComponent', () => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('filters findings by artifactId', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
flushMicrotasks();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
it('filters findings by artifactId', async () => {
|
||||
const component = await initializeComponent();
|
||||
expect(component.findings().length).toBe(2);
|
||||
expect(component.findings().map((f) => f.vuln.vulnId)).toEqual(['v-1', 'v-2']);
|
||||
}));
|
||||
});
|
||||
|
||||
it('toggles deterministic sort with S', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
flushMicrotasks();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
it('toggles deterministic sort with S', async () => {
|
||||
const component = await initializeComponent();
|
||||
expect(component.findingsSort()).toBe('default');
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true, cancelable: true }));
|
||||
@@ -109,15 +124,10 @@ describe('TriageWorkspaceComponent', () => {
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true, cancelable: true }));
|
||||
expect(component.findingsSort()).toBe('default');
|
||||
});
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it('toggles keyboard help with ?', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
flushMicrotasks();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
it('toggles keyboard help with ?', async () => {
|
||||
const component = await initializeComponent();
|
||||
expect(component.showKeyboardHelp()).toBeFalse();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true }));
|
||||
@@ -125,62 +135,58 @@ describe('TriageWorkspaceComponent', () => {
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true }));
|
||||
expect(component.showKeyboardHelp()).toBeFalse();
|
||||
});
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it('selects next finding with ArrowDown', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
flushMicrotasks();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
it('selects next finding with ArrowDown', async () => {
|
||||
const component = await initializeComponent();
|
||||
expect(component.selectedVulnId()).toBe('v-1');
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
||||
expect(component.selectedVulnId()).toBe('v-2');
|
||||
});
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it('switches to reachability tab with /', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
flushMicrotasks();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
it('switches to reachability tab with /', async () => {
|
||||
const component = await initializeComponent();
|
||||
expect(component.activeTab()).toBe('evidence');
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true, cancelable: true }));
|
||||
expect(component.activeTab()).toBe('reachability');
|
||||
});
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it('routes provenance pill to attestations tab', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
flushMicrotasks();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
it('routes provenance pill to attestations tab', async () => {
|
||||
const component = await initializeComponent();
|
||||
component.setTab('evidence');
|
||||
component.onEvidencePillClick('provenance');
|
||||
|
||||
expect(component.activeTab()).toBe('attestations');
|
||||
flush();
|
||||
}));
|
||||
});
|
||||
|
||||
it('reports quick-verify unavailable when DSSE/Rekor proofs are missing', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
flushMicrotasks();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
it('reports quick-verify unavailable when DSSE/Rekor proofs are missing', async () => {
|
||||
const component = await initializeComponent();
|
||||
component.onQuickVerifyClick();
|
||||
flushMicrotasks();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.quickVerification()?.state).toBe('unavailable');
|
||||
expect(component.quickVerification()?.message).toContain('No DSSE signature or Rekor inclusion proof');
|
||||
flush();
|
||||
}));
|
||||
expect(component.quickVerification()?.message).toContain('Signed provenance attestation is not yet verified');
|
||||
});
|
||||
|
||||
it('marks quick-verify as verified when attestation and transparency evidence are present', fakeAsync(() => {
|
||||
it('loads artifact gated buckets using the artifact route contract', async () => {
|
||||
await initializeComponent();
|
||||
expect(gatingService.getArtifactGatedBucketsSummary).toHaveBeenCalledWith('asset-web-prod');
|
||||
});
|
||||
|
||||
it('uses canonical findingId when loading unified evidence', async () => {
|
||||
const component = await initializeComponent();
|
||||
component.selectFinding('v-1');
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(gatingService.getUnifiedEvidence).toHaveBeenCalledWith(
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
jasmine.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks quick-verify as verified when attestation and transparency evidence are present', async () => {
|
||||
const verifiedEvidence: UnifiedEvidenceResponse = {
|
||||
findingId: 'v-1',
|
||||
cveId: 'CVE-2024-0001',
|
||||
@@ -213,15 +219,11 @@ describe('TriageWorkspaceComponent', () => {
|
||||
|
||||
gatingService.getUnifiedEvidence.and.returnValue(of(verifiedEvidence));
|
||||
|
||||
fixture.detectChanges();
|
||||
flushMicrotasks();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
const component = await initializeComponent();
|
||||
component.onQuickVerifyClick();
|
||||
flushMicrotasks();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.quickVerification()?.state).toBe('verified');
|
||||
expect(component.quickVerification()?.message).toContain('passed');
|
||||
flush();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -587,8 +587,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
this.gatingLoading.set(true);
|
||||
this.gatingError.set(null);
|
||||
try {
|
||||
// Use artifactId as scanId for now; adjust when scan context is available
|
||||
const resp = await firstValueFrom(this.gatingService.getGatedBucketsSummary(artifactId));
|
||||
const resp = await firstValueFrom(this.gatingService.getArtifactGatedBucketsSummary(artifactId));
|
||||
this.gatedBuckets.set(resp);
|
||||
} catch (err) {
|
||||
// Non-fatal: workspace should still render without gated buckets
|
||||
@@ -619,7 +618,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
this.gatingLoading.set(true);
|
||||
this.gatingError.set(null);
|
||||
try {
|
||||
const status = await firstValueFrom(this.gatingService.getGatingStatus(findingId));
|
||||
const status = await firstValueFrom(this.gatingService.getGatingStatus(this.resolveApiFindingId(findingId)));
|
||||
this.gatingExplainerFinding.set(status);
|
||||
} catch (err) {
|
||||
this.gatingExplainerFinding.set(null);
|
||||
@@ -637,10 +636,11 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Load unified evidence for selected finding. */
|
||||
async loadUnifiedEvidence(findingId: string): Promise<void> {
|
||||
const apiFindingId = this.resolveApiFindingId(findingId);
|
||||
this.evidenceLoading.set(true);
|
||||
this.evidenceError.set(null);
|
||||
try {
|
||||
const evidence = await firstValueFrom(this.gatingService.getUnifiedEvidence(findingId, {
|
||||
const evidence = await firstValueFrom(this.gatingService.getUnifiedEvidence(apiFindingId, {
|
||||
includeReplayCommand: true,
|
||||
includeDeltas: true,
|
||||
includeReachability: true,
|
||||
@@ -652,9 +652,10 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
this.unifiedEvidenceByFinding.update((state) => ({
|
||||
...state,
|
||||
[findingId]: evidence,
|
||||
[apiFindingId]: evidence,
|
||||
}));
|
||||
}
|
||||
if (this.selectedVulnId() === findingId) {
|
||||
if (this.selectedVulnId() === findingId || this.resolveApiFindingId(this.selectedVulnId()) === apiFindingId) {
|
||||
this.currentEvidence.set(this.selectedEvidenceBundle());
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1511,7 +1512,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async refreshReplayCommand(findingId: string): Promise<void> {
|
||||
const replay = await firstValueFrom(this.gatingService.getReplayCommand(findingId, {
|
||||
const apiFindingId = this.resolveApiFindingId(findingId);
|
||||
const replay = await firstValueFrom(this.gatingService.getReplayCommand(apiFindingId, {
|
||||
shells: ['bash', 'powershell'],
|
||||
includeOffline: true,
|
||||
generateBundle: true,
|
||||
@@ -1530,8 +1532,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
generatedAt: replay.generatedAt,
|
||||
};
|
||||
|
||||
this.unifiedEvidenceByFinding.update((state) => ({ ...state, [findingId]: updated }));
|
||||
if (this.unifiedEvidence()?.findingId === findingId) {
|
||||
this.unifiedEvidenceByFinding.update((state) => ({ ...state, [findingId]: updated, [apiFindingId]: updated }));
|
||||
if (this.unifiedEvidence()?.findingId === apiFindingId || this.unifiedEvidence()?.findingId === findingId) {
|
||||
this.unifiedEvidence.set(updated);
|
||||
}
|
||||
}
|
||||
@@ -1554,6 +1556,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
if (requestedFindingId) {
|
||||
const requested = this.findings().find(
|
||||
(finding) =>
|
||||
finding.vuln.findingId === requestedFindingId ||
|
||||
finding.vuln.vulnId === requestedFindingId ||
|
||||
finding.vuln.cveId === requestedFindingId
|
||||
);
|
||||
@@ -1594,4 +1597,17 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private resolveApiFindingId(findingId: string | null | undefined): string {
|
||||
if (!findingId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const finding = this.findings().find((item) =>
|
||||
item.vuln.vulnId === findingId
|
||||
|| item.vuln.findingId === findingId
|
||||
|| item.vuln.cveId === findingId);
|
||||
|
||||
return finding?.vuln.findingId ?? findingId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
"include": [],
|
||||
"files": [
|
||||
"src/test-setup.ts",
|
||||
"src/app/app.config-paths.spec.ts",
|
||||
"src/app/types/monaco-workers.d.ts",
|
||||
"src/app/core/api/first-signal.client.spec.ts",
|
||||
"src/app/core/api/vulnerability-http.client.spec.ts",
|
||||
"src/app/core/api/watchlist.client.spec.ts",
|
||||
"src/app/core/auth/tenant-activation.service.spec.ts",
|
||||
"src/app/core/console/console-status.service.spec.ts",
|
||||
"src/app/features/change-trace/change-trace-viewer.component.spec.ts",
|
||||
"src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts",
|
||||
@@ -15,6 +19,8 @@
|
||||
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
|
||||
"src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",
|
||||
"src/app/features/registry-admin/registry-admin.component.spec.ts",
|
||||
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
|
||||
"src/app/features/triage/triage-workspace.component.spec.ts",
|
||||
"src/app/features/watchlist/watchlist-page.component.spec.ts",
|
||||
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user