authzloom 1.0.1__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.
Files changed (36) hide show
  1. authzloom-1.0.1/LICENSE +21 -0
  2. authzloom-1.0.1/PKG-INFO +185 -0
  3. authzloom-1.0.1/README.md +161 -0
  4. authzloom-1.0.1/pyproject.toml +34 -0
  5. authzloom-1.0.1/setup.cfg +4 -0
  6. authzloom-1.0.1/src/authzloom/__init__.py +3 -0
  7. authzloom-1.0.1/src/authzloom/__main__.py +4 -0
  8. authzloom-1.0.1/src/authzloom/api.py +180 -0
  9. authzloom-1.0.1/src/authzloom/assertions.py +71 -0
  10. authzloom-1.0.1/src/authzloom/bench.py +115 -0
  11. authzloom-1.0.1/src/authzloom/budget.py +28 -0
  12. authzloom-1.0.1/src/authzloom/captures.py +109 -0
  13. authzloom-1.0.1/src/authzloom/cli.py +167 -0
  14. authzloom-1.0.1/src/authzloom/errors.py +115 -0
  15. authzloom-1.0.1/src/authzloom/executor.py +380 -0
  16. authzloom-1.0.1/src/authzloom/io.py +24 -0
  17. authzloom-1.0.1/src/authzloom/limits.py +69 -0
  18. authzloom-1.0.1/src/authzloom/loopback.py +87 -0
  19. authzloom-1.0.1/src/authzloom/mcp_server.py +245 -0
  20. authzloom-1.0.1/src/authzloom/models.py +413 -0
  21. authzloom-1.0.1/src/authzloom/observe.py +27 -0
  22. authzloom-1.0.1/src/authzloom/pacer.py +96 -0
  23. authzloom-1.0.1/src/authzloom/planner.py +89 -0
  24. authzloom-1.0.1/src/authzloom/policy.py +85 -0
  25. authzloom-1.0.1/src/authzloom/redact.py +170 -0
  26. authzloom-1.0.1/src/authzloom/schema.py +113 -0
  27. authzloom-1.0.1/src/authzloom/store.py +190 -0
  28. authzloom-1.0.1/src/authzloom/transports.py +591 -0
  29. authzloom-1.0.1/src/authzloom.egg-info/PKG-INFO +185 -0
  30. authzloom-1.0.1/src/authzloom.egg-info/SOURCES.txt +34 -0
  31. authzloom-1.0.1/src/authzloom.egg-info/dependency_links.txt +1 -0
  32. authzloom-1.0.1/src/authzloom.egg-info/entry_points.txt +3 -0
  33. authzloom-1.0.1/src/authzloom.egg-info/requires.txt +9 -0
  34. authzloom-1.0.1/src/authzloom.egg-info/top_level.txt +1 -0
  35. authzloom-1.0.1/tests/test_audit_plan.py +881 -0
  36. authzloom-1.0.1/tests/test_authzloom.py +170 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AuthzLoom contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: authzloom
