deluluscan 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. deluluscan/__init__.py +4 -0
  2. deluluscan/active/__init__.py +25 -0
  3. deluluscan/active/advanced.py +161 -0
  4. deluluscan/active/authz_probe.py +136 -0
  5. deluluscan/active/bypass.py +84 -0
  6. deluluscan/active/crawler.py +133 -0
  7. deluluscan/active/filter_bypass.py +165 -0
  8. deluluscan/active/http_tools.py +246 -0
  9. deluluscan/active/injection.py +187 -0
  10. deluluscan/active/jsrecon.py +70 -0
  11. deluluscan/active/jwt_lab.py +323 -0
  12. deluluscan/active/owasp_suite.py +421 -0
  13. deluluscan/active/recon.py +268 -0
  14. deluluscan/active/repair.py +142 -0
  15. deluluscan/active/session_rules.py +147 -0
  16. deluluscan/agent.py +332 -0
  17. deluluscan/agentic/__init__.py +20 -0
  18. deluluscan/agentic/agent.py +164 -0
  19. deluluscan/agentic/capabilities.py +98 -0
  20. deluluscan/agentic/chain.py +73 -0
  21. deluluscan/ai/__init__.py +2 -0
  22. deluluscan/ai/analyst.py +170 -0
  23. deluluscan/ai/providers.py +367 -0
  24. deluluscan/analogy.py +299 -0
  25. deluluscan/apispec/__init__.py +9 -0
  26. deluluscan/apispec/__main__.py +28 -0
  27. deluluscan/apispec/engine.py +23 -0
  28. deluluscan/apispec/linter.py +126 -0
  29. deluluscan/artifacts.py +129 -0
  30. deluluscan/assess/__init__.py +12 -0
  31. deluluscan/assess/__main__.py +54 -0
  32. deluluscan/assess/report.py +152 -0
  33. deluluscan/assess/runner.py +106 -0
  34. deluluscan/assets/dashboard_bundle.html +129 -0
  35. deluluscan/auth.py +135 -0
  36. deluluscan/cli.py +463 -0
  37. deluluscan/cloud/__init__.py +13 -0
  38. deluluscan/cloud/__main__.py +46 -0
  39. deluluscan/cloud/checks.py +173 -0
  40. deluluscan/cloud/engine.py +36 -0
  41. deluluscan/cloud/imds.py +83 -0
  42. deluluscan/compliance.py +319 -0
  43. deluluscan/config.py +368 -0
  44. deluluscan/container/__init__.py +15 -0
  45. deluluscan/container/__main__.py +59 -0
  46. deluluscan/container/analyzers.py +253 -0
  47. deluluscan/container/engine.py +128 -0
  48. deluluscan/correlate/__init__.py +10 -0
  49. deluluscan/correlate/__main__.py +32 -0
  50. deluluscan/correlate/chains.py +97 -0
  51. deluluscan/correlate/engine.py +77 -0
  52. deluluscan/cvss.py +236 -0
  53. deluluscan/dashboard.py +577 -0
  54. deluluscan/data/analogy_patterns.json +179 -0
  55. deluluscan/discovery.py +187 -0
  56. deluluscan/entitlements.py +264 -0
  57. deluluscan/fingerprint.py +278 -0
  58. deluluscan/freshness.py +259 -0
  59. deluluscan/fuzzer.py +263 -0
  60. deluluscan/headers/__init__.py +11 -0
  61. deluluscan/headers/__main__.py +46 -0
  62. deluluscan/headers/analyzer.py +162 -0
  63. deluluscan/headers/engine.py +38 -0
  64. deluluscan/http_client.py +308 -0
  65. deluluscan/integrations/__init__.py +4 -0
  66. deluluscan/integrations/interactsh.py +111 -0
  67. deluluscan/integrations/local_oast.py +88 -0
  68. deluluscan/integrations/nuclei.py +74 -0
  69. deluluscan/integrations/oob_server.py +114 -0
  70. deluluscan/integrations/sqlmap.py +89 -0
  71. deluluscan/integrity.py +237 -0
  72. deluluscan/kb/__init__.py +15 -0
  73. deluluscan/kb/__main__.py +62 -0
  74. deluluscan/kb/index.py +136 -0
  75. deluluscan/kb/mantis.py +118 -0
  76. deluluscan/kb/retriever.py +29 -0
  77. deluluscan/knowledge.py +351 -0
  78. deluluscan/lab/__init__.py +204 -0
  79. deluluscan/llm/__init__.py +18 -0
  80. deluluscan/llm/__main__.py +96 -0
  81. deluluscan/llm/engine.py +140 -0
  82. deluluscan/llm/probes.py +196 -0
  83. deluluscan/llm/target.py +174 -0
  84. deluluscan/memory.py +340 -0
  85. deluluscan/models.py +162 -0
  86. deluluscan/notify.py +284 -0
  87. deluluscan/orchestrator.py +1266 -0
  88. deluluscan/pivot.py +229 -0
  89. deluluscan/plugins.py +222 -0
  90. deluluscan/recheck.py +269 -0
  91. deluluscan/recon/__init__.py +12 -0
  92. deluluscan/recon/__main__.py +74 -0
  93. deluluscan/recon/engine.py +221 -0
  94. deluluscan/recon/signatures.py +139 -0
  95. deluluscan/reporting/__init__.py +5 -0
  96. deluluscan/reporting/coverage.py +97 -0
  97. deluluscan/reporting/evidence_report.py +419 -0
  98. deluluscan/reporting/exporters.py +367 -0
  99. deluluscan/reporting/report.py +289 -0
  100. deluluscan/reporting/sarif.py +60 -0
  101. deluluscan/safety.py +198 -0
  102. deluluscan/sast/__init__.py +12 -0
  103. deluluscan/sast/__main__.py +29 -0
  104. deluluscan/sast/engine.py +84 -0
  105. deluluscan/sast/rules.py +88 -0
  106. deluluscan/sca.py +285 -0
  107. deluluscan/scandiff.py +311 -0
  108. deluluscan/scanners/__init__.py +137 -0
  109. deluluscan/scanners/advanced_scanner.py +210 -0
  110. deluluscan/scanners/advisories.py +296 -0
  111. deluluscan/scanners/ai_llm_scanner.py +159 -0
  112. deluluscan/scanners/auth_enum_scanner.py +669 -0
  113. deluluscan/scanners/auth_flow_scanner.py +125 -0
  114. deluluscan/scanners/base.py +84 -0
  115. deluluscan/scanners/bodyfuzz.py +160 -0
  116. deluluscan/scanners/bodyinject.py +411 -0
  117. deluluscan/scanners/bopla.py +225 -0
  118. deluluscan/scanners/cache_scanner.py +119 -0
  119. deluluscan/scanners/conformance.py +395 -0
  120. deluluscan/scanners/csrf_scanner.py +418 -0
  121. deluluscan/scanners/deep_stored_xss.py +229 -0
  122. deluluscan/scanners/dependency_scanner.py +120 -0
  123. deluluscan/scanners/deser_scanner.py +135 -0
  124. deluluscan/scanners/es_exposure_scanner.py +163 -0
  125. deluluscan/scanners/graphql_cache.py +264 -0
  126. deluluscan/scanners/idor.py +253 -0
  127. deluluscan/scanners/idor_iter_scanner.py +118 -0
  128. deluluscan/scanners/idor_write.py +619 -0
  129. deluluscan/scanners/injection_scanner.py +317 -0
  130. deluluscan/scanners/jwt_scanner.py +191 -0
  131. deluluscan/scanners/known_cve_scanner.py +176 -0
  132. deluluscan/scanners/log_injection_scanner.py +104 -0
  133. deluluscan/scanners/logic_scanner.py +113 -0
  134. deluluscan/scanners/memory_disclosure_scanner.py +126 -0
  135. deluluscan/scanners/misc_scanner.py +119 -0
  136. deluluscan/scanners/oauth_scanner.py +101 -0
  137. deluluscan/scanners/owasp.py +366 -0
  138. deluluscan/scanners/owasp_suite_scanner.py +318 -0
  139. deluluscan/scanners/passive.py +135 -0
  140. deluluscan/scanners/privesc_scanner.py +382 -0
  141. deluluscan/scanners/regex_dos.py +229 -0
  142. deluluscan/scanners/resource_consumption_scanner.py +143 -0
  143. deluluscan/scanners/search_exposure.py +231 -0
  144. deluluscan/scanners/sqli.py +606 -0
  145. deluluscan/scanners/ssrf.py +81 -0
  146. deluluscan/scanners/stored_velocity.py +192 -0
  147. deluluscan/scanners/vanity_redirect.py +230 -0
  148. deluluscan/scanners/xss.py +436 -0
  149. deluluscan/secrets/__init__.py +10 -0
  150. deluluscan/secrets/__main__.py +41 -0
  151. deluluscan/secrets/engine.py +44 -0
  152. deluluscan/secrets/patterns.py +65 -0
  153. deluluscan/secrets/scanner.py +39 -0
  154. deluluscan/semantic_diff.py +44 -0
  155. deluluscan/sourcescan.py +908 -0
  156. deluluscan/telemetry/__init__.py +26 -0
  157. deluluscan/telemetry/correlator.py +316 -0
  158. deluluscan/telemetry/recorder.py +103 -0
  159. deluluscan/telemetry/signatures.py +173 -0
  160. deluluscan/telemetry/sources.py +198 -0
  161. deluluscan/templates.py +458 -0
  162. deluluscan/verify/__init__.py +24 -0
  163. deluluscan/verify/browser.py +77 -0
  164. deluluscan/verify/chains.py +249 -0
  165. deluluscan/verify/controls.py +206 -0
  166. deluluscan/verify/deep.py +304 -0
  167. deluluscan/verify/deep_chain.py +201 -0
  168. deluluscan/verify/differ.py +111 -0
  169. deluluscan/verify/evidence.py +297 -0
  170. deluluscan/verify/exploitability.py +197 -0
  171. deluluscan/verify/models.py +70 -0
  172. deluluscan/verify/readback.py +171 -0
  173. deluluscan/verify/validation.py +151 -0
  174. deluluscan/verify/verifier.py +1372 -0
  175. deluluscan/web/__init__.py +1 -0
  176. deluluscan/web/app.py +156 -0
  177. deluluscan/webapi/__init__.py +13 -0
  178. deluluscan/webapi/__main__.py +59 -0
  179. deluluscan/webapi/engine.py +24 -0
  180. deluluscan/webapi/graphql.py +112 -0
  181. deluluscan/webapi/grpc.py +30 -0
  182. deluluscan/webapi/websocket.py +52 -0
  183. deluluscan-0.3.1.dist-info/METADATA +163 -0
  184. deluluscan-0.3.1.dist-info/RECORD +188 -0
  185. deluluscan-0.3.1.dist-info/WHEEL +5 -0
  186. deluluscan-0.3.1.dist-info/entry_points.txt +2 -0
  187. deluluscan-0.3.1.dist-info/licenses/LICENSE +661 -0
  188. deluluscan-0.3.1.dist-info/top_level.txt +1 -0
