hookbench 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hookbench-0.1.0/PKG-INFO +46 -0
- hookbench-0.1.0/README.md +32 -0
- hookbench-0.1.0/pyproject.toml +27 -0
- hookbench-0.1.0/setup.cfg +4 -0
- hookbench-0.1.0/src/hookbench/__init__.py +3 -0
- hookbench-0.1.0/src/hookbench/__main__.py +5 -0
- hookbench-0.1.0/src/hookbench/cli.py +129 -0
- hookbench-0.1.0/src/hookbench/core.py +96 -0
- hookbench-0.1.0/src/hookbench/server.py +120 -0
- hookbench-0.1.0/src/hookbench.egg-info/PKG-INFO +46 -0
- hookbench-0.1.0/src/hookbench.egg-info/SOURCES.txt +13 -0
- hookbench-0.1.0/src/hookbench.egg-info/dependency_links.txt +1 -0
- hookbench-0.1.0/src/hookbench.egg-info/entry_points.txt +2 -0
- hookbench-0.1.0/src/hookbench.egg-info/top_level.txt +1 -0
- hookbench-0.1.0/tests/test_hookbench.py +128 -0
hookbench-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hookbench
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Webhook integration testing: capture webhooks, verify HMAC signatures, replay fixtures at your handler — stdlib only
|
|
5
|
+
Author: juzorei
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/juzorei/hookbench
|
|
8
|
+
Keywords: webhooks,hmac,testing,integration,agent-skills
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Software Development :: Testing
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# hookbench
|
|
16
|
+
|
|
17
|
+
Capture webhooks as fixtures, verify HMAC signatures the way each vendor means them, and replay captures at your handler while developing.
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
$ hookbench receive --port 8080 --secret-file secret.txt --out captured.ndjson
|
|
21
|
+
receiver listening on http://127.0.0.1:8080/ (Ctrl+C to stop)
|
|
22
|
+
verifying X-Signature against the provided secret
|
|
23
|
+
[1] /hook (signature_valid=True) body: {"event": "payment.succeeded", "id": 7}
|
|
24
|
+
|
|
25
|
+
$ hookbench replay captured.ndjson --url http://localhost:9000/handler --secret-file secret.txt
|
|
26
|
+
#1 /hook -> 200 {"received": true}
|
|
27
|
+
1 webhook(s) replayed to http://localhost:9000/handler
|
|
28
|
+
|
|
29
|
+
$ hookbench verify --payload body.bin --signature "sha256=abc…" --secret whsec_test123
|
|
30
|
+
VALID
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A webhook integration fails in exactly three ways — handler never called (exposure), signature doesn't verify (secret/serialization), handler is wrong (logic). `hookbench` separates them mechanically:
|
|
34
|
+
|
|
35
|
+
- `receive` — a local capture receiver; when a secret is given, every request's signature verdict is recorded with the payload, and an invalid signature answers 400.
|
|
36
|
+
- `verify` — offline single-payload check. Constant-time comparison, raw-payload and timestamped schemes (Stripe-style `t=…,v1=…`), freshness tolerance against replay.
|
|
37
|
+
- `replay` — re-signs fixtures with a fresh timestamp (so your own replay doesn't trip replay protection) and POSTs them at your handler; `--keep-original-signatures` compares against the captured bytes as-is.
|
|
38
|
+
|
|
39
|
+
**The serialization trap:** signatures cover exact bytes. A proxy pretty-printing the body breaks the signature while the payload "looks the same" — fixtures store raw bodies, and verification must read the raw request body, never a parsed-and-re-dumped copy.
|
|
40
|
+
|
|
41
|
+
Python 3.11+, stdlib only.
|
|
42
|
+
|
|
43
|
+
Install: `pip install hookbench` — or from this repo:
|
|
44
|
+
`pip install "git+https://github.com/juzorei/hookbench#subdirectory=tools/hookbench"`
|
|
45
|
+
|
|
46
|
+
The companion agent skill lives in [skills/webhook-integration-testing](../skills/webhook-integration-testing). Skills: `npx skills add juzorei/hookbench`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# hookbench
|
|
2
|
+
|
|
3
|
+
Capture webhooks as fixtures, verify HMAC signatures the way each vendor means them, and replay captures at your handler while developing.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
$ hookbench receive --port 8080 --secret-file secret.txt --out captured.ndjson
|
|
7
|
+
receiver listening on http://127.0.0.1:8080/ (Ctrl+C to stop)
|
|
8
|
+
verifying X-Signature against the provided secret
|
|
9
|
+
[1] /hook (signature_valid=True) body: {"event": "payment.succeeded", "id": 7}
|
|
10
|
+
|
|
11
|
+
$ hookbench replay captured.ndjson --url http://localhost:9000/handler --secret-file secret.txt
|
|
12
|
+
#1 /hook -> 200 {"received": true}
|
|
13
|
+
1 webhook(s) replayed to http://localhost:9000/handler
|
|
14
|
+
|
|
15
|
+
$ hookbench verify --payload body.bin --signature "sha256=abc…" --secret whsec_test123
|
|
16
|
+
VALID
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A webhook integration fails in exactly three ways — handler never called (exposure), signature doesn't verify (secret/serialization), handler is wrong (logic). `hookbench` separates them mechanically:
|
|
20
|
+
|
|
21
|
+
- `receive` — a local capture receiver; when a secret is given, every request's signature verdict is recorded with the payload, and an invalid signature answers 400.
|
|
22
|
+
- `verify` — offline single-payload check. Constant-time comparison, raw-payload and timestamped schemes (Stripe-style `t=…,v1=…`), freshness tolerance against replay.
|
|
23
|
+
- `replay` — re-signs fixtures with a fresh timestamp (so your own replay doesn't trip replay protection) and POSTs them at your handler; `--keep-original-signatures` compares against the captured bytes as-is.
|
|
24
|
+
|
|
25
|
+
**The serialization trap:** signatures cover exact bytes. A proxy pretty-printing the body breaks the signature while the payload "looks the same" — fixtures store raw bodies, and verification must read the raw request body, never a parsed-and-re-dumped copy.
|
|
26
|
+
|
|
27
|
+
Python 3.11+, stdlib only.
|
|
28
|
+
|
|
29
|
+
Install: `pip install hookbench` — or from this repo:
|
|
30
|
+
`pip install "git+https://github.com/juzorei/hookbench#subdirectory=tools/hookbench"`
|
|
31
|
+
|
|
32
|
+
The companion agent skill lives in [skills/webhook-integration-testing](../skills/webhook-integration-testing). Skills: `npx skills add juzorei/hookbench`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hookbench"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Webhook integration testing: capture webhooks, verify HMAC signatures, replay fixtures at your handler — stdlib only"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "juzorei" }]
|
|
13
|
+
keywords = ["webhooks", "hmac", "testing", "integration", "agent-skills"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Software Development :: Testing",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/juzorei/hookbench"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
hookbench = "hookbench.cli:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""hookbench CLI: capture webhooks, verify signatures, replay them at your handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .core import DEFAULT_SIGNATURE_HEADER, DEFAULT_TIMESTAMP_HEADER, verify_signature
|
|
12
|
+
from .server import Receiver, replay
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cmd_receive(args) -> int:
|
|
16
|
+
secret = open(args.secret_file, encoding="utf-8").read().strip() if args.secret_file else None
|
|
17
|
+
receiver = Receiver(port=args.port, secret=secret,
|
|
18
|
+
signature_header=args.signature_header,
|
|
19
|
+
timestamp_header=args.timestamp_header,
|
|
20
|
+
require_timestamp=args.require_timestamp,
|
|
21
|
+
fixture_path=args.out)
|
|
22
|
+
receiver.start()
|
|
23
|
+
print(f"receiver listening on http://127.0.0.1:{receiver.port}/ "
|
|
24
|
+
f"(Ctrl+C to stop)")
|
|
25
|
+
if secret:
|
|
26
|
+
print(f"verifying {args.signature_header} against the provided secret"
|
|
27
|
+
+ (f", freshness via {args.timestamp_header}" if args.timestamp_header else ""))
|
|
28
|
+
if args.out:
|
|
29
|
+
print(f"capturing to {args.out}")
|
|
30
|
+
print("point your sender at the URL above; captured requests are printed below\n")
|
|
31
|
+
try:
|
|
32
|
+
last = 0
|
|
33
|
+
while True:
|
|
34
|
+
time.sleep(0.3)
|
|
35
|
+
while last < len(receiver.captured):
|
|
36
|
+
c = receiver.captured[last]
|
|
37
|
+
last += 1
|
|
38
|
+
verdict = ("signature_valid=?" if c.signature_valid is None
|
|
39
|
+
else f"signature_valid={c.signature_valid}")
|
|
40
|
+
print(f"[{last}] {c.path} ({verdict}) body: {c.body[:160]}")
|
|
41
|
+
except KeyboardInterrupt:
|
|
42
|
+
print("\nstopping…")
|
|
43
|
+
finally:
|
|
44
|
+
receiver.stop()
|
|
45
|
+
if receiver.captured:
|
|
46
|
+
print(f"captured {len(receiver.captured)} request(s)"
|
|
47
|
+
+ (f" -> {args.out}" if args.out else ""))
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_replay(args) -> int:
|
|
52
|
+
secret = open(args.secret_file, encoding="utf-8").read().strip() if args.secret_file else None
|
|
53
|
+
results = replay(args.fixtures, args.url, secret=secret,
|
|
54
|
+
signature_header=args.signature_header,
|
|
55
|
+
timestamp_header=args.timestamp_header,
|
|
56
|
+
fresh_signatures=not args.keep_original_signatures)
|
|
57
|
+
for r in results:
|
|
58
|
+
print(f" #{r['run']} {r['path']} -> {r['status']} {r['response'][:120]}")
|
|
59
|
+
print(f"{len(results)} webhook(s) replayed to {args.url}")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_verify(args) -> int:
|
|
64
|
+
payload = open(args.payload, "rb").read()
|
|
65
|
+
verdict = verify_signature(payload, args.signature, args.secret,
|
|
66
|
+
timestamp_header_value=args.timestamp,
|
|
67
|
+
require_timestamp=args.require_timestamp,
|
|
68
|
+
tolerance_seconds=args.tolerance)
|
|
69
|
+
if args.json:
|
|
70
|
+
print(json.dumps(verdict, indent=2))
|
|
71
|
+
else:
|
|
72
|
+
print("VALID" if verdict["valid"] else "INVALID")
|
|
73
|
+
if verdict["error"]:
|
|
74
|
+
print(f" {verdict['error']}")
|
|
75
|
+
return 0 if verdict["valid"] else 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
79
|
+
parser = argparse.ArgumentParser(prog="hookbench", description=__doc__)
|
|
80
|
+
parser.add_argument("--version", action="version", version=f"hookbench {__version__}")
|
|
81
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
82
|
+
|
|
83
|
+
p = sub.add_parser("receive", help="run a local capture receiver")
|
|
84
|
+
p.add_argument("--port", type=int, default=8080)
|
|
85
|
+
p.add_argument("--secret-file", help="file with the HMAC secret (raw string)")
|
|
86
|
+
p.add_argument("--signature-header", default=DEFAULT_SIGNATURE_HEADER)
|
|
87
|
+
p.add_argument("--timestamp-header", default=None,
|
|
88
|
+
help="set when signatures cover '<timestamp>.<payload>'")
|
|
89
|
+
p.add_argument("--require-timestamp", action="store_true",
|
|
90
|
+
help="reject signatures older than --tolerance")
|
|
91
|
+
p.add_argument("--tolerance", type=int, default=300)
|
|
92
|
+
p.add_argument("--out", help="save captures as a fixture .ndjson for replay")
|
|
93
|
+
p.set_defaults(func=cmd_receive)
|
|
94
|
+
|
|
95
|
+
p = sub.add_parser("replay", help="re-send captured webhooks to a handler URL")
|
|
96
|
+
p.add_argument("fixtures")
|
|
97
|
+
p.add_argument("--url", required=True)
|
|
98
|
+
p.add_argument("--secret-file")
|
|
99
|
+
p.add_argument("--signature-header", default=DEFAULT_SIGNATURE_HEADER)
|
|
100
|
+
p.add_argument("--timestamp-header", default=DEFAULT_TIMESTAMP_HEADER)
|
|
101
|
+
p.add_argument("--keep-original-signatures", action="store_true",
|
|
102
|
+
help="do not re-sign; replay the captured signature bytes as-is")
|
|
103
|
+
p.set_defaults(func=cmd_replay)
|
|
104
|
+
|
|
105
|
+
p = sub.add_parser("verify", help="check one payload/signature pair offline")
|
|
106
|
+
p.add_argument("--payload", required=True, help="file with the raw body")
|
|
107
|
+
p.add_argument("--signature", required=True, help="signature header value, e.g. sha256=…")
|
|
108
|
+
p.add_argument("--secret", required=True)
|
|
109
|
+
p.add_argument("--timestamp", help="timestamp header value, when the scheme covers it")
|
|
110
|
+
p.add_argument("--require-timestamp", action="store_true")
|
|
111
|
+
p.add_argument("--tolerance", type=int, default=300)
|
|
112
|
+
p.add_argument("--json", action="store_true")
|
|
113
|
+
p.set_defaults(func=cmd_verify)
|
|
114
|
+
return parser
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main(argv: list[str] | None = None) -> int:
|
|
118
|
+
args = build_parser().parse_args(argv)
|
|
119
|
+
try:
|
|
120
|
+
return args.func(args)
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
print(f"hookbench: error: {exc}", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
except KeyboardInterrupt:
|
|
125
|
+
return 130
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
sys.exit(main())
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""hookbench core: HMAC verification, local capture receiver, fixture replay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
DEFAULT_SIGNATURE_HEADER = "X-Signature"
|
|
13
|
+
DEFAULT_TIMESTAMP_HEADER = "X-Timestamp"
|
|
14
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
15
|
+
DEFAULT_SCHEME = "sha256"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SignatureError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compute_signature(payload: bytes, secret: str, timestamp: str | None = None,
|
|
23
|
+
scheme: str = DEFAULT_SCHEME) -> str:
|
|
24
|
+
"""Sign like GitHub/Stripe style receivers: HMAC-SHA256 over `timestamp.payload`
|
|
25
|
+
when a timestamp is part of the scheme, else over the raw payload."""
|
|
26
|
+
if timestamp is not None:
|
|
27
|
+
message = timestamp.encode() + b"." + payload
|
|
28
|
+
else:
|
|
29
|
+
message = payload
|
|
30
|
+
return f"{scheme}=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def verify_signature(payload: bytes, signature_header: str, secret: str,
|
|
34
|
+
timestamp: str | None = None,
|
|
35
|
+
timestamp_header_value: str | None = None,
|
|
36
|
+
tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
|
|
37
|
+
require_timestamp: bool = False) -> dict:
|
|
38
|
+
"""Verify a `scheme=<hex>` signature header. Constant-time compare. When the
|
|
39
|
+
signature was computed over a timestamped message, the timestamp comes from
|
|
40
|
+
``timestamp_header_value`` and its freshness is checked."""
|
|
41
|
+
result = {"valid": False, "error": None, "scheme": None, "replayed": None}
|
|
42
|
+
if not signature_header:
|
|
43
|
+
result["error"] = "missing signature header"
|
|
44
|
+
return result
|
|
45
|
+
if "=" not in signature_header:
|
|
46
|
+
result["error"] = "signature header is not in 'scheme=<hex>' form"
|
|
47
|
+
return result
|
|
48
|
+
scheme, _, provided = signature_header.partition("=")
|
|
49
|
+
result["scheme"] = scheme
|
|
50
|
+
ts = timestamp_header_value
|
|
51
|
+
if require_timestamp and not ts:
|
|
52
|
+
result["error"] = f"missing timestamp (needed for the {scheme} scheme freshness check)"
|
|
53
|
+
return result
|
|
54
|
+
expected = compute_signature(payload, secret, ts if ts is not None else None, scheme)
|
|
55
|
+
if not hmac.compare_digest(expected, signature_header.strip()):
|
|
56
|
+
result["error"] = "signature mismatch (wrong secret, or the payload bytes differ)"
|
|
57
|
+
return result
|
|
58
|
+
result["valid"] = True
|
|
59
|
+
if ts is not None:
|
|
60
|
+
try:
|
|
61
|
+
age = abs(time.time() - float(ts))
|
|
62
|
+
result["replayed"] = age > tolerance_seconds
|
|
63
|
+
if result["replayed"]:
|
|
64
|
+
result["error"] = (
|
|
65
|
+
f"signature valid but timestamp is {int(age)}s old "
|
|
66
|
+
f"(tolerance {tolerance_seconds}s) — possible replay")
|
|
67
|
+
result["valid"] = False
|
|
68
|
+
except ValueError:
|
|
69
|
+
result["error"] = f"non-numeric timestamp {ts!r}"
|
|
70
|
+
result["valid"] = False
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class CapturedRequest:
|
|
76
|
+
timestamp: float
|
|
77
|
+
method: str
|
|
78
|
+
path: str
|
|
79
|
+
headers: dict[str, str]
|
|
80
|
+
body: str
|
|
81
|
+
signature_valid: bool | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_fixtures(path: str | Path, captured: list[CapturedRequest]) -> None:
|
|
85
|
+
Path(path).write_text(
|
|
86
|
+
json.dumps([c.__dict__ for c in captured], indent=2), encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_fixtures(path: str | Path) -> list[CapturedRequest]:
|
|
90
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
91
|
+
if isinstance(data, dict):
|
|
92
|
+
data = data.get("requests", [])
|
|
93
|
+
return [CapturedRequest(
|
|
94
|
+
timestamp=c.get("timestamp", 0), method=c.get("method", "POST"),
|
|
95
|
+
path=c.get("path", "/"), headers=c.get("headers", {}),
|
|
96
|
+
body=c.get("body", ""), signature_valid=c.get("signature_valid")) for c in data]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Local capture receiver (stdlib http.server) and replay client (urllib)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
|
|
12
|
+
from .core import CapturedRequest, verify_signature
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Receiver:
|
|
16
|
+
"""Capture webhooks to a list (and optionally stream them to an .ndjson file).
|
|
17
|
+
|
|
18
|
+
When a secret is provided, every request's signature header is verified and
|
|
19
|
+
the verdict is recorded alongside the payload."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, port: int = 8080, secret: str | None = None,
|
|
22
|
+
signature_header: str = "X-Signature",
|
|
23
|
+
timestamp_header: str | None = None,
|
|
24
|
+
require_timestamp: bool = False,
|
|
25
|
+
fixture_path: str | None = None):
|
|
26
|
+
self.port = port
|
|
27
|
+
self.secret = secret
|
|
28
|
+
self.signature_header = signature_header.lower()
|
|
29
|
+
self.timestamp_header = (timestamp_header or "").lower()
|
|
30
|
+
self.require_timestamp = require_timestamp
|
|
31
|
+
self.fixture_path = fixture_path
|
|
32
|
+
self.captured: list[CapturedRequest] = []
|
|
33
|
+
self._server = None
|
|
34
|
+
self._thread = None
|
|
35
|
+
|
|
36
|
+
def _handler(self):
|
|
37
|
+
receiver = self
|
|
38
|
+
|
|
39
|
+
class Handler(BaseHTTPRequestHandler):
|
|
40
|
+
def do_POST(self): # noqa: N802
|
|
41
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
42
|
+
body = self.rfile.read(length).decode("utf-8", "replace")
|
|
43
|
+
valid = None
|
|
44
|
+
if receiver.secret:
|
|
45
|
+
verdict = verify_signature(
|
|
46
|
+
body.encode("utf-8"),
|
|
47
|
+
self.headers.get(receiver.signature_header, ""),
|
|
48
|
+
receiver.secret,
|
|
49
|
+
timestamp_header_value=(
|
|
50
|
+
self.headers.get(receiver.timestamp_header)
|
|
51
|
+
if receiver.timestamp_header else None),
|
|
52
|
+
require_timestamp=receiver.require_timestamp,
|
|
53
|
+
)
|
|
54
|
+
valid = verdict["valid"]
|
|
55
|
+
receiver.captured.append(CapturedRequest(
|
|
56
|
+
timestamp=time.time(), method="POST", path=self.path,
|
|
57
|
+
headers={k: v for k, v in self.headers.items()},
|
|
58
|
+
body=body, signature_valid=valid))
|
|
59
|
+
status = 200 if valid is not False else 400
|
|
60
|
+
self.send_response(status)
|
|
61
|
+
self.send_header("Content-Type", "application/json")
|
|
62
|
+
self.end_headers()
|
|
63
|
+
self.wfile.write(json.dumps({"received": True, "signature_valid": valid}).encode())
|
|
64
|
+
|
|
65
|
+
def log_message(self, *args): # silence default stderr logging
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
return Handler
|
|
69
|
+
|
|
70
|
+
def start(self) -> None:
|
|
71
|
+
self._server = ThreadingHTTPServer(("127.0.0.1", self.port), self._handler())
|
|
72
|
+
self.port = self._server.server_address[1]
|
|
73
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
74
|
+
self._thread.start()
|
|
75
|
+
|
|
76
|
+
def stop(self) -> None:
|
|
77
|
+
if self._server:
|
|
78
|
+
self._server.shutdown()
|
|
79
|
+
self._server.server_close()
|
|
80
|
+
if self.fixture_path and self.captured:
|
|
81
|
+
from .core import save_fixtures
|
|
82
|
+
save_fixtures(self.fixture_path, self.captured)
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> "Receiver":
|
|
85
|
+
self.start()
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __exit__(self, *exc) -> None:
|
|
89
|
+
self.stop()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def replay(fixtures_path: str, target_url: str, secret: str | None = None,
|
|
93
|
+
signature_header: str = "X-Signature",
|
|
94
|
+
timestamp_header: str | None = None,
|
|
95
|
+
fresh_signatures: bool = True) -> list[dict]:
|
|
96
|
+
"""Re-send captured webhooks to a target, re-signing with a fresh timestamp
|
|
97
|
+
so replay-protection does not eat your own test traffic."""
|
|
98
|
+
from .core import compute_signature, load_fixtures
|
|
99
|
+
|
|
100
|
+
results = []
|
|
101
|
+
for i, captured in enumerate(load_fixtures(fixtures_path), 1):
|
|
102
|
+
payload = captured.body.encode("utf-8")
|
|
103
|
+
headers = {"Content-Type": captured.headers.get("content-type", "application/json")}
|
|
104
|
+
if secret:
|
|
105
|
+
ts = str(int(time.time())) if fresh_signatures else captured.headers.get(timestamp_header or "", "")
|
|
106
|
+
use_ts = ts if (timestamp_header and ts) else None
|
|
107
|
+
headers[signature_header] = compute_signature(payload, secret, use_ts)
|
|
108
|
+
if timestamp_header and use_ts:
|
|
109
|
+
headers[timestamp_header] = use_ts
|
|
110
|
+
elif captured.headers.get(signature_header):
|
|
111
|
+
headers[signature_header] = captured.headers[signature_header]
|
|
112
|
+
request = urllib.request.Request(target_url, data=payload, headers=headers, method="POST")
|
|
113
|
+
try:
|
|
114
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
115
|
+
status, body = response.status, response.read().decode("utf-8", "replace")
|
|
116
|
+
except urllib.error.HTTPError as exc:
|
|
117
|
+
status, body = exc.code, exc.read().decode("utf-8", "replace")
|
|
118
|
+
results.append({"run": i, "path": captured.path, "status": status,
|
|
119
|
+
"response": body[:200]})
|
|
120
|
+
return results
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hookbench
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Webhook integration testing: capture webhooks, verify HMAC signatures, replay fixtures at your handler — stdlib only
|
|
5
|
+
Author: juzorei
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/juzorei/hookbench
|
|
8
|
+
Keywords: webhooks,hmac,testing,integration,agent-skills
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Software Development :: Testing
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# hookbench
|
|
16
|
+
|
|
17
|
+
Capture webhooks as fixtures, verify HMAC signatures the way each vendor means them, and replay captures at your handler while developing.
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
$ hookbench receive --port 8080 --secret-file secret.txt --out captured.ndjson
|
|
21
|
+
receiver listening on http://127.0.0.1:8080/ (Ctrl+C to stop)
|
|
22
|
+
verifying X-Signature against the provided secret
|
|
23
|
+
[1] /hook (signature_valid=True) body: {"event": "payment.succeeded", "id": 7}
|
|
24
|
+
|
|
25
|
+
$ hookbench replay captured.ndjson --url http://localhost:9000/handler --secret-file secret.txt
|
|
26
|
+
#1 /hook -> 200 {"received": true}
|
|
27
|
+
1 webhook(s) replayed to http://localhost:9000/handler
|
|
28
|
+
|
|
29
|
+
$ hookbench verify --payload body.bin --signature "sha256=abc…" --secret whsec_test123
|
|
30
|
+
VALID
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A webhook integration fails in exactly three ways — handler never called (exposure), signature doesn't verify (secret/serialization), handler is wrong (logic). `hookbench` separates them mechanically:
|
|
34
|
+
|
|
35
|
+
- `receive` — a local capture receiver; when a secret is given, every request's signature verdict is recorded with the payload, and an invalid signature answers 400.
|
|
36
|
+
- `verify` — offline single-payload check. Constant-time comparison, raw-payload and timestamped schemes (Stripe-style `t=…,v1=…`), freshness tolerance against replay.
|
|
37
|
+
- `replay` — re-signs fixtures with a fresh timestamp (so your own replay doesn't trip replay protection) and POSTs them at your handler; `--keep-original-signatures` compares against the captured bytes as-is.
|
|
38
|
+
|
|
39
|
+
**The serialization trap:** signatures cover exact bytes. A proxy pretty-printing the body breaks the signature while the payload "looks the same" — fixtures store raw bodies, and verification must read the raw request body, never a parsed-and-re-dumped copy.
|
|
40
|
+
|
|
41
|
+
Python 3.11+, stdlib only.
|
|
42
|
+
|
|
43
|
+
Install: `pip install hookbench` — or from this repo:
|
|
44
|
+
`pip install "git+https://github.com/juzorei/hookbench#subdirectory=tools/hookbench"`
|
|
45
|
+
|
|
46
|
+
The companion agent skill lives in [skills/webhook-integration-testing](../skills/webhook-integration-testing). Skills: `npx skills add juzorei/hookbench`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/hookbench/__init__.py
|
|
4
|
+
src/hookbench/__main__.py
|
|
5
|
+
src/hookbench/cli.py
|
|
6
|
+
src/hookbench/core.py
|
|
7
|
+
src/hookbench/server.py
|
|
8
|
+
src/hookbench.egg-info/PKG-INFO
|
|
9
|
+
src/hookbench.egg-info/SOURCES.txt
|
|
10
|
+
src/hookbench.egg-info/dependency_links.txt
|
|
11
|
+
src/hookbench.egg-info/entry_points.txt
|
|
12
|
+
src/hookbench.egg-info/top_level.txt
|
|
13
|
+
tests/test_hookbench.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hookbench
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import urllib.request
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from hookbench.cli import main
|
|
8
|
+
from hookbench.core import (
|
|
9
|
+
CapturedRequest, compute_signature, load_fixtures, save_fixtures, verify_signature)
|
|
10
|
+
from hookbench.server import Receiver, replay
|
|
11
|
+
|
|
12
|
+
SECRET = "whsec_test123"
|
|
13
|
+
PAYLOAD = json.dumps({"event": "payment.succeeded", "id": 7})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_compute_and_verify_roundtrip():
|
|
17
|
+
sig = compute_signature(PAYLOAD.encode(), SECRET)
|
|
18
|
+
assert sig.startswith("sha256=")
|
|
19
|
+
assert verify_signature(PAYLOAD.encode(), sig, SECRET)["valid"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_wrong_secret_fails():
|
|
23
|
+
verdict = verify_signature(PAYLOAD.encode(), compute_signature(PAYLOAD.encode(), "nope"), SECRET)
|
|
24
|
+
assert not verdict["valid"] and "mismatch" in verdict["error"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_tampered_payload_fails():
|
|
28
|
+
sig = compute_signature(PAYLOAD.encode(), SECRET)
|
|
29
|
+
assert not verify_signature(b"{\"event\": \"payment.succeeded\", \"id\": 8}", sig, SECRET)["valid"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_timestamped_scheme_and_replay_detection():
|
|
33
|
+
import time
|
|
34
|
+
ts = str(int(time.time()))
|
|
35
|
+
sig = compute_signature(PAYLOAD.encode(), SECRET, ts)
|
|
36
|
+
ok = verify_signature(PAYLOAD.encode(), sig, SECRET, timestamp_header_value=ts,
|
|
37
|
+
require_timestamp=True)
|
|
38
|
+
assert ok["valid"] and ok["replayed"] is False
|
|
39
|
+
old = str(int(time.time()) - 9999)
|
|
40
|
+
stale_sig = compute_signature(PAYLOAD.encode(), SECRET, old)
|
|
41
|
+
stale = verify_signature(PAYLOAD.encode(), stale_sig, SECRET, timestamp_header_value=old,
|
|
42
|
+
require_timestamp=True)
|
|
43
|
+
assert not stale["valid"] and stale["replayed"] and "replay" in stale["error"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_missing_and_malformed_signatures():
|
|
47
|
+
assert verify_signature(PAYLOAD.encode(), "", SECRET)["error"] == "missing signature header"
|
|
48
|
+
assert verify_signature(PAYLOAD.encode(), "garbage", SECRET)["error"].startswith(
|
|
49
|
+
"signature header is not")
|
|
50
|
+
v = verify_signature(PAYLOAD.encode(), "", SECRET, require_timestamp=True)
|
|
51
|
+
assert not v["valid"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_fixtures_roundtrip(tmp_path):
|
|
55
|
+
captured = [CapturedRequest(timestamp=1.5, method="POST", path="/hook",
|
|
56
|
+
headers={"X-Signature": "sha256=abc", "Content-Type": "application/json"},
|
|
57
|
+
body=PAYLOAD, signature_valid=True)]
|
|
58
|
+
path = tmp_path / "hooks.ndjson"
|
|
59
|
+
save_fixtures(path, captured)
|
|
60
|
+
loaded = load_fixtures(path)
|
|
61
|
+
assert loaded[0].body == PAYLOAD and loaded[0].signature_valid is True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_receiver_captures_and_verifies(tmp_path):
|
|
65
|
+
fixtures = tmp_path / "captured.ndjson"
|
|
66
|
+
with Receiver(port=0, secret=SECRET, fixture_path=str(fixtures)) as receiver:
|
|
67
|
+
url = f"http://127.0.0.1:{receiver.port}/hook"
|
|
68
|
+
good = compute_signature(PAYLOAD.encode(), SECRET)
|
|
69
|
+
req = urllib.request.Request(url, data=PAYLOAD.encode(), headers={
|
|
70
|
+
"Content-Type": "application/json", "X-Signature": good})
|
|
71
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
72
|
+
assert resp.status == 200
|
|
73
|
+
assert json.load(resp)["signature_valid"] is True
|
|
74
|
+
bad = urllib.request.Request(url, data=PAYLOAD.encode(), headers={
|
|
75
|
+
"X-Signature": "sha256=" + "0" * 64})
|
|
76
|
+
try:
|
|
77
|
+
urllib.request.urlopen(bad, timeout=10)
|
|
78
|
+
raised = False
|
|
79
|
+
except urllib.error.HTTPError as exc:
|
|
80
|
+
raised, code = True, exc.code
|
|
81
|
+
assert raised and code == 400
|
|
82
|
+
assert len(receiver.captured) == 2
|
|
83
|
+
assert receiver.captured[0].signature_valid is True
|
|
84
|
+
assert receiver.captured[1].signature_valid is False
|
|
85
|
+
assert fixtures.is_file()
|
|
86
|
+
loaded = load_fixtures(fixtures)
|
|
87
|
+
assert len(loaded) == 2 and loaded[1].signature_valid is False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_replay_hits_local_handler(tmp_path):
|
|
91
|
+
received = []
|
|
92
|
+
|
|
93
|
+
class Collect(Receiver):
|
|
94
|
+
def __init__(self):
|
|
95
|
+
super().__init__(port=0)
|
|
96
|
+
|
|
97
|
+
def start(self):
|
|
98
|
+
super().start()
|
|
99
|
+
original = self._server
|
|
100
|
+
# wrap handler capture
|
|
101
|
+
def capture(self):
|
|
102
|
+
return self.captured
|
|
103
|
+
|
|
104
|
+
# simpler: run a Receiver as the target handler and replay into it
|
|
105
|
+
with Receiver(port=0) as target:
|
|
106
|
+
url = f"http://127.0.0.1:{target.port}/hook"
|
|
107
|
+
fixtures = tmp_path / "f.ndjson"
|
|
108
|
+
captured = [CapturedRequest(timestamp=0, method="POST", path="/hook",
|
|
109
|
+
headers={"X-Signature": compute_signature(PAYLOAD.encode(), SECRET)},
|
|
110
|
+
body=PAYLOAD)]
|
|
111
|
+
save_fixtures(fixtures, captured)
|
|
112
|
+
results = replay(str(fixtures), url, secret=SECRET)
|
|
113
|
+
assert results[0]["status"] == 200
|
|
114
|
+
assert len(target.captured) == 1
|
|
115
|
+
assert target.captured[0].body == PAYLOAD
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_cli_verify(tmp_path, capsys):
|
|
119
|
+
body = tmp_path / "body.bin"
|
|
120
|
+
body.write_bytes(PAYLOAD.encode())
|
|
121
|
+
sig = compute_signature(PAYLOAD.encode(), SECRET)
|
|
122
|
+
assert main(["verify", "--payload", str(body), "--signature", sig, "--secret", SECRET]) == 0
|
|
123
|
+
assert "VALID" in capsys.readouterr().out
|
|
124
|
+
assert main(["verify", "--payload", str(body), "--signature", sig, "--secret", "wrong"]) == 1
|
|
125
|
+
assert "INVALID" in capsys.readouterr().out
|
|
126
|
+
assert main(["verify", "--payload", str(body), "--signature", sig, "--secret", "wrong",
|
|
127
|
+
"--json"]) == 1
|
|
128
|
+
assert json.loads(capsys.readouterr().out)["valid"] is False
|