diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6bf4fd8..6f3c021 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,3 +15,5 @@ jobs: run: php -d zend.assertions=1 -d assert.exception=1 tests/php-test.php - name: Test Node.js example run: node tests/node-test.mjs + - name: Test Python example + run: python3 tests/python-test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c6208..88ddf26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this repository will be documented here. - detailed idempotency guidance for `X-GitHub-Delivery`, including claim/process/complete-fail flow, leases, retries and failure windows. - webhook receiver threat model covering trust boundaries, replay, business authorization, resource exhaustion, secret handling, logging, least privilege and incident response. - PHP and Node.js regression coverage for empty secrets, wrong signature algorithms and malformed SHA-256 signature headers. +- Python HMAC SHA-256 example and regression tests using GitHub's public webhook validation vector. ### Changed diff --git a/README.md b/README.md index a4fdb4b..62ef308 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Guia prático para receber webhooks do GitHub sem confiar cegamente no payload recebido. -Os exemplos mostram como validar `X-Hub-Signature-256` em PHP e Node.js usando comparação em tempo constante. O material é independente de framework e não contém código de nenhum produto comercial. +Os exemplos mostram como validar `X-Hub-Signature-256` em PHP, Node.js e Python usando comparação em tempo constante. O material é independente de framework e não contém código de nenhum produto comercial. ## Checklist mínimo @@ -61,8 +61,9 @@ O [threat model](docs/threat-model.md) cobre: |---|---|---| | PHP 8+ | [`examples/php/verify.php`](examples/php/verify.php) | `php tests/php-test.php` | | Node.js 20+ | [`examples/node/verify.mjs`](examples/node/verify.mjs) | `node tests/node-test.mjs` | +| Python 3.10+ | [`examples/python/verify.py`](examples/python/verify.py) | `python3 tests/python-test.py` | -Os exemplos recebem três valores: corpo bruto, header de assinatura e secret compartilhado. +Os exemplos recebem três valores: corpo bruto, header de assinatura e secret compartilhado. No exemplo Python, passe o corpo bruto da requisição como `bytes`, sem decodificar ou reserializar o JSON antes da validação. ## Validação local @@ -71,6 +72,7 @@ A suíte pode ser executada sem GitHub Actions: ```bash php tests/php-test.php node tests/node-test.mjs +python3 tests/python-test.py ``` O workflow de teste permanece disponível em modo manual. A manutenção normal prioriza execução local para evitar consumo desnecessário de CI. diff --git a/examples/python/verify.py b/examples/python/verify.py new file mode 100644 index 0000000..2fb0882 --- /dev/null +++ b/examples/python/verify.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import hashlib +import hmac +import re + +_SIGNATURE_PATTERN = re.compile(r"^sha256=[0-9a-f]{64}$") + + +def verify_github_webhook( + payload: bytes, + signature_header: str | None, + secret: str, +) -> bool: + """Verify a GitHub X-Hub-Signature-256 value against the raw request body.""" + if ( + not secret + or signature_header is None + or not _SIGNATURE_PATTERN.fullmatch(signature_header) + ): + return False + + expected = "sha256=" + hmac.new( + secret.encode("utf-8"), + payload, + hashlib.sha256, + ).hexdigest() + + return hmac.compare_digest(expected, signature_header) diff --git a/tests/python-test.py b/tests/python-test.py new file mode 100644 index 0000000..2eb10a1 --- /dev/null +++ b/tests/python-test.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import unittest + +MODULE_PATH = Path(__file__).resolve().parents[1] / "examples" / "python" / "verify.py" +SPEC = importlib.util.spec_from_file_location("github_webhook_verify", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +verify_github_webhook = MODULE.verify_github_webhook + + +class VerifyGitHubWebhookTest(unittest.TestCase): + def setUp(self) -> None: + self.secret = "It's a Secret to Everybody" + self.payload = b"Hello, World!" + self.signature = ( + "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17" + ) + + def test_accepts_github_documentation_vector(self) -> None: + self.assertTrue( + verify_github_webhook(self.payload, self.signature, self.secret) + ) + + def test_rejects_wrong_secret(self) -> None: + self.assertFalse( + verify_github_webhook(self.payload, self.signature, "wrong-secret") + ) + + def test_rejects_tampered_payload(self) -> None: + self.assertFalse( + verify_github_webhook( + self.payload + b"tampered", + self.signature, + self.secret, + ) + ) + + def test_rejects_missing_or_malformed_signatures(self) -> None: + invalid_signatures = ( + None, + "", + "sha1=" + "a" * 40, + "sha256=not-hex", + "sha256=" + "A" * 64, + "sha256=" + "a" * 63, + ) + + for signature in invalid_signatures: + with self.subTest(signature=signature): + self.assertFalse( + verify_github_webhook(self.payload, signature, self.secret) + ) + + def test_rejects_empty_secret(self) -> None: + self.assertFalse(verify_github_webhook(self.payload, self.signature, "")) + + +if __name__ == "__main__": + unittest.main()