Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/publish-ubuntu-security-backports.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
contents: read

concurrency:
group: ubuntu-security-backports-v0.1.0-${{ github.event_name }}
group: ubuntu-security-backports-v0.1.1-${{ github.event_name }}
cancel-in-progress: false

jobs:
Expand Down Expand Up @@ -81,7 +81,7 @@ jobs:
artifact-metadata: write
env:
IMAGE: ghcr.io/pasturestack/ubuntu-security-backports
TAG: v0.1.0
TAG: v0.1.1
steps:
- name: Check out the exact protected publisher source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -270,7 +270,7 @@ jobs:
if: always() && steps.inputs.outputs.work != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ubuntu-security-backports-v0.1.0-${{ github.sha }}-${{ github.run_id }}
name: ubuntu-security-backports-v0.1.1-${{ github.sha }}-${{ github.run_id }}
path: |
${{ steps.inputs.outputs.work }}/context/out/
${{ steps.inputs.outputs.work }}/publisher/
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/ubuntu-backport-native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ on:
- 'server/security/ubuntu-backports/jq/**'
- 'server/security/ubuntu-backports/p11-kit/**'
- 'server/security/ubuntu-backports/libxrender/**'
- 'server/security/ubuntu-backports/brotli/**'
- 'server/security/ubuntu-backports/source_helpers.py'
workflow_dispatch:
inputs:
Expand All @@ -22,7 +23,7 @@ on:
required: true
type: choice
default: all
options: [all, glibc, rust-coreutils, jq, p11-kit, libxrender]
options: [all, glibc, rust-coreutils, jq, p11-kit, libxrender, brotli]

permissions:
contents: read
Expand All @@ -39,7 +40,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: ${{ fromJSON(inputs.module && inputs.module != 'all' && format('["{0}"]', inputs.module) || '["glibc","rust-coreutils","jq","p11-kit","libxrender"]') }}
module: ${{ fromJSON(inputs.module && inputs.module != 'all' && format('["{0}"]', inputs.module) || '["glibc","rust-coreutils","jq","p11-kit","libxrender","brotli"]') }}
env:
MODULE: ${{ matrix.module }}
SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
Expand Down
2 changes: 1 addition & 1 deletion server/security/ubuntu-backports/Dockerfile.native-ci
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"]
ADD --checksum=sha256:5b6326f1932cfd7be57130561dc78192f09ed9f58bfbe6606ed0c50510c3b6cd https://archive.ubuntu.com/ubuntu/pool/main/c/ca-certificates/ca-certificates_20260816ubuntu0.26.04.1_all.deb /tmp/ca-certificates.deb
RUN <<'SH'
set -euo pipefail
case "$MODULE" in glibc|rust-coreutils|jq|p11-kit|libxrender) ;; *) exit 2 ;; esac
case "$MODULE" in glibc|rust-coreutils|jq|p11-kit|libxrender|brotli) ;; *) exit 2 ;; esac
mkdir -p /tmp/ca-bootstrap /etc/ssl/certs
dpkg-deb --extract /tmp/ca-certificates.deb /tmp/ca-bootstrap
while IFS= read -r certificate; do
Expand Down
9 changes: 8 additions & 1 deletion server/security/ubuntu-backports/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Do not rerun an unchanged module that already has valid native PASS evidence.
Its module source, lock, compiler/dependency inputs and expected artifact identity
must still match. Changes to shared behavior require revalidating its consumers.

Select `glibc`, `rust-coreutils`, `jq`, `p11-kit` or `libxrender` in the
Select `glibc`, `rust-coreutils`, `jq`, `p11-kit` `libxrender` or `brotli` in the
workflow. Source helpers run as a non-root user; Rust credential regression
subprocesses alone receive privileges inside that temporary container. A failed
helper retains logs and a failing result; exported files alone are not a PASS.
Expand Down Expand Up @@ -51,3 +51,10 @@ providers, in addition to the installer unit tests. A bundle PASS is still not a
product PASS: every consumer must retain its normal source/release gates, exact
final-rootfs SBOM, raw scan, scoped VEX and affected runtime smoke. See
`bundle/installer/README.md` for the fixed mount and command contract.

