From b8ae62fb4a73db17b7cfe7ba2f72a1c32d9fe63b Mon Sep 17 00:00:00 2001 From: Pascal Date: Tue, 23 Jun 2026 23:51:42 +0200 Subject: [PATCH 1/2] ci: add dependency audit workflow Add a Security Audit workflow with a dependency-audit job. Push/PR/manual runs pip-audit against a committed --generate-hashes requirements snapshot (.github/security-audit-requirements.txt) for deterministic CI, while the weekly scheduled run resolves the runtime + test dependency set live across the supported Python/OS matrix to surface newly published advisories. A sync gate (.github/scripts/check_security_requirements.py) fails PRs whose dependency inputs changed without refreshing the committed snapshot, so the committed file can't silently drift from pyproject.toml. --- .../scripts/check_security_requirements.py | 107 +++++++ .github/security-audit-requirements.txt | 253 +++++++++++++++++ .github/workflows/security.yml | 64 +++++ CONTRIBUTING.md | 14 + tests/test_security_workflow.py | 266 ++++++++++++++++++ 5 files changed, 704 insertions(+) create mode 100644 .github/scripts/check_security_requirements.py create mode 100644 .github/security-audit-requirements.txt create mode 100644 .github/workflows/security.yml create mode 100644 tests/test_security_workflow.py diff --git a/.github/scripts/check_security_requirements.py b/.github/scripts/check_security_requirements.py new file mode 100644 index 0000000000..38040d7bd9 --- /dev/null +++ b/.github/scripts/check_security_requirements.py @@ -0,0 +1,107 @@ +"""Check that committed security audit requirements are up to date.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +COMMITTED_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt" +DEPENDENCY_INPUTS = ("pyproject.toml", ".github/security-audit-requirements.txt") + + +def _dependency_diff_refs() -> tuple[str, str]: + base_ref = os.environ.get("DEPENDENCY_DIFF_BASE", "").strip() + head_ref = os.environ.get("DEPENDENCY_DIFF_HEAD", "").strip() or "HEAD" + if base_ref and not set(base_ref) <= {"0"}: + return base_ref, head_ref + # Fallback when no usable base is supplied (push with an all-zero + # ``github.event.before``, manual dispatch, etc.). ``HEAD^`` fails on a + # shallow checkout or a single-commit repo; that ``git diff`` error is + # caught by the caller and deliberately treated as "inputs changed" so the + # audit runs anyway — failing safe (audit) rather than skipping silently. + return "HEAD^", "HEAD" + + +def _dependency_inputs_changed() -> bool: + base_ref, head_ref = _dependency_diff_refs() + try: + result = subprocess.run( + [ + "git", + "diff", + "--name-only", + base_ref, + head_ref, + "--", + *DEPENDENCY_INPUTS, + ], + check=True, + cwd=REPO_ROOT, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + ) + except subprocess.CalledProcessError as exc: + print( + "Could not determine changed dependency inputs; checking requirements.", + file=sys.stderr, + ) + if exc.stderr: + print(exc.stderr.strip(), file=sys.stderr) + return True + + changed_inputs = [line for line in result.stdout.splitlines() if line] + if not changed_inputs: + print("Dependency audit inputs unchanged; sync check skipped.") + return False + + print(f"Dependency audit inputs changed: {', '.join(changed_inputs)}") + return True + + +def main() -> int: + if not _dependency_inputs_changed(): + return 0 + + generated_requirements = Path(os.environ["GENERATED_REQUIREMENTS"]) + generated_requirements.parent.mkdir(parents=True, exist_ok=True) + + subprocess.run( + [ + "uv", + "pip", + "compile", + "pyproject.toml", + "--extra", + "test", + "--universal", + "--upgrade", + "--generate-hashes", + "--quiet", + "--no-header", + "--output-file", + str(generated_requirements), + ], + check=True, + cwd=REPO_ROOT, + ) + + committed = COMMITTED_REQUIREMENTS.read_text(encoding="utf-8") + generated = generated_requirements.read_text(encoding="utf-8") + if committed == generated: + return 0 + + print( + "Regenerate .github/security-audit-requirements.txt with the documented " + "uv pip compile command.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/security-audit-requirements.txt b/.github/security-audit-requirements.txt new file mode 100644 index 0000000000..a7cb1917ce --- /dev/null +++ b/.github/security-audit-requirements.txt @@ -0,0 +1,253 @@ +annotated-doc==0.0.4 \ + --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ + --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 + # via typer +click==8.4.1 \ + --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \ + --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96 + # via specify-cli (pyproject.toml) +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via + # click + # pytest + # typer +coverage==7.14.3 \ + --hash=sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610 \ + --hash=sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26 \ + --hash=sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965 \ + --hash=sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a \ + --hash=sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd \ + --hash=sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27 \ + --hash=sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd \ + --hash=sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f \ + --hash=sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5 \ + --hash=sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c \ + --hash=sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e \ + --hash=sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a \ + --hash=sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b \ + --hash=sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab \ + --hash=sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37 \ + --hash=sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e \ + --hash=sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda \ + --hash=sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845 \ + --hash=sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24 \ + --hash=sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027 \ + --hash=sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92 \ + --hash=sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c \ + --hash=sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977 \ + --hash=sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc \ + --hash=sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889 \ + --hash=sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640 \ + --hash=sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb \ + --hash=sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf \ + --hash=sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc \ + --hash=sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5 \ + --hash=sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137 \ + --hash=sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727 \ + --hash=sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f \ + --hash=sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803 \ + --hash=sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f \ + --hash=sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9 \ + --hash=sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0 \ + --hash=sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8 \ + --hash=sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc \ + --hash=sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18 \ + --hash=sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d \ + --hash=sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c \ + --hash=sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949 \ + --hash=sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed \ + --hash=sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2 \ + --hash=sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336 \ + --hash=sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a \ + --hash=sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205 \ + --hash=sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3 \ + --hash=sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665 \ + --hash=sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73 \ + --hash=sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501 \ + --hash=sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647 \ + --hash=sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e \ + --hash=sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9 \ + --hash=sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87 \ + --hash=sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7 \ + --hash=sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5 \ + --hash=sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3 \ + --hash=sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e \ + --hash=sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde \ + --hash=sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994 \ + --hash=sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784 \ + --hash=sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498 \ + --hash=sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7 \ + --hash=sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de \ + --hash=sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388 \ + --hash=sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce \ + --hash=sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4 \ + --hash=sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c \ + --hash=sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef \ + --hash=sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2 \ + --hash=sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f \ + --hash=sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b \ + --hash=sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891 \ + --hash=sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635 \ + --hash=sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb \ + --hash=sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7 \ + --hash=sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5 \ + --hash=sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d \ + --hash=sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3 \ + --hash=sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150 \ + --hash=sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35 \ + --hash=sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61 \ + --hash=sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d \ + --hash=sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7 \ + --hash=sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a \ + --hash=sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305 \ + --hash=sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027 \ + --hash=sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8 \ + --hash=sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700 + # via pytest-cov +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +json5==0.15.0 \ + --hash=sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618 \ + --hash=sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71 + # via specify-cli (pyproject.toml) +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via rich +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via + # specify-cli (pyproject.toml) + # pytest +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via specify-cli (pyproject.toml) +platformdirs==4.10.0 \ + --hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \ + --hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a + # via specify-cli (pyproject.toml) +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via + # pytest + # pytest-cov +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via + # pytest + # rich +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via + # specify-cli (pyproject.toml) + # pytest-cov +pytest-cov==7.1.0 \ + --hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \ + --hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 + # via specify-cli (pyproject.toml) +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 specify-cli (pyproject.toml) +readchar==4.2.2 \ + --hash=sha256:92daf7e42c52b0787e6c75d01ecfb9a94f4ceff3764958b570c1dddedd47b200 \ + --hash=sha256:e3b270fe16fc90c50ac79107700330a133dd4c63d22939f5b03b4f24564d5dd8 + # via specify-cli (pyproject.toml) +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 + # via + # specify-cli (pyproject.toml) + # typer +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \ + --hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de + # via typer +typer==0.26.7 \ + --hash=sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58 \ + --hash=sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a + # via specify-cli (pyproject.toml) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000000..1f2261b3ac --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,64 @@ +name: Security Audit + +permissions: + contents: read + +on: + push: + branches: ["main"] + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: "17 4 * * 1" + workflow_dispatch: + +jobs: + dependency-audit: + name: Dependency audit (${{ matrix.os }}, Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.12", "3.13"] + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 2 + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ matrix.python-version }} + + # The committed .github/security-audit-requirements.txt is generated with + # --universal (resolves across all interpreters/platforms) and is what + # push/PR runs audit. The scheduled job instead compiles per matrix + # entry with --python-version so it can surface advisories in wheels that + # only resolve on a specific interpreter (e.g. 3.11-only) — coverage the + # universal file may not exercise. This broadening is intentional; PR runs + # trade that depth for determinism against the committed snapshot. + - name: Compile scheduled audit requirements + if: ${{ github.event_name == 'schedule' }} + run: | + uv pip compile pyproject.toml --extra test --python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet --output-file "${{ runner.temp }}/spec-kit-audit-requirements.txt" + + - name: Run pip-audit (scheduled live resolution) + if: ${{ github.event_name == 'schedule' }} + run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r "${{ runner.temp }}/spec-kit-audit-requirements.txt" --progress-spinner off + + - name: Check committed audit requirements are current + if: ${{ github.event_name != 'schedule' }} + env: + DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }} + DEPENDENCY_DIFF_HEAD: ${{ github.sha }} + GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt + run: python .github/scripts/check_security_requirements.py + + - name: Run pip-audit (committed requirements) + if: ${{ github.event_name != 'schedule' }} + run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cf5514a0a..3d14202733 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,6 +113,20 @@ uv pip install -e ".[test]" > `specify_cli` to this checkout's `src/`. This matches the gotcha documented in > `AGENTS.md` (Common Pitfalls). +#### Security checks + +```bash +uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off +``` + +Run this before changing dependency metadata. Pull request, push, and manual CI audits use the committed hashed requirements file so they stay deterministic. The scheduled CI audit also resolves the runtime and `test` extra dependency set across the supported Python and OS matrix to catch newly published advisories. If dependency metadata changes, refresh the committed audit input before running pip-audit: + +```bash +uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes --quiet --no-header --output-file .github/security-audit-requirements.txt +``` + +Upstream package releases drift over time, so even an unrelated PR touching `pyproject.toml` can fail the `dependency-audit` check until the committed file is regenerated with the command above and re-committed. + ### Manual testing #### Testing setup diff --git a/tests/test_security_workflow.py b/tests/test_security_workflow.py new file mode 100644 index 0000000000..74c64bab3b --- /dev/null +++ b/tests/test_security_workflow.py @@ -0,0 +1,266 @@ +"""Static checks for the dependency-audit security workflow.""" + +from __future__ import annotations + +import importlib.util +import re +import subprocess +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parent.parent +SECURITY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "security.yml" +CONTRIBUTING = REPO_ROOT / "CONTRIBUTING.md" +SECURITY_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt" +SECURITY_REQUIREMENTS_SYNC_SCRIPT = ( + REPO_ROOT / ".github" / "scripts" / "check_security_requirements.py" +) + +WORKFLOW_LIVE_AUDIT_REQUIREMENTS = '"${{ runner.temp }}/spec-kit-audit-requirements.txt"' +COMMITTED_AUDIT_REQUIREMENTS = ".github/security-audit-requirements.txt" +WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS = ( + "uv pip compile pyproject.toml --extra test " + '--python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet ' + f"--output-file {WORKFLOW_LIVE_AUDIT_REQUIREMENTS}" +) +LOCAL_REFRESH_TEST_EXTRA_DEPS = ( + "uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes " + f"--quiet --no-header --output-file {COMMITTED_AUDIT_REQUIREMENTS}" +) +WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS = ( + "uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes " + "--quiet --no-header --output-file" +) +WORKFLOW_SYNC_SCRIPT = "python .github/scripts/check_security_requirements.py" +WORKFLOW_LIVE_PIP_AUDIT = ( + "uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes " + f"-r {WORKFLOW_LIVE_AUDIT_REQUIREMENTS} --progress-spinner off" +) +LOCAL_PIP_AUDIT = ( + "uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes " + f"-r {COMMITTED_AUDIT_REQUIREMENTS} --progress-spinner off" +) + + +def _load_security_workflow() -> dict: + return yaml.safe_load(SECURITY_WORKFLOW.read_text(encoding="utf-8")) + + +def _workflow_triggers() -> dict: + workflow = _load_security_workflow() + return workflow.get("on") or workflow[True] + + +def _step(job_name: str, step_name: str) -> dict: + workflow = _load_security_workflow() + for step in workflow["jobs"][job_name]["steps"]: + if step.get("name") == step_name: + return step + raise AssertionError(f"Step {step_name!r} not found in job {job_name!r}.") + + +def _load_sync_script(): + spec = importlib.util.spec_from_file_location( + "check_security_requirements", + SECURITY_REQUIREMENTS_SYNC_SCRIPT, + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestDependencyAuditWorkflow: + """Guard the dependency-audit security workflow.""" + + def test_dependency_audit_uses_committed_requirements_for_prs_and_pushes(self): + scheduled_compile = _step("dependency-audit", "Compile scheduled audit requirements") + scheduled_audit = _step("dependency-audit", "Run pip-audit (scheduled live resolution)") + committed_audit = _step("dependency-audit", "Run pip-audit (committed requirements)") + sync_check = _step("dependency-audit", "Check committed audit requirements are current") + + assert scheduled_compile["if"] == "${{ github.event_name == 'schedule' }}" + assert WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS in scheduled_compile["run"] + assert scheduled_audit["if"] == "${{ github.event_name == 'schedule' }}" + assert scheduled_audit["run"] == WORKFLOW_LIVE_PIP_AUDIT + assert sync_check["if"] == "${{ github.event_name != 'schedule' }}" + assert sync_check["env"]["DEPENDENCY_DIFF_BASE"] == ( + "${{ github.event.pull_request.base.sha || github.event.before || '' }}" + ) + assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == "${{ github.sha }}" + assert sync_check["run"] == WORKFLOW_SYNC_SCRIPT + assert committed_audit["if"] == "${{ github.event_name != 'schedule' }}" + assert committed_audit["run"] == LOCAL_PIP_AUDIT + + dependency_job_text = "\n".join( + step.get("run", "") + for step in _load_security_workflow()["jobs"]["dependency-audit"]["steps"] + ) + protection_text = ( + dependency_job_text + + "\n" + + SECURITY_REQUIREMENTS_SYNC_SCRIPT.read_text(encoding="utf-8") + ) + assert "--generate-hashes" in protection_text + assert "--no-header" in protection_text + assert "--require-hashes" in protection_text + assert "--disable-pip" in protection_text + assert WORKFLOW_LIVE_AUDIT_REQUIREMENTS in dependency_job_text + assert COMMITTED_AUDIT_REQUIREMENTS in protection_text + assert "uv export" not in protection_text + assert "--frozen" not in protection_text + assert "--locked" not in protection_text + assert "uv.lock" not in protection_text + assert "/tmp/" not in protection_text + + def test_dependency_audit_checkout_fetches_previous_commit(self): + checkout = _step("dependency-audit", "Checkout") + + assert checkout["with"]["fetch-depth"] == 2 + + def test_security_workflow_triggers(self): + triggers = _workflow_triggers() + + assert triggers["push"]["branches"] == ["main"] + # Asserted by inclusion so later PRs (e.g. baseline-growth gates) can add + # labeled/unlabeled without rewriting this test. + assert {"opened", "synchronize", "reopened"} <= set( + triggers["pull_request"]["types"] + ) + assert "workflow_dispatch" in triggers + assert triggers["schedule"] == [{"cron": "17 4 * * 1"}] + + def test_dependency_audit_runs_supported_python_os_matrix(self): + workflow = _load_security_workflow() + matrix = workflow["jobs"]["dependency-audit"]["strategy"]["matrix"] + + assert matrix["os"] == ["ubuntu-latest", "windows-latest"] + assert matrix["python-version"] == ["3.11", "3.12", "3.13"] + assert workflow["jobs"]["dependency-audit"]["runs-on"] == "${{ matrix.os }}" + + def test_pip_audit_is_pinned(self): + workflow_text = SECURITY_WORKFLOW.read_text(encoding="utf-8") + + assert WORKFLOW_LIVE_PIP_AUDIT in workflow_text + assert LOCAL_PIP_AUDIT in workflow_text + assert re.search(r"\buvx\s+pip-audit\b", workflow_text) is None + + def test_actions_are_pinned_to_full_commit_shas(self): + workflow = _load_security_workflow() + uses_refs = [ + step["uses"] + for job in workflow["jobs"].values() + for step in job["steps"] + if "uses" in step + ] + + assert uses_refs + for uses_ref in uses_refs: + assert re.search(r"@[0-9a-f]{40}$", uses_ref), uses_ref + assert re.search(r"@v\d+", uses_ref) is None + + def test_committed_audit_requirements_are_hashed(self): + requirements = SECURITY_REQUIREMENTS.read_text(encoding="utf-8") + + assert "--hash=sha256:" in requirements + assert not requirements.startswith("#") + assert "pytest==" in requirements + assert "pytest-cov==" in requirements + + def test_sync_script_skips_when_dependency_inputs_are_unchanged(self, monkeypatch, capsys): + sync_script = _load_sync_script() + + def fake_run(command, **kwargs): + assert command == [ + "git", "diff", "--name-only", "HEAD^", "HEAD", "--", + "pyproject.toml", ".github/security-audit-requirements.txt", + ] + assert kwargs["check"] is True + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr(sync_script.subprocess, "run", fake_run) + + assert sync_script.main() == 0 + assert "sync check skipped" in capsys.readouterr().out + + def test_sync_script_uses_github_diff_refs_when_available(self, monkeypatch): + sync_script = _load_sync_script() + monkeypatch.setenv("DEPENDENCY_DIFF_BASE", "abc123") + monkeypatch.setenv("DEPENDENCY_DIFF_HEAD", "def456") + + def fake_run(command, **_kwargs): + assert command == [ + "git", "diff", "--name-only", "abc123", "def456", "--", + "pyproject.toml", ".github/security-audit-requirements.txt", + ] + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr(sync_script.subprocess, "run", fake_run) + + assert sync_script._dependency_inputs_changed() is False + + def test_sync_script_compiles_and_compares_when_dependency_inputs_changed( + self, monkeypatch, tmp_path + ): + sync_script = _load_sync_script() + committed_requirements = tmp_path / ".github" / "security-audit-requirements.txt" + generated_requirements = tmp_path / "generated-requirements.txt" + committed_requirements.parent.mkdir() + committed_requirements.write_text("pytest==1\n", encoding="utf-8") + compile_commands = [] + + monkeypatch.setattr(sync_script, "REPO_ROOT", tmp_path) + monkeypatch.setattr(sync_script, "COMMITTED_REQUIREMENTS", committed_requirements) + monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements)) + + def fake_run(command, **kwargs): + if command[0] == "git": + return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="") + compile_commands.append(command) + assert kwargs["check"] is True + generated_requirements.write_text("pytest==1\n", encoding="utf-8") + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(sync_script.subprocess, "run", fake_run) + + assert sync_script.main() == 0 + assert len(compile_commands) == 1 + compile_command = " ".join(compile_commands[0]) + assert WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS in compile_command + assert "--output-file" in compile_commands[0] + assert str(generated_requirements) in compile_commands[0] + + def test_sync_script_fails_when_generated_requirements_differ( + self, monkeypatch, tmp_path, capsys + ): + sync_script = _load_sync_script() + committed_requirements = tmp_path / ".github" / "security-audit-requirements.txt" + generated_requirements = tmp_path / "generated-requirements.txt" + committed_requirements.parent.mkdir() + committed_requirements.write_text("pytest==1\n", encoding="utf-8") + + monkeypatch.setattr(sync_script, "REPO_ROOT", tmp_path) + monkeypatch.setattr(sync_script, "COMMITTED_REQUIREMENTS", committed_requirements) + monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements)) + + def fake_run(command, **_kwargs): + if command[0] == "git": + return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="") + generated_requirements.write_text("pytest==2\n", encoding="utf-8") + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(sync_script.subprocess, "run", fake_run) + + assert sync_script.main() == 1 + assert "Regenerate .github/security-audit-requirements.txt" in capsys.readouterr().err + + def test_contributing_documents_security_commands(self): + contributing_text = CONTRIBUTING.read_text(encoding="utf-8") + + assert LOCAL_REFRESH_TEST_EXTRA_DEPS in contributing_text + assert LOCAL_PIP_AUDIT in contributing_text + assert "/tmp/" not in contributing_text + assert "uv export" not in contributing_text From ea424f909abf4af36b92b380760a952c8aa5cc49 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 24 Jun 2026 00:16:03 +0200 Subject: [PATCH 2/2] ci: split dependency audit schedule matrix Assisted-by: Codex (model: GPT-5, autonomous) --- .github/workflows/security.yml | 58 ++++++++++++++++++++------------- tests/test_security_workflow.py | 48 +++++++++++++++++++-------- 2 files changed, 70 insertions(+), 36 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 1f2261b3ac..ab734a6504 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -14,7 +14,36 @@ on: jobs: dependency-audit: - name: Dependency audit (${{ matrix.os }}, Python ${{ matrix.python-version }}) + name: Dependency audit + if: ${{ github.event_name != 'schedule' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 2 + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.13" + + - name: Check committed audit requirements are current + env: + DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }} + DEPENDENCY_DIFF_HEAD: ${{ github.sha }} + GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt + run: python .github/scripts/check_security_requirements.py + + - name: Run pip-audit (committed requirements) + run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off + + dependency-audit-scheduled: + name: Dependency audit scheduled (${{ matrix.os }}, Python ${{ matrix.python-version }}) + if: ${{ github.event_name == 'schedule' }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -24,8 +53,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 2 - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 @@ -37,28 +64,15 @@ jobs: # The committed .github/security-audit-requirements.txt is generated with # --universal (resolves across all interpreters/platforms) and is what - # push/PR runs audit. The scheduled job instead compiles per matrix - # entry with --python-version so it can surface advisories in wheels that - # only resolve on a specific interpreter (e.g. 3.11-only) — coverage the - # universal file may not exercise. This broadening is intentional; PR runs - # trade that depth for determinism against the committed snapshot. + # push/PR/workflow_dispatch runs audit. The scheduled job instead compiles + # per matrix entry with --python-version so it can surface advisories in + # wheels that only resolve on a specific interpreter (e.g. 3.11-only) — + # coverage the universal file may not exercise. This broadening is + # intentional; non-scheduled runs trade that depth for determinism against + # the committed snapshot. - name: Compile scheduled audit requirements - if: ${{ github.event_name == 'schedule' }} run: | uv pip compile pyproject.toml --extra test --python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet --output-file "${{ runner.temp }}/spec-kit-audit-requirements.txt" - name: Run pip-audit (scheduled live resolution) - if: ${{ github.event_name == 'schedule' }} run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r "${{ runner.temp }}/spec-kit-audit-requirements.txt" --progress-spinner off - - - name: Check committed audit requirements are current - if: ${{ github.event_name != 'schedule' }} - env: - DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }} - DEPENDENCY_DIFF_HEAD: ${{ github.sha }} - GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt - run: python .github/scripts/check_security_requirements.py - - - name: Run pip-audit (committed requirements) - if: ${{ github.event_name != 'schedule' }} - run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off diff --git a/tests/test_security_workflow.py b/tests/test_security_workflow.py index 74c64bab3b..21767ed233 100644 --- a/tests/test_security_workflow.py +++ b/tests/test_security_workflow.py @@ -61,6 +61,15 @@ def _step(job_name: str, step_name: str) -> dict: raise AssertionError(f"Step {step_name!r} not found in job {job_name!r}.") +def _job_run_text(*job_names: str) -> str: + workflow = _load_security_workflow() + return "\n".join( + step.get("run", "") + for job_name in job_names + for step in workflow["jobs"][job_name]["steps"] + ) + + def _load_sync_script(): spec = importlib.util.spec_from_file_location( "check_security_requirements", @@ -77,27 +86,26 @@ class TestDependencyAuditWorkflow: """Guard the dependency-audit security workflow.""" def test_dependency_audit_uses_committed_requirements_for_prs_and_pushes(self): - scheduled_compile = _step("dependency-audit", "Compile scheduled audit requirements") - scheduled_audit = _step("dependency-audit", "Run pip-audit (scheduled live resolution)") + workflow = _load_security_workflow() + job = workflow["jobs"]["dependency-audit"] committed_audit = _step("dependency-audit", "Run pip-audit (committed requirements)") sync_check = _step("dependency-audit", "Check committed audit requirements are current") + setup_python = _step("dependency-audit", "Set up Python") - assert scheduled_compile["if"] == "${{ github.event_name == 'schedule' }}" - assert WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS in scheduled_compile["run"] - assert scheduled_audit["if"] == "${{ github.event_name == 'schedule' }}" - assert scheduled_audit["run"] == WORKFLOW_LIVE_PIP_AUDIT - assert sync_check["if"] == "${{ github.event_name != 'schedule' }}" + assert job["if"] == "${{ github.event_name != 'schedule' }}" + assert job["runs-on"] == "ubuntu-latest" + assert "strategy" not in job + assert setup_python["with"]["python-version"] == "3.13" assert sync_check["env"]["DEPENDENCY_DIFF_BASE"] == ( "${{ github.event.pull_request.base.sha || github.event.before || '' }}" ) assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == "${{ github.sha }}" assert sync_check["run"] == WORKFLOW_SYNC_SCRIPT - assert committed_audit["if"] == "${{ github.event_name != 'schedule' }}" assert committed_audit["run"] == LOCAL_PIP_AUDIT - dependency_job_text = "\n".join( - step.get("run", "") - for step in _load_security_workflow()["jobs"]["dependency-audit"]["steps"] + dependency_job_text = _job_run_text( + "dependency-audit", + "dependency-audit-scheduled", ) protection_text = ( dependency_job_text @@ -133,13 +141,25 @@ def test_security_workflow_triggers(self): assert "workflow_dispatch" in triggers assert triggers["schedule"] == [{"cron": "17 4 * * 1"}] - def test_dependency_audit_runs_supported_python_os_matrix(self): + def test_scheduled_dependency_audit_runs_supported_python_os_matrix(self): workflow = _load_security_workflow() - matrix = workflow["jobs"]["dependency-audit"]["strategy"]["matrix"] + job = workflow["jobs"]["dependency-audit-scheduled"] + matrix = job["strategy"]["matrix"] + scheduled_compile = _step( + "dependency-audit-scheduled", + "Compile scheduled audit requirements", + ) + scheduled_audit = _step( + "dependency-audit-scheduled", + "Run pip-audit (scheduled live resolution)", + ) + assert job["if"] == "${{ github.event_name == 'schedule' }}" assert matrix["os"] == ["ubuntu-latest", "windows-latest"] assert matrix["python-version"] == ["3.11", "3.12", "3.13"] - assert workflow["jobs"]["dependency-audit"]["runs-on"] == "${{ matrix.os }}" + assert job["runs-on"] == "${{ matrix.os }}" + assert WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS in scheduled_compile["run"] + assert scheduled_audit["run"] == WORKFLOW_LIVE_PIP_AUDIT def test_pip_audit_is_pinned(self): workflow_text = SECURITY_WORKFLOW.read_text(encoding="utf-8")