deluluscan/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """deluluscan — an AI-augmented, evidence-first security auditor for authorized testing of web,
2
+ API, application, container, cloud and LLM/AI-system targets. Finds and verifies
3
+ OWASP Top 10 issues across multiple identities, confirming to proof before asserting."""
4
+ __version__ = "0.3.1"
@@ -0,0 +1,25 @@
1
+ """Active, AI-assisted testing — the Burp/Postman-style workbench.
2
+
3
+ Unlike the passive scanners, these modules mutate requests (tokens, parameters,
4
+ object ids, bodies) and replay them to confirm whether the server accepts the
5
+ manipulation. Authorized-target only; confirms vulnerabilities by exercising
6
+ them without weaponizing into third-party attacks or bulk data exfiltration.
7
+ """
8
+ from .http_tools import (RequestSpec, Repeater, Intruder, Position,
9
+ IntruderResult, Collection, parse_markers, set_at)
10
+ from .jwt_lab import JwtLab, decode as jwt_decode, JwtTestResult
11
+ from .authz_probe import AuthzProbe, AuthzResult
12
+ from .recon import (ParamMiner, ContentDiscovery, VersionEnumerator,
13
+ SupplyChainProbe)
14
+ from .advanced import VerbTamper, RaceProbe, GraphQLAdvanced
15
+ from .session_rules import MatchReplaceRule, Macro, Extraction, SessionEngine
16
+
17
+ __all__ = [
18
+ "RequestSpec", "Repeater", "Intruder", "Position", "IntruderResult",
19
+ "Collection", "parse_markers", "set_at",
20
+ "JwtLab", "jwt_decode", "JwtTestResult",
21
+ "AuthzProbe", "AuthzResult",
22
+ "ParamMiner", "ContentDiscovery", "VersionEnumerator", "SupplyChainProbe",
23
+ "VerbTamper", "RaceProbe", "GraphQLAdvanced",
24
+ "MatchReplaceRule", "Macro", "Extraction", "SessionEngine",
25
+ ]
@@ -0,0 +1,161 @@
1
+ """Advanced analyzers (v0.6): HTTP verb/method tampering, bounded race-condition
2
+ testing, and deeper GraphQL abuse (batching, alias amplification, depth limits).
3
+
4
+ All authorized-target only. The race prober is intentionally bounded (a small
5
+ number of parallel requests to reveal a TOCTOU window) and is gated behind
6
+ allow_state_changing because it may cause an action to execute more than once on
7
+ your own test instance — it confirms the flaw by exercising it, nothing more.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import concurrent.futures
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from typing import Callable, Optional
15
+
16
+ from ..verify import evidence as E
17
+
18
+
19
+ # ===========================================================================
20
+ # HTTP verb / method tampering — function-level authz bypass (API5 / A01)
21
+ # ===========================================================================
22
+ _ALT_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
23
+ _OVERRIDE_HEADERS = ["X-HTTP-Method-Override", "X-HTTP-Method", "X-Method-Override"]
24
+
25
+
26
+ @dataclass
27
+ class VerbFinding:
28
+ technique: str # "alt_method" | "method_override"
29
+ method: str
30
+ detail: str
31
+ status: int
32
+
33
+
34
+ class VerbTamper:
35
+ """Given a canonical method that is DENIED, try alternate methods and
36
+ method-override headers; flag any that are granted (auth enforced on the
37
+ verb, not the resource)."""
38
+
39
+ def __init__(self, send: Callable):
40
+ # send(method, extra_headers) -> record
41
+ self.send = send
42
+
43
+ @staticmethod
44
+ def _granted(rec) -> bool:
45
+ # real content returned (not empty/denied/permission-message)
46
+ return E.classify_response(rec) == E.DISPOSITION_CONTENT
47
+
48
+ def test(self, canonical_method: str) -> list[VerbFinding]:
49
+ out: list[VerbFinding] = []
50
+ base = self.send(canonical_method, None)
51
+ if self._granted(base):
52
+ return out # canonical already works; nothing to bypass
53
+ for m in _ALT_METHODS:
54
+ if m == canonical_method.upper():
55
+ continue
56
+ rec = self.send(m, None)
57
+ if self._granted(rec):
58
+ out.append(VerbFinding("alt_method", m,
59
+ f"'{m}' reached the resource that '{canonical_method}' denied "
60
+ f"— access control is enforced on the verb, not the object", rec.status))
61
+ break
62
+ for h in _OVERRIDE_HEADERS:
63
+ rec = self.send("POST", {h: canonical_method})
64
+ if self._granted(rec):
65
+ out.append(VerbFinding("method_override", canonical_method,
66
+ f"method-override header '{h}: {canonical_method}' bypassed the "
67
+ f"method restriction", rec.status))
68
+ break
69
+ return out
70
+
71
+
72
+ # ===========================================================================
73
+ # Race conditions — business-logic TOCTOU (API6). Bounded & gated.
74
+ # ===========================================================================
75
+ @dataclass
76
+ class RaceFinding:
77
+ parallel: int
78
+ successes: int
79
+ detail: str
80
+
81
+
82
+ class RaceProbe:
83
+ HARD_CAP = 20
84
+
85
+ def test(self, send_once: Callable, *, parallel: int = 8,
86
+ expected_successes: int = 1, success_pred: Optional[Callable] = None
87
+ ) -> Optional[RaceFinding]:
88
+ parallel = min(parallel, self.HARD_CAP)
89
+ pred = success_pred or (lambda r: r is not None and getattr(r, "status", 0) in (200, 201))
90
+ with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as ex:
91
+ recs = list(ex.map(lambda _: send_once(), range(parallel)))
92
+ successes = sum(1 for r in recs if pred(r))
93
+ if successes > expected_successes:
94
+ return RaceFinding(parallel, successes,
95
+ f"{successes}/{parallel} parallel requests succeeded where only "
96
+ f"{expected_successes} should — a race/TOCTOU window lets the action "
97
+ f"be performed multiple times (e.g. limit/coupon/balance overrun)")
98
+ return None
99
+
100
+
101
+ # ===========================================================================
102
+ # GraphQL deep abuse — batching, alias amplification, missing depth limit
103
+ # ===========================================================================
104
+ @dataclass
105
+ class GraphQLAdvFinding:
106
+ kind: str # "batching" | "alias_amplification" | "no_depth_limit"
107
+ detail: str
108
+ status: int
109
+
110
+
111
+ def _nested_query(depth: int) -> str:
112
+ # a bounded self-referential introspection-ish nesting to test depth limits
113
+ inner = "name"
114
+ for _ in range(depth):
115
+ inner = f"fields{{type{{{inner}}}}}"
116
+ return json.dumps({"query": "query{__type(name:\"Query\"){" + inner + "}}"})
117
+
118
+
119
+ def _batch_query(n: int) -> str:
120
+ return json.dumps([{"query": f"query a{i}{{__typename}}"} for i in range(n)])
121
+
122
+
123
+ def _alias_query(n: int) -> str:
124
+ aliases = " ".join(f"a{i}:__typename" for i in range(n))
125
+ return json.dumps({"query": "query{" + aliases + "}"})
126
+
127
+
128
+ class GraphQLAdvanced:
129
+ DEPTH_CAP = 8
130
+ BATCH_N = 10
131
+ ALIAS_N = 25
132
+
133
+ def test(self, send_body: Callable) -> list[GraphQLAdvFinding]:
134
+ # send_body(raw_json_string) -> record
135
+ out: list[GraphQLAdvFinding] = []
136
+
137
+ # batching: an array batch that all resolve => rate-limit bypass surface
138
+ rec = send_body(_batch_query(self.BATCH_N))
139
+ if rec is not None and rec.status == 200 and (rec.resp_body or "").count("__typename") \
140
+ + (rec.resp_body or "").count("Query") >= 2 and (rec.resp_body or "").strip().startswith("["):
141
+ out.append(GraphQLAdvFinding("batching",
142
+ f"the endpoint executed a batch of {self.BATCH_N} queries in one "
143
+ f"request — batching can bypass per-request rate limits (credential "
144
+ f"stuffing, enumeration)", rec.status))
145
+
146
+ # alias amplification: many aliases resolved in one query
147
+ rec = send_body(_alias_query(self.ALIAS_N))
148
+ if rec is not None and rec.status == 200 and (rec.resp_body or "").count("a0") >= 1 \
149
+ and (rec.resp_body or "").count(":") >= self.ALIAS_N // 2:
150
+ out.append(GraphQLAdvFinding("alias_amplification",
151
+ f"{self.ALIAS_N} aliased fields resolved in a single query — alias "
152
+ f"amplification enables resource abuse and rate-limit bypass", rec.status))
153
+
154
+ # depth limit: a deeply nested (bounded) query that is accepted
155
+ rec = send_body(_nested_query(self.DEPTH_CAP))
156
+ if rec is not None and rec.status == 200 and "error" not in (rec.resp_body or "").lower():
157
+ out.append(GraphQLAdvFinding("no_depth_limit",
158
+ f"a query nested to depth {self.DEPTH_CAP} was accepted without a "
159
+ f"complexity/depth error — nested-query DoS risk; enforce a depth "
160
+ f"and cost limit", rec.status))
161
+ return out
@@ -0,0 +1,136 @@
1
+ """Active authorization & parameter-tampering probe.
2
+
3
+ Where the passive scanners *observe*, this module *acts*: it takes a request that
4
+ works for one identity and replays it with the credentials/parameters changed,
5
+ then checks whether the server still grants access. That is how you confirm
6
+ broken access control by exercising it (BOLA/IDOR, BFLA/privilege escalation,
7
+ missing auth, mass assignment) — the same moves you'd make by hand in Burp
8
+ Repeater, automated.
9
+
10
+ Authorized-target only (the HttpClient safety gate still applies). It proves the
11
+ issue by making the manipulated request succeed; it does not then use that
12
+ access to harvest data at scale.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Optional
18
+
19
+ from .http_tools import Repeater, RequestSpec
20
+ from ..semantic_diff import structural_similarity
21
+ from ..verify import evidence as E
22
+
23
+ # Fields an attacker commonly injects to grant themselves privileges the API
24
+ # forgot to mask on write (mass assignment / BOPLA). These are *test* markers.
25
+ _MASS_ASSIGN_FIELDS = {
26
+ "admin": True, "isAdmin": True, "is_admin": True, "roleId": "1",
27
+ "role": "admin", "roles": ["admin"], "active": True, "approved": True,
28
+ "emailVerified": True, "permissions": ["ADMIN"],
29
+ }
30
+
31
+
32
+ @dataclass
33
+ class AuthzResult:
34
+ test: str
35
+ granted: bool
36
+ detail: str
37
+ status: Optional[int] = None
38
+ similarity: Optional[float] = None
39
+ changes: dict = field(default_factory=dict)
40
+
41
+
42
+ def _looks_denied(rec) -> bool:
43
+ # content-aware: empty result sets and permission-messages served as 200 are
44
+ # denials, not "content served" (NIST 800-115 / OWASP WSTG 4.5).
45
+ return E.classify_response(rec) != E.DISPOSITION_CONTENT
46
+
47
+
48
+ def _login_like(body: str) -> bool:
49
+ low = (body or "").lower()
50
+ return ("login" in low and "password" in low) or "j_security_check" in low
51
+
52
+
53
+ class AuthzProbe:
54
+ def __init__(self, client):
55
+ self.repeater = Repeater(client)
56
+
57
+ # -- missing authentication --------------------------------------------
58
+ def test_missing_auth(self, spec: RequestSpec, good) -> AuthzResult:
59
+ # skip endpoints that are public by design (login/config/published)
60
+ if E.is_public_by_design(spec.path):
61
+ return AuthzResult("missing_auth", False,
62
+ "endpoint is public by design; anonymous access "
63
+ "is intended", None)
64
+ stripped = spec.with_header("Authorization", None)
65
+ stripped = stripped.with_header("Cookie", None)
66
+ rec = self.repeater.send(stripped, identity_label="anonymous")
67
+ # OWASP WSTG 4.5 oracle: the anonymous response must contain the SAME
68
+ # protected data the authorized user gets — not merely a 200.
69
+ res = E.served_protected_content(rec, good)
70
+ return AuthzResult("missing_auth", res.served,
71
+ ("protected resource served to anonymous: " + res.reason)
72
+ if res.served else ("not a bypass: " + res.reason),
73
+ getattr(rec, "status", None), res.similarity)
74
+
75
+ # -- horizontal / vertical privilege via identity swap -----------------
76
+ def test_identity_swap(self, spec: RequestSpec, good, other_label: str,
77
+ other_headers: dict) -> AuthzResult:
78
+ swapped = spec.with_header("Authorization", None).with_header("Cookie", None)
79
+ for k, v in (other_headers or {}).items():
80
+ swapped = swapped.with_header(k, v)
81
+ rec = self.repeater.send(swapped, identity_label=other_label)
82
+ res = E.served_protected_content(rec, good)
83
+ return AuthzResult("identity_swap", res.served,
84
+ (f"'{other_label}' reached the same protected resource: {res.reason}"
85
+ if res.served else f"'{other_label}' did not: {res.reason}"),
86
+ getattr(rec, "status", None), res.similarity)
87
+
88
+ # -- BOLA via object-id swap -------------------------------------------
89
+ def test_bola_id(self, spec: RequestSpec, id_token: str, other_id: str,
90
+ identity_label: str, headers: dict) -> AuthzResult:
91
+ """Replay the request as the SAME (lower-priv) identity but pointed at a
92
+ different principal's object id. If a bogus id 404s and a real other id
93
+ 200s with an object, that's BOLA."""
94
+ base = spec
95
+ for k, v in (headers or {}).items():
96
+ base = base.with_header(k, v)
97
+ # control: a bogus id should NOT return an object
98
+ bogus = "00000000-0000-0000-0000-0000000000ff" if "-" in id_token else "999999999"
99
+ ctrl_spec = base.clone(); ctrl_spec.path = base.path.replace(id_token, bogus)
100
+ ctrl = self.repeater.send(ctrl_spec, identity_label=identity_label)
101
+ tgt_spec = base.clone(); tgt_spec.path = base.path.replace(id_token, other_id)
102
+ target = self.repeater.send(tgt_spec, identity_label=identity_label)
103
+ granted = (not _looks_denied(target)) and _looks_denied(ctrl)
104
+ return AuthzResult("bola_id_swap", granted,
105
+ (f"object id {other_id} returned another principal's "
106
+ f"object while a bogus id did not" if granted
107
+ else "id swap did not yield object-scoped access"),
108
+ getattr(target, "status", None),
109
+ changes={"id": other_id})
110
+
111
+ # -- mass assignment ---------------------------------------------------
112
+ def test_mass_assignment(self, spec: RequestSpec, identity_label: str,
113
+ headers: dict) -> list[AuthzResult]:
114
+ if spec.method.upper() not in ("POST", "PUT", "PATCH"):
115
+ return []
116
+ base = spec
117
+ for k, v in (headers or {}).items():
118
+ base = base.with_header(k, v)
119
+ baseline = self.repeater.send(base, identity_label=identity_label)
120
+ out: list[AuthzResult] = []
121
+ for field_name, value in _MASS_ASSIGN_FIELDS.items():
122
+ spec2 = base.with_json_field(field_name, value)
123
+ rec = self.repeater.send(spec2, identity_label=identity_label)
124
+ # signal: the elevated field is echoed back set, or status improved
125
+ echoed = f'"{field_name}"' in (rec.resp_body or "") and (
126
+ str(value).lower() in (rec.resp_body or "").lower())
127
+ if echoed and rec.status < 400:
128
+ out.append(AuthzResult(
129
+ "mass_assignment", True,
130
+ f"write accepted and reflected elevated field "
131
+ f"{field_name}={value}", rec.status, changes={field_name: value}))
132
+ if not out:
133
+ out.append(AuthzResult("mass_assignment", False,
134
+ "no elevated field was accepted/reflected",
135
+ getattr(baseline, "status", None)))
136
+ return out
@@ -0,0 +1,84 @@
1
+ """Access-control bypass technique primitives.
2
+
3
+ Grounded in the target CVE-2022-45782 (a semicolon/matrix parameter placed before a
4
+ '/' bypasses the target's path-based access-control filters) and the broader
5
+ 403/401-bypass playbook expert hunters use (path normalization tricks, override
6
+ headers, method changes). These are pure functions so they can be unit-tested and
7
+ reused; the scanner applies them only where a baseline request was actually
8
+ denied, and confirms a bypass with the content oracle.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass
16
+ class BypassVariant:
17
+ label: str
18
+ method: str
19
+ path: str
20
+ headers: dict = field(default_factory=dict)
21
+
22
+
23
+ def path_variants(path: str) -> list[BypassVariant]:
24
+ """Path-mangling tricks, incl. the target matrix-parameter (semicolon) bypass."""
25
+ p = path.split("?")[0]
26
+ q = ("?" + path.split("?", 1)[1]) if "?" in path else ""
27
+ segs = [s for s in p.split("/") if s != ""]
28
+ out: list[BypassVariant] = []
29
+
30
+ def v(label, newp):
31
+ out.append(BypassVariant(label, "GET", newp + q))
32
+
33
+ # CVE-2022-45782: matrix parameter (';') before a path separator evades the
34
+ # path-based "require login" filter while still resolving to the resource.
35
+ if segs:
36
+ v("matrix-semicolon-mid", "/" + "/".join(segs[:-1]) + "/;x=1/" + segs[-1])
37
+ v("matrix-semicolon-suffix", p + ";x=1")
38
+ v("matrix-jsessionid", p + ";jsessionid=1")
39
+ v("matrix-dot-semicolon", "/" + "/".join(segs[:-1]) + "/.;/" + segs[-1])
40
+ v("dotdot-semicolon", "/" + "/".join(segs) + "/..;/")
41
+ # generic 403/401 path-normalization bypasses
42
+ v("trailing-slash", p + "/")
43
+ v("trailing-slash-dot", p + "/.")
44
+ v("double-slash", "/" + "//".join(segs))
45
+ v("slash-dot-slash", "/./" + "/".join(segs))
46
+ v("encoded-slash", p.replace("/", "/%2e/", 1) if "/" in p else p)
47
+ v("trailing-encoded", p + "%20")
48
+ v("trailing-hash", p + "%23")
49
+ if segs:
50
+ v("uppercase-seg", "/" + "/".join(segs[:-1] + [segs[-1].upper()]))
51
+ return out
52
+
53
+
54
+ def header_variants(path: str) -> list[BypassVariant]:
55
+ """Override / trust-signal headers that some stacks honor to bypass authz."""
56
+ ov = [
57
+ ("x-original-url", {"X-Original-URL": path}),
58
+ ("x-rewrite-url", {"X-Rewrite-URL": path}),
59
+ ("x-override-url", {"X-Override-URL": path}),
60
+ ("xff-localhost", {"X-Forwarded-For": "127.0.0.1"}),
61
+ ("x-custom-ip-auth", {"X-Custom-IP-Authorization": "127.0.0.1"}),
62
+ ("x-forwarded-host", {"X-Forwarded-Host": "127.0.0.1"}),
63
+ ("x-real-ip", {"X-Real-IP": "127.0.0.1"}),
64
+ ("referer-self", {"Referer": path}),
65
+ ("method-override", {"X-HTTP-Method-Override": "GET"}),
66
+ ]
67
+ return [BypassVariant(l, "GET", path, h) for l, h in ov]
68
+
69
+
70
+ def method_variants(path: str) -> list[BypassVariant]:
71
+ return [BypassVariant(f"method-{m}", m, path, {}) for m in ("POST", "HEAD", "OPTIONS", "PUT")]
72
+
73
+
74
+ def all_variants(path: str) -> list[BypassVariant]:
75
+ return path_variants(path) + header_variants(path) + method_variants(path)
76
+
77
+
78
+ # ---- HTTP parameter pollution ---------------------------------------------
79
+ def hpp_variants(param: str, value: str) -> list[str]:
80
+ """Duplicate/split a parameter to probe parser discrepancies / WAF bypass."""
81
+ return [f"{param}={value}&{param}={value}", # duplicate
82
+ f"{param}[]={value}&{param}[]={value}", # array
83
+ f"{param}={value}%00", # null truncation
84
+ f"{param}={value}%0a{param}={value}"] # newline split
@@ -0,0 +1,133 @@
1
+ """SPA / JS-aware crawler.
2
+
3
+ admin is an Angular SPA — most of its real API surface is only reachable by
4
+ watching what the app calls at runtime or by mining the JS bundles. Top hunters
5
+ (ZSeano's "make use of .js files" methodology) treat JS as the map of hidden
6
+ endpoints, parameters, and leaked secrets.
7
+
8
+ Two modes:
9
+ * render (Playwright): load the app, capture every XHR/fetch the SPA makes, and
10
+ read the rendered DOM — the ground truth of what the front end talks to.
11
+ * static (always available): fetch HTML, follow <script src>, and mine the
12
+ bundles for API paths, SSRF-prone endpoints, and leaked secrets.
13
+
14
+ Same-origin, authorized target only; bounded. No secret is exfiltrated — leaked
15
+ material is reported as a finding for the operator.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from typing import Callable, Optional
22
+
23
+ from .jsrecon import extract_from_js, script_srcs
24
+
25
+ # high-signal secret patterns (ZSeano/JS-recon); reported, never used
26
+ _SECRET_PATTERNS = {
27
+ "aws_access_key": re.compile(r"AKIA[0-9A-Z]{16}"),
28
+ "google_api_key": re.compile(r"AIza[0-9A-Za-z_\-]{35}"),
29
+ "slack_token": re.compile(r"xox[baprs]-[0-9A-Za-z-]{10,}"),
30
+ "jwt": re.compile(r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{5,}"),
31
+ "private_key": re.compile(r"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----"),
32
+ "generic_secret": re.compile(r"(?i)(?:api[_-]?key|secret|passwd|password|token)"
33
+ r"['\"]?\s*[:=]\s*['\"]([A-Za-z0-9_\-]{12,})['\"]"),
34
+ "bearer": re.compile(r"(?i)bearer\s+[A-Za-z0-9_\-\.=]{20,}"),
35
+ }
36
+ _FALSE_SECRET = re.compile(r"(?i)example|placeholder|xxxx|your[_-]?(?:api|key|token)|dummy|test")
37
+
38
+
39
+ @dataclass
40
+ class CrawlResult:
41
+ paths: set[str] = field(default_factory=set)
42
+ ssrf_candidates: set[str] = field(default_factory=set)
43
+ secrets: list[tuple[str, str]] = field(default_factory=list)
44
+ scripts_scanned: int = 0
45
+ mode: str = "static"
46
+
47
+
48
+ def mine_secrets(text: str) -> list[tuple[str, str]]:
49
+ out = []
50
+ for kind, rx in _SECRET_PATTERNS.items():
51
+ for m in rx.finditer(text or ""):
52
+ val = m.group(0)
53
+ if _FALSE_SECRET.search(val):
54
+ continue
55
+ out.append((kind, val[:60]))
56
+ # de-dup
57
+ seen, uniq = set(), []
58
+ for k, v in out:
59
+ if (k, v) not in seen:
60
+ seen.add((k, v)); uniq.append((k, v))
61
+ return uniq[:25]
62
+
63
+
64
+ class SpaCrawler:
65
+ def __init__(self, fetch_text: Callable[[str], str], max_scripts: int = 25):
66
+ # fetch_text(path)-> body text (goes through the safety-gated client)
67
+ self.fetch_text = fetch_text
68
+ self.max_scripts = max_scripts
69
+
70
+ def static_crawl(self, roots=("/", "/admin/", "/html/")) -> CrawlResult:
71
+ out = CrawlResult(mode="static")
72
+ for root in roots:
73
+ html = self.fetch_text(root) or ""
74
+ p, s = extract_from_js(html)
75
+ out.paths |= p; out.ssrf_candidates |= s
76
+ out.secrets += mine_secrets(html)
77
+ for src in script_srcs(html)[: self.max_scripts]:
78
+ if not src.startswith(("/", "http")):
79
+ continue
80
+ body = self.fetch_text(src) or ""
81
+ out.scripts_scanned += 1
82
+ pp, ss = extract_from_js(body)
83
+ out.paths |= pp; out.ssrf_candidates |= ss
84
+ out.secrets += mine_secrets(body)
85
+ # de-dup secrets across all sources
86
+ seen, uniq = set(), []
87
+ for k, v in out.secrets:
88
+ if (k, v) not in seen:
89
+ seen.add((k, v)); uniq.append((k, v))
90
+ out.secrets = uniq
91
+ return out
92
+
93
+
94
+ def render_crawl(base_url: str, extra_headers: Optional[dict] = None,
95
+ timeout_s: float = 15.0) -> CrawlResult:
96
+ """Playwright network-capture crawl: record every request the SPA issues.
97
+ No-ops (returns empty static result) if Playwright/browser isn't available."""
98
+ out = CrawlResult(mode="render")
99
+ try:
100
+ from playwright.sync_api import sync_playwright
101
+ except Exception:
102
+ return out
103
+ from urllib.parse import urlparse
104
+ host = urlparse(base_url).hostname
105
+ try:
106
+ with sync_playwright() as p:
107
+ browser = p.chromium.launch(headless=True)
108
+ ctx = browser.new_context(ignore_https_errors=True,
109
+ extra_http_headers=extra_headers or {})
110
+ page = ctx.new_page()
111
+ seen_urls: set[str] = set()
112
+
113
+ def on_request(req):
114
+ try:
115
+ u = urlparse(req.url)
116
+ if u.hostname == host and ("/api/" in u.path or "/dwr" in u.path
117
+ or u.path.startswith("/html/")):
118
+ seen_urls.add(u.path)
119
+ except Exception:
120
+ pass
121
+ page.on("request", on_request)
122
+ for start in (base_url, base_url.rstrip("/") + "/admin/"):
123
+ try:
124
+ page.goto(start, timeout=int(timeout_s * 1000), wait_until="networkidle")
125
+ page.wait_for_timeout(800)
126
+ except Exception:
127
+ continue
128
+ out.secrets += mine_secrets(page.content() or "")
129
+ out.paths |= seen_urls
130
+ browser.close()
131
+ except Exception:
132
+ return out
133
+ return out