diff --git a/.github/scripts/check_buildx_additional_scopes.py b/.github/scripts/check_buildx_additional_scopes.py new file mode 100644 index 00000000..9bf38901 --- /dev/null +++ b/.github/scripts/check_buildx_additional_scopes.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Qualify three additional Buildx source scopes; do not alter native VEX/raw.""" +import json +import sys + +import check_buildx_code_absence as shared + +INPUT_SHAS = { + 'GO-2026-4883': 'a53fcda63a402fa87ab624163d9308d7e4b5e70eaee694e6fc594cced4ece327', + 'GO-2026-5668': 'be8fd95648c391f93ba3dfbef0fd245bbd73a44ab6e31dd325c4f1a922f6aa2b', + 'GO-2026-5932': 'f277b0400996200a7d5034c676661cd8cf18adeab1fdc5d0dc369e456ddf1fad', +} +PATCH_SHA = '50896e0a5d94c3586c16997cdfd6dc94214a511f7c1493449928d2f34f5e5793' +CRYPTO_PURL = 'pkg:golang/golang.org/x/crypto@v0.57.0' +SCOPES = { + 'CVE-2026-33997': ('github.com/docker/docker', 'v28.5.2+incompatible', shared.PURL), + 'CVE-2026-41568': ('github.com/docker/docker', 'v28.5.2+incompatible', shared.PURL), + 'GO-2026-5932': ('golang.org/x/crypto', 'v0.57.0', CRYPTO_PURL), +} + + +def record_sha(finding): + return shared.sha(json.dumps(finding, sort_keys=True, separators=(',', ':'))) + + +def qualify(data): + # Reuse the complete original four-ID verifier, including binary/source, + # actual compiled file SHA inventory and native source scanner bindings. + source = data['source'] + base = shared.source_check(source) + shared.require(base == data['baseGuard'], 'Original source qualification changed') + shared.require(set(data['osv']) == set(INPUT_SHAS), 'Additional canonical inputs missing or unbounded') + canonical = {} + for identifier, digest in INPUT_SHAS.items(): + content = data['osv'][identifier] + shared.require(shared.sha(content) == digest, 'Additional canonical input bytes changed') + canonical[identifier] = shared.load(content) + shared.require(canonical[identifier]['id'] == identifier, 'Wrong canonical advisory ID') + shared.require(shared.sha(data['pluginFix']) == PATCH_SHA, 'Official plugin fix bytes changed') + patch = shared.load(data['pluginFix']) + patch_paths = ['daemon/pkg/plugin/manager.go', 'daemon/pkg/plugin/manager_test.go'] + shared.require(patch['sha'] == 'f4d6f25bf0c3fa12d4968320a45685947756a22a' + and sorted(f['filename'] for f in patch['files']) == patch_paths, + 'Official plugin fix/package boundary changed') + packages = source['packages'].splitlines() + files = [line.split('\t') for line in source['files'].splitlines()] + # The common verifier already requires the only legacy compiled package/file + # to be the official namesgenerator. These two IDs affect plugin/daemon code. + shared.require(not any('/plugin/' in path or path.endswith('/plugin') + for package, path, _ in files if package.startswith('github.com/docker/docker/')), + 'Plugin source is compiled') + crypto = canonical['GO-2026-5932']['affected'][0] + affected = [p['path'] for p in crypto['ecosystem_specific']['imports']] + shared.require(crypto['package']['name'] == 'golang.org/x/crypto' and len(affected) == 7 + and all(p == 'golang.org/x/crypto/openpgp' or p.startswith('golang.org/x/crypto/openpgp/') + for p in affected), 'OpenPGP official package boundary changed') + shared.require(not any(p == a or p.startswith(a + '/') for p in packages for a in affected) + and not any(package == 'golang.org/x/crypto/openpgp' + or package.startswith('golang.org/x/crypto/openpgp/') + or path.startswith('module/golang.org/x/crypto@v0.57.0/openpgp/') + for package, path, _ in files), 'An affected OpenPGP package/file is compiled') + shared.require('golang.org/x/crypto v0.57.0' in source['modulesAfter'].splitlines(), + 'Actual x/crypto module version changed') + findings = [] + for target, finding in shared.findings(shared.load(source['raw'])): + identifier = finding.get('VulnerabilityID') + if target != shared.TARGET or identifier not in SCOPES: + continue + actual = (finding.get('PkgName'), finding.get('InstalledVersion'), + finding.get('PkgIdentifier', {}).get('PURL')) + shared.require(actual == SCOPES[identifier], 'Additional finding package identity changed') + findings.append({'id': identifier, 'findingSha256': record_sha(finding)}) + shared.require({f['id'] for f in findings} == set(SCOPES), 'Original additional finding controls missing') + return {'schemaVersion': 1, 'status': 'BUILDX_ADDITIONAL_SOURCE_SCOPES_QUALIFIED', + 'target': shared.TARGET, 'binarySha256': base['binarySha256'], 'sourceGuard': base, + 'canonicalOsvSha256': dict(INPUT_SHAS), 'officialPluginFixSha256': PATCH_SHA, + 'officialPluginFixPaths': patch_paths, 'openpgpAffectedPackagesAbsent': affected, + 'notAffected': [{'id': identifier, 'package': value[0], 'version': value[1], 'purl': value[2], + 'status': 'not_affected', 'justification': 'vulnerable_code_not_present'} + for identifier, value in SCOPES.items()], + 'qualifiedOriginalFindings': findings, + 'coverage': 'Three per-ID compiled-source determinations; original native govulncheck covers only its four pinned IDs. No claim of additional native filtering or a fixed dependency.'} + + +def matches(target, finding, qualification): + return (target == shared.TARGET and finding.get('VulnerabilityID') in SCOPES + and (finding.get('PkgName'), finding.get('InstalledVersion'), + finding.get('PkgIdentifier', {}).get('PURL')) == SCOPES[finding['VulnerabilityID']] + and {'id': finding['VulnerabilityID'], 'findingSha256': record_sha(finding)} + in qualification['qualifiedOriginalFindings']) + + +if __name__ == '__main__': + try: + content = sys.stdin.buffer.read(64 * 1024 * 1024 + 1) + shared.require(len(content) <= 64 * 1024 * 1024, 'Evidence exceeds size limit') + print(json.dumps(qualify(shared.load(content.decode('utf-8'))), sort_keys=True)) + except (ValueError, KeyError, TypeError, UnicodeError) as error: + print('BUILDX_ADDITIONAL_SCOPE_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/.github/scripts/check_buildx_code_absence.py b/.github/scripts/check_buildx_code_absence.py new file mode 100644 index 00000000..8569e3a6 --- /dev/null +++ b/.github/scripts/check_buildx_code_absence.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Check stdin evidence before native VEX; never read a caller-selected path.""" +import argparse +from collections import Counter +import hashlib +import json +import re +import sys + +TARGET = 'usr/local/lib/docker/cli-plugins/docker-buildx' +PURL = 'pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible' +CVES = {'CVE-2026-41567', 'CVE-2026-42306'} +NAMES = 'github.com/docker/docker/pkg/namesgenerator' +MAIN = 'github.com/docker/buildx/cmd/buildx' +NAME_FILE = 'module/github.com/docker/docker@v28.5.2+incompatible/pkg/namesgenerator/names-generator.go' +NAME_SHA = 'cd2f9528ad47ae7519d78e6381e0b2313fa9b5ff541814e95e06ebb187aa9b64' +PINS = { + 'schemaVersion': '1', 'upstreamVersion': 'v0.38.1', + 'downstreamVersion': 'v0.38.1-pasturestack.1', + 'sourceCommit': '0dbe87f36ed472fe36b6796c3f70b4210a731fc5', + 'sourceArchiveSha256': 'c5c810b083ed3725f7d4299ba01505b5891d164cf81c3398ea4c3b27a3b71149', + 'originalGoModSha256': 'a93029abb94e88ddaa532ddeaab4802259a1025408f1eac417a9886e140067ef', + 'updatedGoModSha256': '230c98fa66a534b3a4d2dcdb0507e871658deff3c80058b01d85d7a18ce656b9', + 'goVersion': 'go1.27.3', 'goos': 'linux', +} +IDS = {'GO-2026-5617', 'GO-2026-5746', 'GO-2026-6253', 'GO-2026-6611'} +OSV_PINS = { + 'GO-2026-5617': '5a24060e5cc8cbf6a146615ebe5a7e5beac2f8f267c905538736ed6931ae2af5', + 'GO-2026-5746': 'd11da88232883a96ac3b74f848f172ce86c3783b7818d7da4b925335621d5273', + 'GO-2026-6253': '4f337e5aad0f8e83e3db2fc67d058fe0e80017b8c68f494b7e82be87e7120872', + 'GO-2026-6611': '5e89ac4314a24eac41111b71ef4544f22836c7b1baf48747e31c5c1d8282c3de', +} + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def unique_pairs(items): + result = {} + for key, value in items: + require(key not in result, 'Duplicate metadata key') + result[key] = value + return result + + +def load(text): + return json.loads(text, object_pairs_hook=unique_pairs) + + +def objects(text): + decoder = json.JSONDecoder(object_pairs_hook=unique_pairs) + offset = 0 + while text[offset:].strip(): + offset += len(text[offset:]) - len(text[offset:].lstrip()) + item, offset = decoder.raw_decode(text, offset) + yield item + + +def sha(text): + return hashlib.sha256(text.encode('utf-8')).hexdigest() + + +def findings(report): + for result in report.get('Results', []): + for finding in result.get('Vulnerabilities', []): + yield result['Target'], finding + + +def pair(finding): + return finding['VulnerabilityID'], finding['PkgIdentifier']['PURL'] + + +def vex_pairs(document): + pairs = [] + for statement in document['statements']: + require(statement['status'] == 'not_affected' + and statement['justification'] == 'vulnerable_code_not_present', 'Unexpected Dapper VEX status') + pairs.extend((statement['vulnerability']['name'], p['@id']) for p in statement['products']) + require(len(pairs) == len(set(pairs)), 'Duplicate VEX scope') + return set(pairs) + + +def source_check(data): + binding = unique_pairs(line.split('\t') for line in data['binding'].splitlines()) + require(set(binding) == set(PINS) | {'goarch', 'binarySha256'}, 'Incomplete source binding') + require(all(binding[k] == v for k, v in PINS.items()), 'Wrong official source/toolchain') + require(binding['goarch'] in ['amd64', 'arm64'], 'Wrong Buildx architecture') + require(re.fullmatch('[0-9a-f]{64}', data['binarySha256']) is not None + and data['binarySha256'] == binding['binarySha256'], 'Actual binary SHA differs from built source receipt') + packages = data['packages'].splitlines() + require(packages and len(packages) == len(set(packages)), 'Incomplete/duplicate compiled package inventory') + require(MAIN in packages, 'Buildx main package was not compiled') + require([p for p in packages if p == 'github.com/docker/docker' or p.startswith('github.com/docker/docker/')] == [NAMES], + 'Legacy Docker contains more than namesgenerator; daemon absence is unproven') + require('github.com/moby/go-archive/compression' in packages + and 'github.com/moby/go-archive' not in packages, 'Unexpected archive extractor compilation') + source_files = {} + for line in data['files'].splitlines(): + package, path, digest = line.split('\t') + require(package in packages and path.startswith(('source/', 'module/', 'goroot/')) + and not any(p in ['', '.', '..'] for p in path.split('/')) + and '\\' not in path and re.fullmatch('[0-9a-f]{64}', digest), 'Unsafe/invalid compiled file inventory') + require((package, path) not in source_files, 'Duplicate compiled file') + source_files[package, path] = digest + require({k: v for k, v in source_files.items() if k[0] == NAMES} == {(NAMES, NAME_FILE): NAME_SHA}, + 'Namesgenerator source bytes do not match the official module') + before, after = data['modulesBefore'].splitlines(), data['modulesAfter'].splitlines() + changed = ('golang.org/x/net ', 'github.com/moby/go-archive ') + require([s for s in before if not s.startswith(changed)] == [s for s in after if not s.startswith(changed)], + 'Unrelated module version changed') + for module, old, new in [('golang.org/x/net', 'v0.59.0', 'v0.60.0'), ('github.com/moby/go-archive', 'v0.2.0', 'v0.3.0')]: + require([s for s in before if s.startswith(module + ' ')] == [module + ' ' + old] + and [s for s in after if s.startswith(module + ' ')] == [module + ' ' + new], 'Wrong fixed module version') + info = data['buildInfo'].splitlines() + require(info and info[0].endswith(': go1.27.3'), 'Actual binary has a different compiler') + deps = {} + settings = {} + for line in info[1:]: + fields = line.strip().split('\t') + if fields[0] == 'dep': + require(fields[1] not in deps, 'Duplicate binary module'); deps[fields[1]] = fields[2] + if fields[0] == 'build': + key, value = fields[1].split('=', 1) + require(key not in settings, 'Duplicate binary setting'); settings[key] = value + require(all(settings.get(k) == v for k, v in {'GOOS': 'linux', 'GOARCH': binding['goarch'], 'CGO_ENABLED': '0'}.items()), + 'Actual binary platform differs from source check') + require(all(deps.get(k) == v for k, v in {'golang.org/x/net': 'v0.60.0', 'github.com/moby/go-archive': 'v0.3.0', + 'github.com/docker/docker': 'v28.5.2+incompatible'}.items()), 'Actual binary module identity changed') + require(f"github.com/docker/buildx {PINS['downstreamVersion']} {PINS['sourceCommit']}" in data['version'], 'Wrong downstream tool identity') + require(set(data['osvInputs']) == IDS and all(sha(data['osvInputs'][k]) == v for k, v in OSV_PINS.items()), + 'Canonical four vulnerability input bytes do not match their official pins') + checks = list(objects(data['sourceCheck'])) + configs = [x['config'] for x in checks if 'config' in x] + require(len(configs) == 1 and configs[0]['scanner_name'] == 'govulncheck' + and configs[0]['scanner_version'] == 'v1.9.0' and configs[0]['scan_mode'] == 'source' + and configs[0]['scan_level'] == 'package', 'Native package source check missing or replaced by stripped-binary fallback') + sboms = [x['SBOM'] for x in checks if 'SBOM' in x] + require(len(sboms) == 1 and sboms[0]['go_version'] == 'go1.27.3' and sboms[0]['roots'] == [MAIN], + 'Native source check did not inspect this Buildx program') + scanned = unique_pairs((m['path'], m.get('version', '')) for m in sboms[0]['modules']) + require(all(scanned.get(k) == v for k, v in deps.items()), 'Source check module versions differ from the binary') + emitted = {x['osv']['id'] for x in checks if 'osv' in x} + require({'GO-2026-5617', 'GO-2026-5746'} <= emitted <= IDS, + 'Daemon source checks missing or an unbounded OSV was emitted') + for item in checks: + if 'finding' in item: + finding = item['finding'] + require(finding['osv'] in IDS, 'Unbounded source-check result') + require(not any(frame.get('package') or frame.get('function') for frame in finding['trace']), + 'Vulnerable package or function is present in the source check') + raw = load(data['raw']) + scoped = [(target, f) for target, f in findings(raw) if f.get('PkgIdentifier', {}).get('PURL') == PURL] + require(scoped and all(target == TARGET for target, _ in scoped), 'The same legacy PURL occurs in another binary') + require({f['VulnerabilityID'] for _, f in scoped if f['VulnerabilityID'] in CVES} == CVES, + 'The two raw daemon controls are missing') + require({p for p in vex_pairs(data['vex']) if p[1] == PURL} == {(cve, PURL) for cve in CVES}, + 'Daemon VEX exceeds the two qualified scopes') + return {'schemaVersion': 1, 'status': 'BUILDX_DAEMON_CODE_ABSENCE_VERIFIED', 'target': TARGET, + 'purl': PURL, 'sourceCommit': binding['sourceCommit'], 'sourceArchiveSha256': binding['sourceArchiveSha256'], + 'binarySha256': binding['binarySha256'], 'rawSha256': sha(data['raw']), + 'vexSha256': sha(json.dumps(data['vex'], sort_keys=True)), + 'compiledPackageCount': len(packages), 'compiledSourceFileCount': len(source_files), + 'packagesSha256': sha(data['packages']), 'sourceFilesSha256': sha(data['files']), + 'modulesBeforeSha256': sha(data['modulesBefore']), 'modulesAfterSha256': sha(data['modulesAfter']), + 'canonicalOsvSha256': dict(OSV_PINS), 'emittedOsv': sorted(emitted), + 'sourceCheckSha256': sha(data['sourceCheck']), 'scope': sorted(CVES)} + + +def native_check(data): + require(data['guard']['status'] == 'BUILDX_DAEMON_CODE_ABSENCE_VERIFIED' + and sha(data['raw']) == data['guard']['rawSha256'] + and sha(json.dumps(data['vex'], sort_keys=True)) == data['guard']['vexSha256'], + 'Raw report or VEX changed after the source guard') + raw, effective = load(data['raw']), load(data['effective']) + allowed = vex_pairs(data['vex']) + canonical = lambda target, finding: json.dumps([target, finding], sort_keys=True, separators=(',', ':')) + kept = Counter(canonical(t, f) for t, f in findings(raw) if pair(f) not in allowed) + actual = Counter(canonical(t, f) for t, f in findings(effective)) + require(kept == actual, 'Native VEX changed an unrelated finding or retained a qualified scope') + return {'schemaVersion': 1, 'status': 'NATIVE_DAPPER_VEX_VERIFIED', 'rawSha256': sha(data['raw']), + 'effectiveSha256': sha(data['effective']), 'retainedFindings': sum(actual.values()), + 'rawFindings': sum(1 for _ in findings(raw)), 'rawRetainedUnchanged': True} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--native', action='store_true') + args = parser.parse_args() + try: + content = sys.stdin.buffer.read(64 * 1024 * 1024 + 1) + require(len(content) <= 64 * 1024 * 1024, 'Evidence exceeds size limit') + data = load(content.decode('utf-8')) + print(json.dumps(native_check(data) if args.native else source_check(data), sort_keys=True)) + except (ValueError, KeyError, TypeError, UnicodeError) as error: + print('BUILDX_CODE_ABSENCE_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/.github/scripts/enforce-runtime-vulnerabilities.py b/.github/scripts/enforce-runtime-vulnerabilities.py index 86444b12..2ceccda2 100644 --- a/.github/scripts/enforce-runtime-vulnerabilities.py +++ b/.github/scripts/enforce-runtime-vulnerabilities.py @@ -4,6 +4,8 @@ import sys from pathlib import Path +import check_buildx_additional_scopes as buildx_scopes + def violations(report, ledger, repository): if (not isinstance(report, dict) or report.get("SchemaVersion") != 2 @@ -109,6 +111,11 @@ def builder_violations(report, ledger, repository, header_boundary, header_ledge p.startswith("golang.org/x/crypto/openpgp/") for p in packages): raise ValueError("OpenPGP package boundary not proved") openpgp_absent = True + buildx_qualification = None + if repository == "PastureStack/node-agent" and node_proof and "buildxInput" in node_proof: + buildx_qualification = buildx_scopes.qualify(node_proof["buildxInput"]) + if buildx_qualification != node_proof.get("buildxQualification"): + raise ValueError("Buildx additional qualification changed after source validation") retained = [] for result in results: copied = dict(result) @@ -117,6 +124,8 @@ def builder_violations(report, ledger, repository, header_boundary, header_ledge key = (finding.get("VulnerabilityID"), finding.get("PkgName"), finding.get("InstalledVersion"), (finding.get("PkgIdentifier") or {}).get("PURL", "")) + if buildx_qualification and buildx_scopes.matches(result.get("Target"), finding, buildx_qualification): + continue is_header = (key in header_keys and key[1] == "linux-libc-dev" and key[2] == "7.0.0-38.38" and key[3] == expected_purl) unfixed = not str(finding.get("FixedVersion") or "").strip() @@ -198,6 +207,8 @@ def builder_violations(report, ledger, repository, header_boundary, header_ledge node_proof = {"pip": json.loads((repo_root / evidence_dir / "pip-vendor-proof.json").read_text(encoding="utf-8")), "dockerdOpenpgp": json.loads((repo_root / evidence_dir / "dockerd-openpgp-proof.json").read_text(encoding="utf-8")), "containerGraphs": {name: (repo_root / evidence_dir / "container-tools" / (name + "-package-graph.txt")).read_text(encoding="utf-8") for name in ("containerd", "ctr", "containerd-shim-runc-v2", "runc")}} + node_proof["buildxInput"] = json.loads((repo_root / evidence_dir / "buildx-additional-input.json").read_text(encoding="utf-8")) + node_proof["buildxQualification"] = json.loads((repo_root / evidence_dir / "buildx-additional-qualification.json").read_text(encoding="utf-8")) failures = builder_violations(report, ledger, args.repository, boundary, headers, graphs, docker_proof, node_proof) if failures: raise SystemExit("Release vulnerability policy failed:\n" + "\n".join(failures)) diff --git a/.github/scripts/join_buildx_native_vex.py b/.github/scripts/join_buildx_native_vex.py new file mode 100644 index 00000000..e64035c4 --- /dev/null +++ b/.github/scripts/join_buildx_native_vex.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Join a verified single-ELF native VEX result into the unchanged full raw report.""" +import copy +import hashlib +import json +import sys +from collections import Counter + +import check_buildx_code_absence as shared + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':')) + + +def transport_finding(finding, artifact_id, diff_ids, layered): + value = copy.deepcopy(finding) + # Trivy v0.74.0 pkg/fingerprint/fingerprint.go:46-52. A rootfs report has + # no ArtifactID; image and rootfs therefore have different fingerprints. + expected = 'sha256:' + hashlib.sha256( + f"{artifact_id}:{shared.TARGET}:{value['PkgID']}:{value['VulnerabilityID']}".encode() + ).hexdigest() + shared.require(value.pop('Fingerprint', None) == expected, 'Invalid native transport fingerprint') + layer = value.pop('Layer', None) + if layered: + shared.require(isinstance(layer, dict) and set(layer) == {'DiffID'} + and layer['DiffID'] in diff_ids, 'Buildx layer is not in the raw image') + else: + shared.require(layer is None, 'An isolated ELF unexpectedly has an image layer') + return canonical(value) + + +def transport_packages(result, diff_ids, layered): + packages = [] + for package in result.get('Packages', []): + value = copy.deepcopy(package) + layer = value.pop('Layer', None) + if layered: + shared.require(isinstance(layer, dict) and set(layer) == {'DiffID'} + and layer['DiffID'] in diff_ids, 'Package layer is not in the raw image') + else: + shared.require(layer is None, 'An isolated package unexpectedly has an image layer') + packages.append(canonical(value)) + shared.require(packages, 'Buildx package inventory is missing') + return Counter(packages) + + +def single_result(report): + shared.require(report.get('ArtifactType') == 'filesystem' + and report.get('ArtifactName') == '/scan' and not report.get('ArtifactID'), + 'Expected the fixed isolated ELF transport') + results = report.get('Results', []) + shared.require(len(results) == 1 and results[0].get('Target') == shared.TARGET + and results[0].get('Type') == 'gobinary' and results[0].get('Class') == 'lang-pkgs', + 'The isolated scan contains another target') + return results[0] + + +def join(data): + raw = shared.load(data['raw']) + isolated_raw = shared.load(data['isolatedRaw']) + isolated_effective = shared.load(data['isolatedEffective']) + guard, isolated_guard = data['guard'], data['isolatedGuard'] + shared.require(shared.vex_pairs(data['vex']) == {(cve, shared.PURL) for cve in shared.CVES}, + 'The isolated VEX exceeds this source-qualified module scope') + shared.require(guard.get('status') == 'BUILDX_DAEMON_CODE_ABSENCE_VERIFIED' + and shared.sha(data['raw']) == guard['rawSha256'], 'Original raw binding changed') + shared.require({k: v for k, v in guard.items() if k != 'rawSha256'} + == {k: v for k, v in isolated_guard.items() if k != 'rawSha256'}, + 'The isolated ELF has another binary or source qualification') + native = shared.native_check({'raw': data['isolatedRaw'], 'effective': data['isolatedEffective'], + 'guard': isolated_guard, 'vex': data['vex']}) + original = single_result(isolated_raw) + effective = single_result(isolated_effective) + shared.require({k: v for k, v in original.items() if k not in ('Vulnerabilities', 'ModifiedFindings')} + == {k: v for k, v in effective.items() if k not in ('Vulnerabilities', 'ModifiedFindings')}, + 'Native VEX changed isolated package inventory or another result field') + original_findings = Counter(transport_finding(v, '', set(), False) + for v in original.get('Vulnerabilities', [])) + retained = Counter(transport_finding(v, '', set(), False) + for v in effective.get('Vulnerabilities', [])) + packages = transport_packages(original, set(), False) + shared.require(raw.get('ArtifactType') == 'container_image' and raw.get('ArtifactID'), + 'Original full image identity is missing') + diff_ids = set(raw.get('Metadata', {}).get('DiffIDs', [])) + shared.require(diff_ids, 'Original image layers are missing') + scoped = [(t, v) for t, v in shared.findings(raw) + if v.get('PkgIdentifier', {}).get('PURL') == shared.PURL] + shared.require(scoped and all(t == shared.TARGET for t, _ in scoped), + 'Another binary contains the qualified legacy PURL') + output = copy.deepcopy(raw) + matched = [] + for result in output['Results']: + if result.get('Target') != shared.TARGET: + continue + shared.require(result.get('Type') == 'gobinary' and result.get('Class') == 'lang-pkgs', + 'The full raw Buildx target changed type') + values = result.get('Vulnerabilities', []) + layered = bool(values and values[0].get('Layer')) + artifact_id = raw['ArtifactID'] if layered else '' + keys = [transport_finding(v, artifact_id, diff_ids, layered) for v in values] + shared.require(Counter(keys) == original_findings, 'Full and isolated Buildx findings differ') + shared.require(transport_packages(result, diff_ids, layered) == packages, + 'Full and isolated Buildx package inventories differ') + keep = retained.copy() + selected = [] + for value, key in zip(values, keys): + if keep[key]: + selected.append(value) + keep[key] -= 1 + shared.require(not +keep, 'A native retained finding could not be joined') + result['Vulnerabilities'] = selected + matched.append(layered) + # The established full raw is image + standalone container-tools results. + shared.require(sorted(matched) == [False, True], 'Expected both original Buildx scan transports') + encoded = json.dumps(output) + full_native = shared.native_check({'raw': data['raw'], 'effective': encoded, + 'guard': guard, 'vex': data['vex']}) + return {'effective': output, 'receipt': { + 'schemaVersion': 1, 'status': 'NATIVE_SINGLE_ELF_JOIN_VERIFIED', + 'target': shared.TARGET, 'binarySha256': guard['binarySha256'], + 'isolatedRawSha256': shared.sha(data['isolatedRaw']), + 'isolatedEffectiveSha256': shared.sha(data['isolatedEffective']), + 'isolatedNative': native, 'fullNative': full_native, + 'transportFieldsVerified': ['Layer.DiffID', 'Fingerprint'], + 'otherTargetsRetainedUnchanged': True}} + + +if __name__ == '__main__': + try: + content = sys.stdin.buffer.read(64 * 1024 * 1024 + 1) + shared.require(len(content) <= 64 * 1024 * 1024, 'Evidence exceeds size limit') + print(json.dumps(join(shared.load(content.decode('utf-8'))))) + except (ValueError, KeyError, TypeError, UnicodeError) as error: + print('BUILDX_NATIVE_JOIN_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/.github/scripts/scan-dapper-container-binaries.sh b/.github/scripts/scan-dapper-container-binaries.sh index 948831a4..14d1f34a 100644 --- a/.github/scripts/scan-dapper-container-binaries.sh +++ b/.github/scripts/scan-dapper-container-binaries.sh @@ -4,14 +4,24 @@ set -euo pipefail test "$#" -eq 3 image=$1 scanner=$2 cache=$3 root=$(mktemp -d) +buildx_root=$(mktemp -d) container=$(docker create "$image") -trap 'docker rm -f "$container" >/dev/null 2>&1 || true; rm -rf -- "$root"' EXIT -mkdir -p "$root/usr/bin" evidence/container-tools +trap 'docker rm -f "$container" >/dev/null 2>&1 || true; rm -rf -- "$root" "$buildx_root"' EXIT +mkdir -p "$root/usr/bin" "$root/usr/local/lib/docker/cli-plugins" evidence/container-tools for tool in docker dockerd docker-proxy containerd containerd-shim-runc-v2 ctr runc; do docker cp "$container:/usr/bin/$tool" "$root/usr/bin/$tool" test -s "$root/usr/bin/$tool" done docker cp "$container:/licenses/container-tools/." evidence/container-tools/ +docker cp "$container:/usr/local/lib/docker/cli-plugins/docker-buildx" "$root/usr/local/lib/docker/cli-plugins/docker-buildx" +test -s "$root/usr/local/lib/docker/cli-plugins/docker-buildx" +docker run --rm --entrypoint sh "$image" -lc 'test ! -e /usr/libexec/docker/cli-plugins/docker-buildx' +for suffix in SOURCE.tsv PACKAGES.txt SOURCE-FILES.tsv MODULES-before.txt MODULES-after.txt SOURCE-GOVULNCHECK.json GO-2026-5617.json GO-2026-5746.json GO-2026-6253.json GO-2026-6611.json; do + docker cp "$container:/licenses/BUILDX-$suffix" "evidence/BUILDX-$suffix" +done +docker run --rm --entrypoint go "$image" version -m /usr/local/lib/docker/cli-plugins/docker-buildx > evidence/buildx-go-version.txt +docker run --rm --entrypoint docker "$image" buildx version > evidence/buildx-version.txt + sha256sum "$root/usr/bin/dockerd" | awk '{print $1}' > evidence/dockerd.sha256 docker run --rm --entrypoint go "$image" tool nm /usr/bin/dockerd > evidence/dockerd-symbols.txt python3 - <<'PY' @@ -44,10 +54,75 @@ import json from pathlib import Path report=json.loads(Path('evidence/container-tools-security.json').read_text()) results={r['Target']:r for r in report['Results'] if r.get('Type')=='gobinary'} -expected={'usr/bin/'+name for name in ('docker','dockerd','docker-proxy','containerd','containerd-shim-runc-v2','ctr','runc')} +expected={'usr/bin/'+name for name in ('docker','dockerd','docker-proxy','containerd','containerd-shim-runc-v2','ctr','runc')} | {'usr/local/lib/docker/cli-plugins/docker-buildx'} assert set(results)==expected assert all(results[target].get('Packages') for target in expected) image=json.loads(Path('evidence/dapper-security-image.json').read_text()) image['Results']+=report['Results'] Path('evidence/dapper-security-raw.json').write_text(json.dumps(image)) PY + +# Qualify only this newly built tool; the original complete raw reports remain intact. +buildx_sha="$(sha256sum "$root/usr/local/lib/docker/cli-plugins/docker-buildx")" +buildx_sha="${buildx_sha%% *}" +qualify_buildx_raw() { +local report=$1 output=$2 input=$3 +jq -n \ + --rawfile binding evidence/BUILDX-SOURCE.tsv \ + --rawfile packages evidence/BUILDX-PACKAGES.txt \ + --rawfile files evidence/BUILDX-SOURCE-FILES.tsv \ + --rawfile modulesBefore evidence/BUILDX-MODULES-before.txt \ + --rawfile modulesAfter evidence/BUILDX-MODULES-after.txt \ + --rawfile buildInfo evidence/buildx-go-version.txt \ + --rawfile version evidence/buildx-version.txt \ + --rawfile sourceCheck evidence/BUILDX-SOURCE-GOVULNCHECK.json \ + --rawfile daemon42306 evidence/BUILDX-GO-2026-5617.json \ + --rawfile daemon41567 evidence/BUILDX-GO-2026-5746.json \ + --rawfile archive17106 evidence/BUILDX-GO-2026-6253.json \ + --rawfile net78669 evidence/BUILDX-GO-2026-6611.json \ + --rawfile raw "$report" \ + --slurpfile vex security/buildx.openvex.json \ + --arg binarySha256 "$buildx_sha" \ + '{binding:$binding,packages:$packages,files:$files,modulesBefore:$modulesBefore,modulesAfter:$modulesAfter,buildInfo:$buildInfo,version:$version,sourceCheck:$sourceCheck,osvInputs:{"GO-2026-5617":$daemon42306,"GO-2026-5746":$daemon41567,"GO-2026-6253":$archive17106,"GO-2026-6611":$net78669},raw:$raw,vex:$vex[0],binarySha256:$binarySha256}' \ + | tee "$input" | python3 -B .github/scripts/check_buildx_code_absence.py > "$output" +} +qualify_buildx_raw evidence/dapper-security-raw.json evidence/buildx-code-absence.json evidence/buildx-source-input.json + +# Native VEX runs on only the same source-qualified ELF. Re-scanning the whole +# image can choose another duplicate Python package layer; full raw stays intact. +mkdir -p "$buildx_root/usr/local/lib/docker/cli-plugins" +cp "$root/usr/local/lib/docker/cli-plugins/docker-buildx" "$buildx_root/usr/local/lib/docker/cli-plugins/docker-buildx" +test ! -L "$buildx_root/usr/local/lib/docker/cli-plugins/docker-buildx" +test "$(find "$buildx_root" -type f | wc -l)" -eq 1 +test "$(sha256sum "$buildx_root/usr/local/lib/docker/cli-plugins/docker-buildx" | awk '{print $1}')" = "$buildx_sha" +docker run --rm --network none -v "$buildx_root:/scan:ro" -v "$PWD/evidence:/evidence" \ + -v "$cache:/root/.cache/trivy" "$scanner" rootfs --skip-db-update --offline-scan \ + --scanners vuln,secret --list-all-pkgs --format json \ + --output /evidence/buildx-isolated-raw.json /scan +qualify_buildx_raw evidence/buildx-isolated-raw.json evidence/buildx-isolated-code-absence.json /dev/null +docker run --rm --network none -v "$buildx_root:/scan:ro" -v "$PWD/evidence:/evidence" \ + -v "$PWD/security:/vex:ro" -v "$cache:/root/.cache/trivy" "$scanner" rootfs \ + --skip-db-update --offline-scan --scanners vuln,secret --list-all-pkgs \ + --vex /vex/buildx.openvex.json --format json \ + --output /evidence/buildx-isolated-effective.json /scan +test "$(sha256sum "$buildx_root/usr/local/lib/docker/cli-plugins/docker-buildx" | awk '{print $1}')" = "$buildx_sha" +jq -n --rawfile raw evidence/dapper-security-raw.json \ + --rawfile isolatedRaw evidence/buildx-isolated-raw.json \ + --rawfile isolatedEffective evidence/buildx-isolated-effective.json \ + --slurpfile guard evidence/buildx-code-absence.json \ + --slurpfile isolatedGuard evidence/buildx-isolated-code-absence.json \ + --slurpfile vex security/buildx.openvex.json \ + '{raw:$raw,isolatedRaw:$isolatedRaw,isolatedEffective:$isolatedEffective,guard:$guard[0],isolatedGuard:$isolatedGuard[0],vex:$vex[0]}' \ + | python3 -B .github/scripts/join_buildx_native_vex.py > evidence/buildx-native-join.json +python3 - <<'PY' +import json +from pathlib import Path +joined=json.loads(Path('evidence/buildx-native-join.json').read_text()) +Path('evidence/dapper-security-buildx.json').write_text(json.dumps(joined['effective'])) +PY +jq -n --rawfile raw evidence/dapper-security-raw.json \ + --rawfile effective evidence/dapper-security-buildx.json \ + --slurpfile guard evidence/buildx-code-absence.json \ + --slurpfile vex security/buildx.openvex.json \ + '{raw:$raw,effective:$effective,guard:$guard[0],vex:$vex[0]}' \ + | python3 -B .github/scripts/check_buildx_code_absence.py --native > evidence/buildx-native-vex.json diff --git a/.github/scripts/test_buildx_additional_scopes.py b/.github/scripts/test_buildx_additional_scopes.py new file mode 100644 index 00000000..73e1a7e9 --- /dev/null +++ b/.github/scripts/test_buildx_additional_scopes.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Per-ID additional source scopes cannot exempt another target or record.""" +import copy +import importlib.util +import json +from pathlib import Path +import unittest +from unittest.mock import patch + +import check_buildx_additional_scopes as additional +import check_buildx_code_absence as shared +from test_buildx_code_absence import fixture + +spec = importlib.util.spec_from_file_location('policy', Path(__file__).with_name('enforce-runtime-vulnerabilities.py')) +policy = importlib.util.module_from_spec(spec); spec.loader.exec_module(policy) + + +def data_fixture(): + source = fixture() + for field in ['modulesBefore', 'modulesAfter']: + source[field] += 'golang.org/x/crypto v0.57.0\n' + source['buildInfo'] += '\tdep\tgolang.org/x/crypto\tv0.57.0\th1:fixture\n' + checks = list(shared.objects(source['sourceCheck'])) + next(x['SBOM'] for x in checks if 'SBOM' in x)['modules'].append( + {'path': 'golang.org/x/crypto', 'version': 'v0.57.0'}) + source['sourceCheck'] = '\n'.join(json.dumps(x) for x in checks) + raw = json.loads(source['raw']) + for identifier, (package, version, purl) in additional.SCOPES.items(): + raw['Results'][0]['Vulnerabilities'].append({'VulnerabilityID': identifier, 'PkgName': package, + 'InstalledVersion': version, 'PkgIdentifier': {'PURL': purl, 'UID': 'original'}, + 'Severity': 'UNKNOWN' if identifier.startswith('GO-') else 'MEDIUM', + 'FixedVersion': '29.3.1' if identifier == 'CVE-2026-33997' else ''}) + source['raw'] = json.dumps(raw) + root = Path(__file__).resolve().parents[2] / 'security/buildx-source-scopes' + return {'source': source, 'baseGuard': shared.source_check(source), + 'osv': {identifier: (root / (identifier + '.json')).read_text(encoding='utf-8') for identifier in additional.INPUT_SHAS}, + 'pluginFix': (root / 'CVE-2026-33997-official-commit.json').read_text(encoding='utf-8')} + + +class AdditionalScopes(unittest.TestCase): + def setUp(self): + hashes = {k: shared.sha(v) for k, v in fixture()['osvInputs'].items()} + anchor = patch.dict(shared.OSV_PINS, hashes, clear=True) + anchor.start(); self.addCleanup(anchor.stop) + + def test_three_exact_scopes_preserve_native_four_id_claim(self): + data = data_fixture(); result = additional.qualify(data) + self.assertEqual({r['id'] for r in result['notAffected']}, set(additional.SCOPES)) + self.assertEqual(result['sourceGuard']['canonicalOsvSha256'], shared.OSV_PINS) + self.assertTrue(all(r['status'] == 'not_affected' for r in result['notAffected'])) + + def test_missing_or_tampered_canonical_and_official_patch_rejected(self): + for field in ['missing', 'canonical', 'patch', 'base']: + data = data_fixture() + if field == 'missing': data['osv'].pop('GO-2026-4883') + if field == 'canonical': data['osv']['GO-2026-5932'] += '\n' + if field == 'patch': data['pluginFix'] += '\n' + if field == 'base': data['baseGuard']['binarySha256'] = 'b' * 64 + with self.subTest(field=field), self.assertRaises(ValueError): additional.qualify(data) + + def test_vulnerable_legacy_package_or_source_rejected(self): + for field, value in [('packages', 'github.com/docker/docker/daemon\n'), + ('packages', 'github.com/docker/docker/plugin\n'), + ('files', shared.NAMES + '\tmodule/extra.go\t' + 'a' * 64 + '\n')]: + data = data_fixture(); data['source'][field] += value + with self.subTest(value=value), self.assertRaises(ValueError): additional.qualify(data) + + def test_every_official_openpgp_package_and_subpackage_rejected(self): + for package in ['golang.org/x/crypto/openpgp', 'golang.org/x/crypto/openpgp/armor', + 'golang.org/x/crypto/openpgp/packet', 'golang.org/x/crypto/openpgp/clearsign', + 'golang.org/x/crypto/openpgp/errors', 'golang.org/x/crypto/openpgp/elgamal', + 'golang.org/x/crypto/openpgp/s2k', 'golang.org/x/crypto/openpgp/future']: + data = data_fixture(); data['source']['packages'] += package + '\n' + data['baseGuard'] = shared.source_check(data['source']) + with self.subTest(package=package), self.assertRaises(ValueError): additional.qualify(data) + + def test_compiled_openpgp_file_or_module_version_rejected(self): + data = data_fixture() + data['source']['files'] += shared.MAIN + '\tmodule/golang.org/x/crypto@v0.57.0/openpgp/read.go\t' + 'a' * 64 + '\n' + data['baseGuard'] = shared.source_check(data['source']) + with self.assertRaises(ValueError): additional.qualify(data) + data = data_fixture(); data['source']['modulesAfter'] = data['source']['modulesAfter'].replace('v0.57.0', 'v0.58.0') + with self.assertRaises(ValueError): additional.qualify(data) + + def test_maintained_openpgp_fork_has_a_different_official_module_scope(self): + data = data_fixture(); package = 'github.com/ProtonMail/go-crypto/openpgp' + data['source']['packages'] += package + '\n' + data['source']['files'] += package + '\tmodule/github.com/!proton!mail/go-crypto@v1.4.1/openpgp/read.go\t' + 'a' * 64 + '\n' + data['baseGuard'] = shared.source_check(data['source']) + additional.qualify(data) + + def test_fixed_target_exact_record_and_purl_only(self): + data = data_fixture(); proof = additional.qualify(data) + finding = json.loads(data['source']['raw'])['Results'][0]['Vulnerabilities'][-1] + self.assertTrue(additional.matches(shared.TARGET, finding, proof)) + self.assertFalse(additional.matches('usr/bin/containerd', finding, proof)) + for field, value in [('VulnerabilityID', 'GO-UNKNOWN'), ('Severity', 'LOW'), + ('InstalledVersion', 'v0.58.0'), ('PkgIdentifier', {'PURL': shared.PURL})]: + changed = dict(finding, **{field: value}) + with self.subTest(field=field): self.assertFalse(additional.matches(shared.TARGET, changed, proof)) + + def test_existing_builder_policy_recomputes_proof_and_keeps_unknowns(self): + data = data_fixture(); proof = additional.qualify(data); repo = 'PastureStack/node-agent' + raw = json.loads(data['source']['raw']); result = copy.deepcopy(raw['Results'][0]) + result.update(Type='gobinary', Packages=[{'Name': 'example'}]) + result['Vulnerabilities'] = [v for v in result['Vulnerabilities'] if v['VulnerabilityID'] in additional.SCOPES] + report = {'SchemaVersion': 2, 'Results': [result] + [ + {'Target': target, 'Type': 'gobinary', 'Packages': [{'Name': 'example'}]} + for target in ['usr/local/go/bin/go'] + ['usr/bin/' + p for p in ( + 'docker', 'dockerd', 'docker-proxy', 'containerd', 'containerd-shim-runc-v2', 'ctr', 'runc')]]} + node = {'buildxInput': data, 'buildxQualification': proof, 'containerGraphs': { + name: 'github.com/containerd/containerd/v2/cmd/' + name for name in ( + 'containerd', 'ctr', 'containerd-shim-runc-v2')}} + node['containerGraphs']['runc'] = 'github.com/opencontainers/runc' + headers = {'repository': repo, 'package': 'linux-libc-dev', 'version': '7.0.0-38.38', + 'purl': 'pkg:deb/ubuntu/linux-libc-dev@7.0.0-38.38?arch=amd64&distro=ubuntu-26.04', 'ids': []} + run = lambda: policy.builder_violations(report, {'repository': repo, 'unfixed': []}, repo, {}, headers, node_proof=node) + original = copy.deepcopy(report); self.assertEqual(run(), []); self.assertEqual(report, original) + unknown = copy.deepcopy(result['Vulnerabilities'][0]); unknown['VulnerabilityID'] = 'CVE-UNKNOWN' + result['Vulnerabilities'].append(unknown); self.assertTrue(run()); result['Vulnerabilities'].pop() + node['buildxQualification']['binarySha256'] = 'tampered' + with self.assertRaises(ValueError): run() + node.pop('buildxInput'); self.assertTrue(run()) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/test_buildx_code_absence.py b/.github/scripts/test_buildx_code_absence.py new file mode 100644 index 00000000..deafcc98 --- /dev/null +++ b/.github/scripts/test_buildx_code_absence.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Focused source-bound applicability and native VEX regression controls.""" +import copy +import json +import unittest +from unittest.mock import patch + +import check_buildx_code_absence as guard + + +def finding(cve, purl=guard.PURL): + return {'VulnerabilityID': cve, 'PkgIdentifier': {'PURL': purl}, 'Severity': 'HIGH'} + + +def fixture(): + binding = dict(guard.PINS, goarch='amd64', binarySha256='a' * 64) + modules = {'github.com/docker/docker': 'v28.5.2+incompatible', + 'github.com/moby/go-archive': 'v0.3.0', 'golang.org/x/net': 'v0.60.0'} + before = dict(modules, **{'github.com/moby/go-archive': 'v0.2.0', 'golang.org/x/net': 'v0.59.0'}) + checks = [{'config': {'scanner_name': 'govulncheck', 'scanner_version': 'v1.9.0', + 'scan_mode': 'source', 'scan_level': 'package'}}, + {'SBOM': {'go_version': 'go1.27.3', 'roots': [guard.MAIN], + 'modules': [{'path': k, 'version': v} for k, v in modules.items()]}}] + checks.extend({'osv': {'id': identifier}} for identifier in sorted(guard.IDS)) + # Legacy module presence is expected; imported/called daemon code is not. + checks.append({'finding': {'osv': 'GO-2026-5746', 'trace': [ + {'module': 'github.com/docker/docker', 'version': 'v28.5.2+incompatible'}]}}) + raw = {'Results': [{'Target': guard.TARGET, 'Vulnerabilities': [ + finding(cve) for cve in sorted(guard.CVES)]}, + {'Target': 'usr/bin/other', 'Vulnerabilities': [finding('CVE-OTHER', 'pkg:generic/other@1')]}]} + vex = {'statements': [{'vulnerability': {'name': cve}, 'products': [{'@id': guard.PURL}], + 'status': 'not_affected', 'justification': 'vulnerable_code_not_present'} + for cve in sorted(guard.CVES)]} + return { + 'binding': ''.join(f'{k}\t{v}\n' for k, v in binding.items()), + 'binarySha256': binding['binarySha256'], + 'packages': '\n'.join([guard.MAIN, guard.NAMES, 'github.com/moby/go-archive/compression']) + '\n', + 'files': f'{guard.NAMES}\t{guard.NAME_FILE}\t{guard.NAME_SHA}\n', + 'modulesBefore': ''.join(f'{k} {v}\n' for k, v in before.items()), + 'modulesAfter': ''.join(f'{k} {v}\n' for k, v in modules.items()), + 'buildInfo': 'docker-buildx: go1.27.3\n' + ''.join(f'\tdep\t{k}\t{v}\th1:example\n' for k, v in modules.items()) + + '\tbuild\tGOOS=linux\n\tbuild\tGOARCH=amd64\n\tbuild\tCGO_ENABLED=0\n', + 'version': f"github.com/docker/buildx {guard.PINS['downstreamVersion']} {guard.PINS['sourceCommit']}\n", + 'sourceCheck': '\n'.join(json.dumps(c) for c in checks), + 'osvInputs': {identifier: json.dumps({'id': identifier}) for identifier in guard.IDS}, + 'raw': json.dumps(raw), 'vex': vex, + } + + +class BuildxEvidenceTests(unittest.TestCase): + def setUp(self): + # Small synthetic OSVs keep the tests independent of network/local files. + # Production pins are fixed constants; the CLI has no pin override. + hashes = {k: guard.sha(v) for k, v in fixture()['osvInputs'].items()} + pins = patch.dict(guard.OSV_PINS, hashes, clear=True) + pins.start(); self.addCleanup(pins.stop) + + def test_exact_source_and_module_only_notice_pass(self): + result = guard.source_check(fixture()) + self.assertEqual(result['status'], 'BUILDX_DAEMON_CODE_ABSENCE_VERIFIED') + self.assertEqual(result['scope'], sorted(guard.CVES)) + # Fixed module reports may be absent from scanner output; their exact + # canonical input bytes are independently required and SHA checked. + data = fixture() + data['sourceCheck'] = '\n'.join(json.dumps(x) for x in guard.objects(data['sourceCheck']) + if x.get('osv', {}).get('id') not in {'GO-2026-6253', 'GO-2026-6611'}) + guard.source_check(data) + + def test_wrong_source_and_binary_identity_rejected(self): + for field, old in [('binding', guard.PINS['sourceCommit']), + ('binding', guard.PINS['sourceArchiveSha256']), + ('binding', guard.PINS['updatedGoModSha256']), + ('binarySha256', 'a' * 64), ('buildInfo', 'go1.27.3'), + ('buildInfo', 'GOARCH=amd64'), ('buildInfo', 'v0.60.0')]: + with self.subTest(field=field, old=old): + data = fixture(); data[field] = data[field].replace(old, 'wrong', 1) + with self.assertRaises(ValueError): + guard.source_check(data) + + def test_full_daemon_and_wrong_namesgenerator_source_rejected(self): + for field, value in [('packages', 'github.com/docker/docker/daemon\n'), + ('packages', 'github.com/moby/go-archive\n'), + ('files', f'{guard.NAMES}\tmodule/extra.go\t' + 'b' * 64 + '\n')]: + with self.subTest(value=value): + data = fixture(); data[field] += value + with self.assertRaises(ValueError): + guard.source_check(data) + data = fixture(); data['files'] = data['files'].replace(guard.NAME_SHA, 'b' * 64) + with self.assertRaises(ValueError): + guard.source_check(data) + + def test_other_binary_same_purl_rejected(self): + for cve in ['CVE-2026-41567', 'CVE-UNREVIEWED']: + data = fixture(); raw = json.loads(data['raw']) + raw['Results'].append({'Target': 'usr/bin/daemon', 'Vulnerabilities': [finding(cve)]}) + data['raw'] = json.dumps(raw) + with self.subTest(cve=cve), self.assertRaises(ValueError): + guard.source_check(data) + + def test_unrelated_module_bump_rejected(self): + data = fixture(); data['modulesAfter'] += 'example.org/unrelated v2.0.0\n' + with self.assertRaises(ValueError): + guard.source_check(data) + + def test_incomplete_or_other_program_source_scan_rejected(self): + for old, new in [('"source"', '"binary"'), ('"package"', '"module"'), + ('"SBOM"', '"sbom"'), + (guard.MAIN, 'example.org/other'), ('go1.27.3', 'go1.26.10'), + ('GO-2026-6253', 'GO-MISSING')]: + data = fixture(); data['sourceCheck'] = data['sourceCheck'].replace(old, new) + with self.subTest(old=old), self.assertRaises(ValueError): + guard.source_check(data) + for tamper in ['missing-input', 'changed-input', 'missing-daemon-output']: + data = fixture() + if tamper == 'missing-input': data['osvInputs'].pop('GO-2026-6253') + if tamper == 'changed-input': data['osvInputs']['GO-2026-6611'] += '\n' + if tamper == 'missing-daemon-output': + data['sourceCheck'] = '\n'.join(json.dumps(x) for x in guard.objects(data['sourceCheck']) + if x.get('osv', {}).get('id') != 'GO-2026-5746') + with self.subTest(tamper=tamper), self.assertRaises(ValueError): + guard.source_check(data) + + def test_imported_or_called_vulnerable_code_rejected(self): + for frame in [{'module': 'github.com/docker/docker', 'package': 'github.com/docker/docker/daemon'}, + {'module': 'github.com/docker/docker', 'function': 'Daemon.openContainerFS'}]: + data = fixture(); data['sourceCheck'] += '\n' + json.dumps( + {'finding': {'osv': 'GO-2026-5617', 'trace': [frame]}}) + with self.subTest(frame=frame), self.assertRaises(ValueError): + guard.source_check(data) + + def test_vex_cannot_extend_legacy_module_scope(self): + data = fixture(); statement = copy.deepcopy(data['vex']['statements'][0]) + statement['vulnerability']['name'] = 'CVE-UNREVIEWED' + data['vex']['statements'].append(statement) + with self.assertRaises(ValueError): + guard.source_check(data) + + def test_native_vex_keeps_complete_unrelated_finding(self): + data = fixture(); data['guard'] = guard.source_check(data) + effective = json.loads(data['raw']); effective['Results'][0]['Vulnerabilities'] = [] + data['effective'] = json.dumps(effective) + self.assertTrue(guard.native_check(data)['rawRetainedUnchanged']) + self.assertEqual(guard.native_check(data)['retainedFindings'], 1) + + def test_native_tamper_or_unrelated_removal_rejected(self): + for tamper in ['raw', 'vex', 'remove', 'alter', 'retain']: + data = fixture(); data['guard'] = guard.source_check(data) + effective = json.loads(data['raw']); effective['Results'][0]['Vulnerabilities'] = [] + if tamper == 'raw': data['raw'] += '\n' + if tamper == 'vex': data['vex']['statements'].pop() + if tamper == 'remove': effective['Results'][1]['Vulnerabilities'] = [] + if tamper == 'alter': effective['Results'][1]['Vulnerabilities'][0]['Severity'] = 'LOW' + if tamper == 'retain': effective = json.loads(data['raw']) + data['effective'] = json.dumps(effective) + with self.subTest(tamper=tamper), self.assertRaises(ValueError): + guard.native_check(data) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/test_join_buildx_native_vex.py b/.github/scripts/test_join_buildx_native_vex.py new file mode 100644 index 00000000..a2cedb47 --- /dev/null +++ b/.github/scripts/test_join_buildx_native_vex.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Single-ELF join controls; all unrelated complete records stay immutable.""" +import copy +import hashlib +import json +import unittest + +import check_buildx_code_absence as shared +import join_buildx_native_vex as subject + + +IMAGE_ID = 'sha256:' + 'a' * 64 +LAYER = 'sha256:' + 'b' * 64 + + +def fingerprint(finding, artifact=''): + finding['Fingerprint'] = 'sha256:' + hashlib.sha256( + f"{artifact}:{shared.TARGET}:{finding['PkgID']}:{finding['VulnerabilityID']}".encode() + ).hexdigest() + + +def finding(identifier): + result = {'VulnerabilityID': identifier, 'PkgName': 'github.com/docker/docker', + 'PkgID': 'github.com/docker/docker@v28.5.2+incompatible', + 'InstalledVersion': 'v28.5.2+incompatible', 'FixedVersion': 'v99.0.0', + 'PkgIdentifier': {'PURL': shared.PURL, 'UID': 'same-binary-package'}, 'Severity': 'HIGH'} + fingerprint(result) + return result + + +def fixture(): + result = {'Target': shared.TARGET, 'Type': 'gobinary', 'Class': 'lang-pkgs', + 'Packages': [{'Name': 'example', 'Version': '1', 'Identifier': {'UID': 'same'}}], + 'Vulnerabilities': [finding(c) for c in sorted(shared.CVES)] + [finding('CVE-UNKNOWN')]} + raw_elf = {'ArtifactName': '/scan', 'ArtifactType': 'filesystem', 'Results': [result]} + effective = copy.deepcopy(raw_elf) + effective['Results'][0]['Vulnerabilities'] = [copy.deepcopy(result['Vulnerabilities'][-1])] + image = copy.deepcopy(result) + for value in image['Vulnerabilities']: + value['Layer'] = {'DiffID': LAYER}; fingerprint(value, IMAGE_ID) + image['Packages'][0]['Layer'] = {'DiffID': LAYER} + unrelated = {'Target': 'Python', 'Vulnerabilities': [{'VulnerabilityID': 'CVE-OTHER', + 'PkgName': 'setuptools', 'PkgIdentifier': {'UID': 'original-python-uid', + 'PURL': 'pkg:pypi/setuptools@70.3.0'}, + 'Layer': {'DiffID': 'original-python-layer'}, 'Severity': 'HIGH'}]} + raw = {'ArtifactType': 'container_image', 'ArtifactID': IMAGE_ID, + 'Metadata': {'DiffIDs': [LAYER]}, 'Results': [image, unrelated, copy.deepcopy(result)]} + vex = {'statements': [{'vulnerability': {'name': c}, 'products': [{'@id': shared.PURL}], + 'status': 'not_affected', 'justification': 'vulnerable_code_not_present'} + for c in sorted(shared.CVES)]} + base_guard = {'status': 'BUILDX_DAEMON_CODE_ABSENCE_VERIFIED', 'binarySha256': 'c' * 64, + 'vexSha256': shared.sha(json.dumps(vex, sort_keys=True))} + data = {'raw': json.dumps(raw), 'isolatedRaw': json.dumps(raw_elf), + 'isolatedEffective': json.dumps(effective), 'vex': vex, + 'guard': dict(base_guard), 'isolatedGuard': dict(base_guard)} + rebind(data) + return data + + +def rebind(data): + data['guard']['rawSha256'] = shared.sha(data['raw']) + data['isolatedGuard']['rawSha256'] = shared.sha(data['isolatedRaw']) + + +def change(data, field, mutate): + value = json.loads(data[field]); mutate(value); data[field] = json.dumps(value) + rebind(data) + + +class NativeJoinTests(unittest.TestCase): + def test_join_preserves_original_layers_uids_and_unknown_findings(self): + data = fixture(); original = json.loads(data['raw']); output = subject.join(data) + self.assertEqual(output['effective']['Results'][1], original['Results'][1]) + self.assertEqual(output['effective']['Results'][0]['Vulnerabilities'], + original['Results'][0]['Vulnerabilities'][-1:]) + self.assertEqual(output['effective']['Results'][2]['Vulnerabilities'], + original['Results'][2]['Vulnerabilities'][-1:]) + self.assertTrue(output['receipt']['fullNative']['rawRetainedUnchanged']) + + def test_source_and_raw_bindings_are_required(self): + for field in ['raw', 'binary', 'vex']: + data = fixture() + if field == 'raw': data['raw'] += '\n' + if field == 'binary': data['isolatedGuard']['binarySha256'] = 'd' * 64 + if field == 'vex': data['vex']['statements'].pop() + with self.subTest(field=field), self.assertRaises(ValueError): subject.join(data) + + def test_unrelated_finding_fields_cannot_be_normalized(self): + for key, value in [('Severity', 'LOW'), ('FixedVersion', ''), ('InstalledVersion', 'different'), + ('PkgIdentifier', {'PURL': shared.PURL, 'UID': 'wrong'})]: + data = fixture() + change(data, 'raw', lambda r: r['Results'][0]['Vulnerabilities'][0].update({key: value})) + with self.subTest(key=key), self.assertRaises(ValueError): subject.join(data) + + def test_invalid_transport_fingerprints_are_rejected(self): + for field in ['raw', 'isolatedRaw', 'isolatedEffective']: + data = fixture() + change(data, field, lambda r: r['Results'][0]['Vulnerabilities'][0].update({'Fingerprint': 'forged'})) + with self.subTest(field=field), self.assertRaises(ValueError): subject.join(data) + + def test_unverified_or_extra_layer_fields_are_rejected(self): + for layer in [{'DiffID': 'unknown'}, {'DiffID': LAYER, 'Digest': 'extra'}, None]: + data = fixture() + change(data, 'raw', lambda r: r['Results'][0]['Vulnerabilities'][0].update({'Layer': layer})) + with self.subTest(layer=layer), self.assertRaises(ValueError): subject.join(data) + + def test_isolated_scan_must_have_only_the_exact_binary(self): + for field in ['isolatedRaw', 'isolatedEffective']: + data = fixture(); change(data, field, lambda r: r['Results'].append({'Target': 'usr/bin/other'})) + with self.subTest(field=field), self.assertRaises(ValueError): subject.join(data) + data = fixture(); change(data, 'isolatedRaw', lambda r: r.update({'ArtifactID': IMAGE_ID})) + with self.assertRaises(ValueError): subject.join(data) + + def test_package_inventory_and_uid_changes_are_rejected(self): + for field in ['raw', 'isolatedEffective']: + data = fixture() + change(data, field, lambda r: r['Results'][0]['Packages'][0].update({'Identifier': {'UID': 'wrong'}})) + with self.subTest(field=field), self.assertRaises(ValueError): subject.join(data) + + def test_unknown_native_finding_removal_is_rejected(self): + data = fixture() + change(data, 'isolatedEffective', lambda r: r['Results'][0].update({'Vulnerabilities': []})) + with self.assertRaises(ValueError): subject.join(data) + + def test_qualified_native_finding_retained_is_rejected(self): + data = fixture(); data['isolatedEffective'] = data['isolatedRaw'] + with self.assertRaises(ValueError): subject.join(data) + + def test_same_purl_in_another_binary_is_rejected(self): + data = fixture() + change(data, 'raw', lambda r: r['Results'].append({'Target': 'usr/bin/other', + 'Vulnerabilities': [finding('CVE-UNKNOWN')]})) + with self.assertRaises(ValueError): subject.join(data) + + def test_missing_or_duplicate_transport_is_rejected(self): + for action in ['missing', 'duplicate']: + data = fixture() + change(data, 'raw', lambda r: r['Results'].pop() if action == 'missing' + else r['Results'].append(copy.deepcopy(r['Results'][0]))) + with self.subTest(action=action), self.assertRaises(ValueError): subject.join(data) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/codeql-verification.yml b/.github/workflows/codeql-verification.yml index 37eac5a9..0356f518 100644 --- a/.github/workflows/codeql-verification.yml +++ b/.github/workflows/codeql-verification.yml @@ -38,10 +38,10 @@ jobs: set -euo pipefail test -z "$(git status --porcelain)" git merge-base --is-ancestor 73e3a7c5f438dbda42c4078885a66e6f156aa731 HEAD - grep -Fq 'VERSION_OVERRIDE: v0.13.33' .github/workflows/validate.yml + grep -Fq 'VERSION_OVERRIDE: v0.13.34' .github/workflows/validate.yml grep -Fq 'SHA256SUMS' scripts/package grep -Fq 'bash scripts/verify-package "$linux_asset"' .github/workflows/validate.yml - grep -Fq 'bash scripts/verify-package dist/artifacts/node-agent-0.13.33.tar.gz' .github/workflows/security-release-gate.yml + grep -Fq 'bash scripts/verify-package dist/artifacts/node-agent-0.13.34.tar.gz' .github/workflows/security-release-gate.yml - name: Install the release Go toolchain uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -68,7 +68,7 @@ jobs: export GOFLAGS=-mod=vendor mkdir -p "$GITHUB_WORKSPACE/bin" CGO_ENABLED=0 go build -trimpath -tags 'netgo osusergo' \ - -ldflags='-w -s -X main.VERSION=v0.13.33' \ + -ldflags='-w -s -X main.VERSION=v0.13.34' \ -o "$GITHUB_WORKSPACE/bin/node-agent" ./ - name: Analyze without publishing temporary alerts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ee9f389..46f66966 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,11 +17,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} PRODUCER_RUN: ${{ inputs.producer_run }} - RELEASE_TAG: v0.13.33 + RELEASE_TAG: v0.13.34 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: v0.13.33 + ref: v0.13.34 fetch-depth: 0 persist-credentials: false - name: Verify immutable tag and successful producer @@ -47,7 +47,7 @@ jobs: gzip -dc dist/security-runtime/images.tar.gz | docker load printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT - source_image='pasturestack/node-agent:v0.13.33' + source_image='pasturestack/node-agent:v0.13.34' remote_image="ghcr.io/pasturestack/node-agent:$RELEASE_TAG" if docker manifest inspect "$remote_image" >/dev/null 2>"$RUNNER_TEMP/manifest-error"; then echo "Refusing to replace an existing immutable image tag: $remote_image" >&2; exit 1 diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index 92ad9d9b..2d422b74 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -19,9 +19,9 @@ jobs: timeout-minutes: 120 env: DAPPER_IMAGE: pasturestack/node-agent-dapper:${{ github.sha }} - RUNTIME_IMAGE: pasturestack/node-agent:v0.13.33 + RUNTIME_IMAGE: pasturestack/node-agent:v0.13.34 TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 - VERSION_OVERRIDE: v0.13.33 + VERSION_OVERRIDE: v0.13.34 steps: - name: Check out candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -42,33 +42,49 @@ jobs: .github/workflows/codeql-verification.yml \ .github/workflows/security-release-gate.yml \ security/dapper.openvex.json \ + security/buildx.openvex.json toolchain/build-buildx \ + .github/scripts/scan-dapper-container-binaries.sh \ + .github/scripts/check_buildx_code_absence.py \ + .github/scripts/test_buildx_code_absence.py \ + .github/scripts/join_buildx_native_vex.py \ + .github/scripts/test_join_buildx_native_vex.py \ + .github/scripts/check_buildx_additional_scopes.py \ + .github/scripts/test_buildx_additional_scopes.py security/buildx-source-scopes/*.json \ + scripts/pip-security/check-pip-vendor-slice.py scripts/pip-security/filter-pip-vendor-findings.py \ + scripts/pip-security/test-pip-vex.py tests/requirements.lock tests/requirements.txt \ > evidence/source-locks.sha256 + - name: Validate Buildx source applicability guards + run: | + python3 -B .github/scripts/test_buildx_code_absence.py + python3 -B .github/scripts/test_join_buildx_native_vex.py + python3 -B .github/scripts/test_buildx_additional_scopes.py + - name: Run complete CI and package runtime image shell: bash run: | set -euo pipefail CROSS=1 VERSION_OVERRIDE="$VERSION_OVERRIDE" make DAPPER_IMAGE="$DAPPER_IMAGE" ci make DAPPER_IMAGE="$DAPPER_IMAGE" IMAGE_NAME=pasturestack/node-agent \ - TAG=v0.13.33 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image - test -s dist/artifacts/node-agent-0.13.33.tar.gz - test -s dist/artifacts/node-agent-0.13.33-windows-amd64.zip + TAG=v0.13.34 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image + test -s dist/artifacts/node-agent-0.13.34.tar.gz + test -s dist/artifacts/node-agent-0.13.34-windows-amd64.zip test "$(cat dist/image)" = "$RUNTIME_IMAGE" - gzip -t dist/artifacts/node-agent-0.13.33.tar.gz - bash scripts/verify-package dist/artifacts/node-agent-0.13.33.tar.gz - unzip -t dist/artifacts/node-agent-0.13.33-windows-amd64.zip + gzip -t dist/artifacts/node-agent-0.13.34.tar.gz + bash scripts/verify-package dist/artifacts/node-agent-0.13.34.tar.gz + unzip -t dist/artifacts/node-agent-0.13.34-windows-amd64.zip ( cd dist/artifacts - sha256sum node-agent-0.13.33.tar.gz node-agent-0.13.33-windows-amd64.zip > SHA256SUMS + sha256sum node-agent-0.13.34.tar.gz node-agent-0.13.34-windows-amd64.zip > SHA256SUMS sha256sum -c SHA256SUMS ) - sha256sum dist/artifacts/node-agent-0.13.33.tar.gz \ - dist/artifacts/node-agent-0.13.33-windows-amd64.zip \ + sha256sum dist/artifacts/node-agent-0.13.34.tar.gz \ + dist/artifacts/node-agent-0.13.34-windows-amd64.zip \ bin/node-agent bin/windows/amd64/node-agent.exe > evidence/product-artifacts.sha256 docker image inspect "$RUNTIME_IMAGE" > evidence/runtime-image-inspect.json docker run --rm --entrypoint /usr/bin/node-agent "$RUNTIME_IMAGE" --version \ > evidence/runtime-version.txt - grep -Fxq 'node-agent version v0.13.33' evidence/runtime-version.txt + grep -Fxq 'node-agent version v0.13.34' evidence/runtime-version.txt - name: Verify reproducible Linux binary shell: bash @@ -103,7 +119,7 @@ jobs: trap 'rm -rf "$source_tree" "$product_tree"' EXIT git archive --format=tar HEAD | tar -xf - -C "$source_tree" cp bin/node-agent "$product_tree/node-agent" - unzip -p dist/artifacts/node-agent-0.13.33-windows-amd64.zip pasturestack/node-agent.exe > "$product_tree/node-agent.exe" + unzip -p dist/artifacts/node-agent-0.13.34-windows-amd64.zip pasturestack/node-agent.exe > "$product_tree/node-agent.exe" test -s "$product_tree/node-agent.exe" docker run --rm -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image --download-db-only docker run --rm -v "$source_tree:/scan:ro" -v "$PWD/evidence:/evidence" \ @@ -123,13 +139,27 @@ jobs: --skip-db-update --offline-scan --scanners vuln,secret --list-all-pkgs --format json \ --output /work/evidence/dapper-security-image.json "$DAPPER_IMAGE" bash .github/scripts/scan-dapper-container-binaries.sh "$DAPPER_IMAGE" "$TRIVY_IMAGE" "$cache" + jq -n --slurpfile source evidence/buildx-source-input.json \ + --slurpfile baseGuard evidence/buildx-code-absence.json \ + --rawfile pluginFix security/buildx-source-scopes/CVE-2026-33997-official-commit.json \ + --rawfile plugin security/buildx-source-scopes/GO-2026-4883.json \ + --rawfile copy security/buildx-source-scopes/GO-2026-5668.json \ + --rawfile openpgp security/buildx-source-scopes/GO-2026-5932.json \ + '{source:$source[0],baseGuard:$baseGuard[0],pluginFix:$pluginFix,osv:{"GO-2026-4883":$plugin,"GO-2026-5668":$copy,"GO-2026-5932":$openpgp}}' \ + > evidence/buildx-additional-input.json + python3 -B .github/scripts/check_buildx_additional_scopes.py \ + < evidence/buildx-additional-input.json > evidence/buildx-additional-qualification.json docker run --rm --entrypoint python3 "$DAPPER_IMAGE" \ /opt/python-security/scripts/check-pip-vendor-slice.py \ > evidence/pip-vendor-proof.json docker run --rm --entrypoint dockerd "$DAPPER_IMAGE" --version > evidence/dockerd-version.txt + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PWD:/work" -w /work -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image \ + --skip-db-update --offline-scan --format cyclonedx \ + --output /work/evidence/dapper.cdx.json "$DAPPER_IMAGE" python3 scripts/pip-security/test-pip-vex.py python3 scripts/pip-security/filter-pip-vendor-findings.py \ - --raw evidence/dapper-security-raw.json --proof evidence/pip-vendor-proof.json \ + --raw evidence/dapper-security-buildx.json --proof evidence/pip-vendor-proof.json --sbom evidence/dapper.cdx.json \ --output evidence/dapper-security.json --vex evidence/pip-vendor.openvex.json \ --subject "$(docker image inspect --format '{{.Id}}' "$DAPPER_IMAGE")" docker run --rm --entrypoint sh "$DAPPER_IMAGE" -lc \ @@ -182,10 +212,37 @@ jobs: -v "$PWD:/work" -w /work -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image \ --skip-db-update --offline-scan --format cyclonedx \ --output /work/evidence/runtime.cdx.json "$RUNTIME_IMAGE" + mkdir -p evidence + consumer_container=$(docker create "$RUNTIME_IMAGE" /bin/true) + if docker cp "$consumer_container:/usr/share/pasturestack/security/ubuntu-backports" evidence/ubuntu-backports; then + docker rm "$consumer_container" >/dev/null + else + docker rm "$consumer_container" >/dev/null + exit 1 + fi + (cd evidence/ubuntu-backports && sha256sum --check SHA256SUMS) + bundle_image='ghcr.io/pasturestack/ubuntu-security-backports:v0.1.0@sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a' + mkdir -p evidence/bundle-tools + bundle_container=$(docker create "$bundle_image" unused) + if docker cp "$bundle_container:/out/verify-consumer-evidence.py" evidence/bundle-tools/verify-consumer-evidence.py && \ + docker cp "$bundle_container:/out/SHA256SUMS" evidence/bundle-tools/SHA256SUMS; then + docker rm "$bundle_container" >/dev/null + else + docker rm "$bundle_container" >/dev/null + exit 1 + fi + (cd evidence/bundle-tools && \ + test "$(grep -Ec '^[0-9a-f]{64} verify-consumer-evidence\.py$' SHA256SUMS)" -eq 1 && \ + grep -E '^[0-9a-f]{64} verify-consumer-evidence\.py$' SHA256SUMS | sha256sum --check -) + python3 evidence/bundle-tools/verify-consumer-evidence.py \ + --evidence-dir evidence/ubuntu-backports --sbom evidence/runtime.cdx.json \ + --bundle-checksums evidence/bundle-tools/SHA256SUMS \ + --expected-bundle-digest 'sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a' > evidence/ubuntu-backports-validation.json docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ -v "$PWD:/work" -w /work -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image \ - --skip-db-update --offline-scan --format cyclonedx \ - --output /work/evidence/dapper.cdx.json "$DAPPER_IMAGE" + --skip-db-update --offline-scan --scanners vuln,secret --severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL \ + --vex /work/evidence/ubuntu-backports/openvex.json --format json \ + --output /work/evidence/runtime-security.effective.json "$RUNTIME_IMAGE" - name: Enforce zero applicable Critical, High, or secrets shell: bash @@ -194,6 +251,7 @@ jobs: : > evidence/security-summary.txt for scope in source product runtime; do report="evidence/${scope}-security.json" + if [ "$scope" = runtime ]; then report=evidence/runtime-security.effective.json; fi secrets=$(jq '[.Results[]?.Secrets[]?] | length' "$report") critical=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' "$report") high=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' "$report") @@ -263,7 +321,7 @@ jobs: cp security/dapper-runtime-metadata.openvex.json evidence/ python3 .github/scripts/enforce-runtime-vulnerabilities.py --scope builder --repository PastureStack/node-agent < evidence/dapper-security.json python3 .github/scripts/test_runtime_vulnerability_policy.py - python3 .github/scripts/enforce-runtime-vulnerabilities.py --repository PastureStack/node-agent < evidence/runtime-security.json + python3 .github/scripts/enforce-runtime-vulnerabilities.py --repository PastureStack/node-agent < evidence/runtime-security.effective.json python3 .github/scripts/enforce-runtime-vulnerabilities.py --repository PastureStack/node-agent < evidence/product-security.json - name: Retain exact runtime for immutable release @@ -273,8 +331,9 @@ jobs: mkdir -p dist/security-runtime docker image inspect "$RUNTIME_IMAGE" > dist/security-runtime/images.json docker save "$RUNTIME_IMAGE" | gzip -n > dist/security-runtime/images.tar.gz - cp evidence/runtime-security.json evidence/runtime.cdx.json dist/security-runtime/ - cp dist/artifacts/node-agent-0.13.33.tar.gz dist/artifacts/node-agent-0.13.33-windows-amd64.zip dist/security-runtime/ + cp evidence/runtime-security.json evidence/runtime-security.effective.json evidence/runtime.cdx.json dist/security-runtime/ + tar -cf dist/security-runtime/ubuntu-backports-evidence.tar -C evidence ubuntu-backports ubuntu-backports-validation.json + cp dist/artifacts/node-agent-0.13.34.tar.gz dist/artifacts/node-agent-0.13.34-windows-amd64.zip dist/security-runtime/ printf '%s\n' "$GITHUB_SHA" > dist/security-runtime/source-revision.txt (cd dist/security-runtime && sha256sum -- * > SHA256SUMS) - name: Upload exact runtime release bytes @@ -288,10 +347,10 @@ jobs: - name: Retain exact scanned release packages uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: node-agent-scanned-0.13.33 + name: node-agent-scanned-0.13.34 path: | - dist/artifacts/node-agent-0.13.33.tar.gz - dist/artifacts/node-agent-0.13.33-windows-amd64.zip + dist/artifacts/node-agent-0.13.34.tar.gz + dist/artifacts/node-agent-0.13.34-windows-amd64.zip dist/artifacts/SHA256SUMS if-no-files-found: error retention-days: 30 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0e8b0694..706e5b4a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -52,7 +52,7 @@ jobs: id: package env: CROSS: "1" - VERSION_OVERRIDE: v0.13.33 + VERSION_OVERRIDE: v0.13.34 VALIDATION_SCOPE: ${{ inputs.validation_scope || 'full' }} run: | set -euo pipefail @@ -75,8 +75,8 @@ jobs: exit 1 ;; esac - linux_asset="dist/artifacts/node-agent-0.13.33.tar.gz" - windows_asset="dist/artifacts/node-agent-0.13.33-windows-amd64.zip" + linux_asset="dist/artifacts/node-agent-0.13.34.tar.gz" + windows_asset="dist/artifacts/node-agent-0.13.34-windows-amd64.zip" test -s "$linux_asset" test -s "$windows_asset" gzip -t "$linux_asset" @@ -92,8 +92,8 @@ jobs: ( cd dist/artifacts sha256sum \ - node-agent-0.13.33.tar.gz \ - node-agent-0.13.33-windows-amd64.zip >SHA256SUMS + node-agent-0.13.34.tar.gz \ + node-agent-0.13.34-windows-amd64.zip >SHA256SUMS sha256sum -c SHA256SUMS ) printf 'NODE_AGENT_VALIDATION_OK source=%s version=%s scope=%s artifacts=linux,windows\n' \ @@ -102,10 +102,10 @@ jobs: - name: Retain reviewed release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: node-agent-0.13.33 + name: node-agent-0.13.34 path: | - dist/artifacts/node-agent-0.13.33.tar.gz - dist/artifacts/node-agent-0.13.33-windows-amd64.zip + dist/artifacts/node-agent-0.13.34.tar.gz + dist/artifacts/node-agent-0.13.34-windows-amd64.zip dist/artifacts/SHA256SUMS if-no-files-found: error retention-days: 30 diff --git a/Dockerfile.dapper b/Dockerfile.dapper index f06ef179..d73a2481 100644 --- a/Dockerfile.dapper +++ b/Dockerfile.dapper @@ -101,7 +101,7 @@ RUN git config --system --add safe.directory '*' ARG GO_VERSION=1.27.3 ARG GO_SHA256_amd64=65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40 ARG GO_SHA256_arm=d6f4e11dd7157030d7ac11c51c8f73201a2db932d593c3106b1da11cc32bfdf8 -ARG SETUPTOOLS_VERSION=83.0.0 +ARG SETUPTOOLS_VERSION=84.1.0 ARG WHEEL_VERSION=0.47.0 ARG TOX_VERSION=4.57.2 COPY scripts/pip-security /opt/python-security/scripts @@ -110,6 +110,7 @@ RUN python3 /opt/python-security/scripts/build-pip-overlay.py --work /tmp/pip-ov ADD --checksum=sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl /opt/python-security/certifi-2026.10.19-py3-none-any.whl COPY scripts/install-python-security-tools /usr/local/bin/install-python-security-tools COPY scripts/install-dapper-container-tools /usr/local/bin/install-dapper-container-tools +COPY toolchain/build-buildx /usr/local/libexec/pasturestack/build-buildx ENV VIRTUALENV_PIP=26.2.2+pasturestack.1 VIRTUALENV_EXTRA_SEARCH_DIR=/usr/share/python-wheels VIRTUALENV_NO_PERIODIC_UPDATE=1 ENV GOLANG_ARCH_amd64=amd64 GOLANG_ARCH_arm=armv6l GOLANG_ARCH=GOLANG_ARCH_${ARCH} \ GO_SHA256=GO_SHA256_${ARCH} \ @@ -152,6 +153,8 @@ ENV DAPPER_OUTPUT="bin dist" ENV DAPPER_ENV="TAG REPO CROSS VERSION_OVERRIDE NO_TEST TEST_BUSYBOX_IMAGE TEST_BUSYBOX_ALIAS" ENV DAPPER_RUN_ARGS="--privileged --cgroupns=host" ENV HOME=/tmp GOCACHE=/tmp/go-build-cache GOMODCACHE=/tmp/go-mod-cache +RUN bash /usr/local/libexec/pasturestack/build-buildx + WORKDIR ${DAPPER_SOURCE} VOLUME "/var/lib/docker" diff --git a/README.md b/README.md index 1c46126f..98c0ffda 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # PastureStack Node Agent +Linux runtime **`v0.13.34`** integrates shared Ubuntu 26.04 security backports for already-installed packages while preserving the selected coreutils provider. + +The image retains bundle manifests, source inputs and installation receipts under `/usr/share/pasturestack/security/ubuntu-backports/`. + Node Agent receives control-platform events on compute nodes, executes the corresponding container, storage, host, and configuration operations, and publishes compatible replies. PastureStack is an independent community effort to preserve, audit, and modernize the Rancher 1.6 ecosystem. It is not affiliated with or endorsed by Rancher Labs or SUSE. @@ -22,13 +26,12 @@ From a Docker-capable Linux host: make test make build make package -make package-image IMAGE_NAME=pasturestack/node-agent TAG=v0.13.33 +make package-image IMAGE_NAME=pasturestack/node-agent TAG=v0.13.34 ``` Packaging is local only and does not push an image. See [COMPATIBILITY.md](COMPATIBILITY.md), [SECURITY.md](SECURITY.md), and [ORIGIN.md](ORIGIN.md). -The current recipe packages version `0.13.33`. Source changes alone are not -publication proof: use the [official releases](https://github.com/PastureStack/node-agent/releases) +The recipe packages version `0.13.34`. Verify published assets using use the [official releases](https://github.com/PastureStack/node-agent/releases) and their SHA-256 checksums to verify published packages. Published tags and assets remain immutable; later documentation commits do not change their producer identity. The outer bootstrap image version and the Go package version are separate identities. @@ -39,18 +42,19 @@ client 0.5, gopsutil v4.26, mapstructure v2.5, netlink v1.3, and netns v0.0.5. The retired AWS SDK v1, Aliyungo, root Docker module, GOPATH/Godeps, and Trash dependency paths are not part of the build. -`VERSION_OVERRIDE=v0.13.33 make package` produces the deterministic Linux asset `node-agent-0.13.33.tar.gz`. The archive carries the legacy SHA-1 and current SHA-256 manifests required by the authenticated configcontent installer. The producer release is published by this repository; Server can embed that verified archive and distribute it through the existing configuration channel. Component publication and matching Server acceptance are separate evidence boundaries. +`VERSION_OVERRIDE=v0.13.34 make package` produces the Linux asset `node-agent-0.13.34.tar.gz`. The archive carries the legacy SHA-1 and current SHA-256 manifests required by the authenticated configcontent installer. The producer release is published by this repository; Server can embed that verified archive and distribute it through the existing configuration channel. Component publication and matching Server acceptance are separate evidence boundaries. Upgrade existing Linux hosts through that established configcontent/pyagent sequence, installing the compatible Host API package before Node. Keep the existing platform-home data volume and Host ID; do not register a replacement host or create a replacement state volume. Read the installed Node version with `${PASTURESTACK_HOME:-${CATTLE_HOME:-/var/lib/pasturestack}}/node-agent/node-agent --version`; -the expected result is `node-agent version v0.13.33`. +after installing this Linux version, the expected result is +`node-agent version v0.13.34`. On Linux, Node supervises the separately maintained `host-api` producer at `${PASTURESTACK_HOME:-${CATTLE_HOME:-/var/lib/pasturestack}}/bin/host-api`. Host API `0.38.8` must be installed before the Node package by the existing `pyagent` package sequence. Node verifies its exact version, forwards the current agent/host identity only through process environment, restarts an exited child, and binds its lifetime to Node. Missing or incompatible producers never fall back to the embedded legacy Host API. Stream authorization, target binding, and private durable terminal evidence remain owned by the Host API producer. -Windows retains the existing embedded compatibility path; this release does not provide Windows stream delegation or durable terminal evidence support. A replacement Windows bootstrap image and upgrade/rollback tests are still required before Windows hosts are supported. The Server recipe independently pins its Windows artifact; a Linux package publication and CI cross-builds do not establish new Windows runtime acceptance. +Windows retains the existing embedded compatibility path; this Linux image preparation does not rebuild or revalidate the separately pinned Windows runtime or provide Windows stream delegation or durable terminal evidence support. A replacement Windows bootstrap image and upgrade/rollback tests are still required before Windows hosts are supported. The Server recipe independently pins its Windows artifact; a Linux package publication and CI cross-builds do not establish new Windows runtime acceptance. The `host.port.check` event performs a read-only host-port preflight through the existing agent event channel. It reports Docker bindings from running and stopped containers and, on Linux, listening TCP/UDP sockets visible through the existing host `/proc` mount. Incomplete host socket inspection is reported as unknown; it is never presented as an available port. @@ -77,15 +81,11 @@ The disposable Dapper image rebuilds pip `26.2.2+pasturestack.1` from checksum-p ## Full-severity security rebuild -The maintained release is **v0.13.33**. It updates only already-installed Ubuntu 26.04 packages using the official signed 2026-10-23 snapshot and obtains the trust bundle from the SHA-pinned official CA package. Host ID, persistent agent state, startup arguments and the required host-api v0.38.8 remain unchanged. - -The [successful producer gate](https://github.com/PastureStack/node-agent/actions/runs/39984353202) -built commit `0aadd6f79890c994da2c047214a2a224471f9191`; signed release tag -`v0.13.33` points to merged commit `2025a5a9a03c645fc18d1a2c8a345617888c2a57` -with the identical source tree. The [formal publisher](https://github.com/PastureStack/node-agent/actions/runs/39986320411) -retained those exact tested Linux/Windows archives and runtime image. -Published OCI verification digest: -`sha256:8f961ac120e3ef812ddf750d6431a99993e62077ece4a432f220a65e8dbb3877`. +The recipe updates only already-installed Ubuntu 26.04 packages and obtains +the trust bundle from the SHA-pinned official CA package. Host ID, persistent +agent state, startup arguments and the required host-api v0.38.8 are unchanged. +Use the selected release's checksum-covered assets and provenance to verify +its producer and immutable image bytes. The full-severity runtime gate rejects every finding with an official fix, including LOW, MEDIUM and UNKNOWN. Only exact reviewed LOW/MEDIUM no-fix entries diff --git a/package/Dockerfile b/package/Dockerfile index 30f4f9c3..4ae8dc58 100644 --- a/package/Dockerfile +++ b/package/Dockerfile @@ -1,4 +1,5 @@ # syntax=docker/dockerfile:1.7 +FROM ghcr.io/pasturestack/ubuntu-security-backports:v0.1.0@sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a AS ubuntu_security_backports FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b AS certificates COPY ca-certificates.deb /tmp/ca-certificates.deb RUN set -eu; \ @@ -40,4 +41,20 @@ RUN set -eu; \ RUN sh /usr/local/libexec/pasturestack/install-ubuntu-security-updates \ /licenses/ubuntu-security-packages.lock /etc/ssl/certs/ca-certificates.crt COPY node-agent /usr/bin/ +# Install qualified backports only for packages already in this runtime. +# The immutable read-only build mount adds no installer or interpreter to it. +RUN --mount=from=ubuntu_security_backports,source=/out,target=/opt/pasturestack/ubuntu-security-backports,ro \ + set -eux; \ + evidence=/usr/share/pasturestack/security/ubuntu-backports; \ + mkdir -p "${evidence}"; \ + /opt/pasturestack/ubuntu-security-backports/install-existing-backports --install-in-docker-build > "${evidence}/install.json"; \ + /opt/pasturestack/ubuntu-security-backports/install-existing-backports --verify-installed > "${evidence}/verify.json"; \ + for document in manifest.json qualification.json openvex.json source-inputs.lock.json; do \ + cp "/opt/pasturestack/ubuntu-security-backports/${document}" "${evidence}/${document}"; \ + done; \ + printf '%s\n' 'ghcr.io/pasturestack/ubuntu-security-backports:v0.1.0@sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a' > "${evidence}/bundle-image.txt"; \ + (cd "${evidence}" && sha256sum manifest.json qualification.json openvex.json source-inputs.lock.json install.json verify.json bundle-image.txt > SHA256SUMS); \ + dpkg-query -W -f='${binary:Package}\t${Version}\n' | LC_ALL=C sort > /usr/share/pasturestack/ubuntu-security-packages.tsv; \ + rm -f /var/cache/ldconfig/aux-cache + CMD ["node-agent"] diff --git a/scripts/pip-security/README.md b/scripts/pip-security/README.md index e354536a..11125c94 100644 --- a/scripts/pip-security/README.md +++ b/scripts/pip-security/README.md @@ -63,13 +63,16 @@ bash verify-pip-runtime.sh --virtualenv /usr/share/python-wheels 不為消除套件名稱掃描結果而更動未受影響的 msgpack。 - setuptools 70.3.0 僅保留 `pkg_resources`;CVE-2025-47273 所需的 `setuptools/package_index.py` 與 CVE-2026-59890 所需的 - `setuptools/command/egg_info.py` 均不存在。 + `setuptools/command/egg_info.py` 均不存在;GHSA-xmj3-9gf7-h52w 所需的 + `setuptools/wheel.py` 與 `Wheel.install_as_egg` 也不存在。 `check-pip-vendor-slice.py` 會比對實際載入的全部 299 個 vendor 檔案與 固定 wheel,拒絕額外檔案、其他同版本完整 distribution。只有取得此證據後, -`filter-pip-vendor-findings.py` 才可對上述三個 CVE(含 msgpack GHSA 別名) +`filter-pip-vendor-findings.py` 才可對上述 CVE/GHSA 產生綁定該映像的 OpenVEX 與有效掃描檔。原始掃描必須另行保存;未知 CVE、 其他版本、非 SBOM 套件來源與 urllib3 finding 全部保留,不做泛用版本排除。 -執行 `python3 test-pip-vex.py` 可驗證以上六組正反控制。 +namespace wheel GHSA 另要求原生 CycloneDX、精確 PURL 與固定 299 檔 inventory +SHA-256。完整 setuptools distribution 仍須升到官方修補版,不能繼承此缺碼判定。 +執行 `python3 test-pip-vex.py` 可驗證以上八組正反控制。 官方政策:https://pip.pypa.io/en/stable/development/vendoring-policy/ diff --git a/scripts/pip-security/check-pip-vendor-slice.py b/scripts/pip-security/check-pip-vendor-slice.py index 789bc280..3fddeb77 100644 --- a/scripts/pip-security/check-pip-vendor-slice.py +++ b/scripts/pip-security/check-pip-vendor-slice.py @@ -7,8 +7,6 @@ import json from pathlib import Path import zipfile -import pip -from pip._vendor import msgpack, urllib3 EXPECTED_SHA = "be5f63d537bbe611dc422fabb6740a44b5997888b592888df5091727f023802a" FINDINGS = { @@ -16,28 +14,40 @@ "CVE-2026-57585": ("msgpack", "1.1.2", "C/Cython unpacker is absent; runtime selects pure Python fallback"), "CVE-2025-47273": ("setuptools", "70.3.0", "setuptools/package_index.py is absent; only pkg_resources is vendored"), "CVE-2026-59890": ("setuptools", "70.3.0", "setuptools/command/egg_info.py and FileList are absent"), + "GHSA-xmj3-9gf7-h52w": ("setuptools", "70.3.0", "setuptools/wheel.py and Wheel.install_as_egg are absent; only pkg_resources is vendored"), } +def require_setuptools_wheel_absent(wheel_names, live_vendor): + assert "pip/_vendor/setuptools/wheel.py" not in wheel_names, "Vulnerable setuptools wheel module is in the selected wheel" + assert not live_vendor.joinpath("setuptools/wheel.py").is_file(), "Vulnerable setuptools wheel module is installed" + + def verify(wheel): + import pip + from pip._vendor import msgpack, urllib3 assert hashlib.sha256(wheel.read_bytes()).hexdigest() == EXPECTED_SHA assert pip.__version__ == "26.2.2+pasturestack.1" assert urllib3.__version__ == "2.8.0" assert msgpack.Unpacker.__module__ == "pip._vendor.msgpack.fallback" live = importlib.resources.files("pip") checked = 0 + inventory = [] with zipfile.ZipFile(wheel) as archive: names = archive.namelist() expected_files = set() for name in names: if not name.startswith("pip/_vendor/") or name.endswith("/"): continue - assert live.joinpath(name.removeprefix("pip/")).read_bytes() == archive.read(name), name + content = archive.read(name) + assert live.joinpath(name.removeprefix("pip/")).read_bytes() == content, name expected_files.add(name.removeprefix("pip/_vendor/")) + inventory.append({"path": name.removeprefix("pip/_vendor/"), "sha256": hashlib.sha256(content).hexdigest()}) checked += 1 assert not any(name.startswith("pip/_vendor/") and name.endswith((".so", ".pyd")) for name in names) assert not any(name.startswith("pip/_vendor/setuptools/") for name in names) assert not any(name.startswith("pip/_vendor/msgpack/_cmsgpack") for name in names) + require_setuptools_wheel_absent(names, live.joinpath("_vendor")) def live_files(directory, prefix=""): for entry in directory.iterdir(): if entry.name == "__pycache__": @@ -54,10 +64,13 @@ def live_files(directory, prefix=""): assert distribution.version != "70.3.0", "A full setuptools 70.3.0 distribution is installed" if distribution.metadata.get("Name", "").lower() == "msgpack": assert distribution.version != "1.1.2", "A separate msgpack 1.1.2 distribution is installed" + inventory.sort(key=lambda item: item["path"]) + inventory_sha = hashlib.sha256(json.dumps(inventory, sort_keys=True, separators=(",", ":")).encode()).hexdigest() return {"verifiedWheelSha256": EXPECTED_SHA, "pip": pip.__version__, "urllib3": urllib3.__version__, "vendorFilesByteCompared": checked, "msgpackImplementation": msgpack.Unpacker.__module__, "setuptoolsPackageAbsent": True, "scope": "this exact pip vendor slice only", + "setuptoolsWheelModuleAbsent": True, "vendorInventory": inventory, "vendorInventorySha256": inventory_sha, "notAffected": [{"id": key, "package": value[0], "version": value[1], "justification": "vulnerable_code_not_present", "detail": value[2]} for key, value in FINDINGS.items()]} diff --git a/scripts/pip-security/filter-pip-vendor-findings.py b/scripts/pip-security/filter-pip-vendor-findings.py index 5a7c1907..1176e8dd 100644 --- a/scripts/pip-security/filter-pip-vendor-findings.py +++ b/scripts/pip-security/filter-pip-vendor-findings.py @@ -4,18 +4,45 @@ from copy import deepcopy from datetime import datetime, timezone import json +import hashlib +import re from pathlib import Path WHEEL_SHA = "be5f63d537bbe611dc422fabb6740a44b5997888b592888df5091727f023802a" +VENDOR_INVENTORY_SHA = "144483e54419f59a5186652b932f7690d7b8bfaaab61ccc790c2881a89ab6212" RULES = { "GHSA-6v7p-g79w-8964": ("msgpack", "1.1.2"), "CVE-2026-57585": ("msgpack", "1.1.2"), "CVE-2025-47273": ("setuptools", "70.3.0"), "CVE-2026-59890": ("setuptools", "70.3.0"), + "GHSA-xmj3-9gf7-h52w": ("setuptools", "70.3.0"), } -def apply(raw, proof, subject): +def namespace_wheel_absent(proof, sbom): + rows = proof.get("vendorInventory", []) + if proof.get("setuptoolsWheelModuleAbsent") is not True or len(rows) != 299: + return False + if not all(isinstance(row, dict) and set(row) == {"path", "sha256"} + and isinstance(row["path"], str) and row["path"] and "\\" not in row["path"] + and not row["path"].startswith("/") and ".." not in row["path"].split("/") + and "setuptools" not in row["path"].split("/") + and isinstance(row["sha256"], str) and re.fullmatch("[0-9a-f]{64}", row["sha256"]) + for row in rows): + return False + if len({row["path"] for row in rows}) != 299 or rows != sorted(rows, key=lambda row: row["path"]): + return False + digest = hashlib.sha256(json.dumps(rows, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + if digest != proof.get("vendorInventorySha256") or digest != VENDOR_INVENTORY_SHA or sbom is None: + return False + document = json.loads(sbom) + packages = [c for c in document.get("components", []) if c.get("purl") == "pkg:pypi/setuptools@70.3.0"] + return document.get("bomFormat") == "CycloneDX" and packages and all( + c.get("name") == "setuptools" and c.get("version") == "70.3.0" and c.get("type") == "library" + for c in packages) + + +def apply(raw, proof, subject, sbom=None): valid = (proof["verifiedWheelSha256"] == WHEEL_SHA and proof["pip"] == "26.2.2+pasturestack.1" and proof["urllib3"] == "2.8.0" and proof["msgpackImplementation"] == "pip._vendor.msgpack.fallback" and @@ -38,6 +65,11 @@ def apply(raw, proof, subject): (reason.get("package"), reason.get("version")) == actual and section.get("Type") == "python-pkg" and packages and all(pkg.get("AnalyzedBy") == "sbom" for pkg in packages)) + if key == "GHSA-xmj3-9gf7-h52w": + exact_purl = "pkg:pypi/setuptools@70.3.0" + eligible = (eligible and item.get("PkgIdentifier", {}).get("PURL") == exact_purl + and all(pkg.get("Identifier", {}).get("PURL") == exact_purl for pkg in packages) + and namespace_wheel_absent(proof, sbom)) if not eligible: retained.append(item) continue @@ -48,6 +80,9 @@ def apply(raw, proof, subject): "status": "not_affected", "justification": "vulnerable_code_not_present", "impact_statement": reasons[key]["detail"] + "; exact pip wheel SHA-256 " + WHEEL_SHA, }) + if key == "GHSA-xmj3-9gf7-h52w": + statements[-1]["impact_statement"] += ("; live/selected-wheel inventory SHA-256 " + + proof["vendorInventorySha256"] + "; native CycloneDX SHA-256 " + hashlib.sha256(sbom).hexdigest()) section["Vulnerabilities"] = retained vex = {"@context": "https://openvex.dev/ns/v0.2.0", "@id": subject + "/pip-vendor-vex", "author": "PastureStack", "timestamp": datetime.now(timezone.utc).isoformat(), @@ -62,8 +97,10 @@ def apply(raw, proof, subject): parser.add_argument("--output", type=Path, required=True) parser.add_argument("--vex", type=Path, required=True) parser.add_argument("--subject", required=True) + parser.add_argument("--sbom", type=Path, help="Actual native CycloneDX bytes required for the namespace-wheel advisory") args = parser.parse_args() - report, vex = apply(json.loads(args.raw.read_text()), json.loads(args.proof.read_text()), args.subject) + report, vex = apply(json.loads(args.raw.read_text()), json.loads(args.proof.read_text()), args.subject, + args.sbom.read_bytes() if args.sbom else None) args.output.write_text(json.dumps(report, indent=2) + "\n") args.vex.write_text(json.dumps(vex, indent=2) + "\n") print(f"PROVEN_PIP_VENDOR_VEX statements={len(vex['statements'])}") diff --git a/scripts/pip-security/test-pip-vex.py b/scripts/pip-security/test-pip-vex.py index 9baa3e1c..18bbfad3 100644 --- a/scripts/pip-security/test-pip-vex.py +++ b/scripts/pip-security/test-pip-vex.py @@ -3,11 +3,18 @@ from copy import deepcopy import importlib.util from pathlib import Path +import hashlib +import json +import tempfile import unittest +from unittest.mock import patch spec = importlib.util.spec_from_file_location("pip_vex", Path(__file__).with_name("filter-pip-vendor-findings.py")) vex = importlib.util.module_from_spec(spec) spec.loader.exec_module(vex) +proof_spec = importlib.util.spec_from_file_location("pip_vendor_proof", Path(__file__).with_name("check-pip-vendor-slice.py")) +vendor_proof = importlib.util.module_from_spec(proof_spec) +proof_spec.loader.exec_module(vendor_proof) class VexBoundary(unittest.TestCase): @@ -17,17 +24,26 @@ def setUp(self): "setuptoolsPackageAbsent": True, "vendorFilesByteCompared": 299, "notAffected": [{"id": key, "package": value[0], "version": value[1], "detail": "Test fixture only"} for key, value in vex.RULES.items()]} + self.proof["setuptoolsWheelModuleAbsent"] = True + self.proof["vendorInventory"] = [{"path": f"fixture/{n:03d}.py", "sha256": "a" * 64} for n in range(299)] + self.rehash_inventory(self.proof) + # This synthetic metadata fixture is not a claim about installed files. + anchor = patch.object(vex, "VENDOR_INVENTORY_SHA", self.proof["vendorInventorySha256"]) + anchor.start(); self.addCleanup(anchor.stop) + self.sbom = json.dumps({"bomFormat": "CycloneDX", "components": [{"type": "library", "name": "setuptools", + "version": "70.3.0", "purl": "pkg:pypi/setuptools@70.3.0"}]}).encode() self.raw = {"Results": [{"Type": "python-pkg", "Packages": [ - {"Name": name, "Version": version, "AnalyzedBy": "sbom"} + {"Name": name, "Version": version, "AnalyzedBy": "sbom", "Identifier": {"PURL": "pkg:pypi/" + name + "@" + version}} for name, version in set(vex.RULES.values())], "Vulnerabilities": [ - {"VulnerabilityID": key, "PkgName": value[0], "InstalledVersion": value[1]} + {"VulnerabilityID": key, "PkgName": value[0], "InstalledVersion": value[1], + "PkgIdentifier": {"PURL": "pkg:pypi/" + value[0] + "@" + value[1]}} for key, value in vex.RULES.items()]}]} def test_exact_slice_only(self): original = deepcopy(self.raw) - result, document = vex.apply(self.raw, self.proof, "urn:test:exact-image") + result, document = vex.apply(self.raw, self.proof, "urn:test:exact-image", self.sbom) self.assertEqual(result["Results"][0]["Vulnerabilities"], []) - self.assertEqual(len(document["statements"]), 4) + self.assertEqual(len(document["statements"]), 5) self.assertEqual(self.raw, original, "raw evidence must not be mutated") def test_invalid_proof_fails_closed(self): @@ -35,34 +51,68 @@ def test_invalid_proof_fails_closed(self): ("vendorFilesByteCompared", 300), ("setuptoolsPackageAbsent", False)]: proof = dict(self.proof, **{key: value}) with self.subTest(key=key), self.assertRaises(ValueError): - vex.apply(self.raw, proof, "urn:test:image") + vex.apply(self.raw, proof, "urn:test:image", self.sbom) def test_full_distribution_is_not_excluded(self): for package in self.raw["Results"][0]["Packages"]: package["AnalyzedBy"] = "python-pkg" - result, document = vex.apply(self.raw, self.proof, "urn:test:image") - self.assertEqual(len(result["Results"][0]["Vulnerabilities"]), 4) + result, document = vex.apply(self.raw, self.proof, "urn:test:image", self.sbom) + self.assertEqual(len(result["Results"][0]["Vulnerabilities"]), 5) self.assertEqual(document["statements"], []) def test_missing_packages_is_not_excluded(self): self.raw["Results"][0]["Packages"] = [] - result, _ = vex.apply(self.raw, self.proof, "urn:test:image") - self.assertEqual(len(result["Results"][0]["Vulnerabilities"]), 4) + result, _ = vex.apply(self.raw, self.proof, "urn:test:image", self.sbom) + self.assertEqual(len(result["Results"][0]["Vulnerabilities"]), 5) def test_wrong_version_is_not_excluded(self): for finding in self.raw["Results"][0]["Vulnerabilities"]: finding["InstalledVersion"] = "999.0" - result, document = vex.apply(self.raw, self.proof, "urn:test:image") - self.assertEqual(len(result["Results"][0]["Vulnerabilities"]), 4) + result, document = vex.apply(self.raw, self.proof, "urn:test:image", self.sbom) + self.assertEqual(len(result["Results"][0]["Vulnerabilities"]), 5) self.assertEqual(document["statements"], []) def test_unknown_advisory_and_urllib3_are_retained(self): unknown = {"VulnerabilityID": "CVE-TEST-UNKNOWN", "PkgName": "setuptools", "InstalledVersion": "70.3.0"} urllib = {"VulnerabilityID": "CVE-2026-97689", "PkgName": "urllib3", "InstalledVersion": "2.7.0"} self.raw["Results"][0]["Vulnerabilities"] += [unknown, urllib] - result, _ = vex.apply(self.raw, self.proof, "urn:test:image") + result, _ = vex.apply(self.raw, self.proof, "urn:test:image", self.sbom) self.assertEqual(result["Results"][0]["Vulnerabilities"], [unknown, urllib]) + @staticmethod + def rehash_inventory(proof): + proof["vendorInventorySha256"] = hashlib.sha256(json.dumps( + proof["vendorInventory"], sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + def test_actual_wheel_module_presence_fails_source_proof(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + vendor_proof.require_setuptools_wheel_absent([], root) + with self.assertRaisesRegex(AssertionError, "selected wheel"): + vendor_proof.require_setuptools_wheel_absent(["pip/_vendor/setuptools/wheel.py"], root) + (root / "setuptools").mkdir(); (root / "setuptools/wheel.py").write_text("class Wheel: pass\n") + with self.assertRaisesRegex(AssertionError, "installed"): + vendor_proof.require_setuptools_wheel_absent([], root) + + def test_new_advisory_requires_inventory_module_absence_and_native_sbom(self): + for kind in ("missing-proof", "module-present", "inventory-tamper", "missing-sbom", "wrong-sbom", "finding-purl", "package-purl"): + proof = deepcopy(self.proof); sbom = self.sbom; raw = deepcopy(self.raw) + if kind == "missing-proof": proof.pop("setuptoolsWheelModuleAbsent") + if kind == "module-present": + proof["vendorInventory"][-1]["path"] = "setuptools/wheel.py" + proof["vendorInventory"].sort(key=lambda row: row["path"]); self.rehash_inventory(proof) + if kind == "inventory-tamper": proof["vendorInventory"][0]["sha256"] = "b" * 64 + if kind == "missing-sbom": sbom = None + if kind == "wrong-sbom": sbom = b'{"bomFormat":"CycloneDX","components":[]}' + if kind == "finding-purl": + next(v for v in raw["Results"][0]["Vulnerabilities"] if v["VulnerabilityID"] == "GHSA-xmj3-9gf7-h52w")["PkgIdentifier"]["PURL"] = "pkg:pypi/setuptools@999" + if kind == "package-purl": + next(p for p in raw["Results"][0]["Packages"] if p["Name"] == "setuptools")["Identifier"]["PURL"] = "pkg:pypi/setuptools@999" + result, document = vex.apply(raw, proof, "urn:test:image", sbom) + with self.subTest(kind=kind): + self.assertEqual([v["VulnerabilityID"] for v in result["Results"][0]["Vulnerabilities"]], ["GHSA-xmj3-9gf7-h52w"]) + self.assertEqual(len(document["statements"]), 4) + if __name__ == "__main__": unittest.main() diff --git a/scripts/version b/scripts/version index 4db86efb..f2fde1bd 100755 --- a/scripts/version +++ b/scripts/version @@ -5,4 +5,4 @@ if [ -n "$VERSION_OVERRIDE" ]; then return 0 fi -VERSION=v0.13.33 +VERSION=v0.13.34 diff --git a/security/buildx-source-scopes/CVE-2026-33997-official-commit.json b/security/buildx-source-scopes/CVE-2026-33997-official-commit.json new file mode 100644 index 00000000..ad588127 --- /dev/null +++ b/security/buildx-source-scopes/CVE-2026-33997-official-commit.json @@ -0,0 +1 @@ +{"sha":"f4d6f25bf0c3fa12d4968320a45685947756a22a","node_id":"C_kwDOAHVdb9oAKGY0ZDZmMjViZjBjM2ZhMTJkNDk2ODMyMGE0NTY4NTk0Nzc1NmEyMmE","commit":{"author":{"name":"Paweł Gronowski","email":"pawel.gronowski@docker.com","date":"2026-03-19T18:18:23Z"},"committer":{"name":"Paweł Gronowski","email":"pawel.gronowski@docker.com","date":"2026-03-25T11:39:54Z"},"message":"plugin: Fix off-by-one in privilege validation\n\nFix an off-by-one error in isEqual() where the comparison loop started\nat index 1 instead of 0, causing the first privilege (after sorting\nalphabetically by name) to never be validated.\n\nThis allowed a malicious plugin to request different values for\nwhichever privilege sorts first — most notably \"allow-all-devices\",\nwhich grants unrestricted rwm access to all host devices.\n\nThe bug also meant that plugins requesting exactly one privilege had\nzero iterations of the comparison loop, bypassing validation entirely.\n\nAlso fix an existing test case (\"diff-order-but-same-value\") that only\npassed due to the off-by-one bug, and add test cases for single-element\nand first-sorted-element mismatches.\n\nSigned-off-by: Paweł Gronowski \n(cherry picked from commit 99a095ecf04e8849318f2811bb3f687905eab09b)\nSigned-off-by: Paweł Gronowski ","tree":{"sha":"75fea251a7d4e009641d45bc07da4bc585e53894","url":"https://api.github.com/repos/moby/moby/git/trees/75fea251a7d4e009641d45bc07da4bc585e53894"},"url":"https://api.github.com/repos/moby/moby/git/commits/f4d6f25bf0c3fa12d4968320a45685947756a22a","comment_count":1,"verification":{"verified":true,"reason":"valid","signature":"-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgXi70GC3xMawjnpzlUwobhOg7jn\nSUnsOGjQSmZqqQT2cAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\nAAAAQGixpvi7HGVYGfT0iQFEwlPK+GhSZVuPrwV1XPT7OBBqtYtK11Y9UclzK+weX7/Ujq\nNd15c9qAJrBNOYQKDUKw0=\n-----END SSH SIGNATURE-----","payload":"tree 75fea251a7d4e009641d45bc07da4bc585e53894\nparent 841b246add059086c37e45094d30326fe165555c\nauthor Paweł Gronowski 1773944303 +0100\ncommitter Paweł Gronowski 1774438794 +0100\n\nplugin: Fix off-by-one in privilege validation\n\nFix an off-by-one error in isEqual() where the comparison loop started\nat index 1 instead of 0, causing the first privilege (after sorting\nalphabetically by name) to never be validated.\n\nThis allowed a malicious plugin to request different values for\nwhichever privilege sorts first — most notably \"allow-all-devices\",\nwhich grants unrestricted rwm access to all host devices.\n\nThe bug also meant that plugins requesting exactly one privilege had\nzero iterations of the comparison loop, bypassing validation entirely.\n\nAlso fix an existing test case (\"diff-order-but-same-value\") that only\npassed due to the off-by-one bug, and add test cases for single-element\nand first-sorted-element mismatches.\n\nSigned-off-by: Paweł Gronowski \n(cherry picked from commit 99a095ecf04e8849318f2811bb3f687905eab09b)\nSigned-off-by: Paweł Gronowski \n","verified_at":"2026-03-25T15:50:00Z"}},"url":"https://api.github.com/repos/moby/moby/commits/f4d6f25bf0c3fa12d4968320a45685947756a22a","html_url":"https://github.com/moby/moby/commit/f4d6f25bf0c3fa12d4968320a45685947756a22a","comments_url":"https://api.github.com/repos/moby/moby/commits/f4d6f25bf0c3fa12d4968320a45685947756a22a/comments","author":{"login":"vvoland","id":5046555,"node_id":"MDQ6VXNlcjUwNDY1NTU=","avatar_url":"https://avatars.githubusercontent.com/u/5046555?v=4","gravatar_id":"","url":"https://api.github.com/users/vvoland","html_url":"https://github.com/vvoland","followers_url":"https://api.github.com/users/vvoland/followers","following_url":"https://api.github.com/users/vvoland/following{/other_user}","gists_url":"https://api.github.com/users/vvoland/gists{/gist_id}","starred_url":"https://api.github.com/users/vvoland/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vvoland/subscriptions","organizations_url":"https://api.github.com/users/vvoland/orgs","repos_url":"https://api.github.com/users/vvoland/repos","events_url":"https://api.github.com/users/vvoland/events{/privacy}","received_events_url":"https://api.github.com/users/vvoland/received_events","type":"User","user_view_type":"public","site_admin":false},"committer":{"login":"vvoland","id":5046555,"node_id":"MDQ6VXNlcjUwNDY1NTU=","avatar_url":"https://avatars.githubusercontent.com/u/5046555?v=4","gravatar_id":"","url":"https://api.github.com/users/vvoland","html_url":"https://github.com/vvoland","followers_url":"https://api.github.com/users/vvoland/followers","following_url":"https://api.github.com/users/vvoland/following{/other_user}","gists_url":"https://api.github.com/users/vvoland/gists{/gist_id}","starred_url":"https://api.github.com/users/vvoland/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/vvoland/subscriptions","organizations_url":"https://api.github.com/users/vvoland/orgs","repos_url":"https://api.github.com/users/vvoland/repos","events_url":"https://api.github.com/users/vvoland/events{/privacy}","received_events_url":"https://api.github.com/users/vvoland/received_events","type":"User","user_view_type":"public","site_admin":false},"parents":[{"sha":"841b246add059086c37e45094d30326fe165555c","url":"https://api.github.com/repos/moby/moby/commits/841b246add059086c37e45094d30326fe165555c","html_url":"https://github.com/moby/moby/commit/841b246add059086c37e45094d30326fe165555c"}],"stats":{"total":94,"additions":69,"deletions":25},"files":[{"sha":"ec90e465e44e8393dde3248c54b9435b8faa028f","filename":"daemon/pkg/plugin/manager.go","status":"modified","additions":29,"deletions":21,"changes":50,"blob_url":"https://github.com/moby/moby/blob/f4d6f25bf0c3fa12d4968320a45685947756a22a/daemon%2Fpkg%2Fplugin%2Fmanager.go","raw_url":"https://github.com/moby/moby/raw/f4d6f25bf0c3fa12d4968320a45685947756a22a/daemon%2Fpkg%2Fplugin%2Fmanager.go","contents_url":"https://api.github.com/repos/moby/moby/contents/daemon%2Fpkg%2Fplugin%2Fmanager.go?ref=f4d6f25bf0c3fa12d4968320a45685947756a22a","patch":"@@ -1,13 +1,13 @@\n package plugin\n \n import (\n+\t\"cmp\"\n \t\"context\"\n \t\"encoding/json\"\n \t\"io\"\n \t\"os\"\n \t\"path/filepath\"\n-\t\"reflect\"\n-\t\"sort\"\n+\t\"slices\"\n \t\"strings\"\n \t\"sync\"\n \t\"syscall\"\n@@ -337,34 +337,42 @@ func makeLoggerStreams(id string) (stdout, stderr io.WriteCloser) {\n }\n \n func validatePrivileges(requiredPrivileges, privileges plugin.Privileges) error {\n-\tif !isEqual(requiredPrivileges, privileges, isEqualPrivilege) {\n+\tif len(requiredPrivileges) != len(privileges) {\n \t\treturn errors.New(\"incorrect privileges\")\n \t}\n \n-\treturn nil\n-}\n-\n-func isEqual(arrOne, arrOther plugin.Privileges, compare func(x, y plugin.Privilege) bool) bool {\n-\tif len(arrOne) != len(arrOther) {\n-\t\treturn false\n-\t}\n-\n-\tsort.Sort(arrOne)\n-\tsort.Sort(arrOther)\n+\ta := normalizePrivileges(requiredPrivileges)\n+\tb := normalizePrivileges(privileges)\n \n-\tfor i := 1; i < arrOne.Len(); i++ {\n-\t\tif !compare(arrOne[i], arrOther[i]) {\n-\t\t\treturn false\n+\tfor i := range a {\n+\t\tif a[i].Name != b[i].Name {\n+\t\t\treturn errors.New(\"incorrect privileges\")\n+\t\t}\n+\t\tif !slices.Equal(a[i].Value, b[i].Value) {\n+\t\t\treturn errors.New(\"incorrect privileges\")\n \t\t}\n \t}\n \n-\treturn true\n+\treturn nil\n }\n \n-func isEqualPrivilege(a, b plugin.Privilege) bool {\n-\tif a.Name != b.Name {\n-\t\treturn false\n+// normalizePrivileges returns a normalized copy of privileges with privilege names\n+// and each privilege's values sorted for order-insensitive comparison.\n+// The input is not mutated.\n+func normalizePrivileges(privileges plugin.Privileges) plugin.Privileges {\n+\tnormalized := make(plugin.Privileges, len(privileges))\n+\tfor i, privilege := range privileges {\n+\t\tnormalized[i] = plugin.Privilege{\n+\t\t\tName: privilege.Name,\n+\t\t\tDescription: privilege.Description,\n+\t\t\tValue: slices.Clone(privilege.Value),\n+\t\t}\n+\t\tslices.Sort(normalized[i].Value)\n \t}\n \n-\treturn reflect.DeepEqual(a.Value, b.Value)\n+\tslices.SortFunc(normalized, func(a, b plugin.Privilege) int {\n+\t\treturn cmp.Compare(a.Name, b.Name)\n+\t})\n+\n+\treturn normalized\n }"},{"sha":"e51a3367ae4ea43d8c9abd253a71aec19cc9efe6","filename":"daemon/pkg/plugin/manager_test.go","status":"modified","additions":40,"deletions":4,"changes":44,"blob_url":"https://github.com/moby/moby/blob/f4d6f25bf0c3fa12d4968320a45685947756a22a/daemon%2Fpkg%2Fplugin%2Fmanager_test.go","raw_url":"https://github.com/moby/moby/raw/f4d6f25bf0c3fa12d4968320a45685947756a22a/daemon%2Fpkg%2Fplugin%2Fmanager_test.go","contents_url":"https://api.github.com/repos/moby/moby/contents/daemon%2Fpkg%2Fplugin%2Fmanager_test.go?ref=f4d6f25bf0c3fa12d4968320a45685947756a22a","patch":"@@ -44,12 +44,48 @@ func TestValidatePrivileges(t *testing.T) {\n \t\t\t},\n \t\t\tresult: true,\n \t\t},\n+\t\t\"single-element-same\": {\n+\t\t\trequiredPrivileges: []plugin.Privilege{\n+\t\t\t\t{Name: \"allow-all-devices\", Description: \"Description\", Value: []string{\"true\"}},\n+\t\t\t},\n+\t\t\tprivileges: []plugin.Privilege{\n+\t\t\t\t{Name: \"allow-all-devices\", Description: \"Description\", Value: []string{\"true\"}},\n+\t\t\t},\n+\t\t\tresult: true,\n+\t\t},\n+\t\t\"single-element-diff-value\": {\n+\t\t\trequiredPrivileges: []plugin.Privilege{\n+\t\t\t\t{Name: \"allow-all-devices\", Description: \"Description\", Value: []string{\"false\"}},\n+\t\t\t},\n+\t\t\tprivileges: []plugin.Privilege{\n+\t\t\t\t{Name: \"allow-all-devices\", Description: \"Description\", Value: []string{\"true\"}},\n+\t\t\t},\n+\t\t\tresult: false,\n+\t\t},\n+\t\t\"first-sorted-element-diff-value\": {\n+\t\t\trequiredPrivileges: []plugin.Privilege{\n+\t\t\t\t{Name: \"allow-all-devices\", Description: \"Description\", Value: []string{\"false\"}},\n+\t\t\t\t{Name: \"network\", Description: \"Description\", Value: []string{\"host\"}},\n+\t\t\t},\n+\t\t\tprivileges: []plugin.Privilege{\n+\t\t\t\t{Name: \"allow-all-devices\", Description: \"Description\", Value: []string{\"true\"}},\n+\t\t\t\t{Name: \"network\", Description: \"Description\", Value: []string{\"host\"}},\n+\t\t\t},\n+\t\t\tresult: false,\n+\t\t},\n+\t\t\"empty-privileges\": {\n+\t\t\trequiredPrivileges: []plugin.Privilege{},\n+\t\t\tprivileges: []plugin.Privilege{},\n+\t\t\tresult: true,\n+\t\t},\n \t}\n \n \tfor key, data := range testData {\n-\t\terr := validatePrivileges(data.requiredPrivileges, data.privileges)\n-\t\tif (err == nil) != data.result {\n-\t\t\tt.Fatalf(\"Test item %s expected result to be %t, got %t\", key, data.result, (err == nil))\n-\t\t}\n+\t\tt.Run(key, func(t *testing.T) {\n+\t\t\terr := validatePrivileges(data.requiredPrivileges, data.privileges)\n+\t\t\tif (err == nil) != data.result {\n+\t\t\t\tt.Fatalf(\"expected result to be %t, got %t\", data.result, (err == nil))\n+\t\t\t}\n+\t\t})\n \t}\n }"}]} \ No newline at end of file diff --git a/security/buildx-source-scopes/GO-2026-4883.json b/security/buildx-source-scopes/GO-2026-4883.json new file mode 100644 index 00000000..92bac95c --- /dev/null +++ b/security/buildx-source-scopes/GO-2026-4883.json @@ -0,0 +1 @@ +{"schema_version":"1.3.1","id":"GO-2026-4883","modified":"2026-04-02T18:42:30Z","published":"2026-04-02T18:42:30Z","aliases":["CVE-2026-33997","GHSA-pxq6-2prw-chj9"],"summary":"Moby has an Off-by-one error in its plugin privilege validation in github.com/docker/docker","details":"Moby has an Off-by-one error in its plugin privilege validation in github.com/docker/docker","affected":[{"package":{"name":"github.com/docker/docker","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"}]}],"ecosystem_specific":{}},{"package":{"name":"github.com/moby/moby","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"}]}],"ecosystem_specific":{}},{"package":{"name":"github.com/moby/moby/v2","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.0-beta.8"}]}],"ecosystem_specific":{}}],"references":[{"type":"ADVISORY","url":"https://github.com/moby/moby/security/advisories/GHSA-pxq6-2prw-chj9"},{"type":"FIX","url":"https://github.com/moby/moby/commit/f4d6f25bf0c3fa12d4968320a45685947756a22a"},{"type":"WEB","url":"https://docs.docker.com/engine/extend/legacy_plugins"},{"type":"WEB","url":"https://github.com/moby/moby/releases/tag/docker-v29.3.1"}],"database_specific":{"url":"https://pkg.go.dev/vuln/GO-2026-4883","review_status":"UNREVIEWED"}} \ No newline at end of file diff --git a/security/buildx-source-scopes/GO-2026-5668.json b/security/buildx-source-scopes/GO-2026-5668.json new file mode 100644 index 00000000..2467ff9b --- /dev/null +++ b/security/buildx-source-scopes/GO-2026-5668.json @@ -0,0 +1 @@ +{"schema_version":"1.3.1","id":"GO-2026-5668","modified":"2026-08-11T23:21:33Z","published":"2026-06-25T22:34:38Z","aliases":["CVE-2026-41568","GHSA-vp62-88p7-qqf5"],"summary":"Race condition in 'docker cp' in github.com/docker/docker allows creation of arbitrary files","details":"A race condition in the Docker daemon allows an attacker to create arbitrary empty files on the host system during a \"docker cp\" operation by swapping a destination path with a symbolic link.\n\nThe affected code is in the daemon package, specifically the openContainerFS and createIfNotExists methods. This is daemon-owned, Linux-specific code and is not intended for external use as a Go library.","affected":[{"package":{"name":"github.com/docker/docker","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"}]}],"ecosystem_specific":{"imports":[{"path":"github.com/docker/docker/daemon","symbols":["Daemon.createIfNotExists","Daemon.openContainerFS"]}]}},{"package":{"name":"github.com/moby/moby","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"}]}],"ecosystem_specific":{"imports":[{"path":"github.com/moby/moby/daemon","symbols":["Daemon.createIfNotExists","Daemon.openContainerFS"]}]}},{"package":{"name":"github.com/moby/moby/v2","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.0-beta.14"}]}],"ecosystem_specific":{"imports":[{"path":"github.com/moby/moby/v2/daemon","symbols":["Daemon.createIfNotExists","Daemon.openContainerFS"]}]}}],"references":[{"type":"ADVISORY","url":"https://github.com/moby/moby/security/advisories/GHSA-vp62-88p7-qqf5"}],"database_specific":{"url":"https://pkg.go.dev/vuln/GO-2026-5668","review_status":"REVIEWED"}} \ No newline at end of file diff --git a/security/buildx-source-scopes/GO-2026-5932.json b/security/buildx-source-scopes/GO-2026-5932.json new file mode 100644 index 00000000..aedf9429 --- /dev/null +++ b/security/buildx-source-scopes/GO-2026-5932.json @@ -0,0 +1 @@ +{"schema_version":"1.3.1","id":"GO-2026-5932","modified":"2026-07-07T22:15:29Z","published":"2026-07-07T22:15:29Z","summary":"The golang.org/x/crypto/openpgp package is unmaintained, unsafe by design, and has known security issues","details":"The golang.org/x/crypto/openpgp package is unsafe by design, has numerous known security issues, is not maintained, and should not be used.\n\nIf you are required to interoperate with OpenPGP systems and need a maintained package, consider github.com/ProtonMail/go-crypto/openpgp which is a maintained fork that aims to be a drop-in replacement for this package.","affected":[{"package":{"name":"golang.org/x/crypto","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"}]}],"ecosystem_specific":{"imports":[{"path":"golang.org/x/crypto/openpgp"},{"path":"golang.org/x/crypto/openpgp/packet"},{"path":"golang.org/x/crypto/openpgp/armor"},{"path":"golang.org/x/crypto/openpgp/clearsign"},{"path":"golang.org/x/crypto/openpgp/errors"},{"path":"golang.org/x/crypto/openpgp/elgamal"},{"path":"golang.org/x/crypto/openpgp/s2k"}]}}],"references":[{"type":"REPORT","url":"https://go.dev/issue/44226"}],"database_specific":{"url":"https://pkg.go.dev/vuln/GO-2026-5932","review_status":"REVIEWED"}} \ No newline at end of file diff --git a/security/buildx.openvex.json b/security/buildx.openvex.json new file mode 100644 index 00000000..2ad3409c --- /dev/null +++ b/security/buildx.openvex.json @@ -0,0 +1,35 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "urn:uuid:07059b77-8ae2-5ac3-a75e-0ba6d2db1f22", + "author": "PastureStack node-agent maintainers", + "timestamp": "2026-10-24T23:50:49.550029+00:00", + "version": 1, + "statements": [ + { + "vulnerability": { + "name": "CVE-2026-41567" + }, + "products": [ + { + "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" + } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_present", + "impact_statement": "Limited to docker-buildx v0.38.1-pasturestack.1 in this Dapper filesystem. The unmodified official Buildx source commit 0dbe87f36ed472fe36b6796c3f70b4210a731fc5 (archive SHA256 c5c810b083ed3725f7d4299ba01505b5891d164cf81c3398ea4c3b27a3b71149), built with Go 1.27.3 and exactly x/net v0.60.0/go-archive v0.3.0, compiles only pkg/namesgenerator from the legacy Docker module. The affected daemon containerExtractToDir implementation is absent. Before applying native VEX the gate verifies actual binary identity, resolved compiled packages/files and official namesgenerator hash, native source govulncheck, and that no other binary contains this PURL. Original complete raw findings remain retained; a different source, daemon package or other binary fails qualification. This statement does not apply generally to the Docker module or daemon." + }, + { + "vulnerability": { + "name": "CVE-2026-42306" + }, + "products": [ + { + "@id": "pkg:golang/github.com/docker/docker@v28.5.2%2Bincompatible" + } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_present", + "impact_statement": "Limited to docker-buildx v0.38.1-pasturestack.1 in this Dapper filesystem. The unmodified official Buildx source commit 0dbe87f36ed472fe36b6796c3f70b4210a731fc5 (archive SHA256 c5c810b083ed3725f7d4299ba01505b5891d164cf81c3398ea4c3b27a3b71149), built with Go 1.27.3 and exactly x/net v0.60.0/go-archive v0.3.0, compiles only pkg/namesgenerator from the legacy Docker module. The affected daemon openContainerFS implementation is absent. Before applying native VEX the gate verifies actual binary identity, resolved compiled packages/files and official namesgenerator hash, native source govulncheck, and that no other binary contains this PURL. Original complete raw findings remain retained; a different source, daemon package or other binary fails qualification. This statement does not apply generally to the Docker module or daemon." + } + ] +} diff --git a/tests/requirements.lock b/tests/requirements.lock index b5b2596e..f6983dda 100644 --- a/tests/requirements.lock +++ b/tests/requirements.lock @@ -16,7 +16,7 @@ pytest==9.1.1 pytest-mock==3.15.1 python-dateutil==2.9.0.post0 requests==2.34.2 -setuptools==84.0.0 +setuptools==84.1.0 six==1.17.0 tzdata==2026.3 urllib3==2.7.0 diff --git a/tests/requirements.txt b/tests/requirements.txt index 8ac1c795..03428bdf 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -4,4 +4,4 @@ flake8==7.3.0 pytest==9.1.1 pytest-mock==3.15.1 requests==2.34.2 -setuptools==84.0.0 +setuptools==84.1.0 diff --git a/toolchain/build-buildx b/toolchain/build-buildx new file mode 100644 index 00000000..565b4cd3 --- /dev/null +++ b/toolchain/build-buildx @@ -0,0 +1,93 @@ +#!/bin/bash +set -euo pipefail + +# Unmodified official source; only these two accepted module releases change. +source_commit=0dbe87f36ed472fe36b6796c3f70b4210a731fc5 +source_sha=c5c810b083ed3725f7d4299ba01505b5891d164cf81c3398ea4c3b27a3b71149 +original_mod_sha=a93029abb94e88ddaa532ddeaab4802259a1025408f1eac417a9886e140067ef +updated_mod_sha=230c98fa66a534b3a4d2dcdb0507e871658deff3c80058b01d85d7a18ce656b9 +version=v0.38.1-pasturestack.1 +source_dir=/tmp/pasturestack-buildx-source +export GOMODCACHE=/tmp/pasturestack-buildx-mod GOCACHE=/tmp/pasturestack-buildx-cache +export GOFLAGS=-mod=mod GOWORK=off GOTOOLCHAIN=local CGO_ENABLED=0 +export GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org +export GOOS=linux GOARCH="${DAPPER_HOST_ARCH:?}" +case "$GOARCH" in amd64|arm64) ;; *) exit 1 ;; esac +test "$(go env GOVERSION)" = go1.27.3 +test ! -e "$source_dir" +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + --retry 5 --retry-all-errors -o /tmp/pasturestack-buildx.tar.gz \ + "https://codeload.github.com/docker/buildx/tar.gz/${source_commit}" +echo "$source_sha /tmp/pasturestack-buildx.tar.gz" | sha256sum -c - +mkdir -p "$source_dir" /usr/local/lib/docker/cli-plugins /licenses +tar -xzf /tmp/pasturestack-buildx.tar.gz --strip-components=1 -C "$source_dir" +cd "$source_dir" +echo "$original_mod_sha go.mod" | sha256sum -c - +go list -m all > /licenses/BUILDX-MODULES-before.txt +go mod edit -require=golang.org/x/net@v0.60.0 -require=github.com/moby/go-archive@v0.3.0 +echo "$updated_mod_sha go.mod" | sha256sum -c - +go mod download +go mod verify +go list -deps ./cmd/buildx > /licenses/BUILDX-PACKAGES.txt +go list -deps -compiled -f '{{ $p := . }}{{range .CompiledGoFiles}}{{printf "%s\t%s\t%s\n" $p.ImportPath $p.Dir .}}{{end}}{{range .SFiles}}{{printf "%s\t%s\t%s\n" $p.ImportPath $p.Dir .}}{{end}}{{range .HFiles}}{{printf "%s\t%s\t%s\n" $p.ImportPath $p.Dir .}}{{end}}{{range .EmbedFiles}}{{printf "%s\t%s\t%s\n" $p.ImportPath $p.Dir .}}{{end}}' \ + ./cmd/buildx > /tmp/pasturestack-buildx-files.txt +while IFS=$'\t' read -r package directory name; do + [ -n "$package" ] || continue + case "$name" in /*) file="$name" ;; *) file="$directory/$name" ;; esac + test -f "$file" && test ! -L "$file" + case "$file" in + "$source_dir"/*) logical="source/${file#"$source_dir"/}" ;; + "$GOMODCACHE"/*) logical="module/${file#"$GOMODCACHE"/}" ;; + /usr/local/go/src/*) logical="goroot/${file#/usr/local/go/src/}" ;; + *) printf 'Unexpected compiled source path: %s\n' "$file" >&2; exit 1 ;; + esac + hash="$(sha256sum -- "$file")"; hash="${hash%% *}" + printf '%s\t%s\t%s\n' "$package" "$logical" "$hash" +done < /tmp/pasturestack-buildx-files.txt | LC_ALL=C sort -u > /licenses/BUILDX-SOURCE-FILES.tsv + +# Keep the canonical four reports byte-for-byte. This is a bounded source check; +# the existing complete Trivy scans still enforce all of the original policy. +db=/tmp/pasturestack-buildx-vulndb +mkdir -p "$db/ID" "$db/index" /tmp/pasturestack-buildx-tools +while read -r id hash; do + curl --fail --silent --show-error --proto '=https' --retry 5 --retry-all-errors \ + -o "$db/ID/$id.json" "https://vuln.go.dev/ID/$id.json" + echo "$hash $db/ID/$id.json" | sha256sum -c - + cp "$db/ID/$id.json" "/licenses/BUILDX-$id.json" +done <<'PINS' +GO-2026-5617 5a24060e5cc8cbf6a146615ebe5a7e5beac2f8f267c905538736ed6931ae2af5 +GO-2026-5746 d11da88232883a96ac3b74f848f172ce86c3783b7818d7da4b925335621d5273 +GO-2026-6253 4f337e5aad0f8e83e3db2fc67d058fe0e80017b8c68f494b7e82be87e7120872 +GO-2026-6611 5e89ac4314a24eac41111b71ef4544f22836c7b1baf48747e31c5c1d8282c3de +PINS +printf '%s\n' '{"modified":"2026-10-08T22:31:09Z"}' > "$db/index/db.json" +printf '%s\n' '[{"id":"GO-2026-6253","modified":"2026-08-25T19:43:10Z","aliases":["CVE-2026-17106","GHSA-hfg8-hc9c-6c3h"]},{"id":"GO-2026-5746","modified":"2026-07-23T17:09:44Z","aliases":["CVE-2026-41567","GHSA-x86f-5xw2-fm2r"]},{"id":"GO-2026-5617","modified":"2026-07-27T20:14:16Z","aliases":["CVE-2026-42306","GHSA-rg2x-37c3-w2rh"]},{"id":"GO-2026-6611","modified":"2026-10-08T22:31:09Z","aliases":["CVE-2026-78669"]}]' > "$db/index/vulns.json" +printf '%s\n' '[{"path":"github.com/docker/docker","vulns":[{"id":"GO-2026-5617","modified":"2026-07-27T20:14:16Z"},{"id":"GO-2026-5746","modified":"2026-07-23T17:09:44Z"}]},{"path":"github.com/moby/moby","vulns":[{"id":"GO-2026-5617","modified":"2026-07-27T20:14:16Z"},{"id":"GO-2026-5746","modified":"2026-07-23T17:09:44Z"}]},{"path":"github.com/moby/moby/v2","vulns":[{"id":"GO-2026-5617","modified":"2026-07-27T20:14:16Z"},{"id":"GO-2026-5746","modified":"2026-07-23T17:09:44Z"}]},{"path":"github.com/moby/go-archive","vulns":[{"id":"GO-2026-6253","modified":"2026-08-25T19:43:10Z"}]},{"path":"golang.org/x/net","vulns":[{"id":"GO-2026-6611","modified":"2026-10-08T22:31:09Z"}]},{"path":"stdlib","vulns":[{"id":"GO-2026-6611","modified":"2026-10-08T22:31:09Z"}]}]' > "$db/index/modules.json" +GOBIN=/tmp/pasturestack-buildx-tools go install golang.org/x/vuln/cmd/govulncheck@v1.9.0 +# The vulnerable daemon package must be absent, so package-level source +# analysis is sufficient and avoids an unnecessary whole-Buildx call graph. +GOPROXY=off /tmp/pasturestack-buildx-tools/govulncheck -mode=source -scan=package -format=json \ + -db="file://$db" ./cmd/buildx > /licenses/BUILDX-SOURCE-GOVULNCHECK.json +GOPROXY=off go build -trimpath -buildvcs=false \ + -ldflags "-s -w -buildid= -X github.com/docker/buildx/version.Package=github.com/docker/buildx -X github.com/docker/buildx/version.Version=${version} -X github.com/docker/buildx/version.Revision=${source_commit}" \ + -o /usr/local/lib/docker/cli-plugins/docker-buildx ./cmd/buildx +echo "$updated_mod_sha go.mod" | sha256sum -c - +go list -m all > /licenses/BUILDX-MODULES-after.txt +sed -E '/^(golang.org\/x\/net|github.com\/moby\/go-archive) /d' /licenses/BUILDX-MODULES-before.txt > /tmp/pasturestack-buildx-before-other.txt +sed -E '/^(golang.org\/x\/net|github.com\/moby\/go-archive) /d' /licenses/BUILDX-MODULES-after.txt > /tmp/pasturestack-buildx-after-other.txt +git diff --no-index --exit-code /tmp/pasturestack-buildx-before-other.txt /tmp/pasturestack-buildx-after-other.txt +grep -Fx 'golang.org/x/net v0.60.0' /licenses/BUILDX-MODULES-after.txt +grep -Fx 'github.com/moby/go-archive v0.3.0' /licenses/BUILDX-MODULES-after.txt +go version -m /usr/local/lib/docker/cli-plugins/docker-buildx > /licenses/BUILDX-GO-VERSION.txt +docker buildx version > /licenses/BUILDX-VERSION.txt +grep -F "github.com/docker/buildx $version $source_commit" /licenses/BUILDX-VERSION.txt +binary_sha="$(sha256sum /usr/local/lib/docker/cli-plugins/docker-buildx)"; binary_sha="${binary_sha%% *}" +printf '%s\t%s\n' schemaVersion 1 upstreamVersion v0.38.1 downstreamVersion "$version" \ + sourceCommit "$source_commit" sourceArchiveSha256 "$source_sha" \ + originalGoModSha256 "$original_mod_sha" updatedGoModSha256 "$updated_mod_sha" \ + goVersion go1.27.3 goos linux goarch "$GOARCH" binarySha256 "$binary_sha" \ + > /licenses/BUILDX-SOURCE.tsv +cd / +rm -rf /tmp/pasturestack-buildx-source /tmp/pasturestack-buildx-mod /tmp/pasturestack-buildx-cache \ + /tmp/pasturestack-buildx-vulndb /tmp/pasturestack-buildx-tools /tmp/pasturestack-buildx.tar.gz \ + /tmp/pasturestack-buildx-files.txt /tmp/pasturestack-buildx-before-other.txt /tmp/pasturestack-buildx-after-other.txt