3
+ Version: 1.0.1
4
+ Summary: Local-first authorization matrix testing with redacted readback evidence
5
+ License: MIT
6
+ Keywords: authorization,security-testing,mcp,burp-suite,graphql
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: Security
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: yaml
18
+ Requires-Dist: PyYAML>=6.0.2; extra == "yaml"
19
+ Provides-Extra: cdp
20
+ Requires-Dist: playwright>=1.55; extra == "cdp"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.4; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # AuthzLoom
26
+
27
+ I built AuthzLoom to make authorization testing easier to repeat and easier to
28
+ review. You describe the identities, controlled objects and operation you want
29
+ to test. AuthzLoom builds the matrix, runs it within the limits you set and
30
+ keeps redacted evidence of what happened.
31
+
32
+ It works with REST and GraphQL and can be used from the command line, through
33
+ MCP, through its local HTTP API or with the optional Burp Suite extension.
34
+
35
+ AuthzLoom does not decide whether something is a vulnerability and it does not
36
+ assign severity. It collects evidence. The final judgment stays with the
37
+ person reviewing the result.
38
+
39
+ ## What it protects
40
+
41
+ Authorization testing can change data, so I designed the tool around explicit
42
+ limits:
43
+
44
+ - Every scenario defines its allowed hosts, methods, request budget and rate.
45
+ - Every request goes through the same policy validation, including identity
46
+ probes.
47
+ - An operation that changes state must have a readback step.
48
+ - Cleanup runs after a mutation may have been sent, even when the case fails or
49
+ is cancelled.
50
+ - Direct connections are limited to local labs and never follow redirects.
51
+ - Traffic to anything outside the local machine must use a Burp or CDP session
52
+ controlled by the operator.
53
+ - Live credentials do not belong in scenario files. Burp keeps the original
54
+ request and gives Python an opaque `cap_*` handle instead.
55
+ - Local runs stay local. Exported capsules are redacted and never contain raw
56
+ Burp capture bytes.
57
+
58
+ Only use AuthzLoom on systems you own or have explicit permission to test.
59
+
60
+ ## Installation
61
+
62
+ You need Python 3.11 or newer.
63
+
64
+ On Windows:
65
+
66
+ ```powershell
67
+ git clone <repository-url> authzloom
68
+ cd authzloom
69
+ powershell -ExecutionPolicy Bypass -File ./setup-env.ps1 -Dev
70
+ ```
71
+
72
+ If you already manage your own Python environment:
73
+
74
+ ```bash
75
+ python -m pip install -e '.[dev]'
76
+ ```
77
+
78
+ The optional dependency groups are `yaml`, `cdp` and `dev`.
79
+
80
+ ## Try it from the command line
81
+
82
+ The included example expects a synthetic service on `127.0.0.1:8877`.
83
+ Validation and planning are offline. Running the scenario requires the local
84
+ lab service.
85
+
86
+ ```powershell
87
+ ./.venv/Scripts/authzloom.exe init ./scenario.json
88
+ ./.venv/Scripts/authzloom.exe validate ./examples/rest.json
89
+ ./.venv/Scripts/authzloom.exe plan ./examples/rest.json --explain
90
+ ./.venv/Scripts/authzloom.exe run ./examples/rest.json
91
+ ./.venv/Scripts/authzloom.exe status
92
+ ./.venv/Scripts/authzloom.exe export <RUN_ID> --output capsule.redacted.json
93
+ ./.venv/Scripts/authzloom.exe schema > schemas/scenario.v1.json
94
+ ```
95
+
96
+ There is also a GraphQL example in `examples/graphql.json`. To see structured
97
+ logs without secrets, set `AUTHZLOOM_LOG=1`.
98
+
99
+ You can run the module directly too:
100
+
101
+ ```powershell
102
+ ./.venv/Scripts/python.exe -m authzloom.cli --help
103
+ ```
104
+
105
+ ## Using AuthzLoom with an AI client
106
+
107
+ AuthzLoom includes an MCP server:
108
+
109
+ ```powershell
110
+ ./.venv/Scripts/authzloom.exe mcp
111
+ ```
112
+
113
+ For an installed registry package, the canonical package launcher is:
114
+
115
+ ```bash
116
+ uvx authzloom mcp
117
+ ```
118
+
119
+ `authzloom-mcp` remains available as a compatibility entry point, but catalogs
120
+ and managed launchers should identify the `authzloom` package and invoke the
121
+ `mcp` subcommand.
122
+
123
+ MCP communicates over standard input and output. If you start it by hand, it
124
+ will appear to wait silently. That is expected. Normally, your MCP client
125
+ starts and controls the process:
126
+
127
+ ```json
128
+ {
129
+ "mcpServers": {
130
+ "authzloom": {
131
+ "command": "C:/absolute/path/authzloom/.venv/Scripts/python.exe",
132
+ "args": ["-m", "authzloom.cli", "mcp"],
133
+ "env": {
134
+ "AUTHZLOOM_DATA_DIR": "C:/absolute/path/authzloom/.authzloom"
135
+ }
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ The available tools are `authzloom_ingest`, `authzloom_plan`,
142
+ `authzloom_run`, `authzloom_status` and `authzloom_export`. Always plan a
143
+ scenario before running it.
144
+
145
+ ## Using the Burp extension
146
+
147
+ Burp Suite is not included. Point `BURP_JAR` to your local Burp installation,
148
+ build the extension and start the launcher:
149
+
150
+ ```powershell
151
+ $env:BURP_JAR = 'C:\path\to\burpsuite.jar'
152
+ powershell -ExecutionPolicy Bypass -File ./burp-extension/build.ps1
153
+ powershell -ExecutionPolicy Bypass -File ./start-authzloom-burp.ps1
154
+ ```
155
+
156
+ Load `burp-extension/build/authzloom-burp.jar` through Burp's extension
157
+ settings if it is not already loaded.
158
+
159
+ The launcher creates a temporary shared token, starts the local API on
160
+ `127.0.0.1:8891` and opens Burp. The extension runs its replay bridge on
161
+ `127.0.0.1:8892` and adds **Send to AuthzLoom** to Burp's context menu.
162
+
163
+ When you send a request to AuthzLoom, the live bytes stay inside Burp. Python
164
+ receives the host, method, path and an opaque handle. During replay, only an
165
+ allowlisted overlay is applied. Authorization and Cookie headers remain under
166
+ Burp's control.
167
+
168
+ The local API provides `GET /health`, `GET /ready` and `GET /version`.
169
+ `/status` accepts `limit` and `offset`. It only binds to loopback.
170
+
171
+ ## Development
172
+
173
+ Run the complete local check before submitting a change:
174
+
175
+ ```powershell
176
+ powershell -ExecutionPolicy Bypass -File ./verify-integration.ps1
177
+ ```
178
+
179
+ Please keep the public repository independent. Private paths, client
180
+ configuration, target evidence, credentials and generated run data should
181
+ never be committed.
182
+
183
+ ## License
184
+
185
+ AuthzLoom is available under the MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,161 @@
1
+ # AuthzLoom
2
+
3
+ I built AuthzLoom to make authorization testing easier to repeat and easier to
4
+ review. You describe the identities, controlled objects and operation you want
5
+ to test. AuthzLoom builds the matrix, runs it within the limits you set and
6
+ keeps redacted evidence of what happened.
7
+
8
+ It works with REST and GraphQL and can be used from the command line, through
9
+ MCP, through its local HTTP API or with the optional Burp Suite extension.
10
+
11
+ AuthzLoom does not decide whether something is a vulnerability and it does not
12
+ assign severity. It collects evidence. The final judgment stays with the
13
+ person reviewing the result.
14
+
15
+ ## What it protects
16
+
17
+ Authorization testing can change data, so I designed the tool around explicit
18
+ limits:
19
+
20
+ - Every scenario defines its allowed hosts, methods, request budget and rate.
21
+ - Every request goes through the same policy validation, including identity
22
+ probes.
23
+ - An operation that changes state must have a readback step.
24
+ - Cleanup runs after a mutation may have been sent, even when the case fails or
25
+ is cancelled.
26
+ - Direct connections are limited to local labs and never follow redirects.
27
+ - Traffic to anything outside the local machine must use a Burp or CDP session
28
+ controlled by the operator.
29
+ - Live credentials do not belong in scenario files. Burp keeps the original
30
+ request and gives Python an opaque `cap_*` handle instead.
31
+ - Local runs stay local. Exported capsules are redacted and never contain raw
32
+ Burp capture bytes.
33
+
34
+ Only use AuthzLoom on systems you own or have explicit permission to test.
35
+
36
+ ## Installation
37
+
38
+ You need Python 3.11 or newer.
39
+
40
+ On Windows:
41
+
42
+ ```powershell
43
+ git clone <repository-url> authzloom
44
+ cd authzloom
45
+ powershell -ExecutionPolicy Bypass -File ./setup-env.ps1 -Dev
46
+ ```
47
+
48
+ If you already manage your own Python environment:
49
+
50
+ ```bash
51
+ python -m pip install -e '.[dev]'
52
+ ```
53
+
54
+ The optional dependency groups are `yaml`, `cdp` and `dev`.
55
+
56
+ ## Try it from the command line
57
+
58
+ The included example expects a synthetic service on `127.0.0.1:8877`.
59
+ Validation and planning are offline. Running the scenario requires the local
60
+ lab service.
61
+
62
+ ```powershell
63
+ ./.venv/Scripts/authzloom.exe init ./scenario.json
64
+ ./.venv/Scripts/authzloom.exe validate ./examples/rest.json
65
+ ./.venv/Scripts/authzloom.exe plan ./examples/rest.json --explain
66
+ ./.venv/Scripts/authzloom.exe run ./examples/rest.json
67
+ ./.venv/Scripts/authzloom.exe status
68
+ ./.venv/Scripts/authzloom.exe export <RUN_ID> --output capsule.redacted.json
69
+ ./.venv/Scripts/authzloom.exe schema > schemas/scenario.v1.json
70
+ ```
71
+
72
+ There is also a GraphQL example in `examples/graphql.json`. To see structured
73
+ logs without secrets, set `AUTHZLOOM_LOG=1`.
74
+
75
+ You can run the module directly too:
76
+
77
+ ```powershell
78
+ ./.venv/Scripts/python.exe -m authzloom.cli --help
79
+ ```
80
+
81
+ ## Using AuthzLoom with an AI client
82
+
83
+ AuthzLoom includes an MCP server:
84
+
85
+ ```powershell
86
+ ./.venv/Scripts/authzloom.exe mcp
87
+ ```
88
+
89
+ For an installed registry package, the canonical package launcher is:
90
+
91
+ ```bash
92
+ uvx authzloom mcp
93
+ ```
94
+
95
+ `authzloom-mcp` remains available as a compatibility entry point, but catalogs
96
+ and managed launchers should identify the `authzloom` package and invoke the
97
+ `mcp` subcommand.
98
+
99
+ MCP communicates over standard input and output. If you start it by hand, it
100
+ will appear to wait silently. That is expected. Normally, your MCP client
101
+ starts and controls the process:
102
+
103
+ ```json
104
+ {
105
+ "mcpServers": {
106
+ "authzloom": {
107
+ "command": "C:/absolute/path/authzloom/.venv/Scripts/python.exe",
108
+ "args": ["-m", "authzloom.cli", "mcp"],
109
+ "env": {
110
+ "AUTHZLOOM_DATA_DIR": "C:/absolute/path/authzloom/.authzloom"
111
+ }
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ The available tools are `authzloom_ingest`, `authzloom_plan`,
118
+ `authzloom_run`, `authzloom_status` and `authzloom_export`. Always plan a
119
+ scenario before running it.
120
+
121
+ ## Using the Burp extension
122
+
123
+ Burp Suite is not included. Point `BURP_JAR` to your local Burp installation,
124
+ build the extension and start the launcher:
125
+
126
+ ```powershell
127
+ $env:BURP_JAR = 'C:\path\to\burpsuite.jar'
128
+ powershell -ExecutionPolicy Bypass -File ./burp-extension/build.ps1
129
+ powershell -ExecutionPolicy Bypass -File ./start-authzloom-burp.ps1
130
+ ```
131
+
132
+ Load `burp-extension/build/authzloom-burp.jar` through Burp's extension
133
+ settings if it is not already loaded.
134
+
135
+ The launcher creates a temporary shared token, starts the local API on
136
+ `127.0.0.1:8891` and opens Burp. The extension runs its replay bridge on
137
+ `127.0.0.1:8892` and adds **Send to AuthzLoom** to Burp's context menu.
138
+
139
+ When you send a request to AuthzLoom, the live bytes stay inside Burp. Python
140
+ receives the host, method, path and an opaque handle. During replay, only an
141
+ allowlisted overlay is applied. Authorization and Cookie headers remain under
142
+ Burp's control.
143
+
144
+ The local API provides `GET /health`, `GET /ready` and `GET /version`.
145
+ `/status` accepts `limit` and `offset`. It only binds to loopback.
146
+
147
+ ## Development
148
+
149
+ Run the complete local check before submitting a change:
150
+
151
+ ```powershell
152
+ powershell -ExecutionPolicy Bypass -File ./verify-integration.ps1
153
+ ```
154
+
155
+ Please keep the public repository independent. Private paths, client
156
+ configuration, target evidence, credentials and generated run data should
157
+ never be committed.
158
+
159
+ ## License
160
+
161
+ AuthzLoom is available under the MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=75"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "authzloom"
7
+ version = "1.0.1"
8
+ description = "Local-first authorization matrix testing with redacted readback evidence"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ keywords = ["authorization", "security-testing", "mcp", "burp-suite", "graphql"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Security",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ yaml = ["PyYAML>=6.0.2"]
26
+ cdp = ["playwright>=1.55"]
27
+ dev = ["pytest>=8.4"]
28
+
29
+ [project.scripts]
30
+ authzloom = "authzloom.cli:main"
31
+ authzloom-mcp = "authzloom.mcp_server:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """AuthzLoom: bounded authorization testing with readback evidence."""
2
+
3
+ __version__ = "1.0.1"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
4
+
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import parse_qs, urlparse
11
+
12
+ from . import __version__
13
+ from .captures import CaptureRegistry
14
+ from .errors import AuthzLoomError, LimitExceeded, RunInProgress, RunNotFound, public_error
15
+ from .executor import run
16
+ from .limits import Limits
17
+ from .models import Scenario
18
+ from .planner import plan
19
+ from .redact import redact
20
+ from .store import Store
21
+
22
+
23
+ class AuthzLoomService:
24
+ def __init__(self, root: Path, token: str, limits: Limits | None = None):
25
+ self.limits = limits or Limits.from_env()
26
+ self.store, self.token = Store(root, self.limits), token
27
+ self.captures = CaptureRegistry(self.limits)
28
+ self._run_lock = threading.Lock()
29
+ self._running = False
30
+ self._cancel = threading.Event()
31
+ self.ingested: list[dict[str, Any]] = []
32
+
33
+ def close(self) -> None:
34
+ self.store.close()
35
+
36
+ def handler(self):
37
+ service = self
38
+
39
+ class Handler(BaseHTTPRequestHandler):
40
+ def _json(self, status: int, value: Any):
41
+ data = json.dumps(value).encode()
42
+ self.send_response(status)
43
+ self.send_header("Content-Type", "application/json")
44
+ self.send_header("Content-Length", str(len(data)))
45
+ self.end_headers()
46
+ self.wfile.write(data)
47
+
48
+ def _authorized(self) -> bool:
49
+ return bool(service.token) and self.headers.get("Authorization") == f"Bearer {service.token}"
50
+
51
+ def _body(self):
52
+ length = int(self.headers.get("Content-Length", 0) or 0)
53
+ if length > service.limits.max_api_body_bytes:
54
+ raise LimitExceeded("input or output exceeds configured limits")
55
+ if length < 0:
56
+ raise LimitExceeded("input or output exceeds configured limits")
57
+ raw = self.rfile.read(length) if length else b"{}"
58
+ if len(raw) > service.limits.max_api_body_bytes:
59
+ raise LimitExceeded("input or output exceeds configured limits")
60
+ return json.loads(raw or b"{}")
61
+
62
+ def _discard_body(self):
63
+ length = int(self.headers.get("Content-Length", 0) or 0)
64
+ if length <= 0:
65
+ return
66
+ remaining = min(length, service.limits.max_api_body_bytes)
67
+ while remaining > 0:
68
+ chunk = self.rfile.read(min(65_536, remaining))
69
+ if not chunk:
70
+ break
71
+ remaining -= len(chunk)
72
+
73
+ def do_GET(self):
74
+ parsed = urlparse(self.path)
75
+ if parsed.path == "/health":
76
+ return self._json(200, {"ok": True, "service": "authzloom"})
77
+ if parsed.path == "/version":
78
+ return self._json(200, {"name": "authzloom", "version": __version__})
79
+ if parsed.path == "/ready":
80
+ try:
81
+ service.store.list(limit=1)
82
+ except Exception:
83
+ return self._json(503, {"ok": False, "service": "authzloom"})
84
+ return self._json(200, {"ok": True, "service": "authzloom"})
85
+ if not self._authorized():
86
+ return self._json(401, {"error": "unauthorized"})
87
+ if parsed.path == "/status":
88
+ query = parse_qs(parsed.query)
89
+ limit = int((query.get("limit") or ["50"])[0])
90
+ offset = int((query.get("offset") or ["0"])[0])
91
+ return self._json(200, service.store.list(limit=limit, offset=offset))
92
+ if parsed.path.startswith("/export/"):
93
+ try:
94
+ return self._json(200, redact(service.store.get(parsed.path.rsplit("/", 1)[-1])))
95
+ except RunNotFound:
96
+ return self._json(404, {"error": "unknown run"})
97
+ except AuthzLoomError as exc:
98
+ return self._json(exc.http_status, exc.to_dict())
99
+ self._json(404, {"error": "not found"})
100
+
101
+ def do_POST(self):
102
+ if not self._authorized():
103
+ return self._json(401, {"error": "unauthorized"})
104
+ parsed = urlparse(self.path)
105
+ try:
106
+ if parsed.path == "/cancel":
107
+ self._discard_body()
108
+ service._cancel.set()
109
+ return self._json(202, {"ok": True, "state": "cancelled"})
110
+ body = self._body()
111
+ if parsed.path == "/ingest":
112
+ if any(key in body for key in ("request_b64", "response_b64", "raw", "raw_capture")):
113
+ raise LimitExceeded("captures must remain in the Burp bridge; send a handle")
114
+ meta = None
115
+ if body.get("handle") and body.get("host"):
116
+ meta = service.captures.ingest_document(body)
117
+ clean = redact(body)
118
+ service.ingested.append(clean)
119
+ if len(service.ingested) > service.limits.max_ingested_captures:
120
+ service.ingested.pop(0)
121
+ payload = {"ingest_id": len(service.ingested) - 1, "capture": clean}
122
+ if meta:
123
+ payload.update(meta)
124
+ return self._json(202, payload)
125
+ if parsed.path == "/plan":
126
+ scenario = Scenario.from_dict(body)
127
+ return self._json(200, {"cases": [vars_case(x) for x in plan(scenario)]})
128
+ if parsed.path == "/run":
129
+ scenario = Scenario.from_dict(body)
130
+ if not service._run_lock.acquire(blocking=False):
131
+ raise RunInProgress("another run is already active")
132
+ service._running = True
133
+ service._cancel.clear()
134
+ try:
135
+ result = run(
136
+ scenario,
137
+ burp_token=os.environ.get("AUTHZLOOM_TOKEN", service.token),
138
+ cancel=service._cancel,
139
+ )
140
+ run_id = service.store.save(result)
141
+ return self._json(200, {"run_id": run_id, "result": redact(result)})
142
+ finally:
143
+ service._running = False
144
+ service._run_lock.release()
145
+ return self._json(404, {"error": "not found"})
146
+ except AuthzLoomError as exc:
147
+ return self._json(exc.http_status, exc.to_dict())
148
+ except Exception:
149
+ return self._json(400, public_error(Exception()))
150
+
151
+ def log_message(self, *_):
152
+ pass
153
+
154
+ return Handler
155
+
156
+
157
+ def vars_case(case):
158
+ return {"name": case.name, "session": case.session, "object_owner": case.object_owner}
159
+
160
+
161
+ def serve(host: str, port: int, root: Path, token: str) -> None:
162
+ if host not in {"127.0.0.1", "::1", "localhost"}:
163
+ raise ValueError("AuthzLoom API must bind to loopback")
164
+ service = AuthzLoomService(root, token)
165
+ server = ThreadingHTTPServer((host, port), service.handler())
166
+ print(f"AuthzLoom listening on http://{host}:{port}")
167
+
168
+ def _stop(*_):
169
+ threading.Thread(target=server.shutdown, daemon=True).start()
170
+
171
+ try:
172
+ signal.signal(signal.SIGINT, _stop)
173
+ signal.signal(signal.SIGTERM, _stop)
174
+ except (ValueError, OSError):
175
+ pass
176
+ try:
177
+ server.serve_forever()
178
+ finally:
179
+ server.server_close()
180
+ service.close()
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+
7
+ from .models import Assertion
8
+
9
+ STATUS_NOT_RUN = "not_run"
10
+ STATUS_PASSED = "passed"
11
+ STATUS_FAILED = "failed"
12
+ STATUS_ERROR = "error"
13
+
14
+ _INDEX = re.compile(r"^(0|[1-9][0-9]*)$")
15
+
16
+
17
+ def _walk(value: Any, segments: list[Any]) -> Any:
18
+ for part in segments:
19
+ if isinstance(value, list):
20
+ if not isinstance(part, int):
21
+ if not (isinstance(part, str) and _INDEX.fullmatch(part)):
22
+ raise KeyError(part)
23
+ part = int(part)
24
+ value = value[part]
25
+ elif isinstance(value, dict):
26
+ if part in value:
27
+ value = value[part]
28
+ elif str(part) in value:
29
+ value = value[str(part)]
30
+ else:
31
+ raise KeyError(part)
32
+ else:
33
+ raise TypeError(part)
34
+ return value
35
+
36
+
37
+ def path_segments(assertion: Assertion) -> list[Any]:
38
+ if assertion.segments:
39
+ return list(assertion.segments)
40
+ if not assertion.path:
41
+ return []
42
+ parts: list[Any] = []
43
+ for part in assertion.path.strip(".").split("."):
44
+ if _INDEX.fullmatch(part):
45
+ parts.append(int(part))
46
+ else:
47
+ parts.append(part)
48
+ return parts
49
+
50
+
51
+ def evaluate(response_body: str, assertion: Assertion | None, *, truncated: bool = False) -> tuple[bool | None, str]:
52
+ if assertion is None:
53
+ return None, STATUS_NOT_RUN
54
+ if truncated:
55
+ return False, STATUS_ERROR
56
+ try:
57
+ value: Any = json.loads(response_body)
58
+ parse_error = False
59
+ except json.JSONDecodeError:
60
+ value = response_body
61
+ parse_error = True
62
+ segments = path_segments(assertion)
63
+ if segments:
64
+ if parse_error:
65
+ return False, STATUS_ERROR
66
+ try:
67
+ value = _walk(value, segments)
68
+ except (KeyError, IndexError, TypeError, ValueError):
69
+ return False, STATUS_FAILED
70
+ passed = value == assertion.equals
71
+ return passed, STATUS_PASSED if passed else STATUS_FAILED