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.
Files changed (116) hide show
  1. {proxy2model-2.0.0/src/proxy2model.egg-info → proxy2model-2.1.0}/PKG-INFO +73 -28
  2. proxy2model-2.1.0/README.md +163 -0
  3. {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/hero.svg +2 -2
  4. {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/ledger.svg +3 -3
  5. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/__init__.py +1 -1
  6. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/analyze.py +19 -7
  7. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/app.py +1 -0
  8. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/browse.py +1 -0
  9. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/mcp.py +3 -1
  10. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/model.py +63 -20
  11. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/live.py +30 -4
  12. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/parse.py +56 -25
  13. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/redact.py +17 -2
  14. {proxy2model-2.0.0 → proxy2model-2.1.0/src/proxy2model.egg-info}/PKG-INFO +73 -28
  15. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/SOURCES.txt +1 -0
  16. proxy2model-2.1.0/tests/test_capture_wide_masking.py +93 -0
  17. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_juiceshop_sample.py +13 -0
  18. proxy2model-2.0.0/README.md +0 -118
  19. {proxy2model-2.0.0 → proxy2model-2.1.0}/LICENSE +0 -0
  20. {proxy2model-2.0.0 → proxy2model-2.1.0}/MANIFEST.in +0 -0
  21. {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-ai.svg +0 -0
  22. {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-capture.svg +0 -0
  23. {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-crawling.svg +0 -0
  24. {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-model.svg +0 -0
  25. {proxy2model-2.0.0 → proxy2model-2.1.0}/assets/readme/doc-querying.svg +0 -0
  26. {proxy2model-2.0.0 → proxy2model-2.1.0}/pyproject.toml +0 -0
  27. {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/burp-history-admin-sample.xml +0 -0
  28. {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/burp-history-sample.xml +0 -0
  29. {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/juiceshop-accountant.xml +0 -0
  30. {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/juiceshop-admin.xml +0 -0
  31. {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/juiceshop-anon.xml +0 -0
  32. {proxy2model-2.0.0 → proxy2model-2.1.0}/samples/juiceshop-user.xml +0 -0
  33. {proxy2model-2.0.0 → proxy2model-2.1.0}/setup.cfg +0 -0
  34. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/__main__.py +0 -0
  35. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/atomic.py +0 -0
  36. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/__init__.py +0 -0
  37. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/build.py +0 -0
  38. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/common.py +0 -0
  39. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/history.py +0 -0
  40. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/cli/ui.py +0 -0
  41. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/__init__.py +0 -0
  42. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/changes.py +0 -0
  43. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/context.py +0 -0
  44. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/diff.py +0 -0
  45. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/domains.py +0 -0
  46. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/graph.py +0 -0
  47. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/methodology.py +0 -0
  48. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/core/stack.py +0 -0
  49. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/LICENSES.md +0 -0
  50. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  51. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  52. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-200.woff2 +0 -0
  53. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-300.woff2 +0 -0
  54. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-400.woff2 +0 -0
  55. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-500.woff2 +0 -0
  56. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/fonts/nippo-700.woff2 +0 -0
  57. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/data/public_suffix_list.dat +0 -0
  58. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/db/__init__.py +0 -0
  59. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/db/bql.py +0 -0
  60. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/db/bql_js.py +0 -0
  61. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/db/store.py +0 -0
  62. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/export/__init__.py +0 -0
  63. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/export/openapi.py +0 -0
  64. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/__init__.py +0 -0
  65. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/browse.py +0 -0
  66. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/browser.py +0 -0
  67. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/crawl.py +0 -0
  68. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/extract.py +0 -0
  69. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/infer.py +0 -0
  70. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/ingest/journey.py +0 -0
  71. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/py.typed +0 -0
  72. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/recon/__init__.py +0 -0
  73. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/recon/osint.py +0 -0
  74. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/__init__.py +0 -0
  75. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/animate.py +0 -0
  76. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/dash.py +0 -0
  77. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/fonts.py +0 -0
  78. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/html.py +0 -0
  79. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/inventory.py +0 -0
  80. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/map.py +0 -0
  81. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/pages.py +0 -0
  82. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/query.py +0 -0
  83. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/shot.py +0 -0
  84. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model/report/view.py +0 -0
  85. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/dependency_links.txt +0 -0
  86. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/entry_points.txt +0 -0
  87. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/requires.txt +0 -0
  88. {proxy2model-2.0.0 → proxy2model-2.1.0}/src/proxy2model.egg-info/top_level.txt +0 -0
  89. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_bql_parity.py +0 -0
  90. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_browse.py +0 -0
  91. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_browser_crawl.py +0 -0
  92. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_caido.py +0 -0
  93. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_cli.py +0 -0
  94. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_crawl.py +0 -0
  95. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_diff.py +0 -0
  96. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_domains.py +0 -0
  97. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_gaps.py +0 -0
  98. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_graph.py +0 -0
  99. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_har.py +0 -0
  100. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_hardening.py +0 -0
  101. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_journey.py +0 -0
  102. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_leads.py +0 -0
  103. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_logger_csv.py +0 -0
  104. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_map.py +0 -0
  105. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_mcp.py +0 -0
  106. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_methodology.py +0 -0
  107. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_openapi.py +0 -0
  108. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_proxy2model.py +0 -0
  109. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_recon_cmds.py +0 -0
  110. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_redact_credentials.py +0 -0
  111. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_redaction_realistic.py +0 -0
  112. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_report_ui.py +0 -0
  113. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_routes_areas.py +0 -0
  114. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_stack.py +0 -0
  115. {proxy2model-2.0.0 → proxy2model-2.1.0}/tests/test_store_bql.py +0 -0
  116. {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.0.0
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
- Mapping an app by hand takes hours, and it starts over on every engagement: which hosts, pages and
43
- endpoints exist, which page calls what, what the JavaScript names but you never reached, what each
44
- role can see. **proxy2model does that bookkeeping from the traffic you already capture**, so the time
45
- goes to testing. It does not scan or attack, and it never calls anything a vulnerability. It tells
46
- you what is there, which request proves it, and what the capture could not show.
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
- [![license](https://img.shields.io/badge/license-apache--2.0-e85002?style=flat-square&labelColor=0b0a08)](https://github.com/falc0n-researcher/proxy2model/blob/main/LICENSE)
48
+ [![pypi](https://img.shields.io/pypi/v/proxy2model?style=flat-square&labelColor=0b0a08&color=e85002)](https://pypi.org/project/proxy2model/)
49
49
  [![tests](https://img.shields.io/github/actions/workflow/status/falc0n-researcher/proxy2model/ci.yml?branch=main&style=flat-square&label=tests&labelColor=0b0a08&color=ded2c0)](https://github.com/falc0n-researcher/proxy2model/actions/workflows/ci.yml)
50
50
  [![secrets](https://img.shields.io/badge/secrets-masked%20before%20write-ded2c0?style=flat-square&labelColor=0b0a08)](#-boundaries)
51
- ![runtime](https://img.shields.io/badge/python%203.10%2B-no%20dependencies-6f6a61?style=flat-square&labelColor=0b0a08)
51
+ [![license](https://img.shields.io/badge/license-apache--2.0-6f6a61?style=flat-square&labelColor=0b0a08)](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
- ### `//` field test — the bundled capture
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: 104 endpoints mapped, 16 named in code but never called, 79 secret values masked" width="100%">
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
- Four [OWASP Juice Shop](https://owasp.org/www-project-juice-shop/) sessions (417 requests as anonymous,
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
- - **001 · The API, mapped** — 104 endpoints, and the observed API as OpenAPI 3.0: 68 paths, 88 operations, ready for Postman, ZAP or a fuzzer.
63
- - **002 · Named, never called** — 16 endpoints the app's JavaScript names but the browsing never reached: `/rest/admin`, `/rest/chat`, `/rest/track-order`, `/api/SecurityAnswers` ...
64
- - **003 · Anonymous got 200 from admin-looking endpoints**, cited to the request:
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
- Every line cites the request behind it (`ev_93`), and every lead says what is still unknown and the
73
- next safe check. Reproduce it:
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
- ```bash
76
- for r in anon user accountant admin; do proxy2model samples/juiceshop-$r.xml -w juiceshop --role $r; done
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
- `./demo.sh` builds the bundled sample. `proxy2model` on its own lists the everyday commands;
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 ~ $` &nbsp; captured traffic in · a model of the app out · every line cited</sub>
193
+ <sub>`fa1c0n@recon ~ $` &nbsp; 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
+ [![pypi](https://img.shields.io/pypi/v/proxy2model?style=flat-square&labelColor=0b0a08&color=e85002)](https://pypi.org/project/proxy2model/)
19
+ [![tests](https://img.shields.io/github/actions/workflow/status/falc0n-researcher/proxy2model/ci.yml?branch=main&style=flat-square&label=tests&labelColor=0b0a08&color=ded2c0)](https://github.com/falc0n-researcher/proxy2model/actions/workflows/ci.yml)
20
+ [![secrets](https://img.shields.io/badge/secrets-masked%20before%20write-ded2c0?style=flat-square&labelColor=0b0a08)](#-boundaries)
21
+ [![license](https://img.shields.io/badge/license-apache--2.0-6f6a61?style=flat-square&labelColor=0b0a08)](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 ~ $` &nbsp; 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-*.xml -w juiceshop</tspan></text>
27
- <text x="1128" y="361" text-anchor="end" font-size="14.5" font-weight="500"><tspan fill="#f6f1e9">104 endpoints</tspan><tspan fill="#6f6a61"> &#183; </tspan><tspan fill="#f6f1e9">16 never called</tspan></text>
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"> &#183; </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">THE&#160;API,&#160;MAPPED</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">Juice Shop, four roles</text><text x="26" y="150" fill="#f3ece0" font-size="52" font-weight="200" letter-spacing="-1px">104</text><text x="26" y="180" fill="#a39a8d" font-size="13" font-weight="400">endpoints from 417 captured</text><text x="26" y="199" fill="#a39a8d" font-size="13" font-weight="400">requests, each with its proof</text><line x1="26" y1="200" x2="359.3333333333333" y2="200" stroke="#ded2c0" stroke-opacity="0.08"/><rect x="26.0" y="206" width="33.6" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="42.8" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">API</text><rect x="66.6" y="206" width="62.4" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="97.8" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">OPENAPI</text><rect x="136.0" y="206" width="48.0" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="160.0" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">ROLES</text><text x="359.3333333333333" y="220" text-anchor="end" fill="#6f6a61" font-size="10" letter-spacing="1.5px">68&#160;PATHS&#160;·&#160;88&#160;OPS</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">WHAT&#160;NOBODY&#160;WALKED</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">Named, never called</text><text x="433.3333333333333" y="150" fill="#e85002" font-size="52" font-weight="200" letter-spacing="-1px">16</text><text x="433.3333333333333" y="180" fill="#a39a8d" font-size="13" font-weight="400">endpoints the JavaScript names</text><text x="433.3333333333333" y="199" fill="#a39a8d" font-size="13" font-weight="400">that the browsing never reached</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="40.8" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="453.7" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">CODE</text><rect x="481.1" y="206" width="48.0" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="505.1" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">LEADS</text><text x="766.6666666666666" y="220" text-anchor="end" fill="#6f6a61" font-size="10" letter-spacing="1.5px">E.G.&#160;/REST/ADMIN</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">MASKED&#160;AT&#160;READ</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">Proof without secrets</text><text x="840.6666666666666" y="150" fill="#f3ece0" font-size="52" font-weight="200" letter-spacing="-1px">79</text><text x="840.6666666666666" y="180" fill="#a39a8d" font-size="13" font-weight="400">token, cookie and PII values</text><text x="840.6666666666666" y="199" fill="#a39a8d" font-size="13" font-weight="400">masked before the first write</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="55.2" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="868.3" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">TOKENS</text><rect x="902.9" y="206" width="33.6" height="18" rx="3" fill="none" stroke="#ded2c0" stroke-opacity="0.16"/><text x="919.7" y="219" fill="#a39a8d" font-size="9.5" font-weight="400" text-anchor="middle" letter-spacing="1px">PII</text><text x="1174.0" y="220" text-anchor="end" fill="#6f6a61" font-size="10" letter-spacing="1.5px">0&#160;WRITTEN</text></g>
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&#160;MASKED</text></g>
17
17
  </svg>
@@ -1,5 +1,5 @@
1
1
  """proxy2model — turn captured proxy traffic into an evidence-backed web-app model."""
2
- __version__ = "2.0.0"
2
+ __version__ = "2.1.0"
3
3
 
4
4
  import os as _os
5
5
 
@@ -96,19 +96,31 @@ def cmd_crossrole(args) -> int:
96
96
  print(json.dumps(result, indent=2))
97
97
  return 0
98
98
 
99
- def show(title, items):
100
- print(f"{title} ({len(items)})")
101
- for s in items:
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"'{args.low}' reached privileged-looking endpoints", result["low_reached_privileged"])
109
- show(f"'{args.high}' reached, '{args.low}' never tried", result["high_only"])
110
- show("both roles got 2xx", result["shared_same_success"])
111
- print("Each is a hypothesis, not a finding.")
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": "What the lower-privileged role reached that only the higher one should.",
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
- def cross_role(m: Model, low: str, high: str) -> dict:
863
- """Compare what a low and a high role reached. Output is hypotheses.
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
- high_only endpoints the high role reached that the low role
866
- never requested — replay each as the low role.
867
- low_reached_privileged endpoints the low role reached whose path looks
868
- privileged — check the response was meant for it.
869
- shared_same_success both roles got a 2xx — compare response content.
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({**entry, "kind": "high_only",
891
- "unknown": f"whether '{low}' can reach it",
892
- "next": f"replay as '{low}' and compare with '{high}'"})
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({**entry, "kind": "low_reached_privileged",
895
- "unknown": "whether the low-role response was authorised or a leak",
896
- "next": f"compare '{low}' and '{high}' response bodies"})
897
- elif rl and rh and _ok(sbr.get(low)) and _ok(sbr.get(high)):
898
- shared.append({**entry, "kind": "shared_same_success",
899
- "unknown": "whether both roles see the same data",
900
- "next": "diff the two responses for role-specific fields"})
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.sort(key=key)
903
- low_priv.sort(key=key)
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