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.
- deluluscan/__init__.py +4 -0
- deluluscan/active/__init__.py +25 -0
- deluluscan/active/advanced.py +161 -0
- deluluscan/active/authz_probe.py +136 -0
- deluluscan/active/bypass.py +84 -0
- deluluscan/active/crawler.py +133 -0
- deluluscan/active/filter_bypass.py +165 -0
- deluluscan/active/http_tools.py +246 -0
- deluluscan/active/injection.py +187 -0
- deluluscan/active/jsrecon.py +70 -0
- deluluscan/active/jwt_lab.py +323 -0
- deluluscan/active/owasp_suite.py +421 -0
- deluluscan/active/recon.py +268 -0
- deluluscan/active/repair.py +142 -0
- deluluscan/active/session_rules.py +147 -0
- deluluscan/agent.py +332 -0
- deluluscan/agentic/__init__.py +20 -0
- deluluscan/agentic/agent.py +164 -0
- deluluscan/agentic/capabilities.py +98 -0
- deluluscan/agentic/chain.py +73 -0
- deluluscan/ai/__init__.py +2 -0
- deluluscan/ai/analyst.py +170 -0
- deluluscan/ai/providers.py +367 -0
- deluluscan/analogy.py +299 -0
- deluluscan/apispec/__init__.py +9 -0
- deluluscan/apispec/__main__.py +28 -0
- deluluscan/apispec/engine.py +23 -0
- deluluscan/apispec/linter.py +126 -0
- deluluscan/artifacts.py +129 -0
- deluluscan/assess/__init__.py +12 -0
- deluluscan/assess/__main__.py +54 -0
- deluluscan/assess/report.py +152 -0
- deluluscan/assess/runner.py +106 -0
- deluluscan/assets/dashboard_bundle.html +129 -0
- deluluscan/auth.py +135 -0
- deluluscan/cli.py +463 -0
- deluluscan/cloud/__init__.py +13 -0
- deluluscan/cloud/__main__.py +46 -0
- deluluscan/cloud/checks.py +173 -0
- deluluscan/cloud/engine.py +36 -0
- deluluscan/cloud/imds.py +83 -0
- deluluscan/compliance.py +319 -0
- deluluscan/config.py +368 -0
- deluluscan/container/__init__.py +15 -0
- deluluscan/container/__main__.py +59 -0
- deluluscan/container/analyzers.py +253 -0
- deluluscan/container/engine.py +128 -0
- deluluscan/correlate/__init__.py +10 -0
- deluluscan/correlate/__main__.py +32 -0
- deluluscan/correlate/chains.py +97 -0
- deluluscan/correlate/engine.py +77 -0
- deluluscan/cvss.py +236 -0
- deluluscan/dashboard.py +577 -0
- deluluscan/data/analogy_patterns.json +179 -0
- deluluscan/discovery.py +187 -0
- deluluscan/entitlements.py +264 -0
- deluluscan/fingerprint.py +278 -0
- deluluscan/freshness.py +259 -0
- deluluscan/fuzzer.py +263 -0
- deluluscan/headers/__init__.py +11 -0
- deluluscan/headers/__main__.py +46 -0
- deluluscan/headers/analyzer.py +162 -0
- deluluscan/headers/engine.py +38 -0
- deluluscan/http_client.py +308 -0
- deluluscan/integrations/__init__.py +4 -0
- deluluscan/integrations/interactsh.py +111 -0
- deluluscan/integrations/local_oast.py +88 -0
- deluluscan/integrations/nuclei.py +74 -0
- deluluscan/integrations/oob_server.py +114 -0
- deluluscan/integrations/sqlmap.py +89 -0
- deluluscan/integrity.py +237 -0
- deluluscan/kb/__init__.py +15 -0
- deluluscan/kb/__main__.py +62 -0
- deluluscan/kb/index.py +136 -0
- deluluscan/kb/mantis.py +118 -0
- deluluscan/kb/retriever.py +29 -0
- deluluscan/knowledge.py +351 -0
- deluluscan/lab/__init__.py +204 -0
- deluluscan/llm/__init__.py +18 -0
- deluluscan/llm/__main__.py +96 -0
- deluluscan/llm/engine.py +140 -0
- deluluscan/llm/probes.py +196 -0
- deluluscan/llm/target.py +174 -0
- deluluscan/memory.py +340 -0
- deluluscan/models.py +162 -0
- deluluscan/notify.py +284 -0
- deluluscan/orchestrator.py +1266 -0
- deluluscan/pivot.py +229 -0
- deluluscan/plugins.py +222 -0
- deluluscan/recheck.py +269 -0
- deluluscan/recon/__init__.py +12 -0
- deluluscan/recon/__main__.py +74 -0
- deluluscan/recon/engine.py +221 -0
- deluluscan/recon/signatures.py +139 -0
- deluluscan/reporting/__init__.py +5 -0
- deluluscan/reporting/coverage.py +97 -0
- deluluscan/reporting/evidence_report.py +419 -0
- deluluscan/reporting/exporters.py +367 -0
- deluluscan/reporting/report.py +289 -0
- deluluscan/reporting/sarif.py +60 -0
- deluluscan/safety.py +198 -0
- deluluscan/sast/__init__.py +12 -0
- deluluscan/sast/__main__.py +29 -0
- deluluscan/sast/engine.py +84 -0
- deluluscan/sast/rules.py +88 -0
- deluluscan/sca.py +285 -0
- deluluscan/scandiff.py +311 -0
- deluluscan/scanners/__init__.py +137 -0
- deluluscan/scanners/advanced_scanner.py +210 -0
- deluluscan/scanners/advisories.py +296 -0
- deluluscan/scanners/ai_llm_scanner.py +159 -0
- deluluscan/scanners/auth_enum_scanner.py +669 -0
- deluluscan/scanners/auth_flow_scanner.py +125 -0
- deluluscan/scanners/base.py +84 -0
- deluluscan/scanners/bodyfuzz.py +160 -0
- deluluscan/scanners/bodyinject.py +411 -0
- deluluscan/scanners/bopla.py +225 -0
- deluluscan/scanners/cache_scanner.py +119 -0
- deluluscan/scanners/conformance.py +395 -0
- deluluscan/scanners/csrf_scanner.py +418 -0
- deluluscan/scanners/deep_stored_xss.py +229 -0
- deluluscan/scanners/dependency_scanner.py +120 -0
- deluluscan/scanners/deser_scanner.py +135 -0
- deluluscan/scanners/es_exposure_scanner.py +163 -0
- deluluscan/scanners/graphql_cache.py +264 -0
- deluluscan/scanners/idor.py +253 -0
- deluluscan/scanners/idor_iter_scanner.py +118 -0
- deluluscan/scanners/idor_write.py +619 -0
- deluluscan/scanners/injection_scanner.py +317 -0
- deluluscan/scanners/jwt_scanner.py +191 -0
- deluluscan/scanners/known_cve_scanner.py +176 -0
- deluluscan/scanners/log_injection_scanner.py +104 -0
- deluluscan/scanners/logic_scanner.py +113 -0
- deluluscan/scanners/memory_disclosure_scanner.py +126 -0
- deluluscan/scanners/misc_scanner.py +119 -0
- deluluscan/scanners/oauth_scanner.py +101 -0
- deluluscan/scanners/owasp.py +366 -0
- deluluscan/scanners/owasp_suite_scanner.py +318 -0
- deluluscan/scanners/passive.py +135 -0
- deluluscan/scanners/privesc_scanner.py +382 -0
- deluluscan/scanners/regex_dos.py +229 -0
- deluluscan/scanners/resource_consumption_scanner.py +143 -0
- deluluscan/scanners/search_exposure.py +231 -0
- deluluscan/scanners/sqli.py +606 -0
- deluluscan/scanners/ssrf.py +81 -0
- deluluscan/scanners/stored_velocity.py +192 -0
- deluluscan/scanners/vanity_redirect.py +230 -0
- deluluscan/scanners/xss.py +436 -0
- deluluscan/secrets/__init__.py +10 -0
- deluluscan/secrets/__main__.py +41 -0
- deluluscan/secrets/engine.py +44 -0
- deluluscan/secrets/patterns.py +65 -0
- deluluscan/secrets/scanner.py +39 -0
- deluluscan/semantic_diff.py +44 -0
- deluluscan/sourcescan.py +908 -0
- deluluscan/telemetry/__init__.py +26 -0
- deluluscan/telemetry/correlator.py +316 -0
- deluluscan/telemetry/recorder.py +103 -0
- deluluscan/telemetry/signatures.py +173 -0
- deluluscan/telemetry/sources.py +198 -0
- deluluscan/templates.py +458 -0
- deluluscan/verify/__init__.py +24 -0
- deluluscan/verify/browser.py +77 -0
- deluluscan/verify/chains.py +249 -0
- deluluscan/verify/controls.py +206 -0
- deluluscan/verify/deep.py +304 -0
- deluluscan/verify/deep_chain.py +201 -0
- deluluscan/verify/differ.py +111 -0
- deluluscan/verify/evidence.py +297 -0
- deluluscan/verify/exploitability.py +197 -0
- deluluscan/verify/models.py +70 -0
- deluluscan/verify/readback.py +171 -0
- deluluscan/verify/validation.py +151 -0
- deluluscan/verify/verifier.py +1372 -0
- deluluscan/web/__init__.py +1 -0
- deluluscan/web/app.py +156 -0
- deluluscan/webapi/__init__.py +13 -0
- deluluscan/webapi/__main__.py +59 -0
- deluluscan/webapi/engine.py +24 -0
- deluluscan/webapi/graphql.py +112 -0
- deluluscan/webapi/grpc.py +30 -0
- deluluscan/webapi/websocket.py +52 -0
- deluluscan-0.3.1.dist-info/METADATA +163 -0
- deluluscan-0.3.1.dist-info/RECORD +188 -0
- deluluscan-0.3.1.dist-info/WHEEL +5 -0
- deluluscan-0.3.1.dist-info/entry_points.txt +2 -0
- deluluscan-0.3.1.dist-info/licenses/LICENSE +661 -0
- 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
|