Skip to content

ci: enforce the image-size parser mitigation through a test-app gate - #2269

Merged
thymikee merged 1 commit into
mainfrom
ci/test-app-security-gate
Sep 3, 2026
Merged

ci: enforce the image-size parser mitigation through a test-app gate#2269
thymikee merged 1 commit into
mainfrom
ci/test-app-security-gate

Conversation

@thymikee

@thymikee thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member

Summary

image-size (a transitive of the test app's Metro) has two open Dependabot advisories — GHSA-w3rx-r6r6-pgpr (ICNS zero-length entry) and GHSA-5p2g-fcmc-qvqq (JXL/HEIF zero-size box), both DoS via infinite parser loops — and no fixed version exists (latest is still 2.0.2, inside the vulnerable range). The mitigation already landed in #2182: an in-tree pnpm patch (examples/test-app/patches/image-size@1.2.1.patch) plus a budget test (examples/test-app/security/image-size-security.test.mjs, run by the security:test script).

The gaps this closes: that proof was a manual script — nothing in CI ran it, and the dependency-graph cache the test depends on could restore a stale patched artifact for a patch-only change, so even a wired-up gate would have tested the previous patch.

This registers the security suite as a first-class gate, test-app-security, and makes the enforcement sound end to end:

  • Root script test-app:securitypnpm --dir examples/test-app security:test.
  • Catalog entry in CHECK_CATALOG (CI-authoritative, localRunnable: false, mirroring test-app-typecheck — the test app owns an isolated Expo dependency graph and must not be installed by every root-checkout validation).
  • Wired into the Test App Build Cache fingerprint job (runs on every PR and already restores the test app dependency graph, so the patched package is present).
  • Cache key completeness (review finding): setup-test-app-dependencies now hashes examples/test-app/patches/** into its restore/save key alongside the manifests. Before, a patch-only edit left the key invariant, so a cache hit restored the previous patched node_modules and skipped pnpm install — the gate would run against the prior patch. Any patch change now forces a fresh install that applies the current patch.
  • Artifact resolution: the test resolves the installed package by the patchedDependencies hash recorded in pnpm-lock.yaml instead of the first image-size@1.2.1_patch_hash=* directory it finds — the declared artifact is the one under test, and a missing declaration fails loudly.
  • Discriminating fixture: added a JXL container whose zero-size box is named jxlp (the box extractPartialStreams searches for). Ground truth: on unpatched 1.2.1 the prior JXL fixture (zero-size box named junk) terminates via findBox's +8 advance, so it never exercised the advisory vector — only the new fixture and the ICNS fixture hang. A patch regression that drops the boxSize < 8 guard is now caught by the JXL fixture; the ICNS break is still caught by the ICNS fixture.
  • Selector ownership: the gate is selected — and stops failing open — when its defining files change (examples/test-app/patches/, examples/test-app/security/, and examples/test-app/pnpm-workspace.yaml, which carries the patchedDependencies declaration).

The patch itself is verified against both advisory vectors: the boxSize < 8 guard in readBox stops the JXL extractPartialStreams stall on a size-0 jxlp (the guard, not findBox's advance, is what hides a matching zero-size box), and the imageHeader[1] < 8 break in the ICNS parser stops the zero-length-entry loop. The HEIF fixture is a termination budget pin: unpatched 1.2.1 has no unbounded loop in the HEIF path (a single findBox chain over the +8-advancing findBox), so it asserts the format named in the advisory stays within budget.

Closes Dependabot alerts #69 and #70 at the mitigation level (they cannot be cleared by a version bump; see residual note below).

Validation

  • Plant proof of the cache hole (review request): with a patch-only change (dropped the boxSize < 8 guard from the patch file), the pre-fix key hash was byte-identical (000c575f…) — a cache hit would restore the stale guarded artifact and the suite passed against it (installed utils.js carried the guard the tree patch no longer declared). With the fix, the same patch-only edit changes the key (a7004046…24f5bd59…); the resulting install applied the weakened patch and the gate failedJXL zero-size jxlp box exceeded the parser budget (spawnSync … ETIMEDOUT) — before the patch was restored.
  • Restored state: pnpm gate test-app-security green (1 test, 4 fixtures: ICNS entry, HEIF box, JXL box, JXL zero-size jxlp box, each under a 1s budget), artifact resolved via the lockfile patch hash.
  • pnpm check:affected --run green at this commit (format, lint, typecheck, layering, di-seams, fallow, mcp-metadata, build, package, integration, gate-manifest, affected-selector, vitest related over the changed surface).
  • pnpm gate gate-manifest reports 52 checks wired across 27 lanes (was 51) — the new gate is owned by a lane, so the "every gate owned and wired" invariant holds.
  • New selector test asserts the gate is selected for each of its three defining paths and does not fail open; observed it red with the ownership entry removed, green restored.
  • Rebased onto 309a5360f5; main picked up no commits touching this PR's surface.

Residual: Dependabot reports by resolved version, so #69/#70 will remain "open" in the dashboard until upstream ships a fix or the maintainers mark them dismissed-as-mitigated; the patch + gate above are the durable control.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-03 19:36 UTC

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.60 MB 2.60 MB -2.9 kB
JS gzip 875.8 kB 874.3 kB -1.5 kB
npm bundled raw 2.60 MB 2.60 MB -2.9 kB
npm bundled gzip 875.8 kB 874.3 kB -1.5 kB
npm tarball 1.03 MB 1.03 MB -1.4 kB
npm unpacked 3.48 MB 3.48 MB -3.2 kB
npm clean-installed 3.48 MB 3.48 MB -3.2 kB

npm unpacked components

Component Base Current Diff
JS / dist source 2.76 MB 2.76 MB -3.3 kB
Apple runner source/project 544.4 kB 544.4 kB 0 B
Apple snapshot presentation source 33.8 kB 33.8 kB 0 B
macOS helper source 54.8 kB 54.8 kB 0 B
Android helper artifacts 43.3 kB 43.3 kB 0 B
Other package files 46.4 kB 46.5 kB +71 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.4 ms 28.0 ms -0.4 ms
CLI --help 70.4 ms 69.9 ms -0.5 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/session2.js -573 B -161 B
dist/src/daemon-client-lifecycle.js +552 B +129 B
dist/src/internal/daemon.js -66 B -16 B

Top changed packed files

Packed file Base Current Diff
dist/src/artifact-download.js 2.6 kB 0 B -2.6 kB
dist/src/http-health.js 655 B 1.9 kB +1.3 kB
dist/src/transport.js 1.3 kB 0 B -1.3 kB
dist/src/session2.js 218.6 kB 218.0 kB -573 B
dist/src/daemon-client-lifecycle.js 41.9 kB 42.4 kB +552 B
dist/src/client-types.d.ts 57.9 kB 57.5 kB -412 B
dist/src/byte-limit-stream.js 398 B 0 B -398 B
dist/src/archive.js 7.9 kB 8.2 kB +272 B
package.json 20.0 kB 20.1 kB +71 B
dist/src/internal/daemon.js 107.8 kB 107.8 kB -66 B

@thymikee

thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

BLOCKED at exact head 24d621dfe55a0c558b2066cafc406ea82087af6b.

The new gate claims patch-file edits as an owned input, but setup-test-app-dependencies does not hash examples/test-app/patches/ into its node_modules cache key. A patch-only edit can therefore restore stale patched dependencies, skip pnpm install, and run the security test against the previous patch, making this enforcement vacuous for one of its defining inputs.

Include the patch files in the cache key or force frozen reinstall/patch-hash verification, then plant a patch-only change under a cache hit and show the gate fails before restoring it.

@thymikee
thymikee force-pushed the ci/test-app-security-gate branch from 24d621d to fc61b04 Compare September 3, 2026 19:28
@thymikee

thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Confirmed — that was a real hole, and it made the enforcement vacuous for a defining input. Fixed at the cache key, plus two more weaknesses the exercise surfaced. New head: fc61b0402d.

1. Cache key now includes the patch files (the fix you named).
.github/actions/setup-test-app-dependencies/action.yml — both the restore and save keys now hash examples/test-app/patches/** alongside the manifests. The key is now a complete function of everything that determines node_modules content (package.json, lockfile, workspace.yaml, patch files, action). I chose the key fix over forcing a frozen reinstall so the cache keeps its performance value; a forced reinstall would throw away the benefit for the common no-change case.

2. Observed the hole, then the fix (your requested evidence).
I reproduced the key's input set locally and planted a patch-only change (dropped the boxSize < 8 guard from image-size@1.2.1.patch, leaving the other three manifests untouched):

  • Pre-fix: the key's inputs were unchanged by the patch-only edit → the key was invariant → a cache hit would restore the good patched node_modules and skip pnpm install. The security test then ran against that stale artifact and passed — even though the PR's patch no longer contained the guard. Exactly the vacuous path you described.
  • Post-fix: the same patch-only edit changes the key (the patches/** input moved) → cache miss → pnpm test-app:install re-applies the weakened patch. The security test then failed — the JXL fixture hung and hit the 1s budget (spawnSync … ETIMEDOUT) — before I restored the good patch (after which it passes).

So a patch-only regression is now caught instead of silently passing on a stale cache.

3. Two more issues the exercise surfaced (quality):

  • The JXL fixture didn't actually exercise the infinite loop. The original fixture used a zero-size box named junk, which findBox skips (it advances ≥8 bytes on a non-matching box) — so it only asserted the "no codestream" error path and passed even on vulnerable code. I confirmed against unpatched 1.2.1 that the matching zero-size jxlp box is what stalls extractPartialStreams (offset = jxlpBox.offset + jxlpBox.size with size 0), and added that as a fixture. It hangs on vulnerable code and returns under the patch. The readBox < 8 guard is what hides a matching zero box — the findBox advance alone is not the fix.
  • Artifact resolution was first-match-by-directory. The test picked the first image-size@1.2.1_patch_hash=* dir, which could be a stale sibling. It now resolves the package by the patchedDependencies hash recorded in pnpm-lock.yaml (the declared artifact), and fails loudly if the declaration is absent.

Validation at fc61b0402d: pnpm check:affected --run green (format/lint/typecheck/layering/di-seams/fallow/mcp-metadata/build/package/integration/gate-manifest/affected-selector/vitest-related). pnpm gate test-app-security green (4 fixtures: ICNS entry, HEIF box, JXL box, JXL zero-size jxlp box). Note the HEIF fixture remains a termination-budget pin — unpatched 1.2.1's HEIF path has no unbounded loop (single findBox chain over the +8-advancing findBox), so it asserts the advisory-named format stays within budget rather than asserting a hang.

@thymikee

thymikee commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Code review is clean at exact head fc61b04. The patch-only cache-key hole is fixed for restore and save; the planted weakened-patch run discriminates correctly; lockfile-derived artifact selection, gate ownership, and the matching jxlp fixture are sound. The exact-head fingerprint lane has already exercised test-app-security green. No device evidence is owed for this CI/security-only change. Marking ready-for-human while the remaining exact-head CI finishes; merge readiness still depends on those checks completing successfully.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 3, 2026
@thymikee
thymikee merged commit 941ca0e into main Sep 3, 2026
19 of 22 checks passed
@thymikee
thymikee deleted the ci/test-app-security-gate branch September 3, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant