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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions .github/actions/bootstrap-patched-pip/action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
name: Bootstrap patched pip trust
description: Install the Python-provided hash-pinned pip release that distrusts the compromised eMudhra chain.
description: Authenticate patched pip and export its verified clean CA bundle for subsequent CI requests.
inputs:
clients:
description: Comma-separated Linux CI clients invoked by later steps (pip, python, composer, curl, git, npm, cargo).
default: pip

outputs:
ca-file:
description: Absolute path of the verified public CA bundle.
value: ${{ steps.trust.outputs.ca-file }}
ca-directory:
description: Empty directory excluding additional OpenSSL/Git/Composer roots.
value: ${{ steps.trust.outputs.ca-directory }}
ca-sha256:
description: SHA-256 of the verified CA bundle.
value: ${{ steps.trust.outputs.ca-sha256 }}

runs:
using: composite
Expand All @@ -11,13 +26,9 @@ runs:
python -I -m pip --isolated --disable-pip-version-check install --no-deps --upgrade \
"pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"
test "$(python -I -c 'import pip; print(pip.__version__)')" = 26.2.2
bundle="$(python -I -c 'from pip._vendor import certifi; print(certifi.where())')"
subjects="$(openssl crl2pkcs7 -nocrl -certfile "$bundle" | openssl pkcs7 -print_certs -noout)"
for blocked_subject in \
'CN=AAA Certificate Services' \
'CN=emSign ECC Root CA - G3'; do
if grep -Fq "$blocked_subject" <<<"$subjects"; then
echo "blocked eMudhra trust path remains in pip: $blocked_subject" >&2
exit 1
fi
done
- name: Verify and export the effective CI trust bundle
id: trust
shell: bash
env:
TRUST_CLIENTS: ${{ inputs.clients }}
run: python -I "$GITHUB_ACTION_PATH/export-trust.py"
107 changes: 107 additions & 0 deletions .github/actions/bootstrap-patched-pip/export-trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Export the authenticated pip wheel's clean trust bundle to later CI steps."""

from __future__ import annotations

import hashlib
import os
from pathlib import Path
import re
import subprocess


FILE_VARIABLES = (
"SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "COMPOSER_CAFILE",
"REQUESTS_CA_BUNDLE", "PIP_CERT",
)
DIRECTORY_VARIABLES = ("SSL_CERT_DIR", "GIT_SSL_CAPATH", "COMPOSER_CAPATH")
BLOCKED_SUBJECTS = ("AAA Certificate Services", "emSign ECC Root CA - G3")
CLIENT_VARIABLES = {
"pip": ("PIP_CERT",), "python": ("REQUESTS_CA_BUNDLE",),
"composer": ("COMPOSER_CAFILE",), "curl": ("CURL_CA_BUNDLE",),
"git": ("GIT_SSL_CAINFO",), "npm": ("NPM_CONFIG_CAFILE",),
"cargo": ("CARGO_HTTP_CAINFO",),
}


def inspect_bundle(path: Path) -> tuple[bytes, int]:
if not path.is_file():
raise ValueError("The CA bundle must be a regular file")
contents = path.read_bytes()
certificates = re.findall(
rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", contents, re.S,
)
if not certificates:
raise ValueError("The CA bundle has no PEM certificates")
for certificate in certificates:
result = subprocess.run(
["openssl", "x509", "-noout", "-subject", "-nameopt", "RFC2253"],
input=certificate, capture_output=True, check=False,
)
if result.returncode:
raise ValueError("The CA bundle has a malformed certificate")
subject = result.stdout.decode("utf-8").removeprefix("subject=").strip()
for blocked in BLOCKED_SUBJECTS:
if re.search(r"(?:^|[,+])CN=" + re.escape(blocked) + r"(?:$|[,+])", subject):
raise ValueError("Blocked certificate in CA bundle: " + blocked)
return contents, len(certificates)


def write_bundle(bundle: Path, contents: bytes) -> None:
if bundle.exists():
if bundle.read_bytes() != contents:
raise ValueError("Existing CI trust bundle has unexpected contents")
return
with bundle.open("xb") as handle:
handle.write(contents)
bundle.chmod(0o444)


def export_bundle(source: Path, runner_temp: Path, env_file: Path, output_file: Path, clients=("pip",)) -> dict:
if not clients or set(clients) - CLIENT_VARIABLES.keys():
raise ValueError("Select only supported CI trust clients")
contents, count = inspect_bundle(source)
digest = hashlib.sha256(contents).hexdigest()
directory = runner_temp.resolve() / "durable-workflow-trust"
bundle = directory / (digest + ".pem")
empty_directory = directory / "no-additional-roots"
for path in (bundle, empty_directory):
if any(character in str(path) for character in "\r\n"):
raise ValueError("CI trust paths must not contain line breaks")
if directory.is_symlink() or empty_directory.is_symlink() or bundle.is_symlink():
raise ValueError("CI trust output must not be redirected through a symlink")
directory.mkdir(parents=True, exist_ok=True)
empty_directory.mkdir(exist_ok=True)
if any(empty_directory.iterdir()):
raise ValueError("The additional-root directory must be empty")
write_bundle(bundle, contents)
variables = {"SSL_CERT_FILE": str(bundle), "SSL_CERT_DIR": str(empty_directory)}
for client in clients:
variables.update({name: str(bundle) for name in CLIENT_VARIABLES[client]})
for client, name in (("git", "GIT_SSL_CAPATH"), ("composer", "COMPOSER_CAPATH")):
if client in clients:
variables[name] = str(empty_directory)
variables["DW_CI_CA_SHA256"] = digest
with env_file.open("a", encoding="utf-8") as handle:
handle.write("".join(f"{name}={value}\n" for name, value in variables.items()))
with output_file.open("a", encoding="utf-8") as handle:
handle.write(f"ca-file={bundle}\nca-directory={empty_directory}\nca-sha256={digest}\n")
print(f"Verified CI CA bundle: {bundle}; sha256={digest}; certificates={count}; blocked=0/0")
return variables


def main() -> None:
import pip
from pip._vendor import certifi

if pip.__version__ != "26.2.2":
raise ValueError("Authenticated pip 26.2.2 must be installed before exporting trust")
export_bundle(
Path(certifi.where()), Path(os.environ["RUNNER_TEMP"]),
Path(os.environ["GITHUB_ENV"]), Path(os.environ["GITHUB_OUTPUT"]),
tuple(name.strip() for name in os.environ.get("TRUST_CLIENTS", "pip").split(",")),
)


if __name__ == "__main__":
main()
14 changes: 14 additions & 0 deletions .github/actions/verify-composer-trust/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: Verify Composer effective trust
description: Refuse dependency requests unless Composer uses the authenticated CI bundle.
inputs:
working-directory:
description: Project directory whose Composer configuration will be inspected without plugins.
default: .
runs:
using: composite
steps:
- name: Verify actual Composer version and selected trust paths
shell: bash
env:
TRUST_PROJECT_DIRECTORY: ${{ inputs.working-directory }}
run: php "$GITHUB_ACTION_PATH/verify.php" "$(command -v composer)" "$TRUST_PROJECT_DIRECTORY"
132 changes: 132 additions & 0 deletions .github/actions/verify-composer-trust/verify.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

declare(strict_types=1);

namespace DurableWorkflow\Ci;

final class TrustRefused extends \RuntimeException {}