The Brotli module retains Ubuntu 1.2.0-3build1 packaging and applies only the
accepted Google decoder fix for CVE-2026-111497. It exports libbrotli1 only,
requires ordinary upstream/API/streaming tests and all three original library
ABIs, and never executes a crafted CVE stream or installs packages on the host.
Each new bundle requires native and consumer qualifications followed by
separate immutable publication before a product may consume it.
100 changes: 100 additions & 0 deletions server/security/ubuntu-backports/brotli/brotli.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
{
"schemaVersion": 1,
"sourcePackage": "brotli",
"ubuntuSourceVersion": "1.2.0-3build1",
"customVersion": "1.2.0-3build1+pasturestack.1",
"distribution": "resolute",
"osVersion": "26.04",
"architecture": "amd64",
"sourceDateEpoch": 1792886400,
"identity": "First-party rebuild with unchanged Ubuntu packaging and the exact Google security commit; not issued by Canonical.",
"runtimePackages": [
"libbrotli1"
],
"sourceFiles": [
{
"name": "brotli_1.2.0-3build1.dsc",
"url": "https://archive.ubuntu.com/ubuntu/pool/main/b/brotli/brotli_1.2.0-3build1.dsc",
"sha256": "07c51f4299d065d4ae3cc269e9a837b9723453632fe62039cc47723a7d620eda",
"size": 2281
},
{
"name": "brotli_1.2.0.orig.tar.gz",
"url": "https://archive.ubuntu.com/ubuntu/pool/main/b/brotli/brotli_1.2.0.orig.tar.gz",
"sha256": "eb5f7dadf215d0670665fd81566e1fe2dfdc154d983f09142de7299df4c182e6",
"size": 646398
},
{
"name": "brotli_1.2.0-3build1.debian.tar.xz",
"url": "https://archive.ubuntu.com/ubuntu/pool/main/b/brotli/brotli_1.2.0-3build1.debian.tar.xz",
"sha256": "7ebbb009f9e33f54a7790e95da60af4667a3fc4a914b79ad49bfc06bb396d829",
"size": 5976
}
],
"patches": [
{
"name": "b418fa81c48262a86a95332b0bc6d62c30aa78b6.patch",
"url": "https://github.com/google/brotli/commit/b418fa81c48262a86a95332b0bc6d62c30aa78b6.patch",
"sha256": "a4516e1df041108aefba2b3be1faf4c907a8b051710f205dc55d8b8f9d208dec",
"size": 9821,
"commit": "b418fa81c48262a86a95332b0bc6d62c30aa78b6",
"fixedCves": [
"CVE-2026-111497"
],
"firstFixedVersion": "1.2.1"
}
],
"baselineBinary": {
"name": "libbrotli1_1.2.0-3build1_amd64.deb",
"url": "https://archive.ubuntu.com/ubuntu/pool/main/b/brotli/libbrotli1_1.2.0-3build1_amd64.deb",
"sha256": "858242571fc2585c1f8eb83ae01d020f0f4a92679d0de8fe31146f6687bdd84a",
"size": 342830,
"version": "1.2.0-3build1"
},
"baselineSha256": {
"c/dec/decode.c": "f7749b1296128c0add4eaa454b9d82ee1c7b0451c1c9ed47d96733e6de6f2d7b",
"c/include/brotli/decode.h": "027b91e62e85de6955e6334628b2ba0c77901a449e169fd9f95d6ea1cc91f980"
},
"patchedSha256": {
"c/dec/decode.c": "8d7beeafed5ff9f3f00f6e528e92ad6a36059b3549356bed5a9ca3ee16938438",
"c/include/brotli/decode.h": "e2f5fdc3ae41e6b7e8986538e421ae79c095541bacf32f2bbfccf8e056405444"
},
"ubuntuPackagingSha256": {
"debian/brotli.install": "c0f06a1e597977eefcd8b2a5eb99e678659de7a3adc59a74365f34ea29af793a",
"debian/brotli.manpages": "b3185d1368c756cc6cb54aa062781bc1b6222c8857e226b6cbf4c4f189970e63",
"debian/control": "6c68968c8170775d018a9867abd2b16557c1388168b9b80a20239ffa2a3cb534",
"debian/copyright": "24a64e5bb83d0960d1835696a1e23c0896ad6055b0ca47c66ab0eb9a766324b1",
"debian/libbrotli-dev.install": "77b28dcc32564d5844d26e329dcfc133042bad34aea9e759433ec4fceea8a1b6",
"debian/libbrotli1.install": "65e99705c6908377687751449eaf8fba0157ff8330b0f2ca14261fae0c4fe3d3",
"debian/libbrotli1.lintian-overrides": "2348de77b1f367dbd9891dd844d22a63299861d50db72b621849ecd0466467f2",
"debian/libbrotli1.symbols": "b40f838b0bcad033c4fe8e78d2f8d003062f2bad53efa5716d7c5042c3c15432",
"debian/not-installed": "30dbe7c8c1ef2061364b7d9495bf2d3c058d1bc9038a146421052cebe38ef49c",
"debian/patches/0001-disable-BROTLI_MODEL-macro-for-some-targets.patch": "b4db797f22d7932a97d738596d0ba1ca3c1f201421243bed96d3888888ad8f75",
"debian/patches/series": "5674e4744078f82842b0059baf59d616bc8f1fb122a767c7b7b59fc3d43fe54a",
"debian/python3-brotli.lintian-overrides": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"debian/rename-static-libs.sh": "7422c83b1c378e1defc9996bc3d25800ae7fae4a09434baaefaae25f4d8a1536",
"debian/rules": "aca35475dbd2576e31a33528b8862fb04525d6175b506ebaf8a7a4bf26986546",
"debian/salsa-ci.yml": "32997347e7094680bbc5a1dec4ec4f04424b79bcf4ccbb6bb318f0b601e78e3f",
"debian/source/format": "1be7080d72e6b566df3e236ce2c55efdfbbb8fa1c972d825e5b672ff8773be1a",
"debian/tests/control": "caafcc857a57c341a2c24eab30e483185131d98fa9ca6162490bb718b85daf54",
"debian/tests/python3": "b722bcad10002340598980243e88234aeea192bb0a67d9f78f23b69a92044fd4",
"debian/upstream/metadata": "1913e3632e37a9470a125d5045d1b2a85d6464b36a4564135c291df038c30af5",
"debian/watch": "aaba817f66ca20727032e925eabe92462ab364c0004ae17ed290e4563279a6d4"
},
"buildContract": {
"nativeUbuntuAmd64": true,
"buildUser": "non-root",
"originalRulesAndDependenciesPreserved": true,
"addedSkips": 0,
"runtimeInstalled": false,
"requiredSonames": [
"libbrotlicommon.so.1",
"libbrotlidec.so.1",
"libbrotlienc.so.1"
]
},
"limitations": [
"No exploit, CVE trigger or crafted invalid stream is executed. Remediation is established by exact official source-patch closure; ordinary compression/streaming and ABI controls establish compatibility.",
"Source archives are SHA256-pinned and matched by the signed fixed-snapshot index in native CI; no Canonical signature is claimed for first-party rebuilt binaries."
]
}
143 changes: 143 additions & 0 deletions server/security/ubuntu-backports/brotli/build-brotli-backport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Exact official Brotli decoder fix with original Ubuntu package semantics."""
import argparse
import importlib.util
import json
from pathlib import Path
import re
import shlex
import subprocess
import sys
import xml.etree.ElementTree as ET

HERE = Path(__file__).resolve().parent
sys.dont_write_bytecode = True
SPEC = importlib.util.spec_from_file_location('brotli_source_helpers', HERE.parent / 'source_helpers.py')
H = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(H)
COMMIT = 'b418fa81c48262a86a95332b0bc6d62c30aa78b6'
VERSION = '1.2.0-3build1'
SONAMES = ['libbrotlicommon.so.1', 'libbrotlidec.so.1', 'libbrotlienc.so.1']
BOUNDARY = {'c/dec/decode.c', 'c/include/brotli/decode.h'}


def load_lock(file):
lock = json.loads(file.read_text(encoding='utf-8'))
H.require(all(lock.get(k) == v for k, v in {
'schemaVersion': 1, 'sourcePackage': 'brotli', 'ubuntuSourceVersion': VERSION,
'customVersion': VERSION + '+pasturestack.1', 'distribution': 'resolute',
'osVersion': '26.04', 'architecture': 'amd64', 'runtimePackages': ['libbrotli1']}.items()),
'Unexpected Brotli package/platform boundary')
H.require(type(lock['sourceDateEpoch']) is int and lock['sourceDateEpoch'] > 0, 'Invalid source date')
H.require({p['name'] for p in lock['sourceFiles']} == {
'brotli_1.2.0-3build1.dsc', 'brotli_1.2.0.orig.tar.gz', 'brotli_1.2.0-3build1.debian.tar.xz'}
and len(lock['sourceFiles']) == 3, 'Wrong Ubuntu source set')
H.require(len(lock['patches']) == 1 and lock['patches'][0]['commit'] == COMMIT
and lock['patches'][0]['fixedCves'] == ['CVE-2026-111497'], 'Wrong official security patch')
H.require(lock['baselineBinary']['name'] == 'libbrotli1_' + VERSION + '_amd64.deb'
and lock['baselineBinary']['version'] == VERSION, 'Wrong original Ubuntu binary')
for item in H.inputs(lock):
H.require(re.fullmatch(r'[A-Za-z0-9._~+-]+', item['name'])
and re.fullmatch('[0-9a-f]{64}', item['sha256'])
and type(item['size']) is int and 0 < item['size'] < 8 * 1024 * 1024, 'Unsafe/unpinned input')
url = ('https://github.com/google/brotli/commit/' + COMMIT + '.patch' if item in lock['patches']
else 'https://archive.ubuntu.com/ubuntu/pool/main/b/brotli/' + item['name'])
H.require(item['url'] == url, 'Unexpected official source URL')
H.require(set(lock['baselineSha256']) == set(lock['patchedSha256']) == BOUNDARY,
'Security patch source boundary changed')
for group in ['baselineSha256', 'patchedSha256', 'ubuntuPackagingSha256']:
for name, sha in lock[group].items():
H.require(not Path(name).is_absolute() and '..' not in Path(name).parts
and '\\' not in name and re.fullmatch('[0-9a-f]{64}', sha), 'Unsafe/unpinned source path')
H.require(lock['buildContract']['requiredSonames'] == SONAMES, 'Wrong ABI contract')
return lock


def abi(dynamic, symbols, original_dynamic, original_symbols, soname):
sonames = lambda text: re.findall(r'SONAME.*\[(.+?)\]', text)
needed = lambda text: sorted(re.findall(r'NEEDED.*\[(.+?)\]', text))
exports = lambda text: {line.split()[-1] for line in text.splitlines() if line.split()}
H.require(sonames(dynamic) == sonames(original_dynamic) == [soname], 'Brotli SONAME changed')
H.require(needed(dynamic) == needed(original_dynamic), 'Brotli runtime dependencies changed')
H.require(exports(symbols) == exports(original_symbols) and exports(symbols), 'Brotli exported ABI changed')
return {'soname': soname, 'needed': needed(dynamic), 'exports': len(exports(symbols)), 'status': 'PASS'}


def owning_results(xml, python_log):
root = ET.fromstring(xml)
cases = root.findall('.//testcase')
H.require(len(cases) >= 12 and not root.findall('.//failure') and not root.findall('.//error')
and not root.findall('.//skipped')
and all(c.get('status') in (None, 'run') for c in cases), 'Brotli CMake suite failed/skipped/empty')
counts = re.findall(r'^Ran ([0-9]+) tests? in ', python_log, re.M)
H.require(len(counts) == 1 and int(counts[0]) > 0 and re.search(r'^OK\s*$', python_log, re.M)
and not re.search(r'\b(?:skipped|FAILED|ERROR|FAIL)\b', python_log), 'Brotli Python suite failed/skipped/empty')
return {'status': 'PASS', 'cmakeCases': len(cases), 'pythonCases': int(counts[0]), 'addedSkips': 0}


def verify(lock, source, extracted, work, env, log):
caches = list(source.glob('obj-*/CMakeCache.txt'))
H.require(len(caches) == 1, 'Missing/ambiguous original package CMake build')
build = caches[0].parent
cmake_log, junit, python_log = work / 'cmake-tests.log', work / 'cmake-tests.xml', work / 'python-tests.log'
H.run(['ctest', '--test-dir', str(build), '--output-on-failure', '--no-tests=error',
'--output-junit', str(junit)], source, env, cmake_log)
modules = list(source.glob('.pybuild/*/build/brotli.py'))
H.require(len(modules) == 1, 'Missing/ambiguous original Python module build')
child = dict(env, PYTHONPATH=str(modules[0].parent))
H.run(['python3', '-m', 'unittest', 'discover', '-s', 'python', '-p', '*_test.py', '-v'], source, child, python_log)
suites = owning_results(junit.read_text(), python_log.read_text())
original = work / 'original-package'
H.run(['dpkg-deb', '--extract', str(work / 'inputs' / lock['baselineBinary']['name']), str(original)], work, env, log)
controls = []
library_dir = Path('usr/lib/x86_64-linux-gnu')
for soname in SONAMES:
new, old = extracted / library_dir / soname, original / library_dir / soname
dynamic = H.capture(['readelf', '--dynamic', str(new)], work, env)
old_dynamic = H.capture(['readelf', '--dynamic', str(old)], work, env)
nm = ['nm', '--dynamic', '--defined-only', '--extern-only']
controls.append(abi(dynamic, H.capture(nm + [str(new)], work, env), old_dynamic,
H.capture(nm + [str(old)], work, env), soname))
H.require('GNU_RELRO' in H.capture(['readelf', '--program-headers', str(new)], work, env), 'RELRO regressed')
now = lambda text: 'BIND_NOW' in text or bool(re.search(r'FLAGS_1.*NOW', text))
H.require(not now(old_dynamic) or now(dynamic), 'Original BIND_NOW hardening regressed')
probe = work / 'brotli-roundtrip'
cc = ['cc', '-std=c11', '-Wall', '-Wextra', '-Werror']
for name in ['CPPFLAGS', 'CFLAGS', 'LDFLAGS']:
cc += shlex.split(H.capture(['dpkg-buildflags', '--get', name], work, env))
libs = extracted / library_dir
H.run(cc + ['-I' + str(source / 'c/include'), str(HERE / 'roundtrip.c'), '-L' + str(libs),
'-Wl,-rpath,' + str(libs), '-l:libbrotlienc.so.1', '-l:libbrotlidec.so.1',
'-l:libbrotlicommon.so.1', '-o', str(probe)], work, env, log)
result = H.capture([str(probe)], work, dict(env, LD_LIBRARY_PATH=str(libs)))
H.require(result == 'PASS: 15 valid roundtrips, 45 streaming roundtrips, three linked SONAMEs', 'Packaged valid-input API control failed')
report = {'status': 'PASS', 'owningSuites': suites, 'abi': controls, 'packageApi': result,
'runtimeInstalled': False, 'exploitExecuted': False,
'remediationEvidence': 'Exact official two-file source patch and post-patch hashes; no malformed-stream trigger executed.'}
receipt = work / 'brotli-tests.json'
receipt.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
return report, [(p, p.name) for p in [cmake_log, junit, python_log, receipt]]


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--validate-lock', action='store_true')
parser.add_argument('--output-dir', type=Path)
parser.add_argument('--work-dir', type=Path)
parser.add_argument('--cache-dir', type=Path)
parser.add_argument('--jobs', type=int, choices=(2, 4), default=2)
args = parser.parse_args()
lock = load_lock(HERE / 'brotli.lock.json')
if args.validate_lock:
print('OFFICIAL_SOURCE_LOCK_OK brotli')
return
H.require(args.output_dir is not None, '--output-dir is required')
H.build(args, lock, HERE, verify, ['cc', 'ctest', 'dpkg-buildflags'])


if __name__ == '__main__':
try:
main()
except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
print('BROTLI_BACKPORT_FAILED: ' + str(error), file=sys.stderr)
sys.exit(1)
Loading