fix(release): admit real archive sizes and enforce workflow trust

Live stable and RC archive metadata exceed 256 MiB; retain a streaming bounded 512 MiB archive/16 KiB signature/1 GiB binary contract instead. Disable automatic setup caches in the CI credential job and encode derived command outputs with the reviewed writer. Add regressions; do not relax workflow or release guards.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 03:00:30 +01:00
parent fdf464578a
commit 619133aa54
5 changed files with 42 additions and 9 deletions
@@ -69,9 +69,9 @@ jobs:
--from-commit "$from_commit" --to-commit "$to_commit" --tag "$TAG" \
--output "$RUNNER_TEMP/browser-release/packet.json"
python3 -c 'import json,os,pathlib; keys=("GITHUB_WORKFLOW_SHA","GITHUB_SHA","GITHUB_RUN_ID","GITHUB_RUN_ATTEMPT"); pathlib.Path(os.environ["RUNNER_TEMP"]+"/browser-release/control.json").write_text(json.dumps({k:os.environ[k] for k in keys},indent=2)+"\n")'
echo "from=$from" >> "$GITHUB_OUTPUT"
echo "from_commit=$from_commit" >> "$GITHUB_OUTPUT"
echo "to_commit=$to_commit" >> "$GITHUB_OUTPUT"
python3 scripts/write_github_output.py from "$from"
python3 scripts/write_github_output.py from_commit "$from_commit"
python3 scripts/write_github_output.py to_commit "$to_commit"
- name: Authenticate both release archives before execution
env:
FROM: ${{ steps.pair.outputs.from }}
@@ -88,7 +88,7 @@ jobs:
mkdir -m 0700 "$root/$tag"
name="pulse-$tag-linux-amd64.tar.gz"
for file in "$name" "$name.sshsig"; do
curl --fail --silent --show-error --location --retry 0 --max-time 120 \
curl --fail --silent --show-error --location --retry 0 --max-time 120 --max-filesize 536870912 \
"https://github.com/rcourtman/Pulse/releases/download/$tag/$file" --output "$root/$tag/$file"
done
ssh-keygen -Y verify -f "$root/allowed_signers" -I pulse-installer -n pulse-install \
@@ -101,6 +101,8 @@ jobs:
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24.20.0'
cache: ''
package-manager-cache: false
- name: Prepare Chromium and native tools
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ runner.temp }}/browser-release/browsers
@@ -6431,7 +6431,13 @@ Control is reviewed `main`, bound to `expected_workflow_sha`, its checkout and
`GITHUB_WORKFLOW_SHA`. Product inputs are only the latest published immutable
stable and the requested published immutable RC. Peeled commit, release/asset
IDs, sizes and SHA-256 digests are checked by `release_browser_packet.py`; SSH
signatures verify before execution. Each installed binary hash must match its
signatures verify before execution. Archives are bounded to512MiB (current
stable/RC archives are about265MiB), signatures to16KiB, the fixed regular
server binary to1GiB and expanded inventory to2GiB/4096 entries. Streaming
hashes avoid allocating an entire archive; anonymous downloads have the same
archive-size ceiling. Setup-action caches are explicitly disabled in this
CI-credential-bearing job, and derived command-file outputs use the reviewed
collision-safe writer. Each installed binary hash must match its
signed archive, rather than just a reported version. No rebuilt main binary,
frozen-product insertion, candidate re-cut or stable promotion is performed.
+8 -3
View File
@@ -11,6 +11,11 @@ from pathlib import Path
import re
import tarfile
MAX_ARCHIVE = 512 * 1024 * 1024
MAX_SIGNATURE = 16 * 1024
MAX_BINARY = 1024 * 1024 * 1024
MAX_EXPANDED = 2 * MAX_BINARY
def release_identity(release, *, tag, commit, prerelease):
pattern = r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
@@ -31,7 +36,7 @@ def release_identity(release, *, tag, commit, prerelease):
asset = matches[0]
if (asset.get('browser_download_url') != f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}'
or type(asset.get('id')) is not int or asset['id'] <= 0
or type(asset.get('size')) is not int or not 0 < asset['size'] <= 256 * 1024 * 1024
or type(asset.get('size')) is not int or not 0 < asset['size'] <= (MAX_SIGNATURE if name.endswith('.sshsig') else MAX_ARCHIVE)
or not re.fullmatch(r'sha256:[0-9a-f]{64}', str(asset.get('digest')))):
raise ValueError('Unsafe or unbound release asset')
assets.append({k: asset[k] for k in ['name', 'id', 'size', 'digest']})
@@ -50,13 +55,13 @@ def archive_identity(path):
total_size = 0
for i, member in enumerate(archive):
total_size += member.size
if total_size > 1024 * 1024 * 1024:
if total_size > MAX_EXPANDED:
raise ValueError('Oversized expanded release archive')
if i >= 4096:
raise ValueError('Oversized release archive inventory')
if member.name in ('./bin/pulse', 'bin/pulse'):
matches.append(member)
if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= 256 * 1024 * 1024:
if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= MAX_BINARY:
raise ValueError('Missing or ambiguous regular server binary')
stream = archive.extractfile(matches[0])
return hashlib.file_digest(stream, 'sha256').hexdigest()
+11 -1
View File
@@ -48,9 +48,19 @@ class BrowserPacketTest(unittest.TestCase):
with self.assertRaises(ValueError):
packet.packet(self.baseline, release('v6.4.5-rc.9', 'c' * 40), 'a' * 40, 'c' * 40, 'v6.4.5-rc.9')
def test_current_published_archives_fit_bounded_admission(self):
# Actual GitHub metadata readback: stable276780330 and RC277779060
# bytes both exceed the original256MiB limit. No asset bytes executed.
self.baseline['assets'][0]['size'] = 276780330
self.candidate['assets'][0]['size'] = 277779060
self.assertEqual(2, len(self.bind()['releases']))
self.candidate['assets'][1]['size'] = packet.MAX_SIGNATURE + 1
with self.assertRaises(ValueError):
self.bind()
def test_rejects_redirect_asset_duplicate_oversize_or_unbound_digest(self):
for field, value in [('browser_download_url', 'https://example.com/archive'), ('size', 0),
('size', 257 * 1024 * 1024), ('digest', None), ('id', True)]:
('size', 513 * 1024 * 1024), ('digest', None), ('id', True)]:
with self.subTest(field=field), self.assertRaises(ValueError):
candidate = copy.deepcopy(self.candidate)
candidate['assets'][0][field] = value
@@ -177,6 +177,16 @@ class HarnessContractTest(unittest.TestCase):
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
class HostedWorkflowCommandFilesTest(unittest.TestCase):
def test_bound_pair_uses_the_reviewed_collision_safe_output_writer(self) -> None:
source = (REPO_ROOT / '.github/workflows/qualify-browser-update-release.yml').read_text()
for field in ('from', 'from_commit', 'to_commit'):
self.assertIn(f'python3 scripts/write_github_output.py {field} "${field}"', source)
self.assertNotIn('echo "to_commit=', source)
self.assertIn("cache: ''", source)
self.assertIn('package-manager-cache: false', source)
class BrowserRecoveryAccessStopTest(unittest.TestCase):
def test_refused_upgrade_does_not_launch_another_login(self) -> None:
source = HARNESS_PATH.read_text(encoding="utf-8")