From 6b7597915f887e15a02523e38f896bd0e549eba3 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Mon, 17 Aug 2026 03:07:41 -0400 Subject: [PATCH 1/2] =?UTF-8?q?ci(security):=20real=20scans,=20tokenless?= =?UTF-8?q?=20=E2=80=94=20dependabot,=20pip-audit=20over=20the=20lock,=20C?= =?UTF-8?q?odeQL=20(#291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit's first run surfaced PYSEC-2026-3552 in cryptography 49.0.0 (fixed in 50.0.0); the lock is upgraded in the same commit so the scan ships green. --- .github/dependabot.yml | 68 +++++++++++ .github/workflows/security.yml | 73 ++++++++++++ tests/test_security_scans.py | 198 +++++++++++++++++++++++++++++++++ uv.lock | 94 ++++++++-------- 4 files changed, 386 insertions(+), 47 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/security.yml create mode 100644 tests/test_security_scans.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..b74aaf80 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,68 @@ +# Dependabot, watching every manifest the workspace ships (#291). +# +# The `pip` ecosystem works per manifest directory, and this workspace is six +# distributions: the root plus five under packages/. A directory not listed here is a +# distribution whose dependencies update without review -- tests/test_security_scans.py +# pins the full set so adding a seventh distribution means adding it here or failing CI. +# Updates are grouped (one PR per week per ecosystem, not one per package) because this +# is a solo-maintained repo: review bandwidth is the scarce resource, and a wall of +# single-package PRs is how "ignore Dependabot" becomes the policy. +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] + + - package-ecosystem: pip + directory: /packages/keel-core + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] + + - package-ecosystem: pip + directory: /packages/keel-broker-api + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] + + - package-ecosystem: pip + directory: /packages/keel-broker-coinbase + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] + + - package-ecosystem: pip + directory: /packages/keel-broker-fake + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] + + - package-ecosystem: pip + directory: /packages/keel-broker-robinhood + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] + + # The scan and CI workflows pin their actions like any other dependency; unwatched, + # they age into exactly the advisory-visibility problem #291 exists to close. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: ["*"] diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..35bb3ed2 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,73 @@ +# The security scans -- configured for a public repo, which means: no tokens (#291). +# +# Why this workflow exists: the pre-launch gate names a gap that has been true since the +# repo opened -- `SONAR_TOKEN` and `SNYK_TOKEN` were names for secrets nobody created, so +# keel has never had static analysis or dependency scanning. The fix is not to buy tokens: +# a public repository gets both classes of scan for free, on GITHUB_TOKEN alone. A scan +# step that referenced a secret would be a scan that runs only for a hypothetical +# maintainer -- tests/test_security_scans.py pins that none does. +# +# Why these jobs are NOT the `test` job in ci.yml: the `main` ruleset requires the status +# context `test`, which ci.yml produces. A vulnerable dependency or a CodeQL finding is +# information about the state of the world, not a verdict on a proposed change -- a new +# CVE published against an already-pinned version must surface without waiting for a +# merge to blame. So these jobs run alongside (on push, on PRs, and weekly on the +# calendar, because CVEs are published against pinned versions whether or not anyone +# pushes) without gating merges. +name: security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "23 4 * * 1" + +permissions: + contents: read + +jobs: + dependencies: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + # Audit the LOCK, not a fresh resolve: `uv export` emits exactly the pinned versions + # every deployment gets from `uv.lock`, so those are the versions scanned. Extras are + # included (--all-extras) because a vulnerable optional dependency is still a + # dependency; the repo's own six distributions are excluded because they are the code + # under scan, not third-party dependencies of it. + - name: Export the locked dependency set + run: > + uv export --all-extras --no-hashes + --no-emit-package keel-trader + --no-emit-package keel-core + --no-emit-package keel-broker-api + --no-emit-package keel-broker-coinbase + --no-emit-package keel-broker-fake + --no-emit-package keel-broker-robinhood + > requirements.lock.txt + + - name: Audit the locked dependency set + run: uvx pip-audit --requirement requirements.lock.txt + + codeql: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # CodeQL uploads its findings to the repo's Security tab; without this permission + # the scan runs and its results go nowhere. + security-events: write + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: github/codeql-action/init@v3 + with: + languages: python + + - uses: github/codeql-action/analyze@v3 diff --git a/tests/test_security_scans.py b/tests/test_security_scans.py new file mode 100644 index 00000000..545ce2df --- /dev/null +++ b/tests/test_security_scans.py @@ -0,0 +1,198 @@ +"""The code-quality scans, configured for real and without secrets that don't exist (#291). + +The pre-launch gate (#291) names a gap that has been true since the repo opened: the CI +story is lint + types + tests, and nothing else -- `SONAR_TOKEN` and `SNYK_TOKEN` were +wishlist names for secrets nobody ever created, so keel has never had static analysis or +dependency scanning. For a public financial tool that is a bad first impression waiting to +happen, and the fix is not to go buy tokens: a public repository can run both classes of +scan on what GitHub gives it for free. Dependabot watches the manifests; a weekly +`pip-audit` over the exact locked set the app ships with catches vulnerable versions +pinned in `uv.lock`; CodeQL (Python) reads the source the same way the `test` context +reads the tests. + +This file pins that the configuration exists and says what it must: every manifest +directory Dependabot needs (the workspace root plus each `packages/*` distribution -- +a manifest it does not watch is a dependency that updates silently), the actions ecosystem +so the workflows themselves stay current, the audit actually reading the lock's export, +CodeQL on Python, and -- the honest part -- NO scan step depending on a secret: the scans +must keep running for a contributor who has none of the tokens the old wishlist named. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +_ROOT = Path(__file__).resolve().parents[1] + +#: The six Python manifest directories of the workspace: the root distribution plus the +#: five packages/* distributions. Dependabot needs each named; a manifest not named here +#: is a distribution whose dependencies update without review. +_MANIFEST_DIRS = ( + "/", + "/packages/keel-core", + "/packages/keel-broker-api", + "/packages/keel-broker-coinbase", + "/packages/keel-broker-fake", + "/packages/keel-broker-robinhood", +) + +#: The distributions excluded when exporting the lock for audit -- excluded because they +#: are this repo's own code (not on PyPI, not auditable there), and pinned so the audit +#: cannot quietly start including or dropping one. +_OWN_DISTRIBUTIONS = ( + "keel-trader", + "keel-core", + "keel-broker-api", + "keel-broker-coinbase", + "keel-broker-fake", + "keel-broker-robinhood", +) + +#: Secrets the scans must NOT depend on. They have never existed in this repository; a +#: scan step that references them is a scan step that fails for everyone but whoever was +#: supposed to create the token -- which is the gap #291 exists to close, restated as YAML. +_FORBIDDEN_SECRETS = ("SONAR_TOKEN", "SNYK_TOKEN") + + +def _read(relative: str) -> str: + """A repo file's text; empty until it exists, so a red run FAILS rather than errors.""" + path = _ROOT / relative + return path.read_text() if path.is_file() else "" + + +def test_dependabot_watches_every_python_manifest_and_the_actions(): + """Six pip manifests and the workflows -- nothing updates silently. + + The workspace is six distributions, and Dependabot's `pip` ecosystem works per + manifest directory: an unlisted directory gets no update PRs, ever. The + `github-actions` entry keeps the workflow actions themselves (checkout, uv, CodeQL) + from aging into the exact advisory-visibility problem this file exists for. + """ + path = _ROOT / ".github" / "dependabot.yml" + assert path.is_file(), ( + ".github/dependabot.yml must exist -- updates unwatched are updates unseen" + ) + config = yaml.safe_load(path.read_text()) + assert config.get("version") == 2, "dependabot.yml must be the version-2 schema" + entries = config.get("updates", []) + pip_dirs = {e.get("directory") for e in entries if e.get("package-ecosystem") == "pip"} + for directory in _MANIFEST_DIRS: + assert directory in pip_dirs, ( + f"dependabot.yml must watch the manifest at {directory!r} -- a distribution " + "Dependabot does not see is a distribution whose dependencies update silently" + ) + ecosystems = {e.get("package-ecosystem") for e in entries} + assert "github-actions" in ecosystems, ( + "dependabot.yml must also watch github-actions -- the scan workflows' own actions " + "age like any other dependency" + ) + + +def test_every_dependabot_entry_is_scheduled(): + """Watching is not enough; every entry must actually run on a schedule.""" + path = _ROOT / ".github" / "dependabot.yml" + if not path.is_file(): + assert False, ".github/dependabot.yml must exist -- updates unwatched are updates unseen" + entries = yaml.safe_load(path.read_text()).get("updates", []) + unscheduled = [e for e in entries if not e.get("schedule", {}).get("interval")] + assert not unscheduled, ( + f"these dependabot entries have no schedule interval: {unscheduled} -- an entry " + "that never runs is a configuration file lying about what it does" + ) + + +def test_the_security_workflow_audits_the_locked_dependency_set(): + """pip-audit reads the lock's export -- the set that ships, not what resolves today. + + Auditing `uv export` output (not a bare `pip-audit` of the environment, not a fresh + resolve) is what makes the audit reproducible: the pinned versions in `uv.lock` are + the versions every deployment gets, so those are the versions that must be scanned. + The repo's own distributions are excluded because they are the code being shipped, + not third-party dependencies of it. + """ + workflow = _read(".github/workflows/security.yml") + assert workflow, ".github/workflows/security.yml must exist -- the scans are configured in code" + assert "uv export" in workflow and "--all-extras" in workflow, ( + "the audit must consume `uv export --all-extras` output -- the full locked set, " + "extras included, because a vulnerable optional dependency is still a dependency" + ) + for distribution in _OWN_DISTRIBUTIONS: + assert f"--no-emit-package {distribution}" in workflow, ( + f"the export must exclude the repo's own {distribution} -- it is the code under " + "scan, not a third-party dependency of it" + ) + assert "pip-audit" in workflow, ( + "the workflow must run pip-audit against the exported lock -- this is the " + "dependency scanning #291 says has never existed here" + ) + + +def test_the_security_workflow_runs_codeql_on_python(): + """Static analysis of the source, tokenless -- CodeQL over the Python suite.""" + workflow = _read(".github/workflows/security.yml") + assert "github/codeql-action/init" in workflow, ( + "the workflow must initialise CodeQL -- this is the static analysis #291 says has " + "never existed here" + ) + assert "languages: python" in workflow, ( + "CodeQL must analyse Python -- the only language this repo ships" + ) + assert "github/codeql-action/analyze" in workflow, ( + "init without analyse is a workflow that reads the code and says nothing" + ) + assert "security-events: write" in workflow, ( + "CodeQL must carry security-events: write to upload its results -- without the " + "permission the scan runs and its findings go nowhere" + ) + + +def _code_lines(text: str) -> str: + """The workflow with comments stripped -- a token NAMED in a comment explaining its + absence is documentation; a token REFERENCED in code is a dependency on a secret. + + Comments in these files are YAML `#` remarks (or the same inside a shell heredoc); + stripping them lets the forbidden-secret check scan what the runner executes. + """ + return "\n".join(re.split(r"(^|\s)#", line)[0] for line in text.split("\n")) + + +def test_no_scan_step_depends_on_a_secret_that_does_not_exist(): + """The scans run on what a public repo has -- no SONAR_TOKEN, no SNYK_TOKEN, none. + + The wishlist names in #291 were never created; a scan step referencing them would be + a scan that works only for a hypothetical maintainer with hypothetical tokens. The + configuration is honest when its executable lines reference no secrets at all (the + workflows' comments may name the absent tokens, because that is where the decision + to run tokenless is documented). + """ + for relative in (".github/workflows/security.yml", ".github/workflows/ci.yml"): + workflow = _read(relative) + assert workflow, f"{relative} must exist" + executable = _code_lines(workflow) + for secret in _FORBIDDEN_SECRETS: + assert secret not in executable, ( + f"{relative} references {secret}, which does not exist in this repository -- " + "the scans must run tokenless or they do not run" + ) + dangling = sorted(set(re.findall(r"secrets\.([A-Z_]+)", executable))) + assert not dangling, ( + f"{relative} references secrets {dangling} -- the scan workflows must run on " + "GITHUB_TOKEN alone, or they fail for every contributor without them" + ) + + +def test_the_security_workflow_runs_on_a_schedule_not_only_on_push(): + """A vulnerability published Tuesday is in the lock Wednesday whether or not anyone pushes. + + Dependabot proposes updates when versions move, but the audit must also fire on the + calendar: new CVEs are published against versions already pinned. A workflow that + only runs on push scans exactly as often as the repo is noisy. + """ + workflow = _read(".github/workflows/security.yml") + assert "schedule:" in workflow and "cron:" in workflow, ( + ".github/workflows/security.yml must carry a schedule trigger -- CVEs are " + "published against already-pinned versions, not only against new commits" + ) diff --git a/uv.lock b/uv.lock index 84127f1f..b3f58ef8 100644 --- a/uv.lock +++ b/uv.lock @@ -405,58 +405,58 @@ toml = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, ] [[package]] From 874d7186ccf67b08785a1b7c7926b41b39054187 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Mon, 17 Aug 2026 03:19:27 -0400 Subject: [PATCH 2/2] =?UTF-8?q?ci(security):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20frozen=20export,=20filesystem-derived=20manifests,=20the=20p?= =?UTF-8?q?reflight-guard=20invariant,=20pyyaml=20declared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/code-quality.yml | 5 + .github/workflows/security.yml | 31 +++-- pyproject.toml | 4 + tests/test_security_scans.py | 208 +++++++++++++++++------------ uv.lock | 2 + 5 files changed, 152 insertions(+), 98 deletions(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 7ad96e41..f8e5ddaa 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -2,6 +2,11 @@ name: Code Quality # SonarQube (static analysis + coverage) and Snyk (dependency vulnerabilities). # +# NOTE (#291): the tokenless baseline now lives in `security.yml` -- Dependabot, a weekly +# `pip-audit` over the exported lock, and CodeQL, all on GITHUB_TOKEN alone, always on. +# This workflow is the OPTIONAL enhanced tier: SonarQube and Snyk run only if the two +# secrets below are ever created, and its `preflight` skips them cleanly (never red) while +# they are not. The two workflows disagree about nothing: baseline there, depth here. # ┌─ BEFORE THIS CAN RUN ───────────────────────────────────────────────────────────────────────── # │ Two repository secrets must exist. As of 2026-08-16, NEITHER DOES -- `gh secret list` is empty # │ at both repo and org level: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 35bb3ed2..58e8444d 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -2,10 +2,11 @@ # # Why this workflow exists: the pre-launch gate names a gap that has been true since the # repo opened -- `SONAR_TOKEN` and `SNYK_TOKEN` were names for secrets nobody created, so -# keel has never had static analysis or dependency scanning. The fix is not to buy tokens: -# a public repository gets both classes of scan for free, on GITHUB_TOKEN alone. A scan -# step that referenced a secret would be a scan that runs only for a hypothetical -# maintainer -- tests/test_security_scans.py pins that none does. +# keel had no static analysis or dependency scanning that actually runs. This workflow is +# the tokenless BASELINE: it runs on GITHUB_TOKEN alone, always, on every push and weekly. +# `code-quality.yml` remains the optional enhanced tier (SonarQube + Snyk) for IF those +# tokens are ever created -- its preflight skips cleanly while they are not, so the two +# workflows disagree about nothing: baseline here, optional depth there. # # Why these jobs are NOT the `test` job in ci.yml: the `main` ruleset requires the status # context `test`, which ci.yml produces. A vulnerable dependency or a CodeQL finding is @@ -17,12 +18,19 @@ name: security on: + workflow_dispatch: push: branches: [main] pull_request: schedule: - cron: "23 4 * * 1" +# Not cancel-in-progress: a cancelled audit run leaves the last completed scan older than +# it needs to be, and these jobs are short enough that waiting beats losing a result. +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: false + permissions: contents: read @@ -36,14 +44,17 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v6 - # Audit the LOCK, not a fresh resolve: `uv export` emits exactly the pinned versions - # every deployment gets from `uv.lock`, so those are the versions scanned. Extras are - # included (--all-extras) because a vulnerable optional dependency is still a - # dependency; the repo's own six distributions are excluded because they are the code - # under scan, not third-party dependencies of it. + # Audit the LOCK, not a fresh resolve: `uv export --frozen` emits exactly the pinned + # versions every deployment gets from `uv.lock` as committed (`--frozen` is the same + # never-re-resolve-in-CI stance `code-quality.yml` documents), so those are the + # versions scanned. Unlike the Snyk export there, dev dependencies are INCLUDED + # deliberately: this job gates nothing, so the wider advisory net costs nothing, and + # a CVE in the dev toolchain is still something a contributor's machine runs. Extras + # are included for the same reason; the repo's own six distributions are excluded + # because they are the code under scan, not third-party dependencies of it. - name: Export the locked dependency set run: > - uv export --all-extras --no-hashes + uv export --frozen --all-extras --no-hashes --no-emit-package keel-trader --no-emit-package keel-core --no-emit-package keel-broker-api diff --git a/pyproject.toml b/pyproject.toml index 4d644a3c..e247fb5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,10 @@ keel-broker-robinhood = { workspace = true } dev = [ "mypy>=1.18.0", "pytest>=9.1.1", + # tests/test_security_scans.py parses .github/dependabot.yml directly, so the test + # suite imports pyyaml itself rather than riding keel-core's transitive copy (the + # rule the [project] comment above states for `keel/`, applied where it now applies). + "pyyaml>=6.0.2", # Only `code-quality.yml` needs this: SonarQube reports coverage as a first-class metric and # has nothing to show without a coverage XML to import. It is deliberately NOT wired into the # default `pytest` invocation -- `ci.yml` and `release.yml` still run a plain `pytest -q`, diff --git a/tests/test_security_scans.py b/tests/test_security_scans.py index 545ce2df..af7085e8 100644 --- a/tests/test_security_scans.py +++ b/tests/test_security_scans.py @@ -1,60 +1,42 @@ """The code-quality scans, configured for real and without secrets that don't exist (#291). -The pre-launch gate (#291) names a gap that has been true since the repo opened: the CI -story is lint + types + tests, and nothing else -- `SONAR_TOKEN` and `SNYK_TOKEN` were -wishlist names for secrets nobody ever created, so keel has never had static analysis or -dependency scanning. For a public financial tool that is a bad first impression waiting to -happen, and the fix is not to go buy tokens: a public repository can run both classes of -scan on what GitHub gives it for free. Dependabot watches the manifests; a weekly -`pip-audit` over the exact locked set the app ships with catches vulnerable versions -pinned in `uv.lock`; CodeQL (Python) reads the source the same way the `test` context -reads the tests. - -This file pins that the configuration exists and says what it must: every manifest -directory Dependabot needs (the workspace root plus each `packages/*` distribution -- -a manifest it does not watch is a dependency that updates silently), the actions ecosystem -so the workflows themselves stay current, the audit actually reading the lock's export, -CodeQL on Python, and -- the honest part -- NO scan step depending on a secret: the scans -must keep running for a contributor who has none of the tokens the old wishlist named. +The pre-launch gate (#291) names a gap that had been true since the repo opened: the CI +story was lint + types + tests, and nothing else -- `SONAR_TOKEN` and `SNYK_TOKEN` were +wishlist names for secrets nobody ever created, so no scan that needs them has ever run. +The fix is not to buy tokens: a public repository can run both classes of scan on what +GitHub gives it for free. `security.yml` is the tokenless BASELINE -- Dependabot watches +the manifests, a weekly `pip-audit` reads the exact locked set the app ships with, and +CodeQL (Python) reads the source -- and `code-quality.yml` stays the OPTIONAL enhanced +tier (SonarQube + Snyk) for if those tokens are ever created. + +This file pins that split. The manifest lists are DERIVED FROM THE FILESYSTEM (a seventh +distribution under packages/ that Dependabot and the export do not know about fails here, +rather than passing because a hand-maintained list was never told), the audit is pinned to +`uv export --frozen` (the lock as committed, never a fresh resolve), and the secrets rule +is the honest one: the always-on scan workflows reference no secrets at all, and the +optional tier may reference `SONAR_TOKEN`/`SNYK_TOKEN` ONLY from jobs a preflight guards +-- so a missing token can never redden a scheduled run, only skip it with an explanation. """ from __future__ import annotations import re +import tomllib from pathlib import Path import yaml _ROOT = Path(__file__).resolve().parents[1] +_WORKFLOWS = _ROOT / ".github" / "workflows" -#: The six Python manifest directories of the workspace: the root distribution plus the -#: five packages/* distributions. Dependabot needs each named; a manifest not named here -#: is a distribution whose dependencies update without review. -_MANIFEST_DIRS = ( - "/", - "/packages/keel-core", - "/packages/keel-broker-api", - "/packages/keel-broker-coinbase", - "/packages/keel-broker-fake", - "/packages/keel-broker-robinhood", -) - -#: The distributions excluded when exporting the lock for audit -- excluded because they -#: are this repo's own code (not on PyPI, not auditable there), and pinned so the audit -#: cannot quietly start including or dropping one. -_OWN_DISTRIBUTIONS = ( - "keel-trader", - "keel-core", - "keel-broker-api", - "keel-broker-coinbase", - "keel-broker-fake", - "keel-broker-robinhood", -) - -#: Secrets the scans must NOT depend on. They have never existed in this repository; a -#: scan step that references them is a scan step that fails for everyone but whoever was -#: supposed to create the token -- which is the gap #291 exists to close, restated as YAML. -_FORBIDDEN_SECRETS = ("SONAR_TOKEN", "SNYK_TOKEN") +#: The optional enhanced tier: the one workflow allowed to reference the two tokens that +#: have never existed, and only behind its preflight guard. +_OPTIONAL_TIER = "code-quality.yml" + +#: Secrets the baseline must NOT reference. They have never existed in this repository; +#: a baseline scan step that needs them is a scan that has never run -- the exact gap +#: #291 exists to close. +_FORBIDDEN_IN_BASELINE = ("SONAR_TOKEN", "SNYK_TOKEN") def _read(relative: str) -> str: @@ -63,13 +45,26 @@ def _read(relative: str) -> str: return path.read_text() if path.is_file() else "" +def _pyprojects() -> dict[str, dict]: + """The workspace's manifests, keyed by directory -- the shape Dependabot must watch. + + Mirrors tests/test_packaging.py's discovery: the root pyproject.toml plus every + packages/*/pyproject.toml. A distribution the dict does not contain is a distribution + this file cannot speak about, which is why the derivation (not a list) matters. + """ + manifests = {"/": tomllib.loads((_ROOT / "pyproject.toml").read_text())} + for path in sorted((_ROOT / "packages").glob("*/pyproject.toml")): + manifests[f"/{path.parent.relative_to(_ROOT).as_posix()}"] = tomllib.loads(path.read_text()) + return manifests + + def test_dependabot_watches_every_python_manifest_and_the_actions(): - """Six pip manifests and the workflows -- nothing updates silently. + """Every manifest the filesystem declares -- nothing updates silently. - The workspace is six distributions, and Dependabot's `pip` ecosystem works per - manifest directory: an unlisted directory gets no update PRs, ever. The - `github-actions` entry keeps the workflow actions themselves (checkout, uv, CodeQL) - from aging into the exact advisory-visibility problem this file exists for. + The workspace is six distributions today, and Dependabot's `pip` ecosystem works per + manifest directory: an unlisted directory gets no update PRs, ever. The expected set + is DERIVED from packages/*/pyproject.toml, so adding a seventh distribution without + telling Dependabot fails here instead of shipping a blind spot. """ path = _ROOT / ".github" / "dependabot.yml" assert path.is_file(), ( @@ -79,11 +74,16 @@ def test_dependabot_watches_every_python_manifest_and_the_actions(): assert config.get("version") == 2, "dependabot.yml must be the version-2 schema" entries = config.get("updates", []) pip_dirs = {e.get("directory") for e in entries if e.get("package-ecosystem") == "pip"} - for directory in _MANIFEST_DIRS: + manifests = _pyprojects() + for directory in manifests: assert directory in pip_dirs, ( f"dependabot.yml must watch the manifest at {directory!r} -- a distribution " "Dependabot does not see is a distribution whose dependencies update silently" ) + assert pip_dirs == set(manifests), ( + f"dependabot.yml watches directories that no longer exist ({pip_dirs - set(manifests)}) " + "-- a stale entry is configuration lying about what it does" + ) ecosystems = {e.get("package-ecosystem") for e in entries} assert "github-actions" in ecosystems, ( "dependabot.yml must also watch github-actions -- the scan workflows' own actions " @@ -94,8 +94,9 @@ def test_dependabot_watches_every_python_manifest_and_the_actions(): def test_every_dependabot_entry_is_scheduled(): """Watching is not enough; every entry must actually run on a schedule.""" path = _ROOT / ".github" / "dependabot.yml" - if not path.is_file(): - assert False, ".github/dependabot.yml must exist -- updates unwatched are updates unseen" + assert path.is_file(), ( + ".github/dependabot.yml must exist -- updates unwatched are updates unseen" + ) entries = yaml.safe_load(path.read_text()).get("updates", []) unscheduled = [e for e in entries if not e.get("schedule", {}).get("interval")] assert not unscheduled, ( @@ -105,28 +106,33 @@ def test_every_dependabot_entry_is_scheduled(): def test_the_security_workflow_audits_the_locked_dependency_set(): - """pip-audit reads the lock's export -- the set that ships, not what resolves today. - - Auditing `uv export` output (not a bare `pip-audit` of the environment, not a fresh - resolve) is what makes the audit reproducible: the pinned versions in `uv.lock` are - the versions every deployment gets, so those are the versions that must be scanned. - The repo's own distributions are excluded because they are the code being shipped, - not third-party dependencies of it. + """pip-audit reads the lock's export, frozen -- the set that ships, not a resolve. + + Auditing `uv export --frozen` output (not a bare environment audit, not a fresh + resolve) is what makes the audit honest about what deployments run: the pinned + versions in the committed `uv.lock` are the versions every deployment gets, so those + are the versions that must be scanned. The repo's own distributions -- derived, not + listed -- are excluded because they are the code being shipped, not dependencies of + it; dev dependencies are deliberately included (the job gates nothing, and a CVE in + the dev toolchain still runs on contributors' machines). """ workflow = _read(".github/workflows/security.yml") - assert workflow, ".github/workflows/security.yml must exist -- the scans are configured in code" - assert "uv export" in workflow and "--all-extras" in workflow, ( - "the audit must consume `uv export --all-extras` output -- the full locked set, " - "extras included, because a vulnerable optional dependency is still a dependency" + assert workflow, ( + ".github/workflows/security.yml must exist -- the baseline scans are configured in code" + ) + assert "uv export --frozen" in workflow and "--all-extras" in workflow, ( + "the audit must consume a FROZEN `uv export --all-extras` -- the full locked set as " + "committed, extras included, never a fresh resolve in CI" ) - for distribution in _OWN_DISTRIBUTIONS: + own = {data["project"]["name"] for data in _pyprojects().values()} + for distribution in sorted(own): assert f"--no-emit-package {distribution}" in workflow, ( f"the export must exclude the repo's own {distribution} -- it is the code under " "scan, not a third-party dependency of it" ) assert "pip-audit" in workflow, ( "the workflow must run pip-audit against the exported lock -- this is the " - "dependency scanning #291 says has never existed here" + "dependency scanning #291 says has never actually run here" ) @@ -135,7 +141,7 @@ def test_the_security_workflow_runs_codeql_on_python(): workflow = _read(".github/workflows/security.yml") assert "github/codeql-action/init" in workflow, ( "the workflow must initialise CodeQL -- this is the static analysis #291 says has " - "never existed here" + "never actually run here" ) assert "languages: python" in workflow, ( "CodeQL must analyse Python -- the only language this repo ships" @@ -153,34 +159,60 @@ def _code_lines(text: str) -> str: """The workflow with comments stripped -- a token NAMED in a comment explaining its absence is documentation; a token REFERENCED in code is a dependency on a secret. - Comments in these files are YAML `#` remarks (or the same inside a shell heredoc); - stripping them lets the forbidden-secret check scan what the runner executes. + The `#` remarks in these files are YAML comments; stripping them lets the + secret check scan what the runner executes. (No workflow here puts a meaningful `#` + inside an executed string; cron expressions and the folded export scalar do not.) """ return "\n".join(re.split(r"(^|\s)#", line)[0] for line in text.split("\n")) -def test_no_scan_step_depends_on_a_secret_that_does_not_exist(): - """The scans run on what a public repo has -- no SONAR_TOKEN, no SNYK_TOKEN, none. +def test_the_baseline_scans_reference_no_secrets_at_all(): + """The always-on workflows run on GITHUB_TOKEN alone -- no wishlist tokens, none. - The wishlist names in #291 were never created; a scan step referencing them would be - a scan that works only for a hypothetical maintainer with hypothetical tokens. The - configuration is honest when its executable lines reference no secrets at all (the - workflows' comments may name the absent tokens, because that is where the decision - to run tokenless is documented). + The baseline is what makes the scans real: `security.yml` and `ci.yml` must reference + no secret whatsoever, or they would be scans that run only for a hypothetical + maintainer with hypothetical tokens -- the gap #291 exists to close, restated as YAML. """ - for relative in (".github/workflows/security.yml", ".github/workflows/ci.yml"): - workflow = _read(relative) - assert workflow, f"{relative} must exist" - executable = _code_lines(workflow) - for secret in _FORBIDDEN_SECRETS: - assert secret not in executable, ( - f"{relative} references {secret}, which does not exist in this repository -- " - "the scans must run tokenless or they do not run" - ) - dangling = sorted(set(re.findall(r"secrets\.([A-Z_]+)", executable))) - assert not dangling, ( - f"{relative} references secrets {dangling} -- the scan workflows must run on " - "GITHUB_TOKEN alone, or they fail for every contributor without them" + for path in sorted(_WORKFLOWS.glob("*.yml")): + if path.name == _OPTIONAL_TIER: + continue + executable = _code_lines(path.read_text()) + referenced = sorted(set(re.findall(r"secrets\.([A-Za-z_][A-Za-z0-9_]*)", executable))) + assert not referenced or referenced == ["GITHUB_TOKEN"], ( + f"{path.name} references secrets {referenced} -- a workflow that always runs " + "must run on GITHUB_TOKEN alone, or it fails for every contributor without " + "the missing tokens" + ) + + +def test_the_optional_tier_only_asks_for_its_tokens_behind_the_preflight_guard(): + """code-quality.yml may name SONAR_TOKEN/SNYK_TOKEN -- but only guarded. + + The optional tier's design constraint (its own header documents it): a job that needs + a token must declare `needs: preflight` and run under `if: needs.preflight.outputs. + configured == 'true'`, so a missing token SKIPS with an explanation instead of + reddening every scheduled run. This test pins the guard structurally, per job, so a + future edit cannot detach a token-referencing job from its preflight. + """ + workflow = yaml.safe_load((_WORKFLOWS / _OPTIONAL_TIER).read_text()) + jobs = workflow.get("jobs", {}) + assert "preflight" in jobs, ( + f"{_OPTIONAL_TIER} must keep its preflight job -- it is what lets the optional " + "tier skip cleanly while the tokens do not exist" + ) + guard = "needs.preflight.outputs.configured == 'true'" + for name, job in jobs.items(): + serialized = str(job) + referenced = [s for s in _FORBIDDEN_IN_BASELINE if f"secrets.{s}" in serialized] + if not referenced or name == "preflight": + continue + assert "preflight" in (job.get("needs") or []), ( + f"{_OPTIONAL_TIER}'s job {name!r} references {referenced} but does not declare " + "`needs: preflight` -- a token-referencing job must be guarded" + ) + assert job.get("if") == guard, ( + f"{_OPTIONAL_TIER}'s job {name!r} references {referenced} but is not gated on " + f"`{guard}` -- without the guard a missing token reddens every scheduled run" ) diff --git a/uv.lock b/uv.lock index b3f58ef8..9ed0c33b 100644 --- a/uv.lock +++ b/uv.lock @@ -581,6 +581,7 @@ dev = [ { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pyyaml" }, { name = "ruff" }, ] @@ -599,6 +600,7 @@ dev = [ { name = "mypy", specifier = ">=1.18.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, { name = "ruff", specifier = ">=0.15.21" }, ]