diff --git a/apps/stalker-mock-server/fixtures/replay/resolver/root-landing.json b/apps/stalker-mock-server/fixtures/replay/resolver/root-landing.json new file mode 100644 index 000000000..e5fec917c --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/resolver/root-landing.json @@ -0,0 +1,129 @@ +{ + "description": "Synthetic resolver root landing redirect.", + "entry": { + "origin": "portal", + "path": "/c/" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "landing", + "method": "GET", + "operation": "landing", + "origin": "portal", + "path": "/c/", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": {}, + "present": [] + } + }, + "response": { + "body": { + "kind": "empty" + }, + "headers": { + "location": [ + "/portal.php" + ] + }, + "status": 302 + } + } + ], + "mode": "ordered", + "name": "resolve", + "nextState": "landing-resolved", + "state": "start" + }, + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "portal-entry", + "method": "GET", + "operation": "portal-entry", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": {}, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "status": "ok" + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "ordered", + "name": "portal-entry", + "nextState": "complete", + "state": "landing-resolved" + } + ], + "scenarioId": "resolver-root-landing", + "schemaVersion": 1, + "symbols": [], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/project.json b/apps/stalker-mock-server/project.json index 5faa86c83..cf9baa8bb 100644 --- a/apps/stalker-mock-server/project.json +++ b/apps/stalker-mock-server/project.json @@ -36,6 +36,12 @@ }, "test": { "executor": "@nx/jest:jest", + "inputs": [ + "default", + "^production", + "{workspaceRoot}/jest.preset.js", + "stalkerReplayFixtures" + ], "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "apps/stalker-mock-server/jest.config.ts" diff --git a/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts b/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts new file mode 100644 index 000000000..746ebdd9d --- /dev/null +++ b/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts @@ -0,0 +1,88 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { createReplayRun, type ReplayRun } from './replay-run.js'; +import { parseReplayFixtureText } from './replay-schema.js'; +import type { ReplayObservedRequest } from './replay.types.js'; + +const FIXTURE_ROOT = resolve( + process.cwd(), + 'apps/stalker-mock-server/fixtures/replay' +); + +function collectFixturePaths(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => compareCodeUnits(left.name, right.name)) + .flatMap((entry) => { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + return collectFixturePaths(entryPath); + } + if (entry.isFile() && entry.name.endsWith('.json')) { + return [entryPath]; + } + throw new Error(`Unsafe replay fixture entry: ${entry.name}.`); + }); +} + +function emptyGet(path: string): ReplayObservedRequest { + return { + origin: 'portal', + method: 'GET', + path, + query: {}, + headers: {}, + cookies: {}, + body: { kind: 'absent' }, + }; +} + +const FIXTURE_DRIVERS: Readonly< + Record Promise> +> = { + 'resolver-root-landing': async (run) => { + await run.request(emptyGet('/c/')); + await run.request(emptyGet('/portal.php')); + }, +}; + +describe('committed replay fixture corpus', () => { + it('drives every fixture through exact terminal/cardinality evidence', async () => { + const fixturePaths = collectFixturePaths(FIXTURE_ROOT); + expect(fixturePaths.length).toBeGreaterThan(0); + + for (const fixturePath of fixturePaths) { + const fixture = parseReplayFixtureText( + readFileSync(fixturePath, 'utf8') + ); + const driver = FIXTURE_DRIVERS[fixture.scenarioId]; + if (driver === undefined) { + throw new Error( + `Replay fixture driver missing: ${fixture.scenarioId}.` + ); + } + + const run = createReplayRun(fixture, { + runId: `fixture-${fixture.scenarioId}`, + }); + try { + await driver(run); + expect({ + scenarioId: fixture.scenarioId, + result: run.finalize(), + }).toMatchObject({ + scenarioId: fixture.scenarioId, + result: { ok: true }, + }); + } finally { + run.dispose(); + } + } + }); +}); + +function compareCodeUnits(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} diff --git a/nx.json b/nx.json index 808b047e9..39bdfd357 100644 --- a/nx.json +++ b/nx.json @@ -12,7 +12,10 @@ "!{projectRoot}/src/test-setup.[jt]s", "!{projectRoot}/test-setup.[jt]s" ], - "sharedGlobals": ["{workspaceRoot}/.github/workflows/ci.yml"] + "sharedGlobals": ["{workspaceRoot}/.github/workflows/ci.yml"], + "stalkerReplayFixtures": [ + "{workspaceRoot}/apps/stalker-mock-server/fixtures/**/*.json" + ] }, "targetDefaults": { "@angular/build:application": { diff --git a/package.json b/package.json index 45b2170bc..5416df170 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "release:notes:changelog": "node tools/release/build-release-notes.mjs --format changelog", "release:notes:blog": "node tools/release/build-release-notes.mjs --format blog", "release:screenshots": "tsx tools/release/capture-release-screenshots.ts", + "stalker:fixtures:validate": "nx run stalker-fixture-tools:validate", "lint": "nx run-many --target=lint --all", "build": "nx build electron-backend" }, @@ -110,6 +111,7 @@ "rxjs": "7.8.2", "saxes": "6.0.0", "shaka-player": "5.2.1", + "tough-cookie": "5.1.2", "uuid": "9.0.0", "video.js": "8.23.4", "videojs-contrib-quality-levels": "4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06aa48366..76218d350 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: shaka-player: specifier: 5.2.1 version: 5.2.1 + tough-cookie: + specifier: 5.1.2 + version: 5.1.2 uuid: specifier: 9.0.0 version: 9.0.0 diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index 9a4748706..d15c495f9 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -252,6 +252,18 @@ "validationCommand": "pnpm nx test stalker-mock-server", "reason": "Stateful Stalker replay behavior is validated by focused contract tests; percentage coverage is not a shipped-source quality signal." }, + { + "name": "portal-stalker-replay-fixtures", + "root": "libs/portal/stalker/replay-fixtures", + "validationCommand": "pnpm nx test portal-stalker-replay-fixtures", + "reason": "The Node-only replay grammar is covered by focused fail-closed schema contract tests." + }, + { + "name": "stalker-fixture-tools", + "root": "tools/stalker-fixtures", + "validationCommand": "pnpm nx test stalker-fixture-tools", + "reason": "Fixture validation tooling is covered by focused scanner, schema, formatting, and filesystem contract tests." + }, { "name": "remote-control-web", "root": "apps/remote-control-web", diff --git a/tools/stalker-fixtures/jest.config.ts b/tools/stalker-fixtures/jest.config.ts new file mode 100644 index 000000000..0a932e689 --- /dev/null +++ b/tools/stalker-fixtures/jest.config.ts @@ -0,0 +1,16 @@ +export default { + displayName: 'stalker-fixture-tools', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + moduleFileExtensions: ['ts', 'js'], + coverageDirectory: '../../coverage/tools/stalker-fixtures', +}; diff --git a/tools/stalker-fixtures/project.json b/tools/stalker-fixtures/project.json new file mode 100644 index 000000000..0b95ac22d --- /dev/null +++ b/tools/stalker-fixtures/project.json @@ -0,0 +1,40 @@ +{ + "name": "stalker-fixture-tools", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "tools/stalker-fixtures/src", + "projectType": "library", + "tags": ["scope:tools", "domain:stalker", "type:tool"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "cache": true, + "inputs": [ + "default", + "^production", + "{workspaceRoot}/jest.preset.js", + "stalkerReplayFixtures" + ], + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "tools/stalker-fixtures/jest.config.ts", + "tsConfig": "tools/stalker-fixtures/tsconfig.spec.json" + } + }, + "validate": { + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "production", + "^production", + "stalkerReplayFixtures" + ], + "options": { + "command": "pnpm tsx --tsconfig tools/stalker-fixtures/tsconfig.json tools/stalker-fixtures/src/cli.ts validate", + "cwd": "{workspaceRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/tools/stalker-fixtures/src/cli.ts b/tools/stalker-fixtures/src/cli.ts new file mode 100644 index 000000000..40e0d292d --- /dev/null +++ b/tools/stalker-fixtures/src/cli.ts @@ -0,0 +1,38 @@ +import { resolve } from 'node:path'; +import { + FIXTURE_VALIDATION_CLI_ERROR_CODES, + FixtureValidationCliError, + validateReplayFixtureDirectory, +} from './lib/fixture-validation-cli'; +import { FixtureValidationError } from './lib/fixture-validator'; + +const FIXTURE_ROOT = resolve( + process.cwd(), + 'apps/stalker-mock-server/fixtures/replay' +); + +async function main(): Promise { + const arguments_ = process.argv.slice(2); + if (arguments_.length !== 1 || arguments_[0] !== 'validate') { + throw new FixtureValidationCliError( + FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsupportedCommand + ); + } + + const fixtureCount = await validateReplayFixtureDirectory(FIXTURE_ROOT); + process.stdout.write( + `Validated ${fixtureCount} Stalker replay fixture(s).\n` + ); +} + +void main().catch((error: unknown) => { + const safeError = + error instanceof FixtureValidationCliError || + error instanceof FixtureValidationError + ? error + : new FixtureValidationCliError( + FIXTURE_VALIDATION_CLI_ERROR_CODES.InternalError + ); + process.stderr.write(`${safeError.message}\n`); + process.exitCode = 1; +}); diff --git a/tools/stalker-fixtures/src/index.ts b/tools/stalker-fixtures/src/index.ts new file mode 100644 index 000000000..6dcc5ab10 --- /dev/null +++ b/tools/stalker-fixtures/src/index.ts @@ -0,0 +1,8 @@ +export * from './lib/fixture-secret-scanner'; +export { + FIXTURE_VALIDATION_CLI_ERROR_CODES, + FixtureValidationCliError, + validateReplayFixtureDirectory, +} from './lib/fixture-validation-cli'; +export type { FixtureValidationCliErrorCode } from './lib/fixture-validation-cli'; +export * from './lib/fixture-validator'; diff --git a/tools/stalker-fixtures/src/lib/fixture-secret-scanner.spec.ts b/tools/stalker-fixtures/src/lib/fixture-secret-scanner.spec.ts new file mode 100644 index 000000000..44e6e0f98 --- /dev/null +++ b/tools/stalker-fixtures/src/lib/fixture-secret-scanner.spec.ts @@ -0,0 +1,274 @@ +import type { + ReplayFixtureV1, + ReplayTemplateValue, +} from '@iptvnator/portal/stalker/replay-fixtures'; +import { + assertReplayFixtureContainsNoSecrets, + FixtureSecretScanError, + FIXTURE_SECRET_SCAN_ERROR_CODES, + type FixtureSecretScanErrorCode, +} from './fixture-secret-scanner'; + +function replayFixture(value: ReplayTemplateValue = { js: true }): ReplayFixtureV1 { + return { + schemaVersion: 1, + scenarioId: 'fixture-scanner-contract', + description: + 'Synthetic Authorization failed response at https://portal.test/c/.', + origins: { portal: {} }, + entry: { origin: 'portal', path: '/c/' }, + expectedEndpoint: { origin: 'portal', path: '/portal.php' }, + initialState: 'start', + terminalState: 'complete', + failOnUnexpectedRequest: true, + symbols: [ + { + kind: 'generate', + symbol: 'safe-token', + valueKind: 'token', + }, + ], + phases: [ + { + name: 'resolve', + state: 'start', + nextState: 'complete', + mode: 'ordered', + expectations: [ + { + id: 'landing', + operation: 'landing', + origin: 'portal', + method: 'GET', + path: '/c/', + request: { + query: { + exact: {}, + present: ['password'], + absent: ['token'], + }, + headers: { + exact: {}, + present: [], + absent: ['authorization'], + }, + cookies: { + exact: {}, + present: [], + absent: ['session'], + attributes: {}, + }, + body: { kind: 'absent' }, + }, + response: { + status: 200, + headers: { + 'content-type': ['application/json'], + }, + body: { kind: 'json', value }, + }, + cardinality: { min: 1, max: 1 }, + }, + ], + }, + ], + }; +} + +function fixtureWithDescription(description: string): ReplayFixtureV1 { + return { ...replayFixture(), description }; +} + +function expectScanCode( + fixture: ReplayFixtureV1, + code: FixtureSecretScanErrorCode +): void { + try { + assertReplayFixtureContainsNoSecrets(fixture); + throw new Error('fixture unexpectedly passed'); + } catch (error) { + expect(error).toBeInstanceOf(FixtureSecretScanError); + expect((error as FixtureSecretScanError).code).toBe(code); + expect((error as Error).message).toBe( + `Stalker fixture rejected: ${code}.` + ); + } +} + +describe('fixture secret scanner', () => { + it('allows typed symbols, protocol literals, matcher field names, and reserved test hosts', () => { + const fixture = replayFixture({ + js: { + token: { kind: 'ref', symbol: 'safe-token' }, + 'set-cookie': { + kind: 'parts', + parts: [ + { kind: 'literal', value: 'session=' }, + { kind: 'ref', symbol: 'safe-token' }, + { kind: 'literal', value: '; Path=/; HttpOnly' }, + ], + }, + redirect: 'https://auth.portal.test/login', + result: 'Authorization failed', + }, + }); + + expect(() => + assertReplayFixtureContainsNoSecrets(fixture) + ).not.toThrow(); + }); + + it.each([ + 'password=private-value', + 'password%3Dprivate-value', + 'password%253Dprivate-value', + 'pa\\u0073sword=private-value', + '{\\"password\\":\\"private-value\\"}', + 'Set-Cookie: session=private-value', + ])('rejects raw and encoded secret evidence without echoing it: %s', (value) => { + expectScanCode( + fixtureWithDescription(value), + FIXTURE_SECRET_SCAN_ERROR_CODES.SensitiveLiteral + ); + }); + + it('scans JSON object keys for external-origin evidence', () => { + expectScanCode( + replayFixture({ + 'https://untrusted.example.tv/account': true, + }), + FIXTURE_SECRET_SCAN_ERROR_CODES.ExternalUrl + ); + }); + + it('rejects literal fragments smuggled into credential parts', () => { + expectScanCode( + replayFixture({ + password: { + kind: 'parts', + parts: [ + { kind: 'literal', value: 'private-value' }, + { kind: 'ref', symbol: 'safe-token' }, + ], + }, + }), + FIXTURE_SECRET_SCAN_ERROR_CODES.SensitiveLiteral + ); + }); + + it('rejects JWT-shaped literals', () => { + expectScanCode( + fixtureWithDescription( + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhY2NvdW50In0.c2lnbmF0dXJlMTIzNDU2' + ), + FIXTURE_SECRET_SCAN_ERROR_CODES.JwtLiteral + ); + }); + + it('rejects literal MAC addresses', () => { + expectScanCode( + fixtureWithDescription('00:1A:79:12:34:56'), + FIXTURE_SECRET_SCAN_ERROR_CODES.MacLiteral + ); + }); + + it('scans typed placeholder labels for secret evidence', () => { + const fixture = replayFixture(); + fixture.symbols = [ + { + kind: 'generate', + symbol: '00:1A:79:12:34:56', + valueKind: 'token', + }, + ]; + + expectScanCode( + fixture, + FIXTURE_SECRET_SCAN_ERROR_CODES.MacLiteral + ); + }); + + it.each([ + 'authorization', + 'cookie', + 'set-cookie', + 'password', + 'credential', + 'username', + 'account_id', + 'device_id', + 'serial', + 'signature', + 'prehash', + ])('rejects a raw value under sensitive key %s', (key) => { + expectScanCode( + replayFixture({ [key]: 'private-value' }), + FIXTURE_SECRET_SCAN_ERROR_CODES.SensitiveLiteral + ); + }); + + it.each([ + 'https://provider.example.tv/api', + 'https://stream.vendor.example.tv/live/42', + 'https://images.vendor.example.tv/poster.jpg', + 'https://untrusted.example.com/portal.php', + ])('rejects unknown external provider evidence %s', (value) => { + expectScanCode( + fixtureWithDescription(value), + FIXTURE_SECRET_SCAN_ERROR_CODES.ExternalUrl + ); + }); + + it.each(['stream_url', 'artwork_url', 'provider_url'])( + 'rejects a URL under sensitive fixture key %s', + (key) => { + expectScanCode( + replayFixture({ [key]: 'https://media.portal.test/resource' }), + FIXTURE_SECRET_SCAN_ERROR_CODES.ExternalUrl + ); + } + ); + + it('rejects unsupported stream URL schemes', () => { + expectScanCode( + replayFixture({ + stream_url: 'rtsp://media.portal.test/live', + }), + FIXTURE_SECRET_SCAN_ERROR_CODES.ExternalUrl + ); + }); + + it('rejects suspicious high-entropy literals', () => { + expectScanCode( + fixtureWithDescription( + 'N7vQ2mK9xP4sR8tW3yB6cD1fG5hJ0lZ2uV7nQ9pS' + ), + FIXTURE_SECRET_SCAN_ERROR_CODES.HighEntropyLiteral + ); + }); + + it.each([ + '2026-07-27T03:14:15.926Z', + 1_785_123_456, + 1_785_123_456_000, + ])('rejects nondeterministic timestamp evidence %s', (value) => { + expectScanCode( + replayFixture({ observedAt: value }), + FIXTURE_SECRET_SCAN_ERROR_CODES.NondeterministicTimestamp + ); + }); + + it.each([ + 0, + 4_102_444_800, + 4_102_444_800_000, + '1970-01-01T00:00:00.000Z', + '2100-01-01T00:00:00.000Z', + ])('allows canonical deterministic timestamp sentinel %s', (value) => { + expect(() => + assertReplayFixtureContainsNoSecrets( + replayFixture({ observedAt: value }) + ) + ).not.toThrow(); + }); +}); diff --git a/tools/stalker-fixtures/src/lib/fixture-secret-scanner.ts b/tools/stalker-fixtures/src/lib/fixture-secret-scanner.ts new file mode 100644 index 000000000..57f530a7f --- /dev/null +++ b/tools/stalker-fixtures/src/lib/fixture-secret-scanner.ts @@ -0,0 +1,329 @@ +import type { ReplayFixtureV1 } from '@iptvnator/portal/stalker/replay-fixtures'; + +export const FIXTURE_SECRET_SCAN_ERROR_CODES = { + SensitiveLiteral: 'sensitive-literal', + JwtLiteral: 'jwt-literal', + MacLiteral: 'mac-literal', + ExternalUrl: 'external-url', + HighEntropyLiteral: 'high-entropy-literal', + NondeterministicTimestamp: 'nondeterministic-timestamp', +} as const; + +export type FixtureSecretScanErrorCode = + (typeof FIXTURE_SECRET_SCAN_ERROR_CODES)[keyof typeof FIXTURE_SECRET_SCAN_ERROR_CODES]; + +export class FixtureSecretScanError extends Error { + constructor(readonly code: FixtureSecretScanErrorCode) { + super(`Stalker fixture rejected: ${code}.`); + this.name = 'FixtureSecretScanError'; + } +} + +const SENSITIVE_KEY = + /^(?:authorization|cookie|set[-_]?cookie|credential|login|mac|password|passwd|prehash|random|serial|signature\d*|sn|token|username|account_?id|device_?id\d*)$/i; +const SENSITIVE_URL_KEY = + /^(?:artwork|artwork_url|image|image_url|logo|logo_url|poster|poster_url|provider|provider_url|stream|stream_url|thumbnail|thumbnail_url)$/i; +const SENSITIVE_PARTS_KEY = + /^(?:authorization|cookie|set[-_]?cookie)$/i; +const SENSITIVE_ASSIGNMENT = + /(?:^|[?&;\s"'{}:,])(?:authorization|cookie|set[-_]?cookie|credential|login|mac|password|passwd|prehash|random|serial|signature\d*|sn|token|username|account_?id|device_?id\d*)["']?\s*(?:=|:)\s*[^\s&;,}]+/i; +const AUTHORIZATION_VALUE = /\b(?:basic|bearer)\s+[A-Za-z0-9+/_=.-]{8,}/i; +const JWT_LITERAL = + /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{8,}\b/; +const MAC_LITERAL = /\b(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}\b/i; +const ISO_TIMESTAMP = + /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z\b/i; +const URL_LITERAL = + /\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s<>"'\\]+/g; +const HIGH_ENTROPY_TOKEN = /[A-Za-z0-9+/_=-]{32,}/g; +const JSON_UNICODE_ESCAPE = /\\u([0-9A-F]{4})/gi; +const JSON_HEX_ESCAPE = /\\x([0-9A-F]{2})/gi; +const JSON_QUOTE_ESCAPE = /\\"/g; +const UNIX_SECONDS_MIN = 946_684_800; +const UNIX_SECONDS_MAX = 4_102_444_800; +const UNIX_MILLISECONDS_MIN = UNIX_SECONDS_MIN * 1000; +const UNIX_MILLISECONDS_MAX = UNIX_SECONDS_MAX * 1000; +const DETERMINISTIC_TIMESTAMP_NUMBERS = new Set([ + 0, + UNIX_SECONDS_MAX, + UNIX_MILLISECONDS_MAX, +]); +const DETERMINISTIC_ISO_TIMESTAMPS = new Set([ + '1970-01-01T00:00:00.000Z', + '2100-01-01T00:00:00.000Z', +]); + +export function assertReplayFixtureContainsNoSecrets( + fixture: ReplayFixtureV1 +): void { + inspectValue(fixture); +} + +function inspectValue(value: unknown, parentKey?: string): void { + if (typeof value === 'string') { + if ( + parentKey !== undefined && + SENSITIVE_URL_KEY.test(parentKey) && + containsUrl(value) + ) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.ExternalUrl); + } + inspectString(value); + return; + } + + if (typeof value === 'number') { + if (isTimestampNumber(value)) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.NondeterministicTimestamp); + } + return; + } + + if (value === null || typeof value !== 'object') { + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + inspectValue(item, parentKey); + } + return; + } + + const record = value as Record; + if (isOpaqueTypedNode(record)) { + inspectString(record['symbol'] as string); + return; + } + if (record['kind'] === 'parts' && Array.isArray(record['parts'])) { + if ( + parentKey !== undefined && + SENSITIVE_KEY.test(parentKey) && + (!SENSITIVE_PARTS_KEY.test(parentKey) || + !containsReference(record['parts'])) + ) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.SensitiveLiteral); + } + for (const part of record['parts']) { + inspectValue(part); + } + return; + } + if (record['kind'] === 'literal' && typeof record['value'] === 'string') { + inspectString(record['value']); + return; + } + + for (const [key, child] of Object.entries(record)) { + inspectString(key); + if (SENSITIVE_KEY.test(key) && !isProtectedValue(key, child)) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.SensitiveLiteral); + } + if ( + SENSITIVE_URL_KEY.test(key) && + typeof child === 'string' && + containsUrl(child) + ) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.ExternalUrl); + } + inspectValue(child, key); + } +} + +function isOpaqueTypedNode(value: Record): boolean { + return ( + (value['kind'] === 'generate' && + typeof value['symbol'] === 'string' && + typeof value['valueKind'] === 'string') || + (value['kind'] === 'ref' && typeof value['symbol'] === 'string') + ); +} + +function isProtectedValue(key: string, value: unknown): boolean { + if (value === null || typeof value !== 'object') { + return false; + } + if (Array.isArray(value)) { + return ( + value.length > 0 && + value.every((item) => isProtectedValue(key, item)) + ); + } + const record = value as Record; + if (isOpaqueTypedNode(record)) { + return true; + } + return ( + SENSITIVE_PARTS_KEY.test(key) && + record['kind'] === 'parts' && + Array.isArray(record['parts']) && + containsReference(record['parts']) + ); +} + +function containsReference(parts: readonly unknown[]): boolean { + return parts.some((part) => { + if (part === null || typeof part !== 'object' || Array.isArray(part)) { + return false; + } + return (part as Record)['kind'] === 'ref'; + }); +} + +function inspectString(value: string): void { + for (const variant of decodedVariants(value)) { + if ( + SENSITIVE_ASSIGNMENT.test(variant) || + AUTHORIZATION_VALUE.test(variant) + ) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.SensitiveLiteral); + } + if (JWT_LITERAL.test(variant)) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.JwtLiteral); + } + if (MAC_LITERAL.test(variant)) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.MacLiteral); + } + inspectUrls(variant); + if ( + (ISO_TIMESTAMP.test(variant) && + !DETERMINISTIC_ISO_TIMESTAMPS.has(variant)) || + (isIntegerString(variant) && + isTimestampNumber(Number.parseInt(variant, 10))) + ) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.NondeterministicTimestamp); + } + if (containsHighEntropyLiteral(variant)) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.HighEntropyLiteral); + } + } +} + +function decodedVariants(value: string): readonly string[] { + const variants = new Set([value]); + let candidate = decodeJsonEscapes(value); + variants.add(candidate); + + for (let depth = 0; depth < 2; depth += 1) { + if (!/%[0-9A-F]{2}/i.test(candidate)) { + break; + } + try { + const decoded = decodeURIComponent(candidate); + variants.add(decoded); + if (decoded === candidate) { + break; + } + candidate = decodeJsonEscapes(decoded); + variants.add(candidate); + } catch { + break; + } + } + return [...variants]; +} + +function decodeJsonEscapes(value: string): string { + return value + .replace(JSON_UNICODE_ESCAPE, (_match, digits: string) => + String.fromCharCode(Number.parseInt(digits, 16)) + ) + .replace(JSON_HEX_ESCAPE, (_match, digits: string) => + String.fromCharCode(Number.parseInt(digits, 16)) + ) + .replace(JSON_QUOTE_ESCAPE, '"'); +} + +function inspectUrls(value: string): void { + URL_LITERAL.lastIndex = 0; + for (const match of value.matchAll(URL_LITERAL)) { + const candidate = trimUrlPunctuation(match[0]); + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.ExternalUrl); + } + if ( + !['http:', 'https:'].includes(parsed.protocol) || + !isReservedTestHost(parsed.hostname) + ) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.ExternalUrl); + } + for (const key of parsed.searchParams.keys()) { + if (SENSITIVE_KEY.test(key)) { + reject(FIXTURE_SECRET_SCAN_ERROR_CODES.SensitiveLiteral); + } + } + } +} + +function containsUrl(value: string): boolean { + URL_LITERAL.lastIndex = 0; + return URL_LITERAL.test(value); +} + +function trimUrlPunctuation(value: string): string { + return value.replace(/[),.;\]}]+$/g, ''); +} + +function isReservedTestHost(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === 'localhost' || + normalized === '127.0.0.1' || + normalized === '::1' || + normalized === 'example.com' || + normalized === 'example.net' || + normalized === 'example.org' || + normalized.endsWith('.localhost') || + normalized.endsWith('.test') || + normalized.endsWith('.invalid') || + normalized.endsWith('.example') + ); +} + +function isIntegerString(value: string): boolean { + return /^\d{10,13}$/.test(value); +} + +function isTimestampNumber(value: number): boolean { + if (DETERMINISTIC_TIMESTAMP_NUMBERS.has(value)) { + return false; + } + return ( + (Number.isInteger(value) && + value >= UNIX_SECONDS_MIN && + value <= UNIX_SECONDS_MAX) || + (Number.isInteger(value) && + value >= UNIX_MILLISECONDS_MIN && + value <= UNIX_MILLISECONDS_MAX) + ); +} + +function containsHighEntropyLiteral(value: string): boolean { + for (const match of value.matchAll(HIGH_ENTROPY_TOKEN)) { + const token = match[0].replace(/=+$/g, ''); + if (shannonEntropy(token) >= 4) { + return true; + } + } + return false; +} + +function shannonEntropy(value: string): number { + const counts = new Map(); + for (const character of value) { + counts.set(character, (counts.get(character) ?? 0) + 1); + } + let entropy = 0; + for (const count of counts.values()) { + const probability = count / value.length; + entropy -= probability * Math.log2(probability); + } + return entropy; +} + +function reject(code: FixtureSecretScanErrorCode): never { + throw new FixtureSecretScanError(code); +} diff --git a/tools/stalker-fixtures/src/lib/fixture-validation-cli.spec.ts b/tools/stalker-fixtures/src/lib/fixture-validation-cli.spec.ts new file mode 100644 index 000000000..3feadda18 --- /dev/null +++ b/tools/stalker-fixtures/src/lib/fixture-validation-cli.spec.ts @@ -0,0 +1,267 @@ +import { + link, + mkdtemp, + mkdir, + rename, + rm, + symlink, + utimes, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + REPLAY_MAX_FIXTURE_BYTES, + type ReplayFixtureV1, +} from '@iptvnator/portal/stalker/replay-fixtures'; +import { + FIXTURE_VALIDATION_CLI_ERROR_CODES, + FixtureValidationCliError, + readFixtureFileBoundedForValidation, + validateReplayFixtureDirectory, +} from './fixture-validation-cli'; +import { + FIXTURE_VALIDATION_ERROR_CODES, + FixtureValidationError, + validateReplayFixtureText, +} from './fixture-validator'; + +function replayFixture(): ReplayFixtureV1 { + return { + schemaVersion: 1, + scenarioId: 'fixture-cli-contract', + description: 'Synthetic resolver fixture.', + origins: { portal: {} }, + entry: { origin: 'portal', path: '/c/' }, + expectedEndpoint: { origin: 'portal', path: '/portal.php' }, + initialState: 'start', + terminalState: 'complete', + failOnUnexpectedRequest: true, + symbols: [], + phases: [ + { + name: 'resolve', + state: 'start', + nextState: 'complete', + mode: 'ordered', + expectations: [ + { + id: 'landing', + operation: 'landing', + origin: 'portal', + method: 'GET', + path: '/c/', + request: { + query: { exact: {}, present: [], absent: [] }, + headers: { exact: {}, present: [], absent: [] }, + cookies: { + exact: {}, + present: [], + absent: [], + attributes: {}, + }, + body: { kind: 'absent' }, + }, + response: { + status: 200, + headers: { + 'content-type': ['application/json'], + }, + body: { kind: 'json', value: { js: true } }, + }, + cardinality: { min: 1, max: 1 }, + }, + ], + }, + ], + }; +} + +async function expectCliCode( + operation: Promise, + code: string +): Promise { + try { + await operation; + throw new Error('fixture directory unexpectedly passed'); + } catch (error) { + expect(error).toBeInstanceOf(FixtureValidationCliError); + expect((error as FixtureValidationCliError).code).toBe(code); + expect((error as Error).message).toBe( + `Stalker fixture validation failed: ${code}.` + ); + } +} + +async function expectValidationCode( + operation: Promise, + code: string +): Promise { + try { + await operation; + throw new Error('fixture directory unexpectedly passed'); + } catch (error) { + expect(error).toBeInstanceOf(FixtureValidationError); + expect((error as FixtureValidationError).code).toBe(code); + expect((error as Error).message).toBe( + `Stalker fixture validation failed: ${code}.` + ); + } +} + +describe('fixture validation CLI', () => { + let fixtureRoot: string; + + beforeEach(async () => { + fixtureRoot = await mkdtemp(join(tmpdir(), 'stalker-fixtures-')); + }); + + afterEach(async () => { + await rm(fixtureRoot, { force: true, recursive: true }); + }); + + it('validates canonical JSON fixtures recursively', async () => { + const resolverDirectory = join(fixtureRoot, 'resolver'); + await mkdir(resolverDirectory); + const formatted = validateReplayFixtureText( + JSON.stringify(replayFixture()) + ).formatted; + await writeFile(join(resolverDirectory, 'root-landing.json'), formatted); + + await expect(validateReplayFixtureDirectory(fixtureRoot)).resolves.toBe( + 1 + ); + }); + + it('rejects valid but noncanonical JSON', async () => { + await writeFile( + join(fixtureRoot, 'root-landing.json'), + JSON.stringify(replayFixture()) + ); + + await expectCliCode( + validateReplayFixtureDirectory(fixtureRoot), + FIXTURE_VALIDATION_CLI_ERROR_CODES.NoncanonicalFormat + ); + }); + + it('rejects symlinks instead of following them', async () => { + const formatted = validateReplayFixtureText( + JSON.stringify(replayFixture()) + ).formatted; + const sourcePath = join(fixtureRoot, 'source.json'); + await writeFile(sourcePath, formatted); + await symlink(sourcePath, join(fixtureRoot, 'linked.json')); + + await expectCliCode( + validateReplayFixtureDirectory(fixtureRoot), + FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath + ); + }); + + it('rejects an oversized file before materializing its contents', async () => { + await writeFile( + join(fixtureRoot, 'oversized.json'), + 'x'.repeat(REPLAY_MAX_FIXTURE_BYTES + 1) + ); + + await expectValidationCode( + validateReplayFixtureDirectory(fixtureRoot), + FIXTURE_VALIDATION_ERROR_CODES.FixtureTooLarge + ); + }); + + it('rejects multiply linked fixture files', async () => { + const formatted = validateReplayFixtureText( + JSON.stringify(replayFixture()) + ).formatted; + const sourcePath = join(fixtureRoot, 'source.json'); + await writeFile(sourcePath, formatted); + await link(sourcePath, join(fixtureRoot, 'linked.json')); + + await expectCliCode( + validateReplayFixtureDirectory(fixtureRoot), + FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath + ); + }); + + it('rejects a file swapped between preflight and open', async () => { + const formatted = validateReplayFixtureText( + JSON.stringify(replayFixture()) + ).formatted; + const fixturePath = join(fixtureRoot, 'fixture.json'); + const replacementPath = join(fixtureRoot, 'replacement.json'); + await writeFile(fixturePath, formatted); + await writeFile(replacementPath, formatted); + + await expectCliCode( + readFixtureFileBoundedForValidation(fixturePath, async () => { + await rename(fixturePath, join(fixtureRoot, 'original.json')); + await rename(replacementPath, fixturePath); + }), + FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath + ); + }); + + it('rejects same-inode same-size mutation after preflight', async () => { + const formatted = validateReplayFixtureText( + JSON.stringify(replayFixture()) + ).formatted; + const fixturePath = join(fixtureRoot, 'fixture.json'); + await writeFile(fixturePath, formatted); + + await expectCliCode( + readFixtureFileBoundedForValidation(fixturePath, async () => { + await writeFile( + fixturePath, + formatted.replace('Synthetic', 'Fynthetic') + ); + await utimes(fixturePath, new Date(0), new Date(0)); + }), + FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath + ); + }); + + it('rejects malformed UTF-8 without replacement decoding', async () => { + await writeFile( + join(fixtureRoot, 'invalid-utf8.json'), + Buffer.from([0xc3, 0x28]) + ); + + await expectCliCode( + validateReplayFixtureDirectory(fixtureRoot), + FIXTURE_VALIDATION_CLI_ERROR_CODES.InvalidUtf8 + ); + }); + + it('rejects a UTF-8 BOM instead of silently normalizing it', async () => { + const formatted = validateReplayFixtureText( + JSON.stringify(replayFixture()) + ).formatted; + await writeFile( + join(fixtureRoot, 'bom.json'), + Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(formatted), + ]) + ); + + await expectValidationCode( + validateReplayFixtureDirectory(fixtureRoot), + FIXTURE_VALIDATION_ERROR_CODES.InvalidSchema + ); + }); +}); + +describe('committed fixture corpus validation', () => { + it('runs the fail-closed validator over every committed fixture', async () => { + const fixtureRoot = resolve( + process.cwd(), + 'apps/stalker-mock-server/fixtures/replay' + ); + + await expect( + validateReplayFixtureDirectory(fixtureRoot) + ).resolves.toBeGreaterThan(0); + }); +}); diff --git a/tools/stalker-fixtures/src/lib/fixture-validation-cli.ts b/tools/stalker-fixtures/src/lib/fixture-validation-cli.ts new file mode 100644 index 000000000..00ef55971 --- /dev/null +++ b/tools/stalker-fixtures/src/lib/fixture-validation-cli.ts @@ -0,0 +1,244 @@ +import { + constants, + type BigIntStats, + type Stats, +} from 'node:fs'; +import { + lstat, + open, + readdir, + type FileHandle, +} from 'node:fs/promises'; +import { join } from 'node:path'; +import { TextDecoder } from 'node:util'; +import { REPLAY_MAX_FIXTURE_BYTES } from '@iptvnator/portal/stalker/replay-fixtures'; +import { + FIXTURE_VALIDATION_ERROR_CODES, + FixtureValidationError, + validateReplayFixtureText, +} from './fixture-validator'; + +export const FIXTURE_VALIDATION_CLI_ERROR_CODES = { + FixtureRootUnavailable: 'fixture-root-unavailable', + NoFixtures: 'no-fixtures', + TooManyFixtures: 'too-many-fixtures', + UnsafeFixturePath: 'unsafe-fixture-path', + FixtureReadFailed: 'fixture-read-failed', + InvalidUtf8: 'invalid-utf8', + NoncanonicalFormat: 'noncanonical-format', + UnsupportedCommand: 'unsupported-command', + InternalError: 'internal-error', +} as const; + +export type FixtureValidationCliErrorCode = + (typeof FIXTURE_VALIDATION_CLI_ERROR_CODES)[keyof typeof FIXTURE_VALIDATION_CLI_ERROR_CODES]; + +export class FixtureValidationCliError extends Error { + constructor(readonly code: FixtureValidationCliErrorCode) { + super(`Stalker fixture validation failed: ${code}.`); + this.name = 'FixtureValidationCliError'; + } +} + +const MAX_FIXTURE_COUNT = 256; +const MAX_DIRECTORY_DEPTH = 8; +const SAFE_PATH_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export async function validateReplayFixtureDirectory( + fixtureRoot: string +): Promise { + let rootStat: Stats; + try { + rootStat = await lstat(fixtureRoot); + } catch { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.FixtureRootUnavailable); + } + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath); + } + + const fixturePaths: string[] = []; + await collectFixturePaths(fixtureRoot, fixturePaths, 0); + if (fixturePaths.length === 0) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.NoFixtures); + } + + for (const fixturePath of fixturePaths) { + const text = await readFixtureFileBoundedForValidation(fixturePath); + const validated = validateReplayFixtureText(text); + if (validated.formatted !== text) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.NoncanonicalFormat); + } + } + + return fixturePaths.length; +} + +/** + * Internal file-boundary helper exported for deterministic race regression + * coverage. It is deliberately omitted from the package public index. + */ +export async function readFixtureFileBoundedForValidation( + fixturePath: string, + beforeOpen: () => Promise = async () => undefined +): Promise { + let handle: FileHandle | undefined; + try { + const preflight = await lstat(fixturePath, { bigint: true }); + assertSafeRegularFile(preflight); + assertFixtureSize(preflight.size); + + await beforeOpen(); + handle = await open( + fixturePath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const opened = await handle.stat({ bigint: true }); + assertSafeRegularFile(opened); + assertSameFile(preflight, opened); + assertFixtureSize(opened.size); + + const content = await readBounded(handle); + const afterRead = await handle.stat({ bigint: true }); + const afterPath = await lstat(fixturePath, { bigint: true }); + assertSafeRegularFile(afterRead); + assertSafeRegularFile(afterPath); + assertSameFile(opened, afterRead); + assertSameFile(opened, afterPath); + assertFixtureSize(afterRead.size); + assertFixtureSize(afterPath.size); + if (BigInt(content.byteLength) !== afterRead.size) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath); + } + + await handle.close(); + handle = undefined; + return content.text; + } catch (error) { + if ( + error instanceof FixtureValidationCliError || + error instanceof FixtureValidationError + ) { + throw error; + } + throw new FixtureValidationCliError( + FIXTURE_VALIDATION_CLI_ERROR_CODES.FixtureReadFailed + ); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function collectFixturePaths( + directory: string, + fixturePaths: string[], + depth: number +): Promise { + if (depth > MAX_DIRECTORY_DEPTH) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath); + } + + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.FixtureReadFailed); + } + entries.sort((left, right) => compareCodeUnits(left.name, right.name)); + + for (const entry of entries) { + if ( + !SAFE_PATH_SEGMENT.test(entry.name) || + entry.isSymbolicLink() || + (!entry.isDirectory() && !entry.isFile()) + ) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath); + } + + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + await collectFixturePaths(entryPath, fixturePaths, depth + 1); + continue; + } + if (!entry.name.endsWith('.json')) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath); + } + fixturePaths.push(entryPath); + if (fixturePaths.length > MAX_FIXTURE_COUNT) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.TooManyFixtures); + } + } +} + +async function readBounded( + handle: FileHandle +): Promise<{ byteLength: number; text: string }> { + const buffer = Buffer.allocUnsafe(REPLAY_MAX_FIXTURE_BYTES + 1); + let byteLength = 0; + while (byteLength < buffer.length) { + const result = await handle.read( + buffer, + byteLength, + buffer.length - byteLength, + byteLength + ); + if (result.bytesRead === 0) { + break; + } + byteLength += result.bytesRead; + } + assertFixtureSize(byteLength); + return { + byteLength, + text: decodeUtf8(buffer, byteLength), + }; +} + +function decodeUtf8(buffer: Buffer, byteLength: number): string { + try { + return new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }).decode(buffer.subarray(0, byteLength)); + } catch { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.InvalidUtf8); + } +} + +function assertSafeRegularFile(stat: BigIntStats): void { + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath); + } +} + +function assertSameFile(expected: BigIntStats, actual: BigIntStats): void { + if ( + expected.dev !== actual.dev || + expected.ino !== actual.ino || + expected.mode !== actual.mode || + expected.size !== actual.size || + expected.mtimeNs !== actual.mtimeNs || + expected.ctimeNs !== actual.ctimeNs + ) { + reject(FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsafeFixturePath); + } +} + +function assertFixtureSize(size: number | bigint): void { + if (BigInt(size) > BigInt(REPLAY_MAX_FIXTURE_BYTES)) { + throw new FixtureValidationError( + FIXTURE_VALIDATION_ERROR_CODES.FixtureTooLarge + ); + } +} + +function reject(code: FixtureValidationCliErrorCode): never { + throw new FixtureValidationCliError(code); +} + +function compareCodeUnits(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} diff --git a/tools/stalker-fixtures/src/lib/fixture-validator.spec.ts b/tools/stalker-fixtures/src/lib/fixture-validator.spec.ts new file mode 100644 index 000000000..044555a5a --- /dev/null +++ b/tools/stalker-fixtures/src/lib/fixture-validator.spec.ts @@ -0,0 +1,132 @@ +import { + REPLAY_MAX_FIXTURE_BYTES, + type ReplayFixtureV1, +} from '@iptvnator/portal/stalker/replay-fixtures'; +import { + FIXTURE_VALIDATION_ERROR_CODES, + FixtureValidationError, + validateReplayFixtureText, +} from './fixture-validator'; + +function replayFixture(): ReplayFixtureV1 { + return { + schemaVersion: 1, + scenarioId: 'fixture-validator-contract', + description: 'Synthetic resolver fixture.', + origins: { portal: {} }, + entry: { origin: 'portal', path: '/c/' }, + expectedEndpoint: { origin: 'portal', path: '/portal.php' }, + initialState: 'start', + terminalState: 'complete', + failOnUnexpectedRequest: true, + symbols: [], + phases: [ + { + name: 'resolve', + state: 'start', + nextState: 'complete', + mode: 'ordered', + expectations: [ + { + id: 'landing', + operation: 'landing', + origin: 'portal', + method: 'GET', + path: '/c/', + request: { + query: { exact: {}, present: [], absent: [] }, + headers: { exact: {}, present: [], absent: [] }, + cookies: { + exact: {}, + present: [], + absent: [], + attributes: {}, + }, + body: { kind: 'absent' }, + }, + response: { + status: 200, + headers: { + 'content-type': ['application/json'], + }, + body: { + kind: 'json', + value: { + alpha: true, + Zebra: true, + js: true, + }, + }, + }, + cardinality: { min: 1, max: 1 }, + }, + ], + }, + ], + }; +} + +function expectValidationCode(text: string, code: string): void { + try { + validateReplayFixtureText(text); + throw new Error('fixture unexpectedly passed'); + } catch (error) { + expect(error).toBeInstanceOf(FixtureValidationError); + expect((error as FixtureValidationError).code).toBe(code); + expect((error as Error).message).toBe( + `Stalker fixture validation failed: ${code}.` + ); + } +} + +describe('fixture validator', () => { + it('uses the shared runtime parser and emits deterministic formatting', () => { + const fixture = replayFixture(); + const reverseRootOrder = Object.fromEntries( + Object.entries(fixture).reverse() + ); + + const first = validateReplayFixtureText(JSON.stringify(fixture)); + const second = validateReplayFixtureText( + JSON.stringify(reverseRootOrder) + ); + + expect(first.fixture.scenarioId).toBe('fixture-validator-contract'); + expect(first.formatted).toBe(second.formatted); + expect(first.formatted.endsWith('\n')).toBe(true); + expect(first.formatted).toContain('"schemaVersion": 1'); + expect(first.formatted.indexOf('"Zebra"')).toBeLessThan( + first.formatted.indexOf('"alpha"') + ); + }); + + it('rejects oversized input before parsing', () => { + expectValidationCode( + 'x'.repeat(REPLAY_MAX_FIXTURE_BYTES + 1), + FIXTURE_VALIDATION_ERROR_CODES.FixtureTooLarge + ); + }); + + it('maps every shared-parser failure to one sanitized schema code', () => { + expectValidationCode( + JSON.stringify({ schemaVersion: 1 }), + FIXTURE_VALIDATION_ERROR_CODES.InvalidSchema + ); + }); + + it('preserves scanner codes without exposing the offending literal', () => { + const secret = '00:1A:79:12:34:56'; + const fixture = { ...replayFixture(), description: secret }; + + try { + validateReplayFixtureText(JSON.stringify(fixture)); + throw new Error('fixture unexpectedly passed'); + } catch (error) { + expect(error).toBeInstanceOf(FixtureValidationError); + expect((error as FixtureValidationError).code).toBe( + FIXTURE_VALIDATION_ERROR_CODES.MacLiteral + ); + expect((error as Error).message).not.toContain(secret); + } + }); +}); diff --git a/tools/stalker-fixtures/src/lib/fixture-validator.ts b/tools/stalker-fixtures/src/lib/fixture-validator.ts new file mode 100644 index 000000000..a65cca15e --- /dev/null +++ b/tools/stalker-fixtures/src/lib/fixture-validator.ts @@ -0,0 +1,87 @@ +import { + parseReplayFixtureText, + REPLAY_MAX_FIXTURE_BYTES, + type ReplayFixtureV1, +} from '@iptvnator/portal/stalker/replay-fixtures'; +import { + assertReplayFixtureContainsNoSecrets, + FixtureSecretScanError, + FIXTURE_SECRET_SCAN_ERROR_CODES, +} from './fixture-secret-scanner'; + +export const FIXTURE_VALIDATION_ERROR_CODES = { + FixtureTooLarge: 'fixture-too-large', + InvalidSchema: 'invalid-schema', + ...FIXTURE_SECRET_SCAN_ERROR_CODES, +} as const; + +export type FixtureValidationErrorCode = + (typeof FIXTURE_VALIDATION_ERROR_CODES)[keyof typeof FIXTURE_VALIDATION_ERROR_CODES]; + +export class FixtureValidationError extends Error { + constructor(readonly code: FixtureValidationErrorCode) { + super(`Stalker fixture validation failed: ${code}.`); + this.name = 'FixtureValidationError'; + } +} + +export interface ValidatedReplayFixture { + fixture: ReplayFixtureV1; + formatted: string; +} + +export function validateReplayFixtureText( + text: string +): ValidatedReplayFixture { + if (Buffer.byteLength(text) > REPLAY_MAX_FIXTURE_BYTES) { + throw new FixtureValidationError( + FIXTURE_VALIDATION_ERROR_CODES.FixtureTooLarge + ); + } + + let fixture: ReplayFixtureV1; + try { + fixture = parseReplayFixtureText(text); + } catch { + throw new FixtureValidationError( + FIXTURE_VALIDATION_ERROR_CODES.InvalidSchema + ); + } + + try { + assertReplayFixtureContainsNoSecrets(fixture); + } catch (error) { + if (error instanceof FixtureSecretScanError) { + throw new FixtureValidationError(error.code); + } + throw new FixtureValidationError( + FIXTURE_VALIDATION_ERROR_CODES.SensitiveLiteral + ); + } + + return { + fixture, + formatted: `${JSON.stringify(sortJsonValue(fixture), null, 4)}\n`, + }; +} + +function sortJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJsonValue); + } + if (value === null || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => compareCodeUnits(left, right)) + .map(([key, child]) => [key, sortJsonValue(child)]) + ); +} + +function compareCodeUnits(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} diff --git a/tools/stalker-fixtures/tsconfig.json b/tools/stalker-fixtures/tsconfig.json new file mode 100644 index 000000000..49c188f7a --- /dev/null +++ b/tools/stalker-fixtures/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve", + "types": ["node"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/tools/stalker-fixtures/tsconfig.lib.json b/tools/stalker-fixtures/tsconfig.lib.json new file mode 100644 index 000000000..1c0e310e8 --- /dev/null +++ b/tools/stalker-fixtures/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/tools/stalker-fixtures/tsconfig.spec.json b/tools/stalker-fixtures/tsconfig.spec.json new file mode 100644 index 000000000..09849f85f --- /dev/null +++ b/tools/stalker-fixtures/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}