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
28 changes: 23 additions & 5 deletions qualification/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,9 @@ unconditional reviewed `bootstrap-patched-pip` step. Ambient or step-local CA
values cannot supply that identity. Additional Docker, Compose or Buildx build
commands in the registered job also refuse, including calls in shell helpers.

The initial `sdk-php-cooperative-server` entry in
`composer-container-trust.json` has no approved recipes. It therefore refuses
every build until the Server hook and caller pass combined container controls.
This preparation action does not make SDK-PHP's Composer migration mandatory or
authorize its new audit job. Those changes follow combined qualification.
The `sdk-php-cooperative-server` entry in `composer-container-trust.json`
applies only to its registered Compose request and reviewed Server recipes.
It supplies no trust for other build or runtime paths.

The container control runs the actual digest-pinned Composer image against a
local synthetic TLS registry. The ambient store accepts its synthetic blocked
Expand All @@ -200,6 +198,26 @@ digests, extra roots and a missing verifier also refuse. This exercises the
container client; BuildKit mount lifetime and final-image inspection remain
part of the Server hook's combined qualification.

### Composer through a reviewed PHP launcher

The same catalog has a separate `runtime_setups` entry for Sample App's PHP CI
launcher. It binds the exact setup command, target, workflow/jobs, literal PHP
matrix, build inputs, launcher and TLS control by file hash and mode. An
unreviewed Dockerfile exclusion file refuses. The setup probes and exports an
immutable local image config ID; subsequent PHP and Composer invocations use
that ID. The actual Composer TLS control runs in both PHP profiles: ambient
trust reaches a synthetic HTTPS repository, while the selected clean bundle
refuses before an HTTP request.

This recognizes the PHP setup only. The reviewed clean bootstrap must still
precede it, and the reviewed live Composer verifier must run in the container
immediately before every dependency-request step. The launcher mounts the
selected CA file and empty additional-root directory read-only and inspects the
real Composer PHAR. Runtime selector overrides, changed source bytes or modes,
conditional/ignored setup, another target/job, and missing/intervening guards
refuse. This entry does not approve arbitrary Docker commands or another
repository's launcher.

### Composer on the runner

