php2shell-scanner.git

php2shell-scanner

~ Check PHP2Shell (CVE-2026-109618, CVE-2026-109619) from inside your server: distribution backports, missed restarts and interim mitigations, verdicts computed in the browser.
git clone https://github.com/debuman/php2shell-scanner.git
commit 5b515a3ac50b302b6e9cb6e5f238aac3c52daef8
parent ef0d1411ba7e651ed2bf537a84ff10721d12700d
Author: Hyukjin An <110329244+sqzer-x@users.noreply.github.com>
Date: Mon, 26 Oct 2026 09:18:23 +0900

test: pin verdict tests to a frozen data snapshot

The verdict and remediation tests read the live site/data/distro.json, so
every scheduled advisory refresh could change an expected verdict. After
the data commit of 23 Oct (Debian 14 php8.4 fixed, new Red Hat states for
RHEL 6 and 7), five tests failed and every Deploy run stopped at the Test
step, which also held back later deploys and data refreshes.

- test/fixtures/distro-snapshot.json: the data from before that commit;
contract, findings, remediation, retest and round3 tests read it.
- test/data.test.mjs: checks only the shape of the live distro.json and
that the engine reads every release in it, in both languages.
- pages.yml: runs that check again on freshly fetched data before it is
deployed or committed.
- README: how the snapshot works and when to replace it.

Diffstat:
M.github/workflows/pages.yml | 5+++++
MREADME.md | 2++
Mtest/contract.test.mjs | 5+++--
Atest/data.test.mjs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/findings.test.mjs | 5+++--
Atest/fixtures/distro-snapshot.json | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/remediation.test.mjs | 5+++--
Mtest/retest.test.mjs | 7++++---
Mtest/round3.test.mjs | 9+++++----
9 files changed, 405 insertion(+), 13 deletion(-)

diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
@@ -53,6 +53,11 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
+      # The verdict tests above use a frozen copy of the data; this checks the data just fetched before it is
+      # deployed or committed.
+      - name: Check the fetched data
+        run: node --test test/data.test.mjs
+
       - name: Assemble site
         run: node tools/build-site.mjs
 
diff --git a/README.md b/README.md
@@ -183,6 +183,8 @@ npm run serve         # build, then serve _site/ at http://127.0.0.1:8790
 
 `npm test` runs `node --test` with no arguments, so Node.js finds `test/*.test.mjs` with its default patterns on every supported version. CI runs it on Node.js 20, 22 and 24, and the deploy workflow runs the same command before it deploys.
 
+The verdict and remediation tests read a frozen copy of the distribution data, [`test/fixtures/distro-snapshot.json`](test/fixtures/distro-snapshot.json), so a scheduled data refresh never changes an expected verdict. [`test/data.test.mjs`](test/data.test.mjs) checks the live `site/data/distro.json` instead: its shape, and that the engine reads every release in it. The deploy workflow runs that file again on the freshly fetched data before it deploys or commits it. When an engine change needs newer advisories in the tests, replace the snapshot with the current `site/data/distro.json` and update the expected verdicts in the same commit.
+
 `npm run update-data` uses `GITHUB_TOKEN` from the environment if set, which avoids GitHub API rate limits. [`tools/build-site.mjs`](tools/build-site.mjs) copies `site/`, adds both server-side files under `downloads/` with a `SHA256SUMS` file, and writes `config.json`. The deploy workflow runs the same script, so a local preview matches production.
 
 [`tools/serve.mjs`](tools/serve.mjs) is a small static file server with no dependencies. It listens on `127.0.0.1` only, serves `_site/` and nothing outside it, and sends `.php` and `.sh` files as plain text. Set `PORT` to use a port other than 8790.
diff --git a/test/contract.test.mjs b/test/contract.test.mjs
@@ -1,6 +1,7 @@
 // Regression tests for the engine side of the server script, PHP probe and page contract: what the script and the
 // probe now emit (env|root, changelog lines, unknown counts, os_release null, open_basedir, the legacy release keys)