final class ComposerTrust
{
public const FILE_VARIABLES = [
'SSL_CERT_FILE', 'CURL_CA_BUNDLE', 'GIT_SSL_CAINFO', 'COMPOSER_CAFILE',
];

public const OPTIONAL_FILE_VARIABLES = ['REQUESTS_CA_BUNDLE', 'PIP_CERT', 'NPM_CONFIG_CAFILE', 'CARGO_HTTP_CAINFO', 'CARGO_HTTP_PROXY_CAINFO'];

public const DIRECTORY_VARIABLES = ['SSL_CERT_DIR', 'GIT_SSL_CAPATH', 'COMPOSER_CAPATH'];

public static function requireVersion(string $version): void
{
if (!str_starts_with($version, '2.') || version_compare($version, '2.10.3', '<')) {
throw new TrustRefused('Composer 2.10.3 or newer on the 2 line is required.');
}
}

public static function inspectBundle(string $path): int
{
$contents = file_get_contents($path);
if ($contents === false || !preg_match_all('/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/s', $contents, $certificates)) {
throw new TrustRefused('The selected bundle must contain PEM certificates.');
}
foreach ($certificates[0] as $certificate) {
$parsed = @openssl_x509_parse($certificate);
if ($parsed === false) {
throw new TrustRefused('Malformed certificate in the selected bundle.');
}
foreach ((array) ($parsed['subject']['CN'] ?? []) as $subject) {
if (in_array($subject, ['AAA Certificate Services', 'emSign ECC Root CA - G3'], true)) {
throw new TrustRefused('Blocked certificate in the selected bundle: '.$subject);
}
}
}
return count($certificates[0]);
}

private static function selectedPath(array $variables, bool $isDirectory = false): string
{
$selected = '';
foreach ($variables as $variable) {
$value = getenv($variable);
$path = $value === false || $value === '' ? false : realpath($value);
if ($path === false || ($isDirectory ? !is_dir($path) : !is_file($path)) || ($selected !== '' && $selected !== $path)) {
throw new TrustRefused('CI CA overrides must select the same regular file or empty directory: '.$variable);
}
if ($isDirectory && scandir($path) !== ['.', '..']) {
throw new TrustRefused('Additional CA overrides must select the same empty directory: '.$variable);
}
$selected = $path;
}
return $selected;
}

private static function configuration(string $project): \Composer\Config
{
$project = realpath($project);
if ($project === false || !is_dir($project)) {
throw new TrustRefused('The Composer project directory must exist.');
}
$config = \Composer\Factory::createConfig(null, $project);
$manifest = getenv('COMPOSER') ?: 'composer.json';
if (str_contains($manifest, '://')) {
throw new TrustRefused('Remote Composer configuration is not permitted.');
}
if (!str_starts_with($manifest, '/') && !preg_match('/^[A-Za-z]:[\\\\\/]/', $manifest)) {
$manifest = $project.DIRECTORY_SEPARATOR.$manifest;
}
if (is_file($manifest)) {
$data = json_decode((string) file_get_contents($manifest), true, flags: JSON_THROW_ON_ERROR);
$config->merge(['config' => $data['config'] ?? []], 'project');
}
if ($config->get('disable-tls') || !$config->get('secure-http')) {
throw new TrustRefused('Composer TLS and secure HTTP must remain enabled.');
}
return $config;
}

public static function verify(string $composer, string $project): array
{
\Phar::loadPhar($composer);
require_once 'phar://composer.phar/vendor/autoload.php';
$version = \Composer\Composer::VERSION;
self::requireVersion($version);
$variables = self::FILE_VARIABLES;
foreach (self::OPTIONAL_FILE_VARIABLES as $variable) {
if (getenv($variable) !== false) {
$variables[] = $variable;
}
}
$bundle = self::selectedPath($variables);
$directory = self::selectedPath(self::DIRECTORY_VARIABLES, true);
$expectedHash = getenv('DW_CI_CA_SHA256');
$actualHash = hash_file('sha256', $bundle);
if (!is_string($expectedHash) || !preg_match('/^[a-f0-9]{64}$/D', $expectedHash) || !hash_equals($expectedHash, $actualHash)) {
throw new TrustRefused('The selected bundle differs from the authenticated CI bundle.');
}
$config = self::configuration($project);
if (realpath($config->get('cafile')) !== $bundle || realpath($config->get('capath')) !== $directory) {
throw new TrustRefused('Composer must select the authenticated bundle and empty additional-root directory.');
}
return [
'composer_version' => $version,
'ca_file' => $bundle,
'ca_directory' => $directory,
'sha256' => $actualHash,
'certificates' => self::inspectBundle($bundle),
'blocked_roots' => ['AAA Certificate Services' => 0, 'emSign ECC Root CA - G3' => 0],
];
}
}

