feat: Enhance traceability and logging in Risk and Vulnerability clients

- Implemented shared trace ID generation utility for Risk and Vulnerability clients, ensuring consistent trace headers across API calls.
- Updated RiskHttpClient and VulnerabilityHttpClient to utilize the new trace ID generation method.
- Added validation for artifact metadata in PackRun endpoints, ensuring all artifacts include a digest and positive size.
- Enhanced logging payloads in PackRun to include artifact digests and sizes.
- Created a utility for generating trace IDs, preferring crypto.randomUUID when available, with a fallback to a ULID-style string.
- Added unit tests to verify the presence of trace IDs in HTTP requests for VulnerabilityHttpClient.
- Documented query-hash metrics for Vuln Explorer, detailing hashing rules and logging filters to ensure compliance with privacy standards.
- Consolidated findings from late-November reviews into a comprehensive advisory for Scanner and SBOM/VEX areas, outlining remediation tracks and gaps.
This commit is contained in:
StellaOps Bot
2025-12-02 19:24:26 +02:00
parent 76ecea482e
commit acbb0ff637
20 changed files with 186 additions and 71 deletions

View File

@@ -284,6 +284,8 @@ public sealed record LogEntryResponse(
string Level,
string Source,
string Message,
string Digest,
long SizeBytes,
DateTimeOffset Timestamp,
string? Data)
{
@@ -293,6 +295,8 @@ public sealed record LogEntryResponse(
log.Level.ToString().ToLowerInvariant(),
log.Source,
log.Message,
log.Digest,
log.SizeBytes,
log.Timestamp,
log.Data);
}

View File

@@ -535,6 +535,15 @@ public static class PackRunEndpoints
var artifactIds = new List<Guid>();
if (request.Artifacts is { Count: > 0 })
{
if (request.Artifacts.Any(a => string.IsNullOrWhiteSpace(a.Digest) || a.SizeBytes is null or <= 0))
{
return Results.BadRequest(new PackRunErrorResponse(
"invalid_artifact",
"All artifacts must include digest and positive sizeBytes.",
packRunId,
null));
}
var artifacts = request.Artifacts.Select(a => new Artifact(
ArtifactId: Guid.NewGuid(),
TenantId: tenantId,
@@ -616,7 +625,9 @@ public static class PackRunEndpoints
packVersion = packRun.PackVersion,
exitCode = request.ExitCode,
durationMs,
artifactCount = artifactIds.Count
artifactCount = artifactIds.Count,
artifactDigests = request.Artifacts?.Select(a => a.Digest).ToArray() ?? Array.Empty<string>(),
artifactSizes = request.Artifacts?.Select(a => a.SizeBytes ?? 0).ToArray() ?? Array.Empty<long>()
}));
await eventPublisher.PublishAsync(envelope, cancellationToken);

View File

@@ -282,6 +282,8 @@ internal sealed record PackRunLogPayload(
string Level,
string Source,
string Message,
string Digest,
long SizeBytes,
DateTimeOffset Timestamp,
string? Data)
{
@@ -290,6 +292,8 @@ internal sealed record PackRunLogPayload(
log.Level.ToString().ToLowerInvariant(),
log.Source,
log.Message,
log.Digest,
log.SizeBytes,
log.Timestamp,
log.Data);
}

View File

