feat: Add VEX compact fixture and implement offline verifier for Findings Ledger exports

- Introduced a new VEX compact fixture for testing purposes.
- Implemented `verify_export.py` script to validate Findings Ledger exports, ensuring deterministic ordering and applying redaction manifests.
- Added a lightweight stub `HarnessRunner` for unit tests to validate ledger hashing expectations.
- Documented tasks related to the Mirror Creator.
- Created models for entropy signals and implemented the `EntropyPenaltyCalculator` to compute penalties based on scanner outputs.
- Developed unit tests for `EntropyPenaltyCalculator` to ensure correct penalty calculations and handling of edge cases.
- Added tests for symbol ID normalization in the reachability scanner.
- Enhanced console status service with comprehensive unit tests for connection handling and error recovery.
- Included Cosign tool version 2.6.0 with checksums for various platforms.
This commit is contained in:
StellaOps Bot
2025-12-02 21:08:01 +02:00
parent 6d049905c7
commit 47168fec38
146 changed files with 4329 additions and 549 deletions

View File

@@ -66,6 +66,8 @@ describe('ConsoleStatusClient', () => {
const req = httpMock.expectOne('/console/status');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-dev');
expect(req.request.headers.get('X-Stella-Trace-Id')).toBeTruthy();
expect(req.request.headers.get('X-Stella-Request-Id')).toBeTruthy();
req.flush(sample);
});
@@ -75,7 +77,8 @@ describe('ConsoleStatusClient', () => {
expect(eventSourceFactory).toHaveBeenCalled();
const url = eventSourceFactory.calls.mostRecent().args[0];
expect(url).toBe('/console/runs/run-123/stream?tenant=tenant-dev');
expect(url).toContain('/console/runs/run-123/stream?tenant=tenant-dev');
expect(url).toContain('traceId=');
// Simulate incoming message
const fakeSource = eventSourceFactory.calls.mostRecent().returnValue as unknown as FakeEventSource;

View File

@@ -5,6 +5,7 @@ import { map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { ConsoleRunEventDto, ConsoleStatusDto } from './console-status.models';
import { generateTraceId } from './trace.util';
export const CONSOLE_API_BASE_URL = new InjectionToken<string>('CONSOLE_API_BASE_URL');
@@ -29,9 +30,15 @@ export class ConsoleStatusClient {
/**
* Poll console status (queue lag, backlog, run counts).
*/
getStatus(tenantId?: string): Observable<ConsoleStatusDto> {
getStatus(tenantId?: string, traceId?: string): Observable<ConsoleStatusDto> {
const tenant = this.resolveTenant(tenantId);
const headers = new HttpHeaders({ 'X-StellaOps-Tenant': tenant });
const trace = traceId ?? generateTraceId();
const headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
});
return this.http.get<ConsoleStatusDto>(`${this.baseUrl}/status`, { headers }).pipe(
map((dto) => ({
...dto,
@@ -50,9 +57,10 @@ export class ConsoleStatusClient {
* Subscribe to streaming updates for a specific run via SSE.
* Caller is responsible for unsubscribing to close the connection.
*/
streamRun(runId: string, tenantId?: string): Observable<ConsoleRunEventDto> {
streamRun(runId: string, tenantId?: string, traceId?: string): Observable<ConsoleRunEventDto> {
const tenant = this.resolveTenant(tenantId);
const params = new HttpParams().set('tenant', tenant);
const trace = traceId ?? generateTraceId();
const params = new HttpParams().set('tenant', tenant).set('traceId', trace);
const url = `${this.baseUrl}/runs/${encodeURIComponent(runId)}/stream?${params.toString()}`;
return new Observable<ConsoleRunEventDto>((observer) => {

View File

@@ -1,6 +1,6 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, map } from 'rxjs';
import { Observable, catchError, map, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { RiskApi } from './risk.client';
@@ -9,6 +9,12 @@ import { generateTraceId } from './trace.util';
export const RISK_API_BASE_URL = new InjectionToken<string>('RISK_API_BASE_URL');
export class RateLimitError extends Error {
constructor(public readonly retryAfterMs?: number) {
super('rate-limit');
}
}
@Injectable({ providedIn: 'root' })
export class RiskHttpClient implements RiskApi {
constructor(
@@ -35,7 +41,8 @@ export class RiskHttpClient implements RiskApi {
...page,
page: page.page ?? 1,
pageSize: page.pageSize ?? 20,
}))
}),
catchError((err) => throwError(() => this.normalizeError(err)))
);
}
@@ -50,10 +57,23 @@ export class RiskHttpClient implements RiskApi {
map((stats) => ({
countsBySeverity: stats.countsBySeverity,
lastComputation: stats.lastComputation ?? '1970-01-01T00:00:00Z',
}))
})),
catchError((err) => throwError(() => this.normalizeError(err)))
);
}
private normalizeError(err: unknown): Error {
if (err instanceof RateLimitError) return err;
if (err instanceof HttpErrorResponse && err.status === 429) {
const retryAfter = err.headers.get('Retry-After');
const retryAfterMs = retryAfter ? Number(retryAfter) * 1000 : undefined;
return new RateLimitError(Number.isFinite(retryAfterMs) ? retryAfterMs : undefined);
}
if (err instanceof Error) return err;
return new Error('Risk API request failed');
}
private buildHeaders(tenantId: string, projectId?: string, traceId?: string): HttpHeaders {
let headers = new HttpHeaders({ 'X-Stella-Tenant': tenantId });
if (projectId) headers = headers.set('X-Stella-Project', projectId);

View File

@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { RISK_API } from './risk.client';
import { RateLimitError } from './risk-http.client';
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
import { RiskStore } from './risk.store';
@@ -47,6 +48,14 @@ describe('RiskStore', () => {
expect(store.error()).toBe('boom');
});
it('reports rate limit errors with retry hint', () => {
apiSpy.list.and.returnValue(throwError(() => new RateLimitError(5000)));
store.fetchList(defaultOptions);
expect(store.error()).toContain('retry after 5s');
});
it('stores stats results', () => {
const stats: RiskStats = {
countsBySeverity: { none: 0, info: 0, low: 1, medium: 0, high: 1, critical: 0 },

View File

@@ -2,6 +2,7 @@ import { inject, Injectable, Signal, computed, signal } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { RISK_API, RiskApi } from './risk.client';
import { RateLimitError } from './risk-http.client';
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
@Injectable({ providedIn: 'root' })
@@ -47,6 +48,13 @@ export class RiskStore {
}
private normalizeError(err: unknown): string {
if (err instanceof RateLimitError) {
if (err.retryAfterMs && Number.isFinite(err.retryAfterMs)) {
const seconds = Math.ceil(err.retryAfterMs / 1000);
return `Rate limited; retry after ${seconds}s`;
}
return 'Rate limited; retry shortly';
}
if (err instanceof Error) return err.message;
return 'Unknown error fetching risk data';
}

View File

@@ -0,0 +1,75 @@
import { TestBed } from '@angular/core/testing';
import { Subject, of } from 'rxjs';
import { ConsoleRunEventDto, ConsoleStatusDto } from '../api/console-status.models';
import { ConsoleStatusClient } from '../api/console-status.client';
import { ConsoleStatusService } from './console-status.service';
import { ConsoleStatusStore } from './console-status.store';
class FakeConsoleStatusClient {
public streams: { subject: Subject<ConsoleRunEventDto>; traceId?: string }[] = [];
getStatus(): any {
const dto: ConsoleStatusDto = {
backlog: 0,
queueLagMs: 0,
activeRuns: 0,
pendingRuns: 0,
healthy: true,
lastCompletedRunId: null,
lastCompletedAt: null,
};
return of(dto);
}
streamRun(_runId: string, _tenantId?: string, traceId?: string) {
const subject = new Subject<ConsoleRunEventDto>();
this.streams.push({ subject, traceId });
return subject.asObservable();
}
}
describe('ConsoleStatusService', () => {
let service: ConsoleStatusService;
let client: FakeConsoleStatusClient;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ConsoleStatusStore,
ConsoleStatusService,
{ provide: ConsoleStatusClient, useClass: FakeConsoleStatusClient },
],
});
service = TestBed.inject(ConsoleStatusService);
client = TestBed.inject(ConsoleStatusClient) as unknown as FakeConsoleStatusClient;
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('reconnects when heartbeat is missed', () => {
const sub = service.subscribeToRun('run-1', { heartbeatMs: 5, maxRetries: 2, traceId: 'trace-heartbeat' });
expect(client.streams.length).toBe(1);
jasmine.clock().tick(6);
expect(client.streams.length).toBe(2);
sub.unsubscribe();
});
it('retries after stream errors with backoff', () => {
const sub = service.subscribeToRun('run-2', { maxRetries: 1, heartbeatMs: 50, traceId: 'trace-error' });
expect(client.streams.length).toBe(1);
client.streams[0].subject.error(new Error('boom'));
jasmine.clock().tick(1001);
expect(client.streams.length).toBe(2);
sub.unsubscribe();
});
});

View File

@@ -5,6 +5,14 @@ import { switchMap } from 'rxjs/operators';
import { ConsoleStatusClient } from '../api/console-status.client';
import { ConsoleRunEventDto, ConsoleStatusDto } from '../api/console-status.models';
import { ConsoleStatusStore } from './console-status.store';
import { generateTraceId } from '../api/trace.util';
export interface RunStreamOptions {
heartbeatMs?: number;
maxRetries?: number;
traceId?: string;
tenantId?: string;
}
@Injectable({
providedIn: 'root',
@@ -58,14 +66,65 @@ export class ConsoleStatusService {
/**
* Subscribe to run stream events for a given run id.
*/
subscribeToRun(runId: string): Subscription {
subscribeToRun(runId: string, options?: RunStreamOptions): Subscription {
this.store.clearEvents();
return this.client.streamRun(runId).subscribe({
next: (evt: ConsoleRunEventDto) => this.store.appendRunEvent(evt),
error: (err) => {
console.error('console run stream error', err);
this.store.setError('Run stream disconnected');
},
const traceId = options?.traceId ?? generateTraceId();
const heartbeatMs = options?.heartbeatMs ?? 15000;
const maxRetries = options?.maxRetries ?? 3;
const tenantId = options?.tenantId;
let retries = 0;
let heartbeatHandle: ReturnType<typeof setTimeout> | undefined;
let innerSub: Subscription | null = null;
let disposed = false;
const clearHeartbeat = () => {
if (heartbeatHandle) {
clearTimeout(heartbeatHandle);
heartbeatHandle = undefined;
}
};
const scheduleHeartbeat = () => {
clearHeartbeat();
heartbeatHandle = setTimeout(() => {
handleError(new Error('heartbeat-timeout'));
}, heartbeatMs);
};
const handleError = (err: unknown) => {
console.error('console run stream error', err);
this.store.setError('Run stream disconnected');
if (disposed || retries >= maxRetries) {
return;
}
const delay = Math.min(1000 * Math.pow(2, retries), 30000);
retries += 1;
setTimeout(connect, delay);
};
const connect = () => {
if (disposed) return;
innerSub?.unsubscribe();
const stream$ = this.client.streamRun(runId, tenantId, traceId);
innerSub = stream$.subscribe({
next: (evt: ConsoleRunEventDto) => {
retries = 0;
this.store.appendRunEvent(evt);
scheduleHeartbeat();
},
error: handleError,
});
scheduleHeartbeat();
};
connect();
return new Subscription(() => {
disposed = true;
clearHeartbeat();
innerSub?.unsubscribe();
});
}