if (realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
try {
echo json_encode(ComposerTrust::verify($argv[1] ?? '', $argv[2] ?? '.'), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES).PHP_EOL;
} catch (\Throwable $error) {
$message = $error instanceof TrustRefused ? $error->getMessage() : 'Composer trust inspection failed ('.$error::class.').';
fwrite(STDERR, $message.PHP_EOL);
if (getenv('GITHUB_ACTIONS') === 'true') {
echo '::error title=Composer trust refusal::'.strtr($message, ['%' => '%25', "\r" => '%0D', "\n" => '%0A']).PHP_EOL;
}
exit(1);
}
}
29 changes: 25 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ jobs:
python-version: "3.13"

- uses: ./.github/actions/bootstrap-patched-pip # local
with:
clients: pip,python,composer,curl,git,npm,cargo

- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.3'
tools: composer:2.10.3, phpunit:11.5.57
coverage: none

- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24.22.1
cache: npm

- uses: dtolnay/rust-toolchain@4d407b29186a635f0cc27475ef0bc0ae605a8866 # 1.86.0
with:
toolchain: 1.86.0

- uses: ./.github/actions/verify-composer-trust # local

- name: Install Python test dependencies
run: >-
Expand All @@ -41,11 +60,13 @@ jobs:
tests.test_server_codec_counterfactual_schema
tests.test_visual_evidence
tests.test_temporal_runtime
tests.test_ci_trust
tests.test_pip_effective_trust
tests.test_cli_effective_trust
tests.test_composer_trust_contract

- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24.22.1
cache: npm
- name: Test actual Composer trust selection
run: COMPOSER_BINARY="$(command -v composer)" phpunit --configuration phpunit-composer-trust.xml --do-not-cache-result

- name: Install Node dependencies
run: npm ci --ignore-scripts
Expand Down
22 changes: 21 additions & 1 deletion .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,30 @@ jobs:
with:
python-version: '3.13'
- uses: ./.github/actions/bootstrap-patched-pip # local
with:
clients: pip,python,composer,curl,git,npm,cargo
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.3'
tools: composer:2.10.3, phpunit:11.5.57
coverage: xdebug
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24.22.1
- uses: dtolnay/rust-toolchain@4d407b29186a635f0cc27475ef0bc0ae605a8866 # 1.86.0
with:
toolchain: 1.86.0
- uses: ./.github/actions/verify-composer-trust # local
- name: Produce actual Composer control coverage
run: |
mkdir -p build
COMPOSER_BINARY="$(command -v composer)" XDEBUG_MODE=coverage \
phpunit --configuration phpunit-composer-trust.xml --do-not-cache-result \
--coverage-clover build/composer-trust-coverage.xml
- name: Produce source coverage
run: |
python -m pip install --only-binary :all: coverage==7.10.7 jsonschema==4.23.0 PyYAML==6.0.2
coverage run --source=scripts,conformance/cancellation/temporal -m unittest tests.test_qualification_policy tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema tests.test_visual_evidence tests.test_temporal_runtime
coverage run --source=scripts,conformance/cancellation/temporal,.github/actions/bootstrap-patched-pip -m unittest tests.test_qualification_policy tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema tests.test_visual_evidence tests.test_temporal_runtime tests.test_ci_trust tests.test_pip_effective_trust tests.test_cli_effective_trust tests.test_composer_trust_contract
coverage xml -o coverage.xml
- uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0
env:
Expand Down
7 changes: 7 additions & 0 deletions phpunit-composer-trust.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true" failOnWarning="true" cacheDirectory="/tmp/durable-workflow-phpunit-cache">
<testsuites>
<testsuite name="Composer trust"><directory>tests/php</directory></testsuite>
</testsuites>
<source><include><file>.github/actions/verify-composer-trust/verify.php</file></include></source>
</phpunit>
Loading
Loading