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.
- authzloom-1.0.1/LICENSE +21 -0
- authzloom-1.0.1/PKG-INFO +185 -0
- authzloom-1.0.1/README.md +161 -0
- authzloom-1.0.1/pyproject.toml +34 -0
- authzloom-1.0.1/setup.cfg +4 -0
- authzloom-1.0.1/src/authzloom/__init__.py +3 -0
- authzloom-1.0.1/src/authzloom/__main__.py +4 -0
- authzloom-1.0.1/src/authzloom/api.py +180 -0
- authzloom-1.0.1/src/authzloom/assertions.py +71 -0
- authzloom-1.0.1/src/authzloom/bench.py +115 -0
- authzloom-1.0.1/src/authzloom/budget.py +28 -0
- authzloom-1.0.1/src/authzloom/captures.py +109 -0
- authzloom-1.0.1/src/authzloom/cli.py +167 -0
- authzloom-1.0.1/src/authzloom/errors.py +115 -0
- authzloom-1.0.1/src/authzloom/executor.py +380 -0
- authzloom-1.0.1/src/authzloom/io.py +24 -0
- authzloom-1.0.1/src/authzloom/limits.py +69 -0
- authzloom-1.0.1/src/authzloom/loopback.py +87 -0
- authzloom-1.0.1/src/authzloom/mcp_server.py +245 -0
- authzloom-1.0.1/src/authzloom/models.py +413 -0
- authzloom-1.0.1/src/authzloom/observe.py +27 -0
- authzloom-1.0.1/src/authzloom/pacer.py +96 -0
- authzloom-1.0.1/src/authzloom/planner.py +89 -0
- authzloom-1.0.1/src/authzloom/policy.py +85 -0
- authzloom-1.0.1/src/authzloom/redact.py +170 -0
- authzloom-1.0.1/src/authzloom/schema.py +113 -0
- authzloom-1.0.1/src/authzloom/store.py +190 -0
- authzloom-1.0.1/src/authzloom/transports.py +591 -0
- authzloom-1.0.1/src/authzloom.egg-info/PKG-INFO +185 -0
- authzloom-1.0.1/src/authzloom.egg-info/SOURCES.txt +34 -0
- authzloom-1.0.1/src/authzloom.egg-info/dependency_links.txt +1 -0
- authzloom-1.0.1/src/authzloom.egg-info/entry_points.txt +3 -0
- authzloom-1.0.1/src/authzloom.egg-info/requires.txt +9 -0
- authzloom-1.0.1/src/authzloom.egg-info/top_level.txt +1 -0
- authzloom-1.0.1/tests/test_audit_plan.py +881 -0
- authzloom-1.0.1/tests/test_authzloom.py +170 -0
authzloom-1.0.1/LICENSE
ADDED
|
@@ -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.
|
authzloom-1.0.1/PKG-INFO
ADDED
|
@@ -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,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
|