proxy2model 2.0.0__tar.gz → 2.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.
- {proxy2model-2.0.0/src/proxy2model.egg-info → proxy2model-2.1.0}/PKG-INFO +73 -28
- proxy2model-2.1.0/README.md +163 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/hero.svg +2 -2
- {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/ledger.svg +3 -3
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/__init__.py +1 -1
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/analyze.py +19 -7
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/app.py +1 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/browse.py +1 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/mcp.py +3 -1
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/model.py +63 -20
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/live.py +30 -4
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/parse.py +56 -25
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/redact.py +17 -2
- {proxy2model-2.0.0 → proxy2model-2.1.0/src/proxy2model.egg-info}/PKG-INFO +73 -28
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/SOURCES.txt +1 -0
- proxy2model-2.1.0/tests/test_capture_wide_masking.py +93 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_juiceshop_sample.py +13 -0
- proxy2model-2.0.0/README.md +0 -118
- {proxy2model-2.0.0 → proxy2model-2.1.0}/LICENSE +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/MANIFEST.in +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-ai.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-capture.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-crawling.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-model.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-querying.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/pyproject.toml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/burp-history-admin-sample.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/burp-history-sample.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/juiceshop-accountant.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/juiceshop-admin.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/juiceshop-anon.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/juiceshop-user.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/setup.cfg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/__main__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/atomic.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/build.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/common.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/history.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/ui.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/changes.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/context.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/diff.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/domains.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/graph.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/methodology.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/stack.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/LICENSES.md +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-200.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-300.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-400.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-500.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-700.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/public_suffix_list.dat +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/db/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/db/bql.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/db/bql_js.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/db/store.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/export/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/export/openapi.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/browse.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/browser.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/crawl.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/extract.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/infer.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/journey.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/py.typed +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/recon/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/recon/osint.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/animate.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/dash.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/fonts.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/html.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/inventory.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/map.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/pages.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/query.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/shot.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/view.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/dependency_links.txt +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/entry_points.txt +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/requires.txt +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/top_level.txt +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_bql_parity.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_browse.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_browser_crawl.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_caido.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_cli.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_crawl.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_diff.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_domains.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_gaps.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_graph.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_har.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_hardening.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_journey.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_leads.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_logger_csv.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_map.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_mcp.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_methodology.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_openapi.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_proxy2model.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_recon_cmds.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_redact_credentials.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_redaction_realistic.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_report_ui.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_routes_areas.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_stack.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_store_bql.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_v1.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxy2model
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Turn captured proxy traffic into an evidence-backed model of a web app.
|
|
5
5
|
Author: falc0n
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -39,43 +39,89 @@ Dynamic: license-file
|
|
|
39
39
|
|
|
40
40
|
### `//` the instrument
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
**Every claim cites the request that proves it, and the model names what the capture could not see.**
|
|
43
|
+
proxy2model turns the traffic you already capture into a model of the web app: its endpoints, pages,
|
|
44
|
+
parameters and roles. It compares what each role reached and hands an AI cited evidence instead of
|
|
45
|
+
raw traffic. It does not scan or attack, and it never calls anything a vulnerability: it gives
|
|
46
|
+
hypotheses and the next safe check.
|
|
47
47
|
|
|
48
|
-
[](https://pypi.org/project/proxy2model/)
|
|
49
49
|
[](https://github.com/falc0n-researcher/proxy2model/actions/workflows/ci.yml)
|
|
50
50
|
[](#-boundaries)
|
|
51
|
-
](https://github.com/falc0n-researcher/proxy2model/blob/main/LICENSE)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/falc0n-researcher/proxy2model && cd proxy2model && ./demo.sh
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Sixty seconds, nothing to install, no network: four recorded OWASP Juice Shop sessions are modelled,
|
|
58
|
+
anonymous is compared with a signed-in user, the surface the login opened is diffed, and the report opens.
|
|
52
59
|
|
|
53
60
|
---
|
|
54
61
|
|
|
55
|
-
### `//`
|
|
62
|
+
### `//` cross-role — what each role should not have reached
|
|
56
63
|
|
|
57
|
-
<img src="https://raw.githubusercontent.com/falc0n-researcher/proxy2model/main/assets/readme/ledger.svg" alt="From the Juice Shop sample:
|
|
64
|
+
<img src="https://raw.githubusercontent.com/falc0n-researcher/proxy2model/main/assets/readme/ledger.svg" alt="From the Juice Shop sample: an admin endpoint answered anonymous with 200, anonymous changed a product with PUT, and context.json is 37k tokens instead of about 440k of raw traffic" width="100%">
|
|
58
65
|
|
|
59
|
-
|
|
60
|
-
user, accounting and admin), modelled in about two seconds on a laptop:
|
|
66
|
+
Record the app once per role (`--role anon`, `--role user`, ...) and ask what the lower one reached:
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
```
|
|
66
|
-
$ proxy2model cross-role juiceshop --low anon --high user
|
|
67
|
-
'anon' reached privileged-looking endpoints (2)
|
|
68
|
-
GET /rest/admin/application-configuration ev_93 anon=[200] user=[200]
|
|
69
|
-
unknown: whether the low-role response was authorised or a leak · next: compare 'anon' and 'user' response bodies
|
|
70
|
-
```
|
|
68
|
+
```
|
|
69
|
+
$ proxy2model cross-role juiceshop --low anon --high user
|
|
70
|
+
anon vs user: 2 privileged-looking reached · 3 object reads to test · 2 writes by anon · 31 untested · 12 enforced
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
'anon' reached privileged-looking endpoints (2) [function-level authorization (OWASP API5)]
|
|
73
|
+
GET /rest/admin/application-configuration ev_93 anon=[200] user=[200]
|
|
74
|
+
unknown: whether the low-role response was authorised or a leak · next: compare 'anon' and 'user' response bodies
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
'anon' changed state (2) [function-level authorization (OWASP API5)]
|
|
77
|
+
PUT /api/Products/{id} ev_285 anon=[200]
|
|
78
|
+
unknown: whether 'anon' may PUT here on a record it does not own · next: confirm in the app's design ...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Each divergence is named for the question it raises:
|
|
82
|
+
|
|
83
|
+
| Intent | What it means | The next safe check |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| privileged-looking reached | the low role got through to an admin-style path | compare both roles' response bodies |
|
|
86
|
+
| object reads | the low role read an object by id (a BOLA question) | ask for another account's id as the low role |
|
|
87
|
+
| writes | the low role changed state and got a 2xx | confirm the design allows it, on a record you own |
|
|
88
|
+
| untested | only the high role ever tried it | replay it as the low role |
|
|
89
|
+
| enforced | 401/403 for the low role, 2xx for the high | the control every other replay is compared with |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
### `//` hand an AI the model, not the traffic
|
|
94
|
+
|
|
95
|
+
Raw traffic pasted into an LLM is huge, full of secrets, and invites guessing. `context.json` is the
|
|
96
|
+
model packed for an AI: about **37k tokens for the Juice Shop capture instead of ~440k**, every value
|
|
97
|
+
masked, every fact tied to an evidence id, and rules the model must follow:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
Cite an evidence id (ev_N) for every claim; each id is resolved in `evidence`.
|
|
101
|
+
Never name an endpoint, route or parameter that is not in this package.
|
|
102
|
+
State unknowns explicitly; do not guess authorization behaviour.
|
|
103
|
+
Treat an absence in this package as an absence in the capture, never in the target.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Or let an agent ask the model itself over MCP (`proxy2model mcp`): the same cited answers as the CLI,
|
|
107
|
+
read-only. The [skill](https://github.com/falc0n-researcher/proxy2model/tree/main/skill) is the method as a playbook an AI follows, nothing to install.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
### `//` diff — what changed since the last capture
|
|
112
|
+
|
|
113
|
+
Every build is kept. Capture the app again next week, or as another role, and ask what moved:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
$ proxy2model diff juiceshop demo-anon demo-user
|
|
117
|
+
1 new workflow(s) · 24 new · 42 changed · 3 gone
|
|
118
|
+
▸ route /profile (79 things: 2 routes, 29 endpoints, 45 parameters, 2 auths, 1 role)
|
|
119
|
+
/profile → /rest/user/change-password → DELETE /api/Addresss/{id} → ...
|
|
77
120
|
```
|
|
78
121
|
|
|
122
|
+
New workflows, changed fingerprints and what is gone, each with its requests: drift, never findings.
|
|
123
|
+
`proxy2model watch` builds every export dropped into a folder and prints the change.
|
|
124
|
+
|
|
79
125
|
---
|
|
80
126
|
|
|
81
127
|
### `//` intake
|
|
@@ -101,8 +147,7 @@ proxy2model query shop help # ask the model; answers cite requests, no A
|
|
|
101
147
|
proxy2model mcp --out "$PWD/proxy2model-out" # or let an AI agent ask (see Using it with an AI)
|
|
102
148
|
```
|
|
103
149
|
|
|
104
|
-
|
|
105
|
-
`proxy2model <command> --help` explains each.
|
|
150
|
+
`proxy2model` on its own lists the everyday commands; `proxy2model <command> --help` explains each.
|
|
106
151
|
|
|
107
152
|
---
|
|
108
153
|
|
|
@@ -145,4 +190,4 @@ one reaches any output.
|
|
|
145
190
|
- **05 · [Using it with an AI](https://github.com/falc0n-researcher/proxy2model/blob/main/docs/ai.md)** — `context.json` and the MCP server.
|
|
146
191
|
- **[Skill](https://github.com/falc0n-researcher/proxy2model/tree/main/skill)** — the same method as a playbook for an AI, nothing to install.
|
|
147
192
|
|
|
148
|
-
<sub>`fa1c0n@recon ~ $`
|
|
193
|
+
<sub>`fa1c0n@recon ~ $` every claim cites a request · every gap is named</sub>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<!-- ─────────────────────────────────────────────────────────────
|
|
2
|
+
proxy2model · README
|
|
3
|
+
The artwork in assets/readme/ is self-contained SVG (Nippo embedded).
|
|
4
|
+
Do not edit it by hand: change scripts/build_readme_svgs.py and run it.
|
|
5
|
+
Links are absolute so the same file renders on PyPI.
|
|
6
|
+
───────────────────────────────────────────────────────────── -->
|
|
7
|
+
|
|
8
|
+
<img src="https://raw.githubusercontent.com/falc0n-researcher/proxy2model/main/assets/readme/hero.svg" alt="proxy2model: every endpoint, page and role you captured, each tied to its request" width="100%">
|
|
9
|
+
|
|
10
|
+
### `//` the instrument
|
|
11
|
+
|
|
12
|
+
**Every claim cites the request that proves it, and the model names what the capture could not see.**
|
|
13
|
+
proxy2model turns the traffic you already capture into a model of the web app: its endpoints, pages,
|
|
14
|
+
parameters and roles. It compares what each role reached and hands an AI cited evidence instead of
|
|
15
|
+
raw traffic. It does not scan or attack, and it never calls anything a vulnerability: it gives
|
|
16
|
+
hypotheses and the next safe check.
|
|
17
|
+
|
|
18
|
+
[](https://pypi.org/project/proxy2model/)
|
|
19
|
+
[](https://github.com/falc0n-researcher/proxy2model/actions/workflows/ci.yml)
|
|
20
|
+
[](#-boundaries)
|
|
21
|
+
[](https://github.com/falc0n-researcher/proxy2model/blob/main/LICENSE)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/falc0n-researcher/proxy2model && cd proxy2model && ./demo.sh
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Sixty seconds, nothing to install, no network: four recorded OWASP Juice Shop sessions are modelled,
|
|
28
|
+
anonymous is compared with a signed-in user, the surface the login opened is diffed, and the report opens.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
### `//` cross-role — what each role should not have reached
|
|
33
|
+
|
|
34
|
+
<img src="https://raw.githubusercontent.com/falc0n-researcher/proxy2model/main/assets/readme/ledger.svg" alt="From the Juice Shop sample: an admin endpoint answered anonymous with 200, anonymous changed a product with PUT, and context.json is 37k tokens instead of about 440k of raw traffic" width="100%">
|
|
35
|
+
|
|
36
|
+
Record the app once per role (`--role anon`, `--role user`, ...) and ask what the lower one reached:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
$ proxy2model cross-role juiceshop --low anon --high user
|
|
40
|
+
anon vs user: 2 privileged-looking reached · 3 object reads to test · 2 writes by anon · 31 untested · 12 enforced
|
|
41
|
+
|
|
42
|
+
'anon' reached privileged-looking endpoints (2) [function-level authorization (OWASP API5)]
|
|
43
|
+
GET /rest/admin/application-configuration ev_93 anon=[200] user=[200]
|
|
44
|
+
unknown: whether the low-role response was authorised or a leak · next: compare 'anon' and 'user' response bodies
|
|
45
|
+
|
|
46
|
+
'anon' changed state (2) [function-level authorization (OWASP API5)]
|
|
47
|
+
PUT /api/Products/{id} ev_285 anon=[200]
|
|
48
|
+
unknown: whether 'anon' may PUT here on a record it does not own · next: confirm in the app's design ...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Each divergence is named for the question it raises:
|
|
52
|
+
|
|
53
|
+
| Intent | What it means | The next safe check |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| privileged-looking reached | the low role got through to an admin-style path | compare both roles' response bodies |
|
|
56
|
+
| object reads | the low role read an object by id (a BOLA question) | ask for another account's id as the low role |
|
|
57
|
+
| writes | the low role changed state and got a 2xx | confirm the design allows it, on a record you own |
|
|
58
|
+
| untested | only the high role ever tried it | replay it as the low role |
|
|
59
|
+
| enforced | 401/403 for the low role, 2xx for the high | the control every other replay is compared with |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
### `//` hand an AI the model, not the traffic
|
|
64
|
+
|
|
65
|
+
Raw traffic pasted into an LLM is huge, full of secrets, and invites guessing. `context.json` is the
|
|
66
|
+
model packed for an AI: about **37k tokens for the Juice Shop capture instead of ~440k**, every value
|
|
67
|
+
masked, every fact tied to an evidence id, and rules the model must follow:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
Cite an evidence id (ev_N) for every claim; each id is resolved in `evidence`.
|
|
71
|
+
Never name an endpoint, route or parameter that is not in this package.
|
|
72
|
+
State unknowns explicitly; do not guess authorization behaviour.
|
|
73
|
+
Treat an absence in this package as an absence in the capture, never in the target.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or let an agent ask the model itself over MCP (`proxy2model mcp`): the same cited answers as the CLI,
|
|
77
|
+
read-only. The [skill](https://github.com/falc0n-researcher/proxy2model/tree/main/skill) is the method as a playbook an AI follows, nothing to install.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
### `//` diff — what changed since the last capture
|
|
82
|
+
|
|
83
|
+
Every build is kept. Capture the app again next week, or as another role, and ask what moved:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
$ proxy2model diff juiceshop demo-anon demo-user
|
|
87
|
+
1 new workflow(s) · 24 new · 42 changed · 3 gone
|
|
88
|
+
▸ route /profile (79 things: 2 routes, 29 endpoints, 45 parameters, 2 auths, 1 role)
|
|
89
|
+
/profile → /rest/user/change-password → DELETE /api/Addresss/{id} → ...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
New workflows, changed fingerprints and what is gone, each with its requests: drift, never findings.
|
|
93
|
+
`proxy2model watch` builds every export dropped into a folder and prints the change.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### `//` intake
|
|
98
|
+
|
|
99
|
+
| Traffic from | Command |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| Burp (*Save items* XML), Logger++ CSV, Caido (JSON or CSV), or a HAR (browser, ZAP, mitmproxy) | `proxy2model FILE -w app` |
|
|
102
|
+
| A browser it opens and records while you use the app | `proxy2model browse` |
|
|
103
|
+
| A crawl of a running app | `proxy2model crawl URL -w app --yes` |
|
|
104
|
+
|
|
105
|
+
Every value is masked as it is read. One model comes out: a report, a SQLite graph you can query,
|
|
106
|
+
OpenAPI, and a compact package for an AI. Runs locally.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install proxy2model
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
proxy2model history.xml -w shop # build from an export
|
|
114
|
+
proxy2model browse # or open a browser and use the app (asks for URL, scope, mode)
|
|
115
|
+
|
|
116
|
+
proxy2model query shop help # ask the model; answers cite requests, no AI involved
|
|
117
|
+
proxy2model mcp --out "$PWD/proxy2model-out" # or let an AI agent ask (see Using it with an AI)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`proxy2model` on its own lists the everyday commands; `proxy2model <command> --help` explains each.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
### `//` what comes out
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
proxy2model-out/shop/
|
|
128
|
+
├── report.html interactive report: overview, what to check, endpoints, map, every request, query console
|
|
129
|
+
├── model.json the model, and the request behind every claim
|
|
130
|
+
├── openapi.json the observed API, OpenAPI 3.0
|
|
131
|
+
├── context.json what to hand an AI instead of raw traffic
|
|
132
|
+
├── graph.db SQLite graph, queried with `proxy2model q`
|
|
133
|
+
├── exports/ graph.json · graph.graphml · build.svg
|
|
134
|
+
└── inputs/ masked records of every capture, merged on rebuild
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### `//` boundaries
|
|
140
|
+
|
|
141
|
+
| It does | It never |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| Read traffic you captured | Upload, forward or replay it |
|
|
144
|
+
| Mask every value before the first write | Store a token, cookie, key or PII value |
|
|
145
|
+
| Say what it could not observe | Claim coverage it did not have |
|
|
146
|
+
| Offer hypotheses and the evidence to test them | Call anything a vulnerability |
|
|
147
|
+
|
|
148
|
+
A plain build is offline. `crawl`, `browse --active` and `osint` send requests of their own: use
|
|
149
|
+
them only on systems you are authorized to test. CI plants fake secrets in the samples and fails if
|
|
150
|
+
one reaches any output.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
### `//` field manual
|
|
155
|
+
|
|
156
|
+
- **01 · [Capturing traffic](https://github.com/falc0n-researcher/proxy2model/blob/main/docs/capture.md)** — Burp, Caido, HAR, `browse`, roles.
|
|
157
|
+
- **02 · [Crawling a running app](https://github.com/falc0n-researcher/proxy2model/blob/main/docs/crawling.md)** — `crawl` and scripted journeys.
|
|
158
|
+
- **03 · [How it works](https://github.com/falc0n-researcher/proxy2model/blob/main/docs/model.md)** — what is in the model and how it is built.
|
|
159
|
+
- **04 · [Querying the model](https://github.com/falc0n-researcher/proxy2model/blob/main/docs/querying.md)** — `query`, BQL, graph reach, diffs between builds.
|
|
160
|
+
- **05 · [Using it with an AI](https://github.com/falc0n-researcher/proxy2model/blob/main/docs/ai.md)** — `context.json` and the MCP server.
|
|
161
|
+
- **[Skill](https://github.com/falc0n-researcher/proxy2model/tree/main/skill)** — the same method as a playbook for an AI, nothing to install.
|
|
162
|
+
|
|
163
|
+
<sub>`fa1c0n@recon ~ $` every claim cites a request · every gap is named</sub>
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
<rect x="72" y="345" width="54" height="20" rx="4" fill="none" stroke="#e85002" stroke-opacity="0.5"/>
|
|
24
24
|
<circle cx="83" cy="355" r="3" fill="#e85002"><animate attributeName="opacity" values="1;0.25;1" dur="1.8s" repeatCount="indefinite"/></circle>
|
|
25
25
|
<text x="91" y="359" fill="#e85002" font-size="10" font-weight="500" letter-spacing="1.5px">LOCAL</text>
|
|
26
|
-
<text x="142" y="361" font-size="14.5"><tspan fill="#e85002">fa1c0n@recon ~ $</tspan><tspan fill="#ded2c0"> proxy2model juiceshop
|
|
27
|
-
<text x="1128" y="361" text-anchor="end" font-size="14.5" font-weight="500"><tspan fill="#f6f1e9">
|
|
26
|
+
<text x="142" y="361" font-size="14.5"><tspan fill="#e85002">fa1c0n@recon ~ $</tspan><tspan fill="#ded2c0"> proxy2model cross-role juiceshop --low anon --high user</tspan></text>
|
|
27
|
+
<text x="1128" y="361" text-anchor="end" font-size="14.5" font-weight="500"><tspan fill="#f6f1e9">2 admin paths</tspan><tspan fill="#6f6a61"> · </tspan><tspan fill="#f6f1e9">2 anon writes</tspan></text>
|
|
28
28
|
<g transform="translate(1000,22) scale(1.9)"><path d="M37 39.5 L72 39.5 L26.5 66.5 Q43 51 46 49 Q39 45 37 39.5 Z" fill="#e85002"/></g>
|
|
29
29
|
</g>
|
|
30
30
|
</svg>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<clipPath id="round"><rect x="1" y="1" width="1198" height="248" rx="14"/></clipPath>
|
|
12
12
|
</defs>
|
|
13
13
|
<rect width="1200" height="250" fill="none"/>
|
|
14
|
-
<g><rect x="0" y="2" width="385.3333333333333" height="234" rx="9" fill="#100e0b" stroke="#ded2c0" stroke-opacity="0.12"/><rect x="0" y="2" width="385.3333333333333" height="234" rx="9" fill="url(#sheen)"/><text x="26" y="40" fill="#e85002" font-size="12" font-weight="500" letter-spacing="1.5px">001</text><text x="60" y="40" fill="#6f6a61" font-size="10.5" letter-spacing="2px">
|
|
15
|
-
<g><rect x="407.3333333333333" y="2" width="385.3333333333333" height="234" rx="9" fill="#100e0b" stroke="#e85002" stroke-opacity="0.55"/><rect x="407.3333333333333" y="2" width="385.3333333333333" height="234" rx="9" fill="url(#sheen)"/><rect x="407.3333333333333" y="2" width="385.3333333333333" height="3" rx="1.5" fill="#e85002"/><text x="433.3333333333333" y="40" fill="#e85002" font-size="12" font-weight="500" letter-spacing="1.5px">002</text><text x="467.3333333333333" y="40" fill="#6f6a61" font-size="10.5" letter-spacing="2px">
|
|
16
|
-
<g><rect x="814.6666666666666" y="2" width="385.3333333333333" height="234" rx="9" fill="#100e0b" stroke="#ded2c0" stroke-opacity="0.12"/><rect x="814.6666666666666" y="2" width="385.3333333333333" height="234" rx="9" fill="url(#sheen)"/><text x="840.6666666666666" y="40" fill="#e85002" font-size="12" font-weight="500" letter-spacing="1.5px">003</text><text x="874.6666666666666" y="40" fill="#6f6a61" font-size="10.5" letter-spacing="2px">
|
|
14
|
+
<g><rect x="0" y="2" width="385.3333333333333" height="234" rx="9" fill="#100e0b" stroke="#ded2c0" stroke-opacity="0.12"/><rect x="0" y="2" width="385.3333333333333" height="234" rx="9" fill="url(#sheen)"/><text x="26" y="40" fill="#e85002" font-size="12" font-weight="500" letter-spacing="1.5px">001</text><text x="60" y="40" fill="#6f6a61" font-size="10.5" letter-spacing="2px">CROSS-ROLE</text><circle cx="359.3333333333333" cy="36" r="3.5" fill="#6f6a61"/><text x="26" y="78" fill="#f6f1e9" font-size="22" font-weight="500" letter-spacing="-0.3px">Admin answered anonymous</text><text x="26" y="150" fill="#f3ece0" font-size="52" font-weight="200" letter-spacing="-1px">200</text><text x="26" y="180" fill="#a39a8d" font-size="13" font-weight="400">from /rest/admin/application-</text><text x="26" y="199" fill="#a39a8d" font-size="13" font-weight="400">configuration, request cited</text><line x1="26" y1="200" x2="359.3333333333333" y2="200" stroke="#ded2c0" stroke-opacity="0.08"/><rect x="26.0" y="206" width="48.0" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="50.0" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">ROLES</text><rect x="81.0" y="206" width="40.8" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="101.4" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">API5</text><text x="359.3333333333333" y="220" text-anchor="end" fill="#6f6a61" font-size="10" letter-spacing="1.5px">EV_93</text></g>
|
|
15
|
+
<g><rect x="407.3333333333333" y="2" width="385.3333333333333" height="234" rx="9" fill="#100e0b" stroke="#e85002" stroke-opacity="0.55"/><rect x="407.3333333333333" y="2" width="385.3333333333333" height="234" rx="9" fill="url(#sheen)"/><rect x="407.3333333333333" y="2" width="385.3333333333333" height="3" rx="1.5" fill="#e85002"/><text x="433.3333333333333" y="40" fill="#e85002" font-size="12" font-weight="500" letter-spacing="1.5px">002</text><text x="467.3333333333333" y="40" fill="#6f6a61" font-size="10.5" letter-spacing="2px">CROSS-ROLE</text><circle cx="766.6666666666666" cy="36" r="3.5" fill="#e85002"/><text x="433.3333333333333" y="78" fill="#f6f1e9" font-size="22" font-weight="500" letter-spacing="-0.3px">Anonymous changed a product</text><text x="433.3333333333333" y="150" fill="#e85002" font-size="52" font-weight="200" letter-spacing="-1px">PUT</text><text x="433.3333333333333" y="180" fill="#a39a8d" font-size="13" font-weight="400">/api/Products/{id} returned 200</text><text x="433.3333333333333" y="199" fill="#a39a8d" font-size="13" font-weight="400">with no login at all</text><line x1="433.3333333333333" y1="200" x2="766.6666666666666" y2="200" stroke="#ded2c0" stroke-opacity="0.08"/><rect x="433.3" y="206" width="48.0" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="457.3" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">ROLES</text><rect x="488.3" y="206" width="40.8" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="508.7" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">API5</text><text x="766.6666666666666" y="220" text-anchor="end" fill="#6f6a61" font-size="10" letter-spacing="1.5px">EV_285</text></g>
|
|
16
|
+
<g><rect x="814.6666666666666" y="2" width="385.3333333333333" height="234" rx="9" fill="#100e0b" stroke="#ded2c0" stroke-opacity="0.12"/><rect x="814.6666666666666" y="2" width="385.3333333333333" height="234" rx="9" fill="url(#sheen)"/><text x="840.6666666666666" y="40" fill="#e85002" font-size="12" font-weight="500" letter-spacing="1.5px">003</text><text x="874.6666666666666" y="40" fill="#6f6a61" font-size="10.5" letter-spacing="2px">CONTEXT.JSON</text><circle cx="1174.0" cy="36" r="3.5" fill="#6f6a61"/><text x="840.6666666666666" y="78" fill="#f6f1e9" font-size="22" font-weight="500" letter-spacing="-0.3px">Hand an AI the model</text><text x="840.6666666666666" y="150" fill="#f3ece0" font-size="52" font-weight="200" letter-spacing="-1px">37k</text><text x="840.6666666666666" y="180" fill="#a39a8d" font-size="13" font-weight="400">tokens of cited evidence instead</text><text x="840.6666666666666" y="199" fill="#a39a8d" font-size="13" font-weight="400">of ~440k of raw traffic</text><line x1="840.6666666666666" y1="200" x2="1174.0" y2="200" stroke="#ded2c0" stroke-opacity="0.08"/><rect x="840.7" y="206" width="26.4" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="853.9" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">AI</text><rect x="874.1" y="206" width="33.6" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="890.9" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">MCP</text><text x="1174.0" y="220" text-anchor="end" fill="#6f6a61" font-size="10" letter-spacing="1.5px">SECRETS MASKED</text></g>
|
|
17
17
|
</svg>
|
|
@@ -96,19 +96,31 @@ def cmd_crossrole(args) -> int:
|
|
|
96
96
|
print(json.dumps(result, indent=2))
|
|
97
97
|
return 0
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
lo, hi, h = args.low, args.high, result["headline"]
|
|
100
|
+
print(f"{lo} vs {hi}: {h['low_reached_privileged']} privileged-looking reached · "
|
|
101
|
+
f"{h['object_reads']} object reads to test · {h['low_writes']} writes by {lo} · "
|
|
102
|
+
f"{h['high_only']} untested · {h['enforced']} enforced\n")
|
|
103
|
+
|
|
104
|
+
def show(title, items, limit=None):
|
|
105
|
+
if not items:
|
|
106
|
+
return
|
|
107
|
+
print(f"{title} ({len(items)}) [{items[0]['family']}]")
|
|
108
|
+
for s in items[:limit]:
|
|
102
109
|
ev = ", ".join(f"ev_{e}" for e in s["evidence"])
|
|
103
110
|
st = " ".join(f"{r}={v}" for r, v in s["statuses"].items())
|
|
104
111
|
print(f" {s['endpoint']:<44} {ev} {st}")
|
|
105
112
|
print(f" unknown: {s['unknown']} · next: {s['next']}")
|
|
113
|
+
if limit and len(items) > limit:
|
|
114
|
+
print(f" ... {len(items) - limit} more (--json for all)")
|
|
106
115
|
print()
|
|
107
116
|
|
|
108
|
-
show(f"'{
|
|
109
|
-
show(f"'{
|
|
110
|
-
show("
|
|
111
|
-
|
|
117
|
+
show(f"'{lo}' reached privileged-looking endpoints", result["low_reached_privileged"])
|
|
118
|
+
show(f"'{lo}' read objects by id", result["object_reads"])
|
|
119
|
+
show(f"'{lo}' changed state", result["low_writes"])
|
|
120
|
+
show(f"'{hi}' reached, '{lo}' never tried", result["high_only"], limit=None if args.all else 10)
|
|
121
|
+
show(f"denied to '{lo}', allowed to '{hi}'", result["enforced"])
|
|
122
|
+
show("both roles got 2xx", result["shared_same_success"], limit=None if args.all else 10)
|
|
123
|
+
print("Each is a hypothesis to verify in an authorised session, not a finding.")
|
|
112
124
|
return 0
|
|
113
125
|
|
|
114
126
|
|
|
@@ -214,6 +214,7 @@ def main(argv=None) -> int:
|
|
|
214
214
|
cr.add_argument("--high", required=True, type=_name("--high"),
|
|
215
215
|
help="the more-privileged role (as tagged with --role)")
|
|
216
216
|
cr.add_argument("--json", action="store_true", help="print JSON instead of text")
|
|
217
|
+
cr.add_argument("--all", action="store_true", help="list every entry (long sections stop at 10)")
|
|
217
218
|
cr.add_argument("--out", default="proxy2model-out",
|
|
218
219
|
help="output directory used at build time")
|
|
219
220
|
cr.set_defaults(func=cmd_crossrole)
|
|
@@ -226,6 +226,7 @@ def cmd_browse(args) -> int:
|
|
|
226
226
|
if recorder.error:
|
|
227
227
|
print(ui.warn(f"recording stopped early: {recorder.error}"), file=sys.stderr)
|
|
228
228
|
live.flush()
|
|
229
|
+
live.finish()
|
|
229
230
|
if not live.saved:
|
|
230
231
|
print(f"\n nothing in scope was recorded ({', '.join(scope)})")
|
|
231
232
|
return 1
|
|
@@ -32,7 +32,9 @@ TOOLS = [
|
|
|
32
32
|
"inputSchema": {"type": "object", "required": ["app", "expression"],
|
|
33
33
|
"properties": {"app": _APP, "expression": {"type": "string"},
|
|
34
34
|
"limit": {"type": "integer", "minimum": 1, "maximum": 1000}}}},
|
|
35
|
-
{"name": "cross_role", "description": "
|
|
35
|
+
{"name": "cross_role", "description": "Authorization hypotheses between two roles: what the lower one reached "
|
|
36
|
+
"that looks privileged, objects it read by id, state it changed, what it never tried, and where it was denied; "
|
|
37
|
+
"each with the request that shows it and a safe next check.",
|
|
36
38
|
"inputSchema": {"type": "object", "required": ["app", "low", "high"],
|
|
37
39
|
"properties": {"app": _APP, "low": {"type": "string"}, "high": {"type": "string"}}}},
|
|
38
40
|
{"name": "builds", "description": "The builds of a model kept in graph.db, to pick two for diff.",
|
|
@@ -859,16 +859,35 @@ def _ok(statuses) -> bool:
|
|
|
859
859
|
return any(200 <= s < 300 for s in statuses or [])
|
|
860
860
|
|
|
861
861
|
|
|
862
|
-
|
|
863
|
-
|
|
862
|
+
# the authorization question each kind of divergence raises, named the way an API tester names it
|
|
863
|
+
CROSS_ROLE_FAMILY = {
|
|
864
|
+
"low_reached_privileged": "function-level authorization (OWASP API5)",
|
|
865
|
+
"object_reads": "object-level authorization (OWASP API1)",
|
|
866
|
+
"low_writes": "function-level authorization (OWASP API5)",
|
|
867
|
+
"high_only": "untested authorization",
|
|
868
|
+
"enforced": "a control: authorization held here",
|
|
869
|
+
"shared_same_success": "data exposure between roles (OWASP API3)",
|
|
870
|
+
}
|
|
871
|
+
_OBJECT_KEY = re.compile(r"\{(?:id|uuid|hash|code|token)\}")
|
|
872
|
+
_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
873
|
+
|
|
864
874
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
875
|
+
def cross_role(m: Model, low: str, high: str) -> dict:
|
|
876
|
+
"""Compare what a low and a high role reached. Output is hypotheses, never findings.
|
|
877
|
+
|
|
878
|
+
low_reached_privileged the low role got through to a privileged-looking path.
|
|
879
|
+
object_reads the low role got a 2xx from an endpoint keyed by an object id:
|
|
880
|
+
ask for the high role's object as the low role (BOLA).
|
|
881
|
+
low_writes the low role changed state (POST, PUT, PATCH, DELETE) and got a 2xx.
|
|
882
|
+
high_only the high role reached it, the low role never tried: replay it.
|
|
883
|
+
enforced the low role got 401/403 where the high role got 2xx: the
|
|
884
|
+
control to compare every other replay against.
|
|
885
|
+
shared_same_success both roles got a 2xx: compare what each was shown.
|
|
886
|
+
|
|
887
|
+
Each entry carries `family` (the authorization question), `unknown` and a safe `next` check.
|
|
870
888
|
"""
|
|
871
889
|
high_only, low_priv, shared = [], [], []
|
|
890
|
+
objects, writes, enforced = [], [], []
|
|
872
891
|
ev_role = {e["id"]: e.get("role") for e in m.evidence_log.meta()}
|
|
873
892
|
|
|
874
893
|
def evs(node, prefer_role):
|
|
@@ -886,27 +905,51 @@ def cross_role(m: Model, low: str, high: str) -> dict:
|
|
|
886
905
|
entry = {"endpoint": node.label, "id": node.id,
|
|
887
906
|
"evidence": evs(node, low if rl else high),
|
|
888
907
|
"statuses": {r: sbr.get(r, []) for r in (low, high) if r in sbr}}
|
|
908
|
+
low_ok, high_ok = _ok(sbr.get(low)), _ok(sbr.get(high))
|
|
909
|
+
method = (node.attrs.get("method") or "").upper()
|
|
910
|
+
tag = lambda kind, **kw: {**entry, "kind": kind, "family": CROSS_ROLE_FAMILY[kind], **kw}
|
|
889
911
|
if rh and not rl:
|
|
890
|
-
high_only.append(
|
|
891
|
-
|
|
892
|
-
|
|
912
|
+
high_only.append(tag("high_only",
|
|
913
|
+
unknown=f"whether '{low}' can reach it",
|
|
914
|
+
next=f"replay as '{low}' and compare with '{high}'"))
|
|
893
915
|
elif rl and PRIV_RE.search(path):
|
|
894
|
-
low_priv.append(
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
elif rl and rh and
|
|
898
|
-
shared.append(
|
|
899
|
-
|
|
900
|
-
|
|
916
|
+
low_priv.append(tag("low_reached_privileged",
|
|
917
|
+
unknown="whether the low-role response was authorised or a leak",
|
|
918
|
+
next=f"compare '{low}' and '{high}' response bodies"))
|
|
919
|
+
elif rl and rh and low_ok and high_ok:
|
|
920
|
+
shared.append(tag("shared_same_success",
|
|
921
|
+
unknown="whether both roles see the same data",
|
|
922
|
+
next="diff the two responses for role-specific fields"))
|
|
923
|
+
if rl and low_ok and method in _WRITE_METHODS:
|
|
924
|
+
on_object = bool(_OBJECT_KEY.search(path))
|
|
925
|
+
writes.append(tag("low_writes",
|
|
926
|
+
unknown=f"whether '{low}' may {method} here" + (" on a record it does not own" if on_object else ""),
|
|
927
|
+
next=f"confirm in the app's design that '{low}' may {method} here; if not, "
|
|
928
|
+
f"repeat it as '{low}' on a test record you own"))
|
|
929
|
+
elif rl and low_ok and _OBJECT_KEY.search(path):
|
|
930
|
+
objects.append(tag("object_reads",
|
|
931
|
+
unknown=f"whether '{low}' can open an object that belongs to '{high}'",
|
|
932
|
+
next=(f"as '{low}', request an id seen in '{high}' traffic" if rh else
|
|
933
|
+
f"as '{low}', request an id that belongs to another test account")
|
|
934
|
+
+ "; another account's data coming back is the gap, a 403/404 is the control working"))
|
|
935
|
+
if rl and rh and high_ok and any(s in (401, 403) for s in sbr.get(low, [])):
|
|
936
|
+
enforced.append(tag("enforced",
|
|
937
|
+
unknown="whether every route to this data is guarded the same way",
|
|
938
|
+
next=f"use this '{low}' response as the denial to compare other replays with"))
|
|
901
939
|
key = lambda e: e["endpoint"]
|
|
902
|
-
high_only
|
|
903
|
-
|
|
904
|
-
shared.sort(key=key)
|
|
940
|
+
for xs in (high_only, low_priv, shared, objects, writes, enforced):
|
|
941
|
+
xs.sort(key=key)
|
|
905
942
|
return {"low": low, "high": high,
|
|
906
943
|
"roles_present": sorted(m.roles),
|
|
907
944
|
"surface": low_priv + high_only,
|
|
945
|
+
"headline": {"low_reached_privileged": len(low_priv), "object_reads": len(objects),
|
|
946
|
+
"low_writes": len(writes), "high_only": len(high_only),
|
|
947
|
+
"enforced": len(enforced), "shared_same_success": len(shared)},
|
|
908
948
|
"low_reached_privileged": low_priv,
|
|
949
|
+
"object_reads": objects,
|
|
950
|
+
"low_writes": writes,
|
|
909
951
|
"high_only": high_only,
|
|
952
|
+
"enforced": enforced,
|
|
910
953
|
"shared_same_success": shared}
|
|
911
954
|
|
|
912
955
|
|