diff --git a/docs/operations/foundation-ci-supply-chain-2026-08-02.md b/docs/operations/foundation-ci-supply-chain-2026-08-02.md index d4fe3238..f62c5d2b 100644 --- a/docs/operations/foundation-ci-supply-chain-2026-08-02.md +++ b/docs/operations/foundation-ci-supply-chain-2026-08-02.md @@ -37,6 +37,12 @@ Hosted CI remains authoritative for the complete dependency, SAST, container, OpenTofu, build, and release-environment gates. The generators never write runtime evidence inside the repository during these checks. +The `release` environment's required reviewers and branch restrictions are +GitHub repository settings rather than workflow YAML. Before promoting to +`main`, an administrator must verify those settings and record the check in +the release evidence; the repository policy checker deliberately verifies the +workflow's environment reference but cannot infer external protection rules. + ## Rollback Revert the focused CI or generator commit that introduced the behavior, rerun diff --git a/docs/operations/foundation-local-infrastructure-2026-08-02.md b/docs/operations/foundation-local-infrastructure-2026-08-02.md index 70d6fdbc..90cf4adc 100644 --- a/docs/operations/foundation-local-infrastructure-2026-08-02.md +++ b/docs/operations/foundation-local-infrastructure-2026-08-02.md @@ -22,6 +22,9 @@ Task: `FND-003 — Close local infrastructure gaps` check, providing the entry point for persistence evidence. - `persistence-check` writes a five-minute Redis sentinel, restarts only Redis, verifies the sentinel, and deletes it; it never flushes a database or volume. +- The documented lifecycle command set includes `config`, `preflight`, `check`, + `start`, `stop`, `reset`, `restart-check`, `persistence-check`, `status`, + `logs`, and the legacy `smoke` entry point. ## Verification @@ -32,7 +35,6 @@ Passed: - `node tools/repo-cli/src/local-services-smoke.mjs --help` - `node tools/repo-cli/src/local-services.mjs config` - `node tools/repo-cli/src/local-services.mjs preflight --min-free-gib=0` -- `node tools/repo-cli/src/local-services.mjs persistence-check` *(Docker-gated)* - `git diff --check` Environment-gated: @@ -43,8 +45,9 @@ Environment-gated: without starting containers; the evidence run used a zero-GiB threshold so it remains independent of the workstation's available disk headroom. - Live `compose up`, health polling, port-collision simulation, disk-pressure - threshold validation, and restart-persistence checks must run on a machine - with Docker Desktop/Compose v2 before FND-003 can become `verified`. + threshold validation, and restart-persistence checks (including + `persistence-check`) must run on a machine with Docker Desktop/Compose v2 + before FND-003 can become `verified`. ## Rollback diff --git a/infrastructure/aws/modules/compute/main.tf b/infrastructure/aws/modules/compute/main.tf index 0bfcb70b..7e9c14cb 100644 --- a/infrastructure/aws/modules/compute/main.tf +++ b/infrastructure/aws/modules/compute/main.tf @@ -1,5 +1,12 @@ locals { common_tags = merge(var.tags, { Component = "compute" }) + allowed_worker_memory_by_cpu = { + "256" = [512, 1024, 2048] + "512" = [1024, 2048, 3072, 4096] + "1024" = [2048, 3072, 4096, 5120, 6144, 7168, 8192] + "2048" = [4096, 5120, 6144, 7168, 8192, 9216, 10240, 11264, 12288, 13312, 14336, 15360, 16384] + "4096" = [8192, 9216, 10240, 11264, 12288, 13312, 14336, 15360, 16384, 17408, 18432, 19456, 20480, 21504, 22528, 23552, 24576, 25600, 26624, 27648, 28672, 29696, 30720] + } } resource "aws_ecs_cluster" "this" { @@ -151,6 +158,13 @@ resource "aws_ecs_task_definition" "worker" { condition = var.environment != "production" || can(regex("@sha256:[0-9a-f]{64}$", var.worker_image)) error_message = "Production worker deployments must use an immutable image digest." } + precondition { + condition = contains( + lookup(local.allowed_worker_memory_by_cpu, tostring(var.worker_cpu), []), + var.worker_memory, + ) + error_message = "worker_memory must be an AWS-supported Fargate size for worker_cpu." + } } } diff --git a/infrastructure/local/README.md b/infrastructure/local/README.md index ecb9add1..a94eee8e 100644 --- a/infrastructure/local/README.md +++ b/infrastructure/local/README.md @@ -18,6 +18,8 @@ named volumes prefixed by the Compose project name; no repository directory is mounted for database, object, or mail data. The volumes are disposable and are not removed by the lifecycle commands. Remove the named volumes only when you explicitly want to discard local state. +Every published port is bound to `127.0.0.1`, so the development credentials and +data endpoints are not reachable from other hosts on the local network. Container JSON logs are capped at 10 MiB per file with three retained files so diagnostics cannot silently consume the host disk. diff --git a/infrastructure/local/compose.yml b/infrastructure/local/compose.yml index f4c9f170..85fd97de 100644 --- a/infrastructure/local/compose.yml +++ b/infrastructure/local/compose.yml @@ -15,7 +15,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-databreeze} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-databreeze-local-change-me} ports: - - '${POSTGRES_PORT:-5432}:5432' + - '127.0.0.1:${POSTGRES_PORT:-5432}:5432' networks: [local] logging: *default-logging volumes: @@ -36,7 +36,7 @@ services: init: true command: ['redis-server', '--appendonly', 'yes'] ports: - - '${REDIS_PORT:-6379}:6379' + - '127.0.0.1:${REDIS_PORT:-6379}:6379' networks: [local] logging: *default-logging volumes: @@ -56,8 +56,8 @@ services: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-databreeze} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-databreeze-local-change-me} ports: - - '${MINIO_API_PORT:-9000}:9000' - - '${MINIO_CONSOLE_PORT:-9001}:9001' + - '127.0.0.1:${MINIO_API_PORT:-9000}:9000' + - '127.0.0.1:${MINIO_CONSOLE_PORT:-9001}:9001' networks: [local] logging: *default-logging volumes: @@ -95,8 +95,8 @@ services: MP_MAX_MESSAGES: ${MAILPIT_MAX_MESSAGES:-5000} MP_DATABASE: /data/mailpit.db ports: - - '${MAILPIT_SMTP_PORT:-1025}:1025' - - '${MAILPIT_UI_PORT:-8025}:8025' + - '127.0.0.1:${MAILPIT_SMTP_PORT:-1025}:1025' + - '127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025' networks: [local] logging: *default-logging volumes: @@ -113,9 +113,9 @@ services: init: true command: ['--config=/etc/otelcol-contrib/config.yaml'] ports: - - '${OTEL_GRPC_PORT:-4317}:4317' - - '${OTEL_HTTP_PORT:-4318}:4318' - - '${OTEL_HEALTH_PORT:-13133}:13133' + - '127.0.0.1:${OTEL_GRPC_PORT:-4317}:4317' + - '127.0.0.1:${OTEL_HTTP_PORT:-4318}:4318' + - '127.0.0.1:${OTEL_HEALTH_PORT:-13133}:13133' networks: [local] logging: *default-logging volumes: diff --git a/package.json b/package.json index b62a858b..fb21c49a 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "prettier": "3.6.2", "turbo": "2.5.6", "typescript": "5.9.2", - "typescript-eslint": "8.43.0" + "typescript-eslint": "8.43.0", + "yaml": "2.8.1" } } diff --git a/packages/telemetry/src/v1.ts b/packages/telemetry/src/v1.ts index df8d1b7e..e74ea2e2 100644 --- a/packages/telemetry/src/v1.ts +++ b/packages/telemetry/src/v1.ts @@ -166,13 +166,25 @@ function safeScalar(key: string, value: unknown): TelemetryScalarV1 | undefined return safeString(key, value); } -function ownDataEntries(input: Record): Array<[string, unknown]> { +function readOwnDataEntries(input: Record): { + entries: Array<[string, unknown]>; + readable: boolean; +} { const entries: Array<[string, unknown]> = []; - for (const key of Object.keys(input)) { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if (descriptor && 'value' in descriptor) entries.push([key, descriptor.value]); + try { + for (const key of Object.keys(input)) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor || !('value' in descriptor)) return { entries: [], readable: false }; + entries.push([key, descriptor.value]); + } + } catch { + return { entries: [], readable: false }; } - return entries; + return { entries, readable: true }; +} + +function ownDataEntries(input: Record): Array<[string, unknown]> { + return readOwnDataEntries(input).entries; } export function sanitizeTelemetryAttributesV1( @@ -192,17 +204,14 @@ export function sanitizeTelemetryAttributesV1( export function assertSafeTelemetryAttributesV1( input: Record, ): asserts input is SafeTelemetryAttributesV1 { - for (const key of Object.keys(input ?? {})) { + const readable = readOwnDataEntries(input ?? {}); + if (!readable.readable) throw new UnsafeTelemetryAttributeErrorV1('unreadable'); + for (const [key, value] of readable.entries) { assertBoundedKey(key); if (forbiddenKeyPattern.test(key) || !safeAttributeSet.has(key)) { throw new UnsafeTelemetryAttributeErrorV1(key); } - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if ( - !descriptor || - !('value' in descriptor) || - safeScalar(key, descriptor.value) === undefined - ) { + if (safeScalar(key, value) === undefined) { throw new UnsafeTelemetryAttributeErrorV1(key); } } @@ -263,18 +272,48 @@ function readSingleHeader( name: string, ): string | undefined { const values: string[] = []; - for (const key of Object.keys(headers)) { - const descriptor = Object.getOwnPropertyDescriptor(headers, key); + let keys: string[]; + try { + keys = Object.keys(headers); + } catch { + throw new Error(`Unreadable telemetry ${name} header`); + } + for (const key of keys) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(headers, key); + } catch { + throw new Error(`Unreadable telemetry ${name} header`); + } if (!descriptor || !('value' in descriptor)) throw new Error(`Unreadable telemetry ${name} header`); const value = descriptor.value as string | string[] | undefined; if (key.toLowerCase() !== name) continue; - if (Array.isArray(value)) { - if (!value.every((item) => typeof item === 'string')) { + let arrayValue = false; + try { + arrayValue = Array.isArray(value); + } catch { + throw new Error(`Unreadable telemetry ${name} header`); + } + if (arrayValue) { + let valid = false; + try { + valid = Array.prototype.every.call(value, (item: unknown) => typeof item === 'string'); + } catch { + throw new Error(`Unreadable telemetry ${name} header`); + } + if (!valid) { throw new Error(`Unreadable telemetry ${name} header`); } - values.push(...value); - } else if (value !== undefined) values.push(value); + try { + values.push(...(value as string[])); + } catch { + throw new Error(`Unreadable telemetry ${name} header`); + } + } else if (value !== undefined) { + if (typeof value !== 'string') throw new Error(`Unreadable telemetry ${name} header`); + values.push(value); + } } if (values.length > 1) throw new Error(`Ambiguous telemetry ${name} header`); if (values.length === 0) return undefined; diff --git a/packages/telemetry/test/telemetry-v1.test.mjs b/packages/telemetry/test/telemetry-v1.test.mjs index a00b9234..e8aaa7fc 100644 --- a/packages/telemetry/test/telemetry-v1.test.mjs +++ b/packages/telemetry/test/telemetry-v1.test.mjs @@ -85,6 +85,32 @@ test('telemetry never executes accessor-backed correlation headers', () => { assert.equal(accessed, false); }); +test('telemetry rejects proxies that fail during reflection without exposing trap errors', () => { + const hostileAttributes = new Proxy( + {}, + { + ownKeys() { + throw new Error('attribute trap cause'); + }, + }, + ); + assert.deepEqual(sanitizeTelemetryAttributesV1(hostileAttributes), {}); + assert.throws( + () => assertSafeTelemetryAttributesV1(hostileAttributes), + UnsafeTelemetryAttributeErrorV1, + ); + + const hostileHeaders = new Proxy( + {}, + { + ownKeys() { + throw new Error('header trap cause'); + }, + }, + ); + assert.throws(() => correlationFromHeadersV1(hostileHeaders), /Unreadable telemetry/u); +}); + test('correlation headers round-trip without accepting malformed identifiers', () => { const context = createCorrelationContextV1({ correlationId, @@ -118,6 +144,10 @@ test('correlation headers round-trip without accepting malformed identifiers', ( }), ); assert.throws(() => correlationFromHeadersV1({})); + assert.throws( + () => correlationFromHeadersV1({ 'x-correlation-id': 1 }), + /Unreadable telemetry x-correlation-id header/u, + ); assert.throws(() => correlationFromHeadersV1({ 'x-correlation-id': [correlationId, correlationId] }), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b99d5e1e..43cd5809 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: typescript-eslint: specifier: 8.43.0 version: 8.43.0(eslint@9.36.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.2) + yaml: + specifier: 2.8.1 + version: 2.8.1 apps/desktop: dependencies: @@ -67,7 +70,7 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: 6.0.5 - version: 6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1)) electron: specifier: 43.2.0 version: 43.2.0(supports-color@7.2.0) @@ -79,10 +82,10 @@ importers: version: 5.9.2 vite: specifier: 8.2.0 - version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1) vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1)) apps/web: dependencies: @@ -119,7 +122,7 @@ importers: version: 1.62.1 '@tailwindcss/vite': specifier: 4.3.3 - version: 4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1)) '@testing-library/react': specifier: 16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -137,7 +140,7 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: 6.0.5 - version: 6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1)) jsdom: specifier: 30.0.1 version: 30.0.1 @@ -149,10 +152,10 @@ importers: version: 5.9.2 vite: specifier: 8.2.0 - version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1) vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1)) packages/config: dependencies: @@ -3061,6 +3064,11 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3835,12 +3843,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1) '@tanstack/query-core@5.101.4': {} @@ -4119,10 +4127,10 @@ snapshots: d3-time-format: 4.1.0 internmap: 2.0.3 - '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1) '@vitest/expect@4.1.10': dependencies: @@ -4133,13 +4141,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1) '@vitest/pretty-format@4.1.10': dependencies: @@ -5396,7 +5404,7 @@ snapshots: validator@13.15.35: {} - vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1): + vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -5409,11 +5417,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.1 + yaml: 2.8.1 - vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -5430,7 +5439,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 @@ -5477,6 +5486,8 @@ snapshots: xmlchars@2.2.0: {} + yaml@2.8.1: {} + yocto-queue@0.1.0: {} zeptomatch@2.1.0: diff --git a/services/engine/src/databreeze_engine/telemetry.py b/services/engine/src/databreeze_engine/telemetry.py index ab659f9f..b8418ab2 100644 --- a/services/engine/src/databreeze_engine/telemetry.py +++ b/services/engine/src/databreeze_engine/telemetry.py @@ -107,6 +107,10 @@ _LEVELS = frozenset({"debug", "info", "warn", "error"}) +class _LocalTelemetryValidationError(ValueError): + """A validation error raised by this module rather than a provider object.""" + + @dataclass(frozen=True) class CorrelationContext: correlation_id: str @@ -152,7 +156,7 @@ def _validate_key(key: object) -> str: or len(key) > 64 or not _KEY_PATTERN.fullmatch(key) ): - raise ValueError(f"invalid telemetry key: {key!r}") + raise _LocalTelemetryValidationError(f"invalid telemetry key: {key!r}") return key @@ -184,9 +188,9 @@ def assert_safe_attributes(attributes: Mapping[str, Any]) -> None: for raw_key, value in attributes.items(): key = _validate_key(raw_key) if key not in SAFE_ATTRIBUTE_KEYS or _safe_scalar(key, value) is None: - raise ValueError(f"telemetry attribute is not allowed: {key}") - except ValueError: - raise + raise _LocalTelemetryValidationError(f"telemetry attribute is not allowed: {key}") + except _LocalTelemetryValidationError as error: + raise ValueError(str(error)) from None except Exception: raise ValueError("telemetry attributes are not readable") from None @@ -259,19 +263,19 @@ def _single_header(headers: Mapping[str, str | Sequence[str] | None], name: str) try: for key, value in headers.items(): if not isinstance(key, str): - raise ValueError("telemetry header name is not a string") + raise _LocalTelemetryValidationError("telemetry header name is not a string") if key.lower() != name: continue if isinstance(value, str): values.append(value) elif value is not None: if not isinstance(value, Sequence) or isinstance(value, (bytes, bytearray)): - raise ValueError("telemetry header value is not readable") + raise _LocalTelemetryValidationError("telemetry header value is not readable") if not all(isinstance(item, str) for item in value): - raise ValueError("telemetry header value is not readable") + raise _LocalTelemetryValidationError("telemetry header value is not readable") values.extend(value) - except ValueError: - raise + except _LocalTelemetryValidationError as error: + raise ValueError(str(error)) from None except Exception: raise ValueError("telemetry headers are not readable") from None if len(values) > 1: diff --git a/services/engine/tests/test_telemetry.py b/services/engine/tests/test_telemetry.py index c5d4e35c..2860e7a9 100644 --- a/services/engine/tests/test_telemetry.py +++ b/services/engine/tests/test_telemetry.py @@ -78,6 +78,17 @@ def items(self): # type: ignore[override] assert_safe_attributes(HostileMapping()) +def test_engine_telemetry_normalizes_provider_value_errors() -> None: + class ValueErrorMapping(dict[str, object]): + def items(self): # type: ignore[override] + raise ValueError("provider value error must not escape") + + assert sanitize_attributes(ValueErrorMapping()) == {} + with pytest.raises(ValueError, match="not readable") as error: + assert_safe_attributes(ValueErrorMapping()) + assert "provider value error" not in str(error.value) + + def test_engine_telemetry_rejects_hostile_or_non_string_header_values() -> None: class HostileHeaders(dict[str, object]): def items(self): # type: ignore[override] @@ -92,6 +103,14 @@ def items(self): # type: ignore[override] } ) + class ValueErrorHeaders(dict[str, object]): + def items(self): # type: ignore[override] + raise ValueError("provider header value error must not escape") + + with pytest.raises(ValueError, match="not readable") as error: + correlation_from_headers(ValueErrorHeaders()) + assert "provider header value error" not in str(error.value) + def test_engine_accepts_mixed_case_header_names() -> None: context = CorrelationContext( diff --git a/tools/repo-cli/src/check-aws-infrastructure.mjs b/tools/repo-cli/src/check-aws-infrastructure.mjs index 82fe4f30..3fd0fd52 100644 --- a/tools/repo-cli/src/check-aws-infrastructure.mjs +++ b/tools/repo-cli/src/check-aws-infrastructure.mjs @@ -31,6 +31,40 @@ const allTerraform = requiredFiles .filter((relativePath) => relativePath.endsWith('.tf')) .map((relativePath) => readFileSync(path.join(infrastructureRoot, relativePath), 'utf8')) .join('\n'); + +function balancedBlocks(text, keyword) { + const blocks = []; + const startPattern = new RegExp(`\\b${keyword}\\s*\\{`, 'g'); + for (const match of text.matchAll(startPattern)) { + const openingBrace = text.indexOf('{', match.index); + let depth = 0; + let quoted = false; + let escaped = false; + for (let index = openingBrace; index < text.length; index += 1) { + const character = text[index]; + if (quoted) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') quoted = false; + continue; + } + if (character === '"') { + quoted = true; + continue; + } + if (character === '{') depth += 1; + if (character === '}') { + depth -= 1; + if (depth === 0) { + blocks.push(text.slice(openingBrace, index + 1)); + break; + } + } + } + } + return blocks; +} + for (const requiredText of [ 'ap-southeast-1', 'hashicorp/aws', @@ -58,10 +92,19 @@ for (const requiredBoundary of [ if (!allTerraform.includes(requiredBoundary)) fail(`missing required safety boundary ${requiredBoundary}`); } -if (/ingress[\s\S]{0,400}cidr_blocks\s*=\s*\["0\.0\.0\.0\/0"\]/u.test(allTerraform)) { +if ( + balancedBlocks(allTerraform, 'ingress').some((block) => + /\bcidr_blocks\s*=\s*\[[^\]]*"0\.0\.0\.0\/0"/u.test(block), + ) +) { fail('a private service security group permits unrestricted ingress'); } -if (/resource\s+"aws_s3_bucket_policy"[\s\S]*?Principal\s*=\s*"\*"/u.test(allTerraform)) { +if ( + /resource\s+"aws_s3_bucket_policy"[\s\S]*?Principal\s*=\s*"\*"/u.test(allTerraform) || + balancedBlocks(allTerraform, 'principals').some((block) => + /identifiers\s*=\s*\[[^\]]*"\*"/u.test(block), + ) +) { fail('the Web bucket policy grants a wildcard principal'); } if ( diff --git a/tools/repo-cli/src/check-ci-policy.mjs b/tools/repo-cli/src/check-ci-policy.mjs index 46e88660..6c97dd12 100644 --- a/tools/repo-cli/src/check-ci-policy.mjs +++ b/tools/repo-cli/src/check-ci-policy.mjs @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; const WORKFLOW_DIRECTORY = '.github/workflows'; const REQUIRED_WORKFLOWS = ['quality.yml', 'security.yml', 'release.yml']; @@ -12,67 +13,112 @@ function readWorkflow(root, name) { return fs.readFileSync(filename, 'utf8'); } -function assertPinnedActions(text, filename) { - for (const match of text.matchAll(/(^|\s)uses:\s*([^\s#]+)/gim)) { - const reference = match[2]; - if (reference.startsWith('./') || reference.startsWith('docker://')) continue; +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function parseWorkflow(text, filename) { + try { + const workflow = parse(text, { strict: true, uniqueKeys: true }); + if (!isRecord(workflow)) throw new Error('top-level document must be a mapping'); + return workflow; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${filename} is not valid workflow YAML: ${detail}`); + } +} + +function walk(value, visit) { + if (Array.isArray(value)) { + for (const item of value) walk(item, visit); + return; + } + if (!isRecord(value)) return; + visit(value); + for (const child of Object.values(value)) walk(child, visit); +} + +function containsText(value, expected) { + let found = false; + walk(value, (node) => { + if ( + Object.values(node).some((child) => typeof child === 'string' && child.includes(expected)) + ) { + found = true; + } + }); + return found; +} + +function assertPinnedActions(workflow, filename) { + walk(workflow, (node) => { + if (typeof node.uses !== 'string') return; + const reference = node.uses; + if (reference.startsWith('./') || reference.startsWith('docker://')) return; const at = reference.lastIndexOf('@'); if (at < 1 || !SHA_REFERENCE.test(reference.slice(at + 1))) { throw new Error(`${filename} uses an unpinned action: ${reference}`); } - } + }); } -function assertLeastPrivilege(text, filename) { - if (/permissions:\s*write-all/iu.test(text)) { +function assertLeastPrivilege(workflow, filename) { + const permissions = workflow.permissions; + if (permissions === 'write-all' || (isRecord(permissions) && permissions['write-all'])) { throw new Error(`${filename} grants write-all permissions`); } - if (!/^permissions:\s*$/im.test(text)) { + if (!isRecord(permissions)) { throw new Error(`${filename} must declare a top-level permissions block`); } - if (!/^\s+contents:\s*read\s*$/im.test(text)) { + if (permissions.contents !== 'read') { throw new Error(`${filename} must grant contents: read explicitly`); } - if (/pull_request_target:/iu.test(text)) { - throw new Error(`${filename} must not execute untrusted code from pull_request_target`); - } - if (/AWS_(?:ACCESS_KEY_ID|SECRET_ACCESS_KEY)\s*:/iu.test(text)) { - throw new Error(`${filename} must not define long-lived AWS key environment variables`); - } - if (/uses:\s*actions\/checkout@/iu.test(text) && !/persist-credentials:\s*false/iu.test(text)) { - throw new Error(`${filename} must disable checkout credential persistence`); - } + walk(workflow, (node) => { + if (Object.hasOwn(node, 'pull_request_target')) { + throw new Error(`${filename} must not execute untrusted code from pull_request_target`); + } + if (Object.keys(node).some((key) => /^AWS_(?:ACCESS_KEY_ID|SECRET_ACCESS_KEY)$/u.test(key))) { + throw new Error(`${filename} must not define long-lived AWS key environment variables`); + } + if (typeof node.uses === 'string' && node.uses.startsWith('actions/checkout@')) { + if (!isRecord(node.with) || node.with['persist-credentials'] !== false) { + throw new Error(`${filename} must disable checkout credential persistence`); + } + } + }); } -function assertBoundedJobs(text, filename) { - const jobCount = (text.match(/^\s+runs-on:\s*\S+/gim) ?? []).length; - const timeoutCount = (text.match(/^\s+timeout-minutes:\s*\d+/gim) ?? []).length; - if (jobCount > timeoutCount) { - throw new Error(`${filename} must bound every runner job with timeout-minutes`); +function assertBoundedJobs(workflow, filename) { + if (!isRecord(workflow.jobs)) return; + for (const job of Object.values(workflow.jobs)) { + if (!isRecord(job) || !Object.hasOwn(job, 'runs-on')) continue; + if (!Number.isInteger(job['timeout-minutes']) || job['timeout-minutes'] < 1) { + throw new Error(`${filename} must bound every runner job with timeout-minutes`); + } } } -function assertArtifactOutputs(text, filename) { - const artifactSteps = - text.match( - /-\s+uses:\s+actions\/upload-artifact@[^\n]+[\s\S]*?(?=\n\s+-\s+name:|\n\s+\w+:\s*$|$)/gim, - ) ?? []; - for (const step of artifactSteps) { - if (!/if-no-files-found:\s*error/iu.test(step)) { +function assertArtifactOutputs(workflow, filename) { + walk(workflow, (node) => { + if (typeof node.uses !== 'string' || !node.uses.startsWith('actions/upload-artifact@')) return; + if (!isRecord(node.with) || node.with['if-no-files-found'] !== 'error') { throw new Error(`${filename} artifact uploads must fail when an output is missing`); } - } + }); } export function checkCiPolicy(root = process.cwd()) { const workflows = Object.fromEntries( - REQUIRED_WORKFLOWS.map((name) => [name, readWorkflow(root, name)]), + REQUIRED_WORKFLOWS.map((name) => { + const text = readWorkflow(root, name); + return [name, parseWorkflow(text, name)]; + }), ); - for (const [name, text] of Object.entries(workflows)) { - assertPinnedActions(text, name); - assertLeastPrivilege(text, name); - assertBoundedJobs(text, name); - assertArtifactOutputs(text, name); + for (const [name, workflow] of Object.entries(workflows)) { + assertPinnedActions(workflow, name); + assertLeastPrivilege(workflow, name); + assertBoundedJobs(workflow, name); + assertArtifactOutputs(workflow, name); } const security = workflows['security.yml']; for (const required of [ @@ -82,16 +128,20 @@ export function checkCiPolicy(root = process.cwd()) { 'check-container-policy.mjs', 'generate-sbom.mjs', ]) { - if (!security.includes(required)) throw new Error(`security.yml is missing ${required}`); + if (!containsText(security, required)) throw new Error(`security.yml is missing ${required}`); } const release = workflows['release.yml']; - if (!/id-token:\s*write/iu.test(release)) { + if (!isRecord(release.permissions) || release.permissions['id-token'] !== 'write') { throw new Error('release.yml must request OIDC id-token permission explicitly'); } - if (!release.includes('generate-provenance.mjs')) { + if (!containsText(release, 'generate-provenance.mjs')) { throw new Error('release.yml must generate a provenance record'); } - if (!/^\s+environment:\s*release\s*$/im.test(release)) { + let hasReleaseEnvironment = false; + walk(release, (node) => { + if (node.environment === 'release') hasReleaseEnvironment = true; + }); + if (!hasReleaseEnvironment) { throw new Error('release.yml must use the protected release environment'); } return { workflowCount: REQUIRED_WORKFLOWS.length }; diff --git a/tools/repo-cli/src/local-services.mjs b/tools/repo-cli/src/local-services.mjs index 6da592b8..1d8409a3 100644 --- a/tools/repo-cli/src/local-services.mjs +++ b/tools/repo-cli/src/local-services.mjs @@ -68,8 +68,8 @@ function parseEnvFile(file) { if (!existsSync(file)) return new Map(); const values = new Map(); for (const line of readFileSync(file, 'utf8').split(/\r?\n/u)) { - const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/u.exec(line); - if (!match || match[1].startsWith('#')) continue; + const match = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/u.exec(line); + if (!match) continue; values.set(match[1], match[2].replace(/^(['"])(.*)\1$/u, '$2')); } return values; @@ -84,6 +84,9 @@ function environment() { if (process.env[definition.key] !== undefined) fileValues.set(definition.key, process.env[definition.key]); } + if (process.env.DATABREEZE_MIN_FREE_GIB !== undefined) { + fileValues.set('DATABREEZE_MIN_FREE_GIB', process.env.DATABREEZE_MIN_FREE_GIB); + } return fileValues; } @@ -112,14 +115,23 @@ function composeArgs(values = environment()) { return ['compose', '--project-name', project, '--env-file', envFile, '-f', composeFile]; } -function runDocker(args, { allowFailure = false } = {}) { - const result = spawnSync('docker', args, { cwd: repositoryRoot, encoding: 'utf8' }); +function runDocker(args, { allowFailure = false, capture = true, timeoutMs = 30_000 } = {}) { + const result = spawnSync( + 'docker', + args, + capture + ? { cwd: repositoryRoot, encoding: 'utf8', timeout: timeoutMs } + : { cwd: repositoryRoot, stdio: 'inherit', timeout: timeoutMs }, + ); if (!allowFailure && (result.error || result.status !== 0)) { if (result.error?.code === 'ENOENT') { fail( 'Docker CLI is not installed or not on PATH; start Docker Desktop before using this command', ); } + if (result.error?.code === 'ETIMEDOUT') { + fail(`docker ${args.join(' ')} timed out after ${timeoutMs}ms`); + } const detail = (result.stderr || result.stdout || result.error?.message || '').trim(); fail(`docker ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`); } @@ -130,12 +142,16 @@ function requireDocker() { const result = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { cwd: repositoryRoot, encoding: 'utf8', + timeout: 15_000, }); if (result.error?.code === 'ENOENT') { fail( 'Docker CLI is not installed or not on PATH; start Docker Desktop before using this command', ); } + if (result.error?.code === 'ETIMEDOUT') { + fail('Docker CLI check timed out after 15000ms; verify Docker Desktop is responsive'); + } if (result.status !== 0) { fail( 'Docker daemon is unavailable; start Docker Desktop or another Docker Engine before using this command', @@ -212,13 +228,20 @@ function inspectHealth(service, values) { const idResult = runDocker([...composeArgs(values), 'ps', '-q', service], { allowFailure: true }); const id = idResult.stdout.trim(); if (!id) return { state: 'missing', health: 'unknown', detail: 'no container' }; - const inspect = runDocker([ - 'inspect', - '--format', - '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health{{end}}', - id, - ]); - const [state, health] = inspect.stdout.trim().split('|'); + const inspect = runDocker( + [ + 'inspect', + '--format', + '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health{{end}}', + id, + ], + { allowFailure: true }, + ); + const inspection = inspect.stdout?.trim(); + if (inspect.error || inspect.status !== 0 || !inspection) { + return { state: 'unknown', health: 'unknown', detail: 'unknown/unknown (inspect unavailable)' }; + } + const [state, health] = inspection.split('|'); return { state, health, detail: `${state}/${health}` }; } @@ -243,7 +266,7 @@ async function waitForReady(values, waitSeconds) { fail(`readiness timeout after ${waitSeconds}s`); } -function parseArguments(argv) { +function parseArguments(argv, values = environment()) { let command = 'smoke'; const argumentsToParse = [...argv]; if (argumentsToParse[0] && !argumentsToParse[0].startsWith('-')) @@ -253,7 +276,7 @@ function parseArguments(argv) { waitSeconds: 60, tail: 100, service: undefined, - minFreeGib: Number(process.env.DATABREEZE_MIN_FREE_GIB || 5), + minFreeGib: Number(values.get('DATABREEZE_MIN_FREE_GIB') ?? 5), }; for (const argument of argumentsToParse) { if (argument === '--help' || argument === '-h') return { command: 'help', options }; @@ -316,12 +339,12 @@ function parseArguments(argv) { } export async function main(argv = process.argv.slice(2)) { - const { command, options } = parseArguments(argv); + const values = environment(); + const { command, options } = parseArguments(argv, values); if (command === 'help') { usage(); return; } - const values = environment(); if (command === 'config') { validateCompose(values); console.log('Local Compose configuration is valid.'); @@ -344,13 +367,10 @@ export async function main(argv = process.argv.slice(2)) { } if (command === 'logs') { const selected = options.service ? [options.service] : logServices; - runDocker([ - ...composeArgs(values), - 'logs', - '--no-color', - `--tail=${options.tail}`, - ...selected, - ]); + runDocker( + [...composeArgs(values), 'logs', '--no-color', `--tail=${options.tail}`, ...selected], + { capture: false, timeoutMs: 120_000 }, + ); return; } if (command === 'stop') { @@ -389,32 +409,40 @@ export async function main(argv = process.argv.slice(2)) { if (command === 'persistence-check') { const key = `databreeze:local:persistence-check:${process.pid}`; const value = `${Date.now()}`; - runDocker([ - ...composeArgs(values), - 'exec', - '-T', - 'redis', - 'redis-cli', - 'SET', - key, - value, - 'EX', - '300', - ]); - runDocker([...composeArgs(values), 'restart', 'redis']); - await waitForReady(values, options.waitSeconds); - const result = runDocker([ - ...composeArgs(values), - 'exec', - '-T', - 'redis', - 'redis-cli', - 'GET', - key, - ]); - if (result.stdout.trim() !== value) - fail('Redis persistence sentinel was not recovered after restart'); - runDocker([...composeArgs(values), 'exec', '-T', 'redis', 'redis-cli', 'DEL', key]); + let recovered = false; + try { + runDocker([ + ...composeArgs(values), + 'exec', + '-T', + 'redis', + 'redis-cli', + 'SET', + key, + value, + 'EX', + '300', + ]); + runDocker([...composeArgs(values), 'restart', 'redis']); + await waitForReady(values, options.waitSeconds); + const result = runDocker([ + ...composeArgs(values), + 'exec', + '-T', + 'redis', + 'redis-cli', + 'GET', + key, + ]); + if (result.stdout.trim() !== value) + fail('Redis persistence sentinel was not recovered after restart'); + recovered = true; + } finally { + runDocker([...composeArgs(values), 'exec', '-T', 'redis', 'redis-cli', 'DEL', key], { + allowFailure: true, + }); + } + if (!recovered) return; console.log('Local Redis persistence check passed; sentinel was removed.'); return; } diff --git a/tools/repo-cli/test/aws-infrastructure.test.mjs b/tools/repo-cli/test/aws-infrastructure.test.mjs index 404300cb..3c3e5af8 100644 --- a/tools/repo-cli/test/aws-infrastructure.test.mjs +++ b/tools/repo-cli/test/aws-infrastructure.test.mjs @@ -65,7 +65,8 @@ test('AWS sources expose encryption, private data, and OIDC boundaries without s ]) assert.match(sources, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); assert.doesNotMatch(sources, /AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH) PRIVATE KEY/); - assert.doesNotMatch(sources, /ingress[\s\S]{0,400}cidr_blocks\s*=\s*\["0\.0\.0\.0\/0"\]/u); + assert.doesNotMatch(sources, /ingress[\s\S]*?cidr_blocks\s*=\s*\["0\.0\.0\.0\/0"\]/u); + assert.doesNotMatch(sources, /principals[\s\S]*?identifiers\s*=\s*\[[^\]]*"\*"/u); assert.match(sources, /assign_public_ip\s*=\s*false/u); assert.match(sources, /token\.actions\.githubusercontent\.com:sub/u); assert.match(sources, /repo:\$\{var\.github_repository\}:ref:refs\/heads\/dev/u); @@ -122,6 +123,8 @@ test('AWS production profile enables recovery and prevents public data paths', ( assert.match(computeVariables, /variable "worker_memory"/u); assert.match(computeVariables, /supported Fargate CPU size/u); assert.match(computeVariables, /between 512 and 30720 MiB/u); + assert.match(compute, /allowed_worker_memory_by_cpu/u); + assert.match(compute, /AWS-supported Fargate size for worker_cpu/u); }); test('AWS foundation keeps state and apply outside the repository', () => { diff --git a/tools/repo-cli/test/local-infrastructure.test.mjs b/tools/repo-cli/test/local-infrastructure.test.mjs index 0291e8ba..084cad42 100644 --- a/tools/repo-cli/test/local-infrastructure.test.mjs +++ b/tools/repo-cli/test/local-infrastructure.test.mjs @@ -37,12 +37,17 @@ test('local compose defines pinned, healthy disposable dependencies', () => { assert.match(compose, /minio-init:[\s\S]*depends_on:[\s\S]*condition: service_healthy/u); assert.match(compose, /minio-init:[\s\S]*restart: 'no'/u); assert.match(compose, /postgres-data:[\s\S]*name: \$\{COMPOSE_PROJECT_NAME/u); - assert.match(compose, /networks: \[local\]/u); + assert.equal((compose.match(/networks: \[local\]/g) ?? []).length, 7); assert.match(compose, /name: \$\{COMPOSE_PROJECT_NAME:-databreeze-local\}-network/u); assert.match(compose, /x-default-logging: &default-logging/u); assert.match(compose, /max-size: 10m/u); assert.match(compose, /max-file: '3'/u); assert.equal((compose.match(/logging: \*default-logging/g) ?? []).length, 7); + assert.equal((compose.match(/127\.0\.0\.1:\$\{/g) ?? []).length, 9); + assert.match( + read('infrastructure/local/README.md'), + /Every published port is bound to `127\.0\.0\.1`/u, + ); }); test('local bootstrap is credential-free and creates every owned module schema', () => { @@ -215,12 +220,25 @@ test('local lifecycle commands fail safely around Docker, ports, disk, and volum cwd: repositoryRoot, encoding: 'utf8', }); - if (composeConfig.status === 0) + if (composeConfig.status === 0) { assert.match(composeConfig.stdout, /Compose configuration is valid/u); - assert.match(script, /logs[\s\S]*--no-color/u); - assert.match(script, /--service must name one of/u); - assert.match(script, /--tail must be an integer/u); - assert.match(script, /COMPOSE_PROJECT_NAME must start/u); - assert.match(script, /Redis persistence sentinel was not recovered/u); + } else { + assert.match( + `${composeConfig.stdout}\n${composeConfig.stderr}`, + /Docker CLI is not installed or not on PATH/u, + ); + } + const preflight = spawnSync(process.execPath, [helpScript, 'preflight', '--min-free-gib=0'], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + if (preflight.status === 0) { + assert.match(preflight.stdout, /preflight passed without starting services/u); + } else { + assert.match( + `${preflight.stdout}\n${preflight.stderr}`, + /Docker CLI is not installed or not on PATH/u, + ); + } assert.doesNotMatch(script, /redis-cli\s+FLUSH(?:ALL|DB)/iu); }); diff --git a/tools/repo-cli/test/provenance.test.mjs b/tools/repo-cli/test/provenance.test.mjs index 0f4fbe89..70ebec9c 100644 --- a/tools/repo-cli/test/provenance.test.mjs +++ b/tools/repo-cli/test/provenance.test.mjs @@ -27,6 +27,13 @@ test('provenance generation records sorted artifact digests', () => { provenance.subject.map((subject) => path.posix.basename(subject.path)), ['a-output.json', 'z-output.json'], ); + assert.deepEqual( + provenance.subject.map((subject) => subject.sha256), + [ + 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb', + '594e519ae499312b29433b7dd8a97ff068defcba9755b6d5d00e84c524d67b06', + ], + ); } finally { rmSync(directory, { recursive: true, force: true }); } diff --git a/tools/repo-cli/test/sbom.test.mjs b/tools/repo-cli/test/sbom.test.mjs index 4d0159a4..72ec7d73 100644 --- a/tools/repo-cli/test/sbom.test.mjs +++ b/tools/repo-cli/test/sbom.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -27,6 +27,12 @@ test('SBOM generation writes to an explicit output path', () => { }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Wrote .*sbom\.json/u); + assert.equal(existsSync(output), true); + const sbom = JSON.parse(readFileSync(output, 'utf8')); + assert.equal(sbom.bomFormat, 'CycloneDX'); + assert.equal(sbom.specVersion, '1.5'); + assert.ok(Array.isArray(sbom.components)); + assert.ok(sbom.components.some((component) => component.name === '@databreeze/platform')); } finally { rmSync(directory, { recursive: true, force: true }); }