From c98d9fdfd0d7f88b49fb45c23ecd69b43ba4e9d1 Mon Sep 17 00:00:00 2001 From: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:02:23 +0530 Subject: [PATCH] test(ci): guard telemetry OIDC permission baseline Preserve current PyPI, npm, release-asset and Scorecard grants as a proposed source-only baseline for .github#54. Run the CLI in CI with hash-pinned test-only PyYAML and paired unittest controls. Signed-off-by: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 3 + RELEASING.md | 17 +++++ requirements/test.in | 3 + requirements/test.txt | 75 ++++++++++++++++++++ tests/test_workflow_oidc.py | 134 +++++++++++++++++++++++++++++++++++ tools/check_workflow_oidc.py | 76 ++++++++++++++++++++ 7 files changed, 309 insertions(+) create mode 100644 tests/test_workflow_oidc.py create mode 100644 tools/check_workflow_oidc.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4471e2..a6196ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,7 @@ jobs: # hash-pin an editable install in the same invocation. - run: python -m pip install --require-hashes -r requirements/test.txt - run: python -m pip install --no-deps -e . + - run: python tools/check_workflow_oidc.py - run: python tools/check_versions.py - run: python tools/check_schemas.py - run: python tools/check_otel_compatibility.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 63a7255..da2c0ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Changed +- CI checks the existing four job-level OIDC grants across all workflow files, + refusing source permission drift without changing any publishing grant. + - The project name is spelled AgenTrust (one shared t) in docs, docstrings, the compatibility matrix notes and the TypeScript package description, and the LICENSE and NOTICE copyright holder reads "AgenTrust Contributors". Package diff --git a/RELEASING.md b/RELEASING.md index f1c32b4..266334f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -65,3 +65,20 @@ Note that npm sets `latest` on a package's very first published version whatever `--tag` says. That is expected on a bootstrap publish and corrects itself when the first stable version ships. PyPI and npm create registry provenance through trusted publishing; GitHub also attests the downloadable release assets. + +## OIDC source drift + +CI runs `python tools/check_workflow_oidc.py` across every `.yml` and `.yaml` +workflow. The proposed existing-source baseline preserves `release.yml` jobs +`publish-pypi`, `publish-npm` and `release-assets`, and `scorecard.yml/analysis`. +It refuses workflow-wide OIDC grants even when current jobs override them, +`write-all` at either scope, and additions, removals or renames of these grants. +An intentional change needs review of the baseline and workflow delta together. +Unittest controls start from passing copies of the real workflows before each +mutation; a direct Python `-O` CLI check still refuses a broad grant. + +This preserves current permissions, including the release-asset attestation and +Scorecard grants; it does not establish that each grant remains necessary. +The guard checks source, not live environment reviewers, administrator bypass, +deployment restrictions, trusted-publisher bindings or backup-operator coverage. +Those remain part of [the release-safeguard sweep](https://github.com/agentrust-io/.github/issues/54). diff --git a/requirements/test.in b/requirements/test.in index d189180..1042a56 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -14,3 +14,6 @@ opentelemetry-api>=1.30,<2 agentrust-trace>=0.9,<1; python_version >= '3.11' tomli>=2.5.0,<3; python_version < '3.11' build + +# Source-only workflow OIDC regression guard. Not a runtime dependency. +PyYAML==6.0.3 diff --git a/requirements/test.txt b/requirements/test.txt index 06d293f..566541e 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -363,6 +363,81 @@ python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 # via arrow +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via -r requirements/test.in referencing==0.37.0 \ --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 diff --git a/tests/test_workflow_oidc.py b/tests/test_workflow_oidc.py new file mode 100644 index 0000000..1eb1cb4 --- /dev/null +++ b/tests/test_workflow_oidc.py @@ -0,0 +1,134 @@ +"""Paired source-permission controls; no workflow dispatch or live setting access.""" + +import importlib.util +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / "tools/check_workflow_oidc.py" +spec = importlib.util.spec_from_file_location("check_workflow_oidc", TOOL) +assert spec and spec.loader +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +class WorkflowOidcTests(unittest.TestCase): + def copy_workflows(self, directory): + root = Path(directory) + for path in [*gate.WORKFLOWS.glob("*.yml"), *gate.WORKFLOWS.glob("*.yaml")]: + shutil.copyfile(path, root / path.name) + gate.check_oidc_permissions(root) + return root + + def test_real_workflow_baseline(self): + gate.check_oidc_permissions(gate.WORKFLOWS) + + def test_each_permission_drift_is_refused_from_a_passing_copy(self): + scenarios = [ + "new-yml", "new-yaml", "extra-job", "inherited-default", "shadowed-default", + "default-write-all", "shadowed-write-all", "job-write-all", "allowed-write-all", + "remove-pypi", "remove-npm", "remove-assets", "remove-scorecard", + "rename-job", "rename-file", + ] + for scenario in scenarios: + with self.subTest(scenario=scenario), tempfile.TemporaryDirectory() as directory: + root = self.copy_workflows(directory) + path = root / "release.yml" + workflow = yaml.safe_load(path.read_text(encoding="utf-8")) + if scenario.startswith("new-"): + path = root / ("unexpected." + scenario.removeprefix("new-")) + workflow = {"permissions": {}, "jobs": {"unexpected": { + "runs-on": "ubuntu-latest", "permissions": {"id-token": "write"}, + "steps": [{"run": "echo test"}], + }}} + elif scenario == "extra-job": + workflow["jobs"]["unexpected"] = {"permissions": {"id-token": "write"}} + elif scenario in {"inherited-default", "shadowed-default"}: + workflow["permissions"] = {"id-token": "write"} + if scenario == "shadowed-default": + for job in workflow["jobs"].values(): + job.setdefault("permissions", {}) + elif scenario in {"default-write-all", "shadowed-write-all"}: + workflow["permissions"] = "write-all" + if scenario == "shadowed-write-all": + for job in workflow["jobs"].values(): + job.setdefault("permissions", {}) + elif scenario in {"job-write-all", "allowed-write-all"}: + job = "build" if scenario == "job-write-all" else "publish-pypi" + workflow["jobs"][job]["permissions"] = "write-all" + elif scenario.startswith("remove-"): + filename, job = { + "remove-pypi": ("release.yml", "publish-pypi"), + "remove-npm": ("release.yml", "publish-npm"), + "remove-assets": ("release.yml", "release-assets"), + "remove-scorecard": ("scorecard.yml", "analysis"), + }[scenario] + path = root / filename + workflow = yaml.safe_load(path.read_text(encoding="utf-8")) + workflow["jobs"][job]["permissions"].pop("id-token") + elif scenario == "rename-job": + workflow["jobs"]["renamed"] = workflow["jobs"].pop("publish-pypi") + elif scenario == "rename-file": + path.unlink() + path = root / "release.yaml" + path.write_text(yaml.safe_dump(workflow), encoding="utf-8", newline="\n") + with self.assertRaisesRegex(ValueError, "OIDC"): + gate.check_oidc_permissions(root) + + def test_read_only_inheritance_and_narrowing_are_valid(self): + with tempfile.TemporaryDirectory() as directory: + root = self.copy_workflows(directory) + (root / "read-only.yaml").write_text( + "permissions: read-all\njobs:\n inherited: {}\n" + " narrow:\n permissions:\n contents: read\n", encoding="utf-8", + ) + gate.check_oidc_permissions(root) + + def test_malformed_permissions_refuse(self): + for value in [None, [], 1, "unsupported", {"id-token": True}]: + with self.subTest(value=value), self.assertRaises(ValueError): + gate._oidc_write(value) + + def test_empty_and_malformed_workflow_inventories_refuse(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with self.assertRaisesRegex(ValueError, "no workflows"): + gate.check_oidc_permissions(root) + for text in ["null", "[]", "jobs: null", "jobs: {test: null}"]: + with self.subTest(text=text): + (root / "bad.yml").write_text(text, encoding="utf-8") + with self.assertRaisesRegex(ValueError, "mapping"): + gate.check_oidc_permissions(root) + + def test_optimized_cli_has_a_positive_control_and_detects_permission_drift(self): + with tempfile.TemporaryDirectory() as directory: + root = self.copy_workflows(directory) + command = [sys.executable, "-O", str(TOOL), str(root)] + positive = subprocess.run(command, capture_output=True, text=True, check=False) + self.assertEqual(positive.returncode, 0, positive.stderr) + (root / "new.yaml").write_text( + "permissions: write-all\njobs: {}\n", encoding="utf-8", + ) + refused = subprocess.run(command, capture_output=True, text=True, check=False) + self.assertEqual(refused.returncode, 1) + self.assertIn("workflow-wide OIDC grant", refused.stderr) + + def test_ci_runs_the_guard_without_an_optional_parser_skip(self): + ci = yaml.safe_load((gate.WORKFLOWS / "ci.yml").read_text(encoding="utf-8")) + commands = [step.get("run", "") for step in ci["jobs"]["test"]["steps"]] + self.assertIn("python tools/check_workflow_oidc.py", commands) + inputs = (ROOT / "requirements/test.in").read_text(encoding="utf-8") + self.assertIn("PyYAML==6.0.3", inputs.splitlines()) + lock = (ROOT / "requirements/test.txt").read_text(encoding="utf-8") + self.assertIn("pyyaml==6.0.3", lock) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_workflow_oidc.py b/tools/check_workflow_oidc.py new file mode 100644 index 0000000..64dc700 --- /dev/null +++ b/tools/check_workflow_oidc.py @@ -0,0 +1,76 @@ +"""Source-only OIDC grant baseline for the release-safeguard sweep (.github#54).""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import yaml + + +WORKFLOWS = Path(__file__).resolve().parents[1] / ".github/workflows" +# Proposed existing-source baseline, not approval of live environment settings. +OIDC_JOBS = { + ("release.yml", "publish-pypi"), + ("release.yml", "publish-npm"), + ("release.yml", "release-assets"), + ("scorecard.yml", "analysis"), +} + + +def _oidc_write(permissions): + if isinstance(permissions, str): + if permissions not in {"read-all", "write-all"}: + raise ValueError("unsupported permission shorthand") + return permissions == "write-all" + if not isinstance(permissions, dict): + raise ValueError("permissions must be a mapping or supported shorthand") + if any(not isinstance(key, str) or value not in ("read", "write", "none") + for key, value in permissions.items()): + raise ValueError("permission entries must name read, write or none") + return permissions.get("id-token") == "write" + + +def check_oidc_permissions(workflows): + """Refuse source drift even when narrower jobs shadow a broad default. + + Explicit raises are intentional: a direct python -O invocation still refuses. + actionlint separately validates workflow syntax, expressions and duplicates. + """ + paths = sorted([*workflows.glob("*.yml"), *workflows.glob("*.yaml")]) + if not paths: + raise ValueError("no workflows found") + actual = set() + for path in paths: + workflow = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(workflow, dict) or not isinstance(workflow.get("jobs"), dict): + raise ValueError(f"workflow and jobs must be mappings: {path.name}") + defaults = workflow.get("permissions", {}) + if _oidc_write(defaults): + raise ValueError(f"workflow-wide OIDC grant: {path.name}") + for name, job in workflow["jobs"].items(): + if not isinstance(job, dict): + raise ValueError(f"job must be a mapping: {path.name}/{name}") + permissions = job.get("permissions", defaults) + if permissions == "write-all": + raise ValueError(f"OIDC write-all job grant: {path.name}/{name}") + if _oidc_write(permissions): + actual.add((path.name, name)) + if actual != OIDC_JOBS: + raise ValueError(f"OIDC job allowance drift: {sorted(actual ^ OIDC_JOBS)}") + + + +def main(): + workflows = Path(sys.argv[1]) if len(sys.argv) > 1 else WORKFLOWS + try: + check_oidc_permissions(workflows) + except (ValueError, yaml.YAMLError) as error: + print(f"FAIL {error}", file=sys.stderr) + return 1 + print("PASS workflow OIDC grants match the proposed source baseline") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())