diff --git a/.github/actions/setup-test-app-dependencies/action.yml b/.github/actions/setup-test-app-dependencies/action.yml index 993e517290..01d879299f 100644 --- a/.github/actions/setup-test-app-dependencies/action.yml +++ b/.github/actions/setup-test-app-dependencies/action.yml @@ -10,7 +10,8 @@ runs: using: 'composite' steps: # Expo's dependency graph contains native binaries, so cache it by operating - # system, architecture, Node major, pnpm version, and the exact manifest. + # system, architecture, Node major, pnpm version, the exact manifest, and the + # pnpm patch files applied into it. - name: Resolve test app dependency cache key id: key shell: bash @@ -24,7 +25,7 @@ runs: uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 with: path: examples/test-app/node_modules - key: test-app-dependencies-v2-${{ runner.os }}-${{ runner.arch }}-node${{ steps.key.outputs.node_major }}-pnpm${{ steps.key.outputs.pnpm_version }}-${{ hashFiles('examples/test-app/package.json', 'examples/test-app/pnpm-lock.yaml', 'examples/test-app/pnpm-workspace.yaml', '.github/actions/setup-test-app-dependencies/action.yml') }} + key: test-app-dependencies-v2-${{ runner.os }}-${{ runner.arch }}-node${{ steps.key.outputs.node_major }}-pnpm${{ steps.key.outputs.pnpm_version }}-${{ hashFiles('examples/test-app/package.json', 'examples/test-app/pnpm-lock.yaml', 'examples/test-app/pnpm-workspace.yaml', 'examples/test-app/patches/**', '.github/actions/setup-test-app-dependencies/action.yml') }} - name: Install test app dependencies if: steps.restore.outputs.cache-hit != 'true' @@ -36,4 +37,4 @@ runs: uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 with: path: examples/test-app/node_modules - key: test-app-dependencies-v2-${{ runner.os }}-${{ runner.arch }}-node${{ steps.key.outputs.node_major }}-pnpm${{ steps.key.outputs.pnpm_version }}-${{ hashFiles('examples/test-app/package.json', 'examples/test-app/pnpm-lock.yaml', 'examples/test-app/pnpm-workspace.yaml', '.github/actions/setup-test-app-dependencies/action.yml') }} + key: test-app-dependencies-v2-${{ runner.os }}-${{ runner.arch }}-node${{ steps.key.outputs.node_major }}-pnpm${{ steps.key.outputs.pnpm_version }}-${{ hashFiles('examples/test-app/package.json', 'examples/test-app/pnpm-lock.yaml', 'examples/test-app/pnpm-workspace.yaml', 'examples/test-app/patches/**', '.github/actions/setup-test-app-dependencies/action.yml') }} diff --git a/.github/workflows/test-app-build-cache.yml b/.github/workflows/test-app-build-cache.yml index ba8f372cb7..44e223341a 100644 --- a/.github/workflows/test-app-build-cache.yml +++ b/.github/workflows/test-app-build-cache.yml @@ -50,6 +50,12 @@ jobs: uses: ./.github/actions/run-gate with: { gate: test-app-typecheck } + # image-size has no fixed release for its parser DoS advisories; this gate + # proves the in-tree pnpm patch still terminates the zero-length vectors. + - name: Run the image-size parser security test + uses: ./.github/actions/run-gate + with: { gate: test-app-security } + - name: Resolve native fingerprint id: fingerprint env: diff --git a/examples/test-app/security/image-size-security.test.mjs b/examples/test-app/security/image-size-security.test.mjs index e67556905a..41556de980 100644 --- a/examples/test-app/security/image-size-security.test.mjs +++ b/examples/test-app/security/image-size-security.test.mjs @@ -5,14 +5,13 @@ import path from 'node:path'; import test from 'node:test'; const packageStore = path.join(process.cwd(), 'node_modules', '.pnpm'); +const patchHash = /^ {2}image-size@1\.2\.1: ([0-9a-f]{64})$/m.exec( + fs.readFileSync(path.join(process.cwd(), 'pnpm-lock.yaml'), 'utf8'), +)?.[1]; +assert.ok(patchHash, 'the lockfile must record the image-size patch hash'); const imageSizePath = process.env.IMAGE_SIZE_PACKAGE_DIR ?? - path.join( - packageStore, - fs.readdirSync(packageStore).find((entry) => entry.startsWith('image-size@1.2.1_patch_hash=')), - 'node_modules', - 'image-size', - ); + path.join(packageStore, `image-size@1.2.1_patch_hash=${patchHash}`, 'node_modules', 'image-size'); assert.ok(fs.existsSync(imageSizePath), 'the image-size package must be installed'); test('zero-length image records and boxes return within the parser budget', () => { @@ -38,6 +37,17 @@ test('zero-length image records and boxes return within the parser budget', () = ]), 'No codestream found in JXL container', ], + [ + 'JXL zero-size jxlp box', + Buffer.concat([ + box(8, 'JXL '), + box(16, 'ftyp'), + Buffer.from('jxl '), + Buffer.alloc(4), + zeroBox('jxlp'), + ]), + 'No codestream found in JXL container', + ], ]; const selectedFixtures = process.env.IMAGE_SIZE_FIXTURE ? fixtures.filter(([name]) => name === process.env.IMAGE_SIZE_FIXTURE) diff --git a/package.json b/package.json index d622692000..5551f4e14b 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,7 @@ "test-app:ios": "pnpm --dir examples/test-app ios", "test-app:android": "pnpm --dir examples/test-app android", "test-app:typecheck": "pnpm --dir examples/test-app typecheck", + "test-app:security": "pnpm --dir examples/test-app security:test", "test-app:replay:ios": "pnpm ad test examples/test-app/replays --platform ios --artifacts-dir .tmp/test-app-replay/ios", "test-app:replay:android": "pnpm ad test examples/test-app/replays --platform android --artifacts-dir .tmp/test-app-replay/android", "test-app:maestro": "node scripts/run-test-app-maestro-suite.mjs", diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index ff13cdafb5..a68e03a8f3 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -38,6 +38,12 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ // The test app intentionally owns a separate Expo dependency graph. Do not // make every root-checkout validation install it implicitly. gate('test-app-typecheck', 'Expo test app typecheck', 'test-app:typecheck', false), + gate( + 'test-app-security', + 'Expo test app image-size parser security test', + 'test-app:security', + false, + ), gate('layering', 'Import-direction layering guard', 'check:layering'), gate('di-seams', 'Test-only DI seam guard', 'check:di-seams'), gate('fallow', 'Fallow code-quality audit', 'check:fallow'), diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index a0b2d9f1b3..12fb1d0efc 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -184,6 +184,21 @@ test('test app source selects root lint and format plus its isolated typecheck', ]); }); +test('the image-size parser mitigation is selected when its defining files change', () => { + for (const file of [ + 'examples/test-app/patches/image-size@1.2.1.patch', + 'examples/test-app/security/image-size-security.test.mjs', + 'examples/test-app/pnpm-workspace.yaml', + ]) { + const result = plan([file]); + assert.equal(result.failOpen, false, `${file} must not fail open`); + assert.ok( + result.checks.includes('test-app-security'), + `expected test-app-security for ${file}`, + ); + } +}); + test('unknown path fails open to the full check set', () => { const result = plan(['fixtures/unknown.data']); assert.equal(result.failOpen, true); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index 65028f6f23..043d23fc05 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -33,6 +33,7 @@ export type CheckId = | 'lint' | 'typecheck' | 'test-app-typecheck' + | 'test-app-security' | 'layering' | 'di-seams' | 'fallow' @@ -96,6 +97,7 @@ export const ALL_CHECKS: readonly CheckId[] = [ 'lint', 'typecheck', 'test-app-typecheck', + 'test-app-security', 'layering', 'di-seams', 'fallow', @@ -496,6 +498,17 @@ const BUILD_OWNERSHIP: ReadonlyArray<{ detail: 'MCP registry metadata must stay in sync', owns: (file) => file === 'server.json' || file === 'smithery.yaml', }, + // image-size ships no fixed version for its parser DoS advisories; the in-tree + // pnpm patch is the mitigation, and only its defining files own the proof. + { + check: 'test-app-security', + rule: 'own:test-app-security', + detail: 'the image-size parser mitigation is proven by the test-app security suite', + owns: (file) => + file.startsWith('examples/test-app/patches/') || + file.startsWith('examples/test-app/security/') || + file === 'examples/test-app/pnpm-workspace.yaml', + }, // TS/Swift golden tables (`contracts/fixtures/*.json`): the vitest parity test and the // runner XCTest twin both read them, so a table edit owns the unit lane and both runner // builds. Without this a `.json` under contracts/ has no derivable owner and fails open.