-// and what the page reads from a result (r.family, string facts and advisories). Real data files in site/data/.
+// and what the page reads from a result (r.family, string facts and advisories). Runs against site/data/upstream.json
+// and a frozen copy of site/data/distro.json (fixtures/distro-snapshot.json), so data refreshes do not move the verdicts.
 import { test } from 'node:test';
 import assert from 'node:assert/strict';
 import { readFileSync } from 'node:fs';
@@ -8,7 +9,7 @@ import { parseInput } from '../site/lib/parse.js';
 import { judge, sapiExposure } from '../site/lib/judge.js';
 
 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
-const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('./fixtures/distro-snapshot.json', import.meta.url), 'utf8'));
 
 const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
 const server = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${['tool|php2shell-server-check|1.0.0', ...lines].join('\n')}\n-----END PHP2SHELL SERVERCHECK-----\n`;
diff --git a/test/data.test.mjs b/test/data.test.mjs
@@ -0,0 +1,100 @@
+// The live data file site/data/distro.json is refreshed every six hours, so these tests check only its shape and
+// that the engine reads every release in it, in both languages. The verdict tests run against a frozen copy
+// (fixtures/distro-snapshot.json), so a data refresh never fails the build on its own.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { parseInput } from '../site/lib/parse.js';
+import { judge, VERDICT_TITLES } from '../site/lib/judge.js';
+
+const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+
+const CVES = upstream.cves.map((c) => c.id);
+const VERDICTS = Object.keys(VERDICT_TITLES);
+const LEFTOVER = /\{\w+\}|undefined|NaN|\[object /;
+const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
+const server = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${['tool|php2shell-server-check|1.0.0', ...lines].join('\n')}\n-----END PHP2SHELL SERVERCHECK-----\n`;
+const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+const text = (r) => [...r.reasons, ...r.notes, ...r.actions.map((a) => `${a.text} ${a.code || ''}`)].join('\n');
+
+// Judges input in both languages and returns the English result.
+function check(input, where) {
+  const [en, ko] = ['en', 'ko'].map((lang) => judge(parseInput(input, { lang }), upstream, distro, { lang }));
+  for (const [lang, r] of [['en', en], ['ko', ko]]) {
+    assert.ok(VERDICTS.includes(r.verdict), `${where} (${lang}): ${r.verdict}`);
+    assert.ok(r.reasons.length, `${where} (${lang})`);
+    assert.doesNotMatch(text(r), LEFTOVER, `${where} (${lang})`);
+  }
+  assert.equal(ko.verdict, en.verdict, where);
+  return en;
+}
+
+test('distro.json has the schema, the date and the three sources', () => {
+  assert.equal(distro.schema, 1);
+  assert.ok(!Number.isNaN(Date.parse(distro.updated_at)), distro.updated_at);
+  for (const key of ['debian', 'ubuntu', 'redhat', 'redhat_notes']) assert.ok(isObject(distro[key]), key);
+  assert.ok(Object.keys(distro.debian).length && Object.keys(distro.ubuntu).length && Object.keys(distro.redhat).length);
+});
+
+test('Debian and Ubuntu releases list packages with a fixed version or null for known CVEs', () => {
+  for (const family of ['debian', 'ubuntu']) {
+    for (const [release, rel] of Object.entries(distro[family])) {
+      const where = `${family} ${release}`;
+      assert.ok(rel.codename === null || typeof rel.codename === 'string', where);
+      assert.ok(isObject(rel.packages) && Object.keys(rel.packages).length, where);
+      for (const [name, pkg] of Object.entries(rel.packages)) {
+        assert.equal(typeof pkg.pro, 'boolean', `${where} ${name}`);
+        assert.ok(isObject(pkg.fixed) && Object.keys(pkg.fixed).length, `${where} ${name}`);
+        for (const [cve, fixed] of Object.entries(pkg.fixed)) {
+          assert.ok(CVES.includes(cve), `${where} ${name} ${cve}`);
+          assert.ok(fixed === null || (typeof fixed === 'string' && fixed.length), `${where} ${name} ${cve}`);
+        }
+      }
+    }
+  }
+});
+
+test('Red Hat releases list advisories and package states for known CVEs', () => {
+  for (const [release, rel] of Object.entries(distro.redhat)) {
+    assert.ok(Array.isArray(rel.advisories) && Array.isArray(rel.states), `RHEL ${release}`);
+    for (const a of rel.advisories) {
+      assert.ok(CVES.includes(a.cve), `RHEL ${release} ${a.cve}`);
+      assert.ok(typeof a.advisory === 'string' && typeof a.package === 'string', `RHEL ${release} ${a.advisory}`);
+    }
+    for (const s of rel.states) {
+      assert.ok(CVES.includes(s.cve), `RHEL ${release} ${s.cve}`);
+      assert.ok(typeof s.package === 'string' && typeof s.state === 'string', `RHEL ${release} ${s.state}`);
+    }
+  }
+  for (const [cve, note] of Object.entries(distro.redhat_notes)) {
+    assert.ok(CVES.includes(cve), cve);
+    assert.ok(isObject(note), cve);
+  }
+});
+
+test('the engine reads every Debian and Ubuntu release, and never calls an old package patched', () => {
+  for (const family of ['debian', 'ubuntu']) {
+    for (const [release, rel] of Object.entries(distro[family])) {
+      for (const name of Object.keys(rel.packages)) {
+        const branch = name.replace(/^php/, '');
+        const r = check(probe({
+          php_version: branch === '5' ? '5.5.9' : `${branch}.0`,
+          sapi: 'fpm-fcgi',
+          os_release: { ID: family, VERSION_ID: release, ...(rel.codename ? { VERSION_CODENAME: rel.codename } : {}) },
+          dpkg_packages: [{ name: `${name}-common`, version: '0.1-1', source: name }],
+        }), `${family} ${release} ${name}`);
+        assert.notEqual(r.verdict, 'patched', `${family} ${release} ${name}`);
+      }
+    }
+  }
+});
+
+test('the engine reads every Red Hat release', () => {
+  for (const release of Object.keys(distro.redhat)) {
+    check(server([
+      'env|root|1', 'os|ID|rhel', 'os|ID_LIKE|fedora', `os|VERSION_ID|${release}`,
+      `pkg|rpm|php-common|0.1-1.el${release}|`, ...CVES.map((cve) => `changelog|php-common|${cve}|0`), 'proc|php-fpm|4|ok',
+    ]), `RHEL ${release}`);
+  }
+});
diff --git a/test/findings.test.mjs b/test/findings.test.mjs
@@ -1,5 +1,6 @@
 // Regression tests for the verdict-engine review findings. Each test reproduces the
