mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
test(stalker): add fail-closed fixture validation
This commit is contained in:
1 parent
375169a69b
commit
a2fd389e74
20 files changed
+1731
-1
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, (run: ReplayRun) => Promise<void>>
|
||||
> = {
|
||||
'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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
displayName: 'stalker-fixture-tools',
|
||||
preset: '../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
|
||||
],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
coverageDirectory: '../../coverage/tools/stalker-fixtures',
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>): 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<string, unknown>;
|
||||
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<string, unknown>)['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<string>([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<string, number>();
|
||||
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);
|
||||
}
|
||||
@@ -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<unknown>,
|
||||
code: string
|
||||
): Promise<void> {
|
||||
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<unknown>,
|
||||
code: string
|
||||
): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<number> {
|
||||
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<void> = async () => undefined
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user