For the initial Ubuntu 24.04 or 26.04 x64 PHP 8.3 production profile, the policy
Expand Down
82 changes: 82 additions & 0 deletions qualification/composer-container-trust.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,87 @@
}
]
}
},
"runtime_setups": {
"sample-app-php-ci": {
"target": "sample-app",
"matrix": {
"php": ["8.4", "8.5"]
},
"workflows": {
"ci.yml": [
"test",
"microservice-test"
],
"composer-lock-audit.yml": [
"audit"
],
"playground-journeys.yml": [
"journeys"
]
},
"commands": [
"bash \"$GITHUB_WORKSPACE/scripts/ci/setup-php-container.sh\" \"${{ matrix.php }}\"",
"bash \"$GITHUB_WORKSPACE/scripts/ci/setup-php-container.sh\" \"8.4\""
],
"environment": [
"SAMPLE_APP_PHP_CI_IMAGE",
"SAMPLE_APP_PHP_CI_ROOT",
"SAMPLE_APP_PHP_CI_STATE"
],
"absent_files": [
"docker/php-ci/Dockerfile.dockerignore"
],
"files": {
".dockerignore": {
"sha256": "3d3b99f9bbf9d4ce35b7dc8462c4da1c87983b2b65a3a829d8ec9d80f43551cc",
"mode": 420
},
"docker/php-ci/Dockerfile": {
"sha256": "dcdd1e2f99db98825f9cf6b89ab2b0191252d2816001af09f391b6fb1a8b035a",
"mode": 420
},
"scripts/ci/php-container-images.json": {
"sha256": "fc4d410589d1fdb322107e404bfcf9f4593e6ac7e3fcce729b1d78fafd9be1c4",
"mode": 420
},
"scripts/ci/run-php-container.sh": {
"sha256": "56aef0c6ca67f277dce114eac3d5de396f5b751697df6c01e8f760cb18cc9f49",
"mode": 493
},
"scripts/ci/setup-php-container.sh": {
"sha256": "104481886027a954f0af0d614933f6b8d934302c9ce55d1dc1f71e50130445ab",
"mode": 493
},
"tests/ci/test_php_container_tls.py": {
"sha256": "936a6584fbe2b3523a8835e4f18df272b9bf384b9d95419b6ff3368e986052e4",
"mode": 420
},
".devcontainer/docker/verify-php-trust.php": {
"sha256": "bd8682984a757eb28887e996f77c9cafed32634cc7e19155b9885da1bb5b48c9",
"mode": 420
},
"docker/install-phpredis.sh": {
"sha256": "17b21ea9724199f502605abeeff6856f8af721027691e1de74d12cc6ccb7d33b",
"mode": 420
},
"docker/brotli/install-vendor.sh": {
"sha256": "d7eb8b2a3e0af1c3072a56bcb81632b22f061f211d8fe5e57ee901c8bf94c92f",
"mode": 420
},
"docker/brotli/verify-runtime.php": {
"sha256": "580cc465e349f3872eca592355294e7320fe7f1a4560ff5d4c83d5900921c00e",
"mode": 420
},
"docker/brotli/verify-system-brotli.c": {
"sha256": "3353927d206aaf5e63157e46be715c6cbee46bac777e29f8f9e25c4c1f656667",
"mode": 420
},
"docker/brotli/version": {
"sha256": "e064953e075ddb99fb7b48ad29e215f2dfa075812c127eb1d6ab680e9920fba2",
"mode": 420
}
}
}
}
}
1 change: 1 addition & 0 deletions qualification/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@
"sample-app": {
"branch": "main",
"repository": "sample-app",
"composer_trust_required": true,
"workflows": [
{
"action_policy_preflight": true,
Expand Down
116 changes: 103 additions & 13 deletions scripts/qualification_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import os
import re
import shlex
import stat
import sys
import time
import urllib.error
Expand Down Expand Up @@ -2536,8 +2537,8 @@ def _composer_step_source(source: str, root: Path, visited: set[Path] | None = N
return "\n".join(contents)


def _composer_trust_modified(source: str) -> bool:
fields = "|".join(sorted(COMPOSER_TRUST_FIELDS))
def _composer_trust_modified(source: str, runtime_fields: set[str] | None = None) -> bool:
fields = "|".join(sorted(COMPOSER_TRUST_FIELDS | (runtime_fields or set())))
assignment = r"\b(?:" + fields + r")\b[\"']?\s*(?::?=)|\bunset\s+[^\n]*(?:" + fields + ")"
configuration = r"\bcomposer\b[^;\n]*\bconfig\b[^;\n]*\b(?:cafile|capath|disable-tls|secure-http)\b"
return re.search(assignment, source) is not None or re.search(configuration, source) is not None
Expand Down Expand Up @@ -2584,30 +2585,111 @@ def _advance_composer_trust(step: dict[str, Any], state: tuple[bool, bool, bool,
return clean, checked, setup_safe, python_clean


def _require_composer_request_trust(step: dict[str, Any], state: tuple[bool, bool, bool, bool], label: str, source: str) -> None:
def _require_composer_request_trust(step: dict[str, Any], state: tuple[bool, bool, bool, bool], label: str, source: str,
runtime_fields: set[str] | None = None) -> None:
clean, checked, _, _ = state
if not clean or not checked:
raise PolicyError(f"{label} Composer requests lack the reviewed clean bootstrap and live verifier")
if re.search(r"(?:always|failure|cancelled)\s*\(", step.get("if", "")):
raise PolicyError(f"{label} Composer requests may run after trust refusal")
overrides = set(step.get("env") or {}) & COMPOSER_TRUST_FIELDS
overrides = set(step.get("env") or {}) & (COMPOSER_TRUST_FIELDS | (runtime_fields or set()))
if overrides:
raise PolicyError(f"{label} overrides Composer trust after verification")
if _composer_trust_modified(source):
if _composer_trust_modified(source, runtime_fields):
raise PolicyError(f"{label} changes Composer trust inside its request step or helper")


def _composer_job_sinks(job: dict[str, Any], root: Path, approved: set[str], bound_setups: set[str], local: bool, label: str) -> list[str]:
if set(job.get("env") or {}) & COMPOSER_TRUST_FIELDS:
def _composer_runtime_setups(catalog: dict[str, Any], target: str, filename: str, job: str) -> list[dict[str, Any]]:
return [recipe for recipe in catalog.get("runtime_setups", {}).values()
if recipe["target"] == target and job in recipe["workflows"].get(Path(filename).name, [])]


def _require_composer_runtime_matrix(step: dict[str, Any], job: dict[str, Any], recipe: dict[str, Any]) -> None:
for dimension, allowed in recipe.get("matrix", {}).items():
if "${{ matrix." + dimension + " }}" not in step["run"]:
continue
matrix = (job.get("strategy") or {}).get("matrix")
if (not isinstance(matrix, dict) or "include" in matrix or "exclude" in matrix
or not isinstance(matrix.get(dimension), list) or matrix[dimension] != allowed):
raise PolicyError("Composer runtime setup requires its reviewed literal PHP matrix")


def _composer_runtime_path(root: Path, name: str) -> Path:
relative = Path(name)
if relative.is_absolute() or ".." in relative.parts:
raise PolicyError("Composer runtime recipe path must stay inside the repository")
path = root / relative
if (not path.resolve().is_relative_to(root.resolve())
or any(parent.is_symlink() for parent in path.parents if parent != root and root in parent.parents)):
raise PolicyError("Composer runtime recipe path must not traverse symlinks")
return path


def _require_composer_runtime_file(root: Path, name: str, expected: dict[str, Any]) -> None:
path = _composer_runtime_path(root, name)
try:
metadata = path.lstat()
mode = stat.S_IMODE(metadata.st_mode)
# Git binds executability, while the checkout umask may remove read
# permissions. Keep private 0600/0700 and canonical 0644/0755 valid,
# without admitting extra execute, group/other write, or special bits.
if (not stat.S_ISREG(metadata.st_mode) or mode & ~expected["mode"]
or bool(mode & stat.S_IXUSR) != bool(expected["mode"] & stat.S_IXUSR)
or hashlib.sha256(path.read_bytes()).hexdigest() != expected["sha256"]):
raise PolicyError(f"Reviewed Composer runtime source differs: {name}")
except OSError as error:
raise PolicyError(f"Reviewed Composer runtime source is unavailable: {name}") from error


def _require_composer_runtime_sources(root: Path, recipe: dict[str, Any]) -> None:
if not recipe["files"]:
raise PolicyError("Composer runtime setup has no reviewed source files")
for name in recipe.get("absent_files", []):
path = _composer_runtime_path(root, name)
if path.exists() or path.is_symlink():
raise PolicyError(f"Unreviewed Composer runtime input is present: {name}")
for name, expected in recipe["files"].items():
_require_composer_runtime_file(root, name, expected)


def _reviewed_composer_runtime(step: dict[str, Any], root: Path, recipes: list[dict[str, Any]], job: dict[str, Any]) -> bool:
"""Recognize setup only; the next request still requires its live verifier."""
matching = [recipe for recipe in recipes if step.get("run", "").strip() in recipe["commands"]]
if not matching:
return False
if len(matching) != 1:
raise PolicyError("Composer runtime setup has ambiguous reviewed recipes")
if (step.get("uses") or step.get("if") or step.get("env") or step.get("working-directory")
or step.get("shell", "bash") != "bash"
or str(step.get("continue-on-error", "false")) != "false"):
raise PolicyError("Composer runtime setup must run unconditionally without overrides")
_require_composer_runtime_matrix(step, job, matching[0])
_require_composer_runtime_sources(root, matching[0])
return True


def _composer_job_sinks(job: dict[str, Any], root: Path, approved: set[str], bound_setups: set[str], local: bool, label: str,
runtime_setups: list[dict[str, Any]] | None = None) -> list[str]:
recipes = runtime_setups or []
runtime_fields = {name for recipe in recipes for name in recipe["environment"]}
if set(job.get("env") or {}) & (COMPOSER_TRUST_FIELDS | runtime_fields):
raise PolicyError(f"{label} declares unverified job-level Composer trust overrides")
evidence = []
state = (False, False, False, False)
for index, step in enumerate(job.get("steps") or []):
step_label = f"{label}:step-{index + 1}"
if _reviewed_composer_runtime(step, root, recipes, job):
clean, _, _, python_clean = state
state = (clean, False, clean and python_clean, python_clean)
continue
expanded = _composer_step_source(step.get("run", ""), root)
if _composer_network_commands(expanded):
_require_composer_request_trust(step, state, step_label, expanded)
_require_composer_request_trust(step, state, step_label, expanded, runtime_fields)
evidence.append(step_label)
if (set(step.get("env") or {}) & runtime_fields
or runtime_fields and _composer_trust_modified(expanded, runtime_fields)):
clean, _, _, python_clean = state
state = (clean, False, False, python_clean)
state = _advance_composer_trust(step, state, approved, bound_setups, local)
return evidence

Expand Down Expand Up @@ -2659,23 +2741,31 @@ def _unscoped_composer_guard(documents: dict[str, Any], selected: set[tuple[str,
return False


def _composer_document_sinks(documents: dict[str, Any], root: Path, approved: set[str], bound_setups: set[str], local: bool, selected: set[tuple[str, str]] | None) -> list[str]:
def _composer_document_sinks(documents: dict[str, Any], root: Path, approved: set[str], bound_setups: set[str], local: bool,
selected: set[tuple[str, str]] | None, catalog: dict[str, Any], target: str) -> list[str]:
evidence = []
for filename, document in documents.items():
jobs = {name: job for name, job in (document.get("jobs") or {}).items()
if selected is None or (filename, name) in selected}
if not jobs:
continue
if set(document.get("env") or {}) & COMPOSER_TRUST_FIELDS:
runtime_fields = {field for name in jobs for recipe in _composer_runtime_setups(catalog, target, filename, name)
for field in recipe["environment"]}
if set(document.get("env") or {}) & (COMPOSER_TRUST_FIELDS | runtime_fields):
raise PolicyError(f"{filename} declares unverified workflow-level Composer trust overrides")
for name, job in jobs.items():
requests = _composer_job_sinks(job, root, approved, bound_setups, local, f"{filename}:{name}")
recipes = _composer_runtime_setups(catalog, target, filename, name)
requests = _composer_job_sinks(job, root, approved, bound_setups, local, f"{filename}:{name}", recipes)
if selected is not None and not requests:
raise PolicyError("A scoped Composer job has no enumerated dependency request")
evidence.extend(requests)
return evidence


def _composer_container_catalog() -> dict[str, Any]:
return json.loads((Path(__file__).resolve().parents[1] / "qualification/composer-container-trust.json").read_text())


def verify_composer_network_sinks(policy: dict[str, Any], target: str, sources: dict[str, str], root: Path) -> list[str]:
configuration = policy["targets"][target]
scopes = configuration.get("composer_trust_scopes", [])
Expand All @@ -2688,9 +2778,9 @@ def verify_composer_network_sinks(policy: dict[str, Any], target: str, sources:
documents = {filename: _parse_yaml(source, filename) for filename, source in sources.items()}
selected = _composer_scoped_jobs(scopes, documents, bound_setups, target == "github-control-plane")
full = whole or not scopes or _unscoped_composer_guard(documents, selected)
catalog = _composer_container_catalog()
evidence = _composer_document_sinks(documents, root, approved, bound_setups, target == "github-control-plane",
None if full else selected)
catalog = json.loads((Path(__file__).resolve().parents[1] / "qualification/composer-container-trust.json").read_text())
None if full else selected, catalog, target)
evidence.extend(verify_composer_container_callers(target, sources, approved, catalog, root))
return evidence

Expand Down
Loading
Loading