-// reviewer's scenario against the real data files in site/data/.
+// reviewer's scenario against site/data/upstream.json and a frozen copy of site/data/distro.json
+// (fixtures/distro-snapshot.json), so data refreshes do not move the verdicts.
 import { test } from 'node:test';
 import assert from 'node:assert/strict';
 import { readFileSync } from 'node:fs';
@@ -9,7 +10,7 @@ import { parsePhpVersion } from '../site/lib/version.js';
 import { MESSAGES } from '../site/lib/messages.js';
 
 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
-const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('./fixtures/distro-snapshot.json', import.meta.url), 'utf8'));
 
 function selfcheck(fields) {
   const report = {
diff --git a/test/fixtures/distro-snapshot.json b/test/fixtures/distro-snapshot.json
@@ -0,0 +1,280 @@
+{
+  "updated_at": "2026-10-23T00:52:34.437Z",
+  "debian": {
+    "12": {
+      "codename": "bookworm",
+      "packages": {
+        "php8.2": {
+          "fixed": {
+            "CVE-2026-109618": "8.2.35-1~deb12u1",
+            "CVE-2026-109619": "8.2.35-1~deb12u1"
+          },
+          "pro": false
+        }
+      }
+    },
+    "13": {
+      "codename": "trixie",
+      "packages": {
+        "php8.4": {
+          "fixed": {
+            "CVE-2026-109618": "8.4.27-1~deb13u1",
+            "CVE-2026-109619": "8.4.27-1~deb13u1"
+          },
+          "pro": false
+        }
+      }
+    },
+    "14": {
+      "codename": "forky",
+      "packages": {
+        "php8.4": {
+          "fixed": {
+            "CVE-2026-109618": null,
+            "CVE-2026-109619": null
+          },
+          "pro": false
+        }
+      }
+    }
+  },
+  "redhat": {
+    "6": {
+      "advisories": [],
+      "states": [
+        {
+          "cve": "CVE-2026-109618",
+          "package": "php",
+          "state": "Under investigation"
+        },
+        {
+          "cve": "CVE-2026-109619",
+          "package": "php",
+          "state": "Not affected"
+        }
+      ]
+    },
+    "7": {
+      "advisories": [],
+      "states": [
+        {
+          "cve": "CVE-2026-109618",
+          "package": "php",
+          "state": "Under investigation"
+        },
+        {
+          "cve": "CVE-2026-109619",
+          "package": "php",
+          "state": "Not affected"
+        }
+      ]
+    },
+    "8": {
+      "advisories": [
+        {
+          "advisory": "RHSA-2026:87073",
+          "cve": "CVE-2026-109618",
+          "package": "php:8.2-8100020261020140951.f7998665"
+        },
+        {
+          "advisory": "RHSA-2026:87080",
+          "cve": "CVE-2026-109618",
+          "package": "php:7.4-8100020261021081045.f7998665"
+        },
+        {
+          "advisory": "RHSA-2026:87073",
+          "cve": "CVE-2026-109619",
+          "package": "php:8.2-8100020261020140951.f7998665"
+        },
+        {
+          "advisory": "RHSA-2026:87080",
+          "cve": "CVE-2026-109619",
+          "package": "php:7.4-8100020261021081045.f7998665"
+        }
+      ],
+      "states": []
+    },
+    "9": {
+      "advisories": [
+        {
+          "advisory": "RHSA-2026:87072",
+          "cve": "CVE-2026-109618",
+          "package": "php:8.2-9080020261020145734.9"
+        },
+        {
+          "advisory": "RHSA-2026:87079",
+          "cve": "CVE-2026-109618",
+          "package": "php:8.3-9080020261020115403.9"
+        },
+        {
+          "advisory": "RHSA-2026:87151",
+          "cve": "CVE-2026-109618",
+          "package": "php-0:8.0.30-8.el9_8.1"
+        },
+        {
+          "advisory": "RHSA-2026:87072",
+          "cve": "CVE-2026-109619",
+          "package": "php:8.2-9080020261020145734.9"
+        },
+        {
+          "advisory": "RHSA-2026:87079",
+          "cve": "CVE-2026-109619",
+          "package": "php:8.3-9080020261020115403.9"
+        },
+        {
+          "advisory": "RHSA-2026:87151",
+          "cve": "CVE-2026-109619",
+          "package": "php-0:8.0.30-8.el9_8.1"
+        }
+      ],
+      "states": [
+        {
+          "cve": "CVE-2026-109618",
+          "package": "php:8.4/php",
+          "state": "Affected"
+        },
+        {
+          "cve": "CVE-2026-109619",
+          "package": "php:8.4/php",
+          "state": "Affected"
+        }
+      ]
+    },
+    "10": {
+      "advisories": [
+        {
+          "advisory": "RHSA-2026:87070",
+          "cve": "CVE-2026-109618",
+          "package": "php-0:8.3.36-1.el10_2"
+        },
+        {
+          "advisory": "RHSA-2026:87071",
+          "cve": "CVE-2026-109618",
+          "package": "php8.4-0:8.4.27-1.el10_2"
+        },
+        {
+          "advisory": "RHSA-2026:87070",
+          "cve": "CVE-2026-109619",
+          "package": "php-0:8.3.36-1.el10_2"
+        },
+        {
+          "advisory": "RHSA-2026:87071",
+          "cve": "CVE-2026-109619",
+          "package": "php8.4-0:8.4.27-1.el10_2"
+        }
+      ],
+      "states": []
+    }
+  },
+  "redhat_notes": {
+    "CVE-2026-109618": {
+      "cvss3": "8.1",
+      "severity": "Important",
+      "statement": "Rated Important. An unauthenticated remote attacker could corrupt engine memory and potentially execute arbitrary code, but reliable exploitation requires precise heap manipulation and timing. Standard Red Hat Enterprise Linux deployments use PHP-FPM rather than mod_php. PHP-FPM workers exit on early initialization failures before reaching the corrupted structures, so remote code execution is difficult in practice and typically requires non-default behavior such as parsing a second request body during script execution."
+    },
+    "CVE-2026-109619": {
+      "cvss3": "8.1",
+      "severity": "Important",
+      "statement": "An unauthenticated remote attacker can trigger memory corruption leading to potential arbitrary code execution. In typical Red Hat Enterprise Linux deployments, PHP applications exposed to public networks handle incoming requests directly, so no prior authentication is needed to reach the vulnerable code. Exploitation requires hitting a precise out-of-memory condition during request processing, which keeps this below Critical."
+    }
+  },
+  "schema": 1,
+  "ubuntu": {
+    "14.04": {
+      "codename": "trusty",
+      "packages": {
+        "php5": {
+          "fixed": {
+            "CVE-2026-109618": "5.5.9+dfsg-1ubuntu4.29+esm18",
+            "CVE-2026-109619": "5.5.9+dfsg-1ubuntu4.29+esm18"
+          },
+          "pro": true
+        }
+      }
+    },
+    "16.04": {
+      "codename": "xenial",
+      "packages": {
+        "php7.0": {
+          "fixed": {
+            "CVE-2026-109618": "7.0.33-0ubuntu0.16.04.16+esm22",
+            "CVE-2026-109619": "7.0.33-0ubuntu0.16.04.16+esm22"
+          },
+          "pro": true
+        }
+      }
+    },
+    "18.04": {
+      "codename": "bionic",
+      "packages": {
+        "php7.2": {
+          "fixed": {
+            "CVE-2026-109618": "7.2.24-0ubuntu0.18.04.17+esm14",
+            "CVE-2026-109619": "7.2.24-0ubuntu0.18.04.17+esm14"
+          },
+          "pro": true
+        }
+      }
+    },
+    "20.04": {
+      "codename": "focal",
+      "packages": {
+        "php7.4": {
+          "fixed": {
+            "CVE-2026-109618": "7.4.3-4ubuntu2.29+esm5",
+            "CVE-2026-109619": "7.4.3-4ubuntu2.29+esm5"
+          },
+          "pro": true
+        }
+      }
+    },
+    "22.04": {
+      "codename": "jammy",
+      "packages": {
+        "php8.1": {
+          "fixed": {
+            "CVE-2026-109618": "8.1.2-1ubuntu2.28",
+            "CVE-2026-109619": "8.1.2-1ubuntu2.28"
+          },
+          "pro": false
+        }
+      }
+    },
+    "24.04": {
+      "codename": "noble",
+      "packages": {
+        "php8.3": {
+          "fixed": {
+            "CVE-2026-109618": "8.3.6-0ubuntu0.24.04.13",
+            "CVE-2026-109619": "8.3.6-0ubuntu0.24.04.13"
+          },
+          "pro": false
+        }
+      }
+    },
+    "26.04": {
+      "codename": "resolute",
+      "packages": {
+        "php8.5": {
+          "fixed": {
+            "CVE-2026-109618": "8.5.4-0ubuntu1.5",
+            "CVE-2026-109619": "8.5.4-0ubuntu1.5"
+          },
+          "pro": false
+        }
+      }
+    },
+    "26.10": {
+      "codename": "stonking",
+      "packages": {
+        "php8.5": {
+          "fixed": {
+            "CVE-2026-109618": "8.5.9-0ubuntu2.1",
+            "CVE-2026-109619": "8.5.9-0ubuntu2.1"
+          },
+          "pro": false
+        }
+      }
+    }
+  }
+}
diff --git a/test/remediation.test.mjs b/test/remediation.test.mjs
@@ -1,6 +1,7 @@
 // Remediation steps (r.actions): for each kind of result, the steps name what runs now, the release or package that
 // fixes it, the exact commands built from the observation (package names, image tag, service names) and how to
-// confirm the fix, in English and Korean. Real data files in site/data/.
+// confirm the fix, in English and Korean. Runs against site/data/upstream.json and a frozen copy of
+// site/data/distro.json (fixtures/distro-snapshot.json), so data refreshes do not move the verdicts.
 import { test } from 'node:test';
 import assert from 'node:assert/strict';
 import { readFileSync } from 'node:fs';
@@ -8,7 +9,7 @@ import { parseInput } from '../site/lib/parse.js';
 import { judge } from '../site/lib/judge.js';
 
 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
-const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('./fixtures/distro-snapshot.json', import.meta.url), 'utf8'));
 
 const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
 const server = (lines, closed = true) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${['tool|php2shell-server-check|1.0.0', ...lines].join('\n')}\n${closed ? '-----END PHP2SHELL SERVERCHECK-----\n' : ''}`;
diff --git a/test/retest.test.mjs b/test/retest.test.mjs
@@ -1,5 +1,6 @@
-// Regression tests for the second round of tester reports. Each test replays the tester's input against the
-// real data files in site/data/. Numbers follow the report: tester 1 #1-#12, tester 2 #13-#30.
+// Regression tests for the second round of tester reports. Each test replays the tester's input against
+// upstream.json and a frozen copy of distro.json (fixtures/distro-snapshot.json), so data refreshes do not move the
+// verdicts. Numbers follow the report: tester 1 #1-#12, tester 2 #13-#30.
 import { test } from 'node:test';
 import assert from 'node:assert/strict';
 import { readFileSync } from 'node:fs';
@@ -8,7 +9,7 @@ import { judge } from '../site/lib/judge.js';
 import { parsePhpVersion } from '../site/lib/version.js';
 
 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
-const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('./fixtures/distro-snapshot.json', import.meta.url), 'utf8'));
 
 const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
 const server = (lines, closed = true) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${lines.join('\n')}\n${closed ? '-----END PHP2SHELL SERVERCHECK-----\n' : ''}`;