@@ -2,9 +2,12 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Surface.FS;
using Xunit;
@@ -34,7 +37,8 @@ public sealed class FileSurfaceManifestStoreTests : IAsyncDisposable
_store = new FileSurfaceManifestStore(
cacheOptions,
manifestOptions,
NullLogger<FileSurfaceManifestStore>.Instance);
NullLogger<FileSurfaceManifestStore>.Instance,
new TestCryptoHash());
}
[Fact]
@@ -201,6 +205,31 @@ public sealed class FileSurfaceManifestStoreTests : IAsyncDisposable
$"{hex}.json");
}
private sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
=> SHA256.HashData(data);
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
await using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return SHA256.HashData(buffer.ToArray());
}
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
public async ValueTask DisposeAsync()
{
await Task.Run(() =>

View File

@@ -5,7 +5,7 @@
| WEB-AOC-19-002 | DONE (2025-11-30) | Added provenance builder, checksum utilities, and DSSE/CMS signature verification helpers with unit tests. |
| WEB-AOC-19-003 | DONE (2025-11-30) | Added client-side guard validator (forbidden/derived/unknown fields, provenance/signature checks) with unit fixtures. |
| WEB-CONSOLE-23-002 | DOING (2025-12-01) | Console status polling + SSE run stream client/store/UI added; tests pending once env fixed. |
| WEB-RISK-66-001 | DOING (2025-12-02) | Added risk gateway HTTP client (trace-id headers), store, `/risk` dashboard with filters, empty state, vuln link, auth guard; added `/vulnerabilities/:vulnId` detail + specs; risk/vuln providers switch via quickstart; awaiting gateway endpoints/test harness. |
| WEB-RISK-66-001 | DOING (2025-12-02) | Added risk + vuln gateway HTTP clients (shared trace util), store, `/risk` dashboard with filters/empty state/vuln link, auth guard; added `/vulnerabilities/:vulnId` detail + specs; providers switch via quickstart; awaiting gateway endpoints/test harness. |
| WEB-EXC-25-001 | TODO | Exceptions workflow CRUD pending policy scopes. |
| WEB-TEN-47-CONTRACT | DONE (2025-12-01) | Gateway tenant auth/ABAC contract doc v1.0 published (`docs/api/gateway/tenant-auth.md`). |
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |

View File

@@ -5,6 +5,7 @@ import { Observable, map } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { RiskApi } from './risk.client';
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
import { generateTraceId } from './trace.util';
export const RISK_API_BASE_URL = new InjectionToken<string>('RISK_API_BASE_URL');
@@ -18,7 +19,7 @@ export class RiskHttpClient implements RiskApi {
list(options: RiskQueryOptions): Observable<RiskResultPage> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? crypto.randomUUID?.() ?? this.generateTraceId();
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, options.projectId, traceId);
let params = new HttpParams();
@@ -40,7 +41,7 @@ export class RiskHttpClient implements RiskApi {
stats(options: Pick<RiskQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<RiskStats> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? crypto.randomUUID?.() ?? this.generateTraceId();
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, options.projectId, traceId);
return this.http
@@ -60,13 +61,6 @@ export class RiskHttpClient implements RiskApi {
return headers;
}
private generateTraceId(): string {
// Lightweight ULID-like generator (time + random) for trace correlation.
const time = Date.now().toString(36);
const rand = crypto.getRandomValues(new Uint32Array(1))[0].toString(36).padStart(6, '0');
return `${time}-${rand}`;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {

View File

@@ -0,0 +1,13 @@
/**
* Generate a correlation/trace identifier.
* Prefers crypto.randomUUID when available; falls back to a lightweight ULID-style string.
*/
export function generateTraceId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
const time = Date.now().toString(36);
const rand = crypto.getRandomValues(new Uint32Array(1))[0].toString(36).padStart(6, '0');
return `${time}-${rand}`;
}

View File

@@ -40,6 +40,7 @@ describe('VulnerabilityHttpClient', () => {
const req = httpMock.expectOne('https://api.example.local/vuln?page=1&pageSize=5');
expect(req.request.headers.get('X-Stella-Tenant')).toBe('tenant-dev');
expect(req.request.headers.has('X-Stella-Trace-Id')).toBeTrue();
req.flush(stub);
});

View File

@@ -9,6 +9,7 @@ import {
Vulnerability,
VulnerabilityStats,
} from './vulnerability.models';
import { generateTraceId } from './trace.util';
import { VulnerabilityApi } from './vulnerability.client';
export const VULNERABILITY_API_BASE_URL = new InjectionToken<string>('VULNERABILITY_API_BASE_URL');
@@ -23,7 +24,8 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
const tenant = this.resolveTenant(options?.tenantId);
const headers = this.buildHeaders(tenant, options?.projectId, options?.traceId);
const traceId = options?.traceId ?? generateTraceId();
const headers = this.buildHeaders(tenant, options?.projectId, traceId);
let params = new HttpParams();
if (options?.page) params = params.set('page', options.page);
@@ -39,13 +41,15 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
getVulnerability(vulnId: string): Observable<Vulnerability> {
const tenant = this.resolveTenant();
const headers = this.buildHeaders(tenant, undefined, undefined);
const traceId = generateTraceId();
const headers = this.buildHeaders(tenant, undefined, traceId);
return this.http.get<Vulnerability>(`${this.baseUrl}/vuln/${encodeURIComponent(vulnId)}`, { headers });
}
getStats(): Observable<VulnerabilityStats> {
const tenant = this.resolveTenant();
const headers = this.buildHeaders(tenant, undefined, undefined);
const traceId = generateTraceId();
const headers = this.buildHeaders(tenant, undefined, traceId);
return this.http.get<VulnerabilityStats>(`${this.baseUrl}/vuln/status`, { headers });
}
@@ -63,4 +67,5 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
}
return tenant;
}
}