mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user