diff --git a/test/round3.test.mjs b/test/round3.test.mjs
@@ -1,7 +1,8 @@
 // Regression tests for the third round of tester reports, and for the engine notes of the same round. Each test
-// replays the tester's input (real probe and server script outputs where the tester had them) against the real
-// data files in site/data/. Groups: D (Debian and Ubuntu), R (RHEL family and Docker), C (crafted inputs),
-// X (rendering and robustness), N (notes from the server script and probe work).
+// replays the tester's input (real probe and server script outputs where the tester had them) against
+// upstream.json and a frozen copy of distro.json (fixtures/distro-snapshot.json), so data refreshes do not move the
+// verdicts. Groups: D (Debian and Ubuntu), R (RHEL family and Docker), C (crafted inputs), X (rendering and
+// robustness), N (notes from the server script and probe work).
 import { test } from 'node:test';
 import assert from 'node:assert/strict';
 import { readFileSync } from 'node:fs';
@@ -9,7 +10,7 @@ import { parseInput } from '../site/lib/parse.js';
 import { judge, sapiLabel } from '../site/lib/judge.js';
 
 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
-const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('./fixtures/distro-snapshot.json', import.meta.url), 'utf8'));
 
 // A probe block as the probe prints it (the fields in the probe's own order).
 const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;