hookbench 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Webhook integration testing: capture, verify HMAC signatures, replay fixtures."""
2
+
3
+ __version__ = "0.1.0"
hookbench/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
hookbench/cli.py ADDED
@@ -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())
hookbench/core.py ADDED
@@ -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]
hookbench/server.py ADDED
@@ -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,10 @@
1
+ hookbench/__init__.py,sha256=uB2QplRa0YmigL3U7svN_shSnOUmGTnzHAD0mUbENr0,108
2
+ hookbench/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ hookbench/cli.py,sha256=cpytsFlZu5PVbklgBDh2XqMPfwd6j8cKK63szIULBxU,5553
4
+ hookbench/core.py,sha256=TdgANAAirqNQ8SuMjk2qowD2QqS68PSfIt0oDU036N4,3716
5
+ hookbench/server.py,sha256=Nuhg87Rk3tRmHiQ-djepaXdzl_yDRmMqK6tqIDRNWbQ,5188
6
+ hookbench-0.1.0.dist-info/METADATA,sha256=x7IkHqCRVEFzA7GtZ8tL78EdhbgIPZS0GDmc275B9Po,2622
7
+ hookbench-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ hookbench-0.1.0.dist-info/entry_points.txt,sha256=p5w3c5PZZgTnFZ-22ITcdnHPbbt_QaSAB2LQ8XWCipk,49
9
+ hookbench-0.1.0.dist-info/top_level.txt,sha256=MrLr2GBq3h7BIyL4H_9d5dPbJzobCwes1_m96mDHzdI,10
10
+ hookbench-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hookbench = hookbench.cli:main
@@ -0,0 +1 @@
1
+ hookbench