blackops-sql 0.1.6__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.
- blackops_sql-0.1.6.dist-info/METADATA +250 -0
- blackops_sql-0.1.6.dist-info/RECORD +29 -0
- blackops_sql-0.1.6.dist-info/WHEEL +4 -0
- blackops_sql-0.1.6.dist-info/entry_points.txt +2 -0
- blackops_sql-0.1.6.dist-info/licenses/LICENSE +661 -0
- blackops_sql-0.1.6.dist-info/licenses/NOTICE +27 -0
- blackopssql/__init__.py +111 -0
- blackopssql/__main__.py +287 -0
- blackopssql/_cli/__init__.py +0 -0
- blackopssql/_cli/args.py +229 -0
- blackopssql/_cli/summary.py +216 -0
- blackopssql/engine/__init__.py +35 -0
- blackopssql/engine/_scanner/__init__.py +0 -0
- blackopssql/engine/_scanner/active/__init__.py +526 -0
- blackopssql/engine/_scanner/active/_helpers.py +301 -0
- blackopssql/engine/_scanner/blind.py +315 -0
- blackopssql/engine/_scanner/extract.py +302 -0
- blackopssql/engine/_scanner/options.py +96 -0
- blackopssql/engine/_scanner/passive.py +86 -0
- blackopssql/engine/_scanner/payloads/__init__.py +80 -0
- blackopssql/engine/_scanner/pipeline.py +547 -0
- blackopssql/engine/_scanner/stacked.py +131 -0
- blackopssql/engine/crawler.py +7 -0
- blackopssql/engine/http/__init__.py +0 -0
- blackopssql/engine/http/injector.py +10 -0
- blackopssql/engine/http/waf_detect.py +51 -0
- blackopssql/engine/log.py +7 -0
- blackopssql/engine/reporter.py +208 -0
- blackopssql/engine/scanner.py +95 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
BlackOpsSQL
|
|
2
|
+
===========
|
|
3
|
+
|
|
4
|
+
BlackOpsSQL is a modified fork of BreachSQL by CommonHuman-Lab.
|
|
5
|
+
Original project: https://github.com/CommonHuman-Lab/breachsql
|
|
6
|
+
License: AGPL-3.0-or-later
|
|
7
|
+
|
|
8
|
+
BlackOpsSQL is an AGPL-3.0-or-later modified fork of BreachSQL by CommonHuman-Lab.
|
|
9
|
+
|
|
10
|
+
Original copyright (c) 2026 CommonHuman-Lab
|
|
11
|
+
Modifications copyright (c) 2026 roc1t1z3not
|
|
12
|
+
|
|
13
|
+
This program is free software: you can redistribute it and/or modify
|
|
14
|
+
it under the terms of the GNU Affero General Public License as published
|
|
15
|
+
by the Free Software Foundation, either version 3 of the License, or
|
|
16
|
+
(at your option) any later version.
|
|
17
|
+
|
|
18
|
+
Upstream dependency notices
|
|
19
|
+
----------------------------
|
|
20
|
+
BlackOpsSQL depends on the following upstream libraries from CommonHuman-Lab,
|
|
21
|
+
used as external PyPI packages (not bundled source):
|
|
22
|
+
|
|
23
|
+
- commonhuman-core — HTTP client, crawler, OpenAPI loader, auth helpers
|
|
24
|
+
- commonhuman-payloads — SQL injection payload library and WAF signatures
|
|
25
|
+
- commonhuman-cli — CLI framework, logging, colour helpers, entry point utils
|
|
26
|
+
|
|
27
|
+
These libraries remain unmodified and are governed by their own licenses.
|
blackopssql/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab (original BreachSQL)
|
|
3
|
+
# Modifications copyright (c) 2026 roc1t1z3not (BlackOpsSQL)
|
|
4
|
+
"""
|
|
5
|
+
BlackOpsSQL — API-aware SQL injection reconnaissance and validation engine.
|
|
6
|
+
|
|
7
|
+
BlackOpsSQL is an AGPL-3.0-or-later modified fork of BreachSQL by CommonHuman-Lab.
|
|
8
|
+
|
|
9
|
+
Quick start:
|
|
10
|
+
|
|
11
|
+
from blackopssql import scan, ScanOptions
|
|
12
|
+
|
|
13
|
+
result = scan("https://target.com/search?q=test")
|
|
14
|
+
print(result.total_findings)
|
|
15
|
+
|
|
16
|
+
opts = ScanOptions(level=2, crawl=True, dbms="mysql")
|
|
17
|
+
result = scan("https://target.com/search?q=test", opts)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from blackopssql.engine import scan, ScanOptions
|
|
21
|
+
from blackopssql.engine.reporter import (
|
|
22
|
+
ScanResult,
|
|
23
|
+
ErrorBasedFinding,
|
|
24
|
+
BooleanFinding,
|
|
25
|
+
TimeFinding,
|
|
26
|
+
UnionFinding,
|
|
27
|
+
OOBFinding,
|
|
28
|
+
FindingType,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.6"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _make_banner() -> str:
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
_tty = sys.stdout.isatty()
|
|
39
|
+
|
|
40
|
+
def _r(t: str) -> str:
|
|
41
|
+
return f"\033[31;1m{t}\033[0m" if _tty else t
|
|
42
|
+
|
|
43
|
+
def _g(t: str) -> str:
|
|
44
|
+
return f"\033[38;5;46m{t}\033[0m" if _tty else t
|
|
45
|
+
|
|
46
|
+
def _d(t: str) -> str:
|
|
47
|
+
return f"\033[2m{t}\033[0m" if _tty else t
|
|
48
|
+
|
|
49
|
+
def _vlen(s: str) -> int:
|
|
50
|
+
return len(re.sub(r"\033\[[0-9;]*m", "", s))
|
|
51
|
+
|
|
52
|
+
# slant font: "BlackOps" (green) + "SQL" (red) merged side-by-side
|
|
53
|
+
_BO_W = 44
|
|
54
|
+
_BO = (
|
|
55
|
+
" ____ __ __ ____ ",
|
|
56
|
+
" / __ )/ /___ ______/ /__/ __ \\____ _____",
|
|
57
|
+
" / __ / / __ `/ ___/ //_/ / / / __ \\/ ___/",
|
|
58
|
+
" / /_/ / / /_/ / /__/ ,< / /_/ / /_/ (__ ) ",
|
|
59
|
+
"/_____/_/\\__,_/\\___/_/|_|\\____/ .___/____/ ",
|
|
60
|
+
" /_/ ",
|
|
61
|
+
)
|
|
62
|
+
_SQL = (
|
|
63
|
+
" _____ ____ __ ",
|
|
64
|
+
" / ___// __ \\ / / ",
|
|
65
|
+
" \\__ \\/ / / / / / ",
|
|
66
|
+
" ___/ / /_/ / / /___",
|
|
67
|
+
"/____/\\___\\_\\/_____/",
|
|
68
|
+
" ",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
art_colored = [_g(bo.ljust(_BO_W)) + _r(sql) for bo, sql in zip(_BO, _SQL)]
|
|
72
|
+
art_raw = [bo.ljust(_BO_W) + sql for bo, sql in zip(_BO, _SQL)]
|
|
73
|
+
|
|
74
|
+
info = [
|
|
75
|
+
_d(" API-aware SQL injection reconnaissance and validation engine"),
|
|
76
|
+
_d(" fork of BreachSQL by CommonHuman-Lab | AGPL-3.0-or-later"),
|
|
77
|
+
_d(" Big thanks to Deorsa for Graphical and Visual design."),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
art_raw_widths = [len(l) for l in art_raw]
|
|
81
|
+
info_raw_widths = [_vlen(l) for l in info]
|
|
82
|
+
inner_w = max(art_raw_widths + info_raw_widths)
|
|
83
|
+
frame_w = inner_w + 4 # "# " + content + " #"
|
|
84
|
+
|
|
85
|
+
top_bot = _r("#" * frame_w)
|
|
86
|
+
empty = _r("#") + " " * (frame_w - 2) + _r("#")
|
|
87
|
+
|
|
88
|
+
rows = ["", top_bot, empty]
|
|
89
|
+
for colored, raw_w in zip(art_colored, art_raw_widths):
|
|
90
|
+
rows.append(_r("#") + " " + colored + " " * (inner_w - raw_w) + " " + _r("#"))
|
|
91
|
+
rows.append(empty)
|
|
92
|
+
for colored, raw_w in zip(info, info_raw_widths):
|
|
93
|
+
rows.append(_r("#") + " " + colored + " " * (inner_w - raw_w) + " " + _r("#"))
|
|
94
|
+
rows += [empty, top_bot, ""]
|
|
95
|
+
return "\n".join(rows)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
BANNER = _make_banner()
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"__version__",
|
|
102
|
+
"scan",
|
|
103
|
+
"ScanOptions",
|
|
104
|
+
"ScanResult",
|
|
105
|
+
"ErrorBasedFinding",
|
|
106
|
+
"BooleanFinding",
|
|
107
|
+
"TimeFinding",
|
|
108
|
+
"UnionFinding",
|
|
109
|
+
"OOBFinding",
|
|
110
|
+
"FindingType",
|
|
111
|
+
]
|
blackopssql/__main__.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab (original BreachSQL)
|
|
3
|
+
# Modifications copyright (c) 2026 roc1t1z3not (BlackOpsSQL fork)
|
|
4
|
+
"""
|
|
5
|
+
BlackOpsSQL — __main__.py
|
|
6
|
+
CLI entry point.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python -m blackopssql -u https://target.com/search?q=test
|
|
10
|
+
blackops-sql -u https://target.com/search?q=test
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.parse as _up
|
|
21
|
+
|
|
22
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
_PARENT = os.path.dirname(_HERE)
|
|
24
|
+
for _p in (_PARENT, _HERE):
|
|
25
|
+
if _p not in sys.path:
|
|
26
|
+
sys.path.insert(0, _p)
|
|
27
|
+
|
|
28
|
+
from blackopssql import BANNER
|
|
29
|
+
from blackopssql.engine import scan, ScanOptions
|
|
30
|
+
from blackopssql.engine.scanner import _unique_stem, _output_stem
|
|
31
|
+
from blackopssql.engine.log import get_logger
|
|
32
|
+
from blackopssql._cli.args import build_parser, interactive_prompts
|
|
33
|
+
from blackopssql._cli.summary import print_summary
|
|
34
|
+
from blackops_cli.colour import BOLD, CYAN
|
|
35
|
+
from blackops_cli.logging import setup_logging
|
|
36
|
+
from blackops_cli.entrypoint import (
|
|
37
|
+
load_url_list, compile_exclude_patterns, parse_headers, validate_timeout,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_cli_logger = get_logger("blackopssql")
|
|
41
|
+
|
|
42
|
+
_AUTH_PATH_RE = re.compile(r'/(login|signin|authenticate|session|token)\b', re.IGNORECASE)
|
|
43
|
+
_AUTH_SYNTH_BODY = '{"email":"test@test.com","password":"test","username":"test"}'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _split_param_list(val) -> list[str]:
|
|
47
|
+
"""Accept either a list (from interactive mode) or a comma-separated string."""
|
|
48
|
+
if isinstance(val, list):
|
|
49
|
+
return [p.strip() for p in val if str(p).strip()]
|
|
50
|
+
return [p.strip() for p in str(val).split(",") if p.strip()]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None:
|
|
54
|
+
parser = build_parser()
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
|
|
57
|
+
setup_logging(verbose=args.verbose, quiet=args.quiet or args.json_output, logger_name="blackopssql")
|
|
58
|
+
|
|
59
|
+
# Collect target URLs
|
|
60
|
+
urls: list[str] = []
|
|
61
|
+
if args.url:
|
|
62
|
+
urls.append(args.url)
|
|
63
|
+
if args.url_list:
|
|
64
|
+
urls.extend(load_url_list(args.url_list))
|
|
65
|
+
|
|
66
|
+
# No URL supplied → interactive mode
|
|
67
|
+
if not urls:
|
|
68
|
+
args = interactive_prompts()
|
|
69
|
+
urls = [args.url]
|
|
70
|
+
elif not args.json_output:
|
|
71
|
+
print(CYAN(BANNER))
|
|
72
|
+
|
|
73
|
+
validate_timeout(args.timeout)
|
|
74
|
+
|
|
75
|
+
exclude_patterns = compile_exclude_patterns(args.exclude)
|
|
76
|
+
headers = parse_headers(args.header)
|
|
77
|
+
|
|
78
|
+
# Form login
|
|
79
|
+
if getattr(args, "login_url", "") and getattr(args, "login_user", ""):
|
|
80
|
+
from blackops_core.auth import form_login as _form_login
|
|
81
|
+
if not args.quiet and not args.json_output:
|
|
82
|
+
print(f"[*] Authenticating via {args.login_url} ...")
|
|
83
|
+
auth = _form_login(
|
|
84
|
+
login_url=args.login_url,
|
|
85
|
+
username=args.login_user,
|
|
86
|
+
password=getattr(args, "login_pass", ""),
|
|
87
|
+
username_field=getattr(args, "login_user_field", "username"),
|
|
88
|
+
password_field=getattr(args, "login_pass_field", "password"),
|
|
89
|
+
)
|
|
90
|
+
if auth.cookies and not args.cookie:
|
|
91
|
+
args.cookie = auth.cookies
|
|
92
|
+
headers.update(auth.headers)
|
|
93
|
+
|
|
94
|
+
# OpenAPI spec import
|
|
95
|
+
if getattr(args, "openapi", ""):
|
|
96
|
+
from blackops_core.openapi import load_openapi as _load_openapi
|
|
97
|
+
if not args.quiet and not args.json_output:
|
|
98
|
+
print(f"[*] Loading OpenAPI spec from {args.openapi} ...")
|
|
99
|
+
api_eps = _load_openapi(args.openapi, base_url=getattr(args, "base_url", ""))
|
|
100
|
+
seen_oa = set(urls)
|
|
101
|
+
for ep in api_eps:
|
|
102
|
+
if ep.url not in seen_oa:
|
|
103
|
+
urls.append(ep.url)
|
|
104
|
+
seen_oa.add(ep.url)
|
|
105
|
+
if not args.quiet and not args.json_output:
|
|
106
|
+
print(f"[*] OpenAPI: {len(api_eps)} endpoint(s) added")
|
|
107
|
+
|
|
108
|
+
# Per-URL POST body overrides: populated by JS discovery for auth endpoints.
|
|
109
|
+
url_data_overrides: dict[str, str] = {}
|
|
110
|
+
|
|
111
|
+
# JS API discovery — parse SPA bundles for REST/API endpoints
|
|
112
|
+
if (args.crawl or getattr(args, "browser_crawl", False)) and urls:
|
|
113
|
+
from blackops_core.js_api_discover import js_api_discover as _js_discover
|
|
114
|
+
seed = urls[0]
|
|
115
|
+
if not args.quiet and not args.json_output:
|
|
116
|
+
print(f"[*] JS API discovery on {seed} ...")
|
|
117
|
+
seen_js = set(urls)
|
|
118
|
+
js_found = _js_discover(seed)
|
|
119
|
+
new_js: list[str] = []
|
|
120
|
+
for _method, js_url, _tmpl in js_found:
|
|
121
|
+
if js_url not in seen_js:
|
|
122
|
+
seen_js.add(js_url)
|
|
123
|
+
new_js.append(js_url)
|
|
124
|
+
urls.append(js_url)
|
|
125
|
+
# Synthesise a JSON body for discovered POST auth endpoints so
|
|
126
|
+
# the scanner can build injectable surfaces (email/password fields).
|
|
127
|
+
if _method == "POST" and _AUTH_PATH_RE.search(_up.urlparse(js_url).path):
|
|
128
|
+
url_data_overrides[js_url] = _AUTH_SYNTH_BODY
|
|
129
|
+
if not args.quiet and not args.json_output:
|
|
130
|
+
print(f"[*] JS discovery: {len(new_js)} endpoint(s) found")
|
|
131
|
+
|
|
132
|
+
# Browser crawl — headless JS endpoint discovery
|
|
133
|
+
if getattr(args, "browser_crawl", False) and urls:
|
|
134
|
+
from blackops_core.browser_crawler import browser_crawl as _browser_crawl
|
|
135
|
+
seed = urls[0]
|
|
136
|
+
if not args.quiet and not args.json_output:
|
|
137
|
+
print(f"[*] Browser-crawling {seed} ...")
|
|
138
|
+
bc_found = _browser_crawl(
|
|
139
|
+
start_url=seed,
|
|
140
|
+
max_pages=args.max_pages,
|
|
141
|
+
max_depth=args.max_depth,
|
|
142
|
+
cookies=args.cookie or "",
|
|
143
|
+
)
|
|
144
|
+
seen_bc = set(urls)
|
|
145
|
+
new_bc = [u for u in bc_found if u not in seen_bc]
|
|
146
|
+
urls.extend(new_bc)
|
|
147
|
+
if not args.quiet and not args.json_output:
|
|
148
|
+
print(f"[*] Browser crawl: {len(new_bc)} additional endpoint(s) queued")
|
|
149
|
+
|
|
150
|
+
# --exploit implies --dump-all + auto-named outputs under <host>/ directory
|
|
151
|
+
if getattr(args, "exploit", False):
|
|
152
|
+
args.dump_all = True
|
|
153
|
+
if not args.output and urls:
|
|
154
|
+
_host = _up.urlparse(urls[0]).hostname or "target"
|
|
155
|
+
os.makedirs(_host, exist_ok=True)
|
|
156
|
+
args.output = os.path.join(_host, _host)
|
|
157
|
+
if not getattr(args, "report_html", "") and args.output:
|
|
158
|
+
args.report_html = args.output + ".html"
|
|
159
|
+
|
|
160
|
+
# Resolve collision-free stem once for the whole session so that every
|
|
161
|
+
# scan() call in the crawl loop writes to the same base name.
|
|
162
|
+
if args.output:
|
|
163
|
+
args.output = _unique_stem(_output_stem(args.output))
|
|
164
|
+
if getattr(args, "report_html", ""):
|
|
165
|
+
args.report_html = _unique_stem(_output_stem(args.report_html)) + ".html"
|
|
166
|
+
|
|
167
|
+
opts = ScanOptions(
|
|
168
|
+
crawl=args.crawl,
|
|
169
|
+
data=args.data,
|
|
170
|
+
headers=headers,
|
|
171
|
+
cookies=args.cookie,
|
|
172
|
+
proxy=args.proxy,
|
|
173
|
+
threads=args.threads,
|
|
174
|
+
timeout=args.timeout,
|
|
175
|
+
delay=args.delay,
|
|
176
|
+
level=args.level,
|
|
177
|
+
max_pages=args.max_pages,
|
|
178
|
+
max_depth=args.max_depth,
|
|
179
|
+
output=args.output,
|
|
180
|
+
exclude_patterns=exclude_patterns,
|
|
181
|
+
dbms=args.dbms,
|
|
182
|
+
technique=args.technique,
|
|
183
|
+
oob_callback=args.oob,
|
|
184
|
+
time_threshold=args.time_threshold,
|
|
185
|
+
risk=args.risk,
|
|
186
|
+
second_url=getattr(args, "second_url", ""),
|
|
187
|
+
path_params=_split_param_list(getattr(args, "path_params", "")),
|
|
188
|
+
cookie_params=_split_param_list(getattr(args, "cookie_params", "")),
|
|
189
|
+
header_params=_split_param_list(getattr(args, "header_params", "")),
|
|
190
|
+
exploit=(
|
|
191
|
+
getattr(args, "exploit", False)
|
|
192
|
+
or bool(getattr(args, "dump", ""))
|
|
193
|
+
or getattr(args, "dump_all", False)
|
|
194
|
+
),
|
|
195
|
+
dump=getattr(args, "dump", ""),
|
|
196
|
+
dump_all=getattr(args, "dump_all", False),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
all_results = []
|
|
200
|
+
any_findings = False
|
|
201
|
+
multi = len(urls) > 1
|
|
202
|
+
|
|
203
|
+
for target_url in urls:
|
|
204
|
+
if any(p.search(target_url) for p in exclude_patterns):
|
|
205
|
+
_cli_logger.info("Skipping excluded URL: %s", target_url)
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
if not args.json_output and not args.quiet:
|
|
209
|
+
if not multi:
|
|
210
|
+
print(BOLD(f"[*] Target : {target_url}"))
|
|
211
|
+
print(BOLD(f"[*] Level : {args.level} Threads: {args.threads} Crawl: {args.crawl}"))
|
|
212
|
+
print(BOLD(f"[*] DBMS hint : {args.dbms} Techniques: {args.technique} Risk: {args.risk}"))
|
|
213
|
+
print()
|
|
214
|
+
|
|
215
|
+
_scan_opts = opts
|
|
216
|
+
if target_url in url_data_overrides and not opts.data:
|
|
217
|
+
_scan_opts = copy.copy(opts)
|
|
218
|
+
_scan_opts.data = url_data_overrides[target_url]
|
|
219
|
+
|
|
220
|
+
result = scan(target_url, _scan_opts)
|
|
221
|
+
all_results.append(result)
|
|
222
|
+
if result.total_findings > 0:
|
|
223
|
+
any_findings = True
|
|
224
|
+
|
|
225
|
+
if args.json_output:
|
|
226
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if multi:
|
|
230
|
+
# In multi-URL mode: only show a line when findings exist
|
|
231
|
+
if result.total_findings > 0:
|
|
232
|
+
print(f" [+] {result.total_findings} finding(s) — {target_url}")
|
|
233
|
+
else:
|
|
234
|
+
print_summary(result)
|
|
235
|
+
|
|
236
|
+
if not args.json_output and multi:
|
|
237
|
+
# Merge all results into one combined summary
|
|
238
|
+
from .engine.reporter import ScanResult
|
|
239
|
+
combined = ScanResult(target=urls[0])
|
|
240
|
+
combined.duration_s = sum(r.duration_s for r in all_results)
|
|
241
|
+
combined.requests_sent = sum(r.requests_sent for r in all_results)
|
|
242
|
+
combined.crawled_urls = sum(r.crawled_urls for r in all_results)
|
|
243
|
+
combined.params_tested = sum(r.params_tested for r in all_results)
|
|
244
|
+
combined.waf_detected = next((r.waf_detected for r in all_results if r.waf_detected), None)
|
|
245
|
+
combined.evasion_applied = next((r.evasion_applied for r in all_results if r.evasion_applied), None)
|
|
246
|
+
combined.dbms_detected = next((r.dbms_detected for r in all_results if r.dbms_detected), None)
|
|
247
|
+
for r in all_results:
|
|
248
|
+
combined.error_based.extend(r.error_based)
|
|
249
|
+
combined.boolean_based.extend(r.boolean_based)
|
|
250
|
+
combined.time_based.extend(r.time_based)
|
|
251
|
+
combined.union_based.extend(r.union_based)
|
|
252
|
+
combined.oob.extend(r.oob)
|
|
253
|
+
combined.stacked.extend(r.stacked)
|
|
254
|
+
combined.extracted.extend(r.extracted)
|
|
255
|
+
combined.errors.extend(r.errors)
|
|
256
|
+
combined.target = f"{urls[0]} (+{len(urls)-1} more)" if len(urls) > 1 else urls[0]
|
|
257
|
+
print()
|
|
258
|
+
print_summary(combined)
|
|
259
|
+
|
|
260
|
+
# ── HTML report ───────────────────────────────────────────────────────────
|
|
261
|
+
_report_html = getattr(args, "report_html", "")
|
|
262
|
+
if _report_html and all_results:
|
|
263
|
+
from blackops_cli.report_html import render_html as _render_html
|
|
264
|
+
try:
|
|
265
|
+
_html_str = _render_html(
|
|
266
|
+
results=[
|
|
267
|
+
{**r.to_dict(), "table_dumps": r.dumps_to_dict()["table_dumps"]}
|
|
268
|
+
for r in all_results
|
|
269
|
+
],
|
|
270
|
+
tool_name="BlackOpsSQL",
|
|
271
|
+
tool_version=__import__("blackopssql").__version__,
|
|
272
|
+
)
|
|
273
|
+
with open(_report_html, "w", encoding="utf-8") as _fh:
|
|
274
|
+
_fh.write(_html_str)
|
|
275
|
+
if not args.json_output and not args.quiet:
|
|
276
|
+
print(f"[+] HTML report written to {_report_html}")
|
|
277
|
+
except OSError as _exc:
|
|
278
|
+
print(f"[!] Cannot write HTML report: {_exc}", file=sys.stderr)
|
|
279
|
+
|
|
280
|
+
if args.json_output:
|
|
281
|
+
sys.exit(0 if not any_findings else 1)
|
|
282
|
+
|
|
283
|
+
sys.exit(1 if any_findings else 0)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
main()
|
|
File without changes
|
blackopssql/_cli/args.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from blackops_cli.colour import CYAN, DIM, YELLOW
|
|
8
|
+
from blackops_cli.prompts import (
|
|
9
|
+
safe_int as _safe_int,
|
|
10
|
+
prompt as _prompt,
|
|
11
|
+
prompt_bool as _prompt_bool,
|
|
12
|
+
section as _section,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from blackopssql import __version__, BANNER
|
|
17
|
+
except ImportError:
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
BANNER = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def interactive_prompts() -> argparse.Namespace:
|
|
23
|
+
"""Walk the user through all scan options interactively."""
|
|
24
|
+
print(CYAN(BANNER))
|
|
25
|
+
print(DIM(" No arguments supplied — entering interactive mode."))
|
|
26
|
+
print(DIM(" Press Enter to accept defaults. Ctrl+C to exit.\n"))
|
|
27
|
+
|
|
28
|
+
_section("Target")
|
|
29
|
+
url = ""
|
|
30
|
+
while not url:
|
|
31
|
+
url = _prompt(" Target URL", hint="e.g. https://target.com/search?q=test")
|
|
32
|
+
if not url:
|
|
33
|
+
print(YELLOW(" [!] URL is required."))
|
|
34
|
+
elif not url.startswith(("http://", "https://")):
|
|
35
|
+
print(YELLOW(" [!] URL must start with http:// or https://"))
|
|
36
|
+
url = ""
|
|
37
|
+
|
|
38
|
+
_section("Authentication (optional)")
|
|
39
|
+
login_url = _prompt(" Login URL", hint="https://target.com/login (blank to skip)")
|
|
40
|
+
if login_url:
|
|
41
|
+
login_user = _prompt(" Username")
|
|
42
|
+
login_pass = _prompt(" Password")
|
|
43
|
+
else:
|
|
44
|
+
login_user = login_pass = ""
|
|
45
|
+
cookie = _prompt(" Cookies", hint="name=val; name2=val2 (blank if using --login-url)")
|
|
46
|
+
headers_raw: list[str] = []
|
|
47
|
+
while True:
|
|
48
|
+
h = _prompt(" Header", hint="KEY:VALUE (blank to finish)")
|
|
49
|
+
if not h:
|
|
50
|
+
break
|
|
51
|
+
headers_raw.append(h)
|
|
52
|
+
|
|
53
|
+
_section("Request")
|
|
54
|
+
data = _prompt(" POST body", hint="form-encoded or JSON (blank = GET)")
|
|
55
|
+
proxy = _prompt(" Proxy", hint="http://127.0.0.1:8080")
|
|
56
|
+
|
|
57
|
+
_section("SQLi options")
|
|
58
|
+
dbms = _prompt(" Target DBMS", default="auto",
|
|
59
|
+
hint="mysql | mariadb | mssql | postgres | sqlite | oracle | auto")
|
|
60
|
+
technique = _prompt(" Techniques", default="EBTUO",
|
|
61
|
+
hint="E=error B=bool T=time U=union O=oob (e.g. EBT)")
|
|
62
|
+
oob = _prompt(" OOB callback URL", hint="https://your.interactsh.io/x (blank to skip)")
|
|
63
|
+
time_thr = _prompt(" Time threshold", default="4", hint="seconds to flag time-based hit")
|
|
64
|
+
risk_str = _prompt(" Risk level", default="1", hint="1=safe 2=moderate 3=aggressive")
|
|
65
|
+
second_url = _prompt(" Second URL", hint="read SQLi response from this URL (blank to skip)")
|
|
66
|
+
path_params = _prompt(" Path params", hint="comma-separated names e.g. id,slug (blank=auto)")
|
|
67
|
+
cookie_params = _prompt(" Cookie params", hint="comma-separated cookie names to inject (blank=skip)")
|
|
68
|
+
header_params = _prompt(" Header params", hint="comma-separated header names to inject (blank=skip)")
|
|
69
|
+
|
|
70
|
+
_section("Scan options")
|
|
71
|
+
level_str = _prompt(" Scan level", default="1", hint="1=fast 2=thorough 3=deep")
|
|
72
|
+
threads_str = _prompt(" Threads", default="5")
|
|
73
|
+
timeout_str = _prompt(" Timeout", default="15", hint="seconds per request")
|
|
74
|
+
|
|
75
|
+
crawl = _prompt_bool(" Enable crawler", default=False)
|
|
76
|
+
if crawl:
|
|
77
|
+
max_pages_str = _prompt(" Max pages", default="100")
|
|
78
|
+
max_depth_str = _prompt(" Max depth", default="3")
|
|
79
|
+
else:
|
|
80
|
+
max_pages_str = "100"
|
|
81
|
+
max_depth_str = "3"
|
|
82
|
+
|
|
83
|
+
_section("Exploitation (optional)")
|
|
84
|
+
exploit = _prompt_bool(" Extract data after finding SQLi", default=False)
|
|
85
|
+
dump = _prompt(" Dump table", hint="table name to dump rows from (blank to skip)") if exploit else ""
|
|
86
|
+
print()
|
|
87
|
+
|
|
88
|
+
return argparse.Namespace(
|
|
89
|
+
url=url,
|
|
90
|
+
url_list="",
|
|
91
|
+
crawl=crawl,
|
|
92
|
+
data=data,
|
|
93
|
+
header=headers_raw,
|
|
94
|
+
cookie=cookie,
|
|
95
|
+
proxy=proxy,
|
|
96
|
+
threads=_safe_int(threads_str, 5, 1, 20),
|
|
97
|
+
timeout=_safe_int(timeout_str, 15, 5, 120),
|
|
98
|
+
delay=0.0,
|
|
99
|
+
level=_safe_int(level_str, 1, 1, 3),
|
|
100
|
+
max_pages=_safe_int(max_pages_str, 100, 1, 500),
|
|
101
|
+
max_depth=_safe_int(max_depth_str, 3, 1, 10),
|
|
102
|
+
exclude=[],
|
|
103
|
+
output="",
|
|
104
|
+
json_output=False,
|
|
105
|
+
quiet=False,
|
|
106
|
+
verbose=False,
|
|
107
|
+
dbms=dbms,
|
|
108
|
+
technique=technique.upper(),
|
|
109
|
+
oob=oob,
|
|
110
|
+
time_threshold=_safe_int(time_thr, 4, 1, 30),
|
|
111
|
+
risk=_safe_int(risk_str, 1, 1, 3),
|
|
112
|
+
second_url=second_url,
|
|
113
|
+
path_params=[p.strip() for p in path_params.split(",") if p.strip()],
|
|
114
|
+
cookie_params=[p.strip() for p in cookie_params.split(",") if p.strip()],
|
|
115
|
+
header_params=[p.strip() for p in header_params.split(",") if p.strip()],
|
|
116
|
+
login_url=login_url,
|
|
117
|
+
login_user=login_user,
|
|
118
|
+
login_pass=login_pass,
|
|
119
|
+
login_user_field="username",
|
|
120
|
+
login_pass_field="password",
|
|
121
|
+
openapi="",
|
|
122
|
+
base_url="",
|
|
123
|
+
browser_crawl=False,
|
|
124
|
+
exploit=exploit,
|
|
125
|
+
dump=dump,
|
|
126
|
+
dump_all=False,
|
|
127
|
+
report_html="",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
132
|
+
p = argparse.ArgumentParser(
|
|
133
|
+
prog="blackops-sql",
|
|
134
|
+
description="BlackOpsSQL — API-aware SQL injection reconnaissance and validation engine",
|
|
135
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
136
|
+
)
|
|
137
|
+
p.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
138
|
+
|
|
139
|
+
# --- Target ---
|
|
140
|
+
p.add_argument("-u", "--url", default="", help="Target URL")
|
|
141
|
+
p.add_argument("-L", "--url-list", default="", metavar="FILE",
|
|
142
|
+
help="File of target URLs (one per line)")
|
|
143
|
+
|
|
144
|
+
# --- Request ---
|
|
145
|
+
p.add_argument("--crawl", action="store_true", help="Enable BFS crawler")
|
|
146
|
+
p.add_argument("-d", "--data", default="", help="POST body")
|
|
147
|
+
p.add_argument("-H", "--header", action="append", default=[], metavar="KEY:VALUE",
|
|
148
|
+
help="Custom header (repeatable)")
|
|
149
|
+
p.add_argument("-c", "--cookie", default="", help="Cookie string")
|
|
150
|
+
p.add_argument("--proxy", default="", help="HTTP proxy URL")
|
|
151
|
+
p.add_argument("-t", "--threads", type=int, default=5,
|
|
152
|
+
help="Worker threads 1-20 (default 5)")
|
|
153
|
+
p.add_argument("--timeout", type=int, default=15,
|
|
154
|
+
help="Request timeout seconds 5-120 (default 15)")
|
|
155
|
+
p.add_argument("--delay", type=float, default=0.0,
|
|
156
|
+
help="Seconds between requests (default 0)")
|
|
157
|
+
|
|
158
|
+
# --- Scan depth ---
|
|
159
|
+
p.add_argument("--level", type=int, default=1, choices=[1, 2, 3],
|
|
160
|
+
help="Scan depth: 1=fast 2=thorough 3=deep (default 1)")
|
|
161
|
+
p.add_argument("--max-pages", type=int, default=100,
|
|
162
|
+
help="Max pages to crawl (default 100)")
|
|
163
|
+
p.add_argument("--max-depth", type=int, default=3,
|
|
164
|
+
help="Max crawl depth (default 3)")
|
|
165
|
+
p.add_argument("--exclude", action="append", default=[], metavar="PATTERN",
|
|
166
|
+
help="Regex pattern of URLs to skip (repeatable)")
|
|
167
|
+
|
|
168
|
+
# --- SQLi-specific ---
|
|
169
|
+
p.add_argument("--dbms", default="auto",
|
|
170
|
+
choices=["auto", "mysql", "mariadb", "mssql", "postgres", "sqlite", "oracle"],
|
|
171
|
+
help="Target DBMS hint (default: auto-detect)")
|
|
172
|
+
p.add_argument("--technique", default="EBTUO", metavar="TECHNIQUES",
|
|
173
|
+
help="Techniques to use: E=error B=bool T=time U=union O=oob (default: EBTUO)")
|
|
174
|
+
p.add_argument("--oob", default="", metavar="URL",
|
|
175
|
+
help="Out-of-band callback URL for OOB detection")
|
|
176
|
+
p.add_argument("--time-threshold", type=int, default=4, dest="time_threshold",
|
|
177
|
+
help="Seconds delta to flag time-based SQLi (default 4)")
|
|
178
|
+
p.add_argument("--risk", type=int, default=1, choices=[1, 2, 3],
|
|
179
|
+
help="Risk level: 1=safe 2=moderate 3=aggressive (default 1)")
|
|
180
|
+
p.add_argument("--second-url", default="", dest="second_url", metavar="URL",
|
|
181
|
+
help="Read SQLi response from this URL after injecting into target "
|
|
182
|
+
"(e.g. DVWA high: inject to session-input.php, read from sqli/)")
|
|
183
|
+
p.add_argument("--path-params", default="", dest="path_params", metavar="NAMES",
|
|
184
|
+
help="Comma-separated path segment names to inject "
|
|
185
|
+
"(auto-detected from :name/{name} patterns if omitted)")
|
|
186
|
+
p.add_argument("--cookie-params", default="", dest="cookie_params", metavar="NAMES",
|
|
187
|
+
help="Comma-separated cookie names to inject as SQLi surfaces")
|
|
188
|
+
p.add_argument("--header-params", default="", dest="header_params", metavar="NAMES",
|
|
189
|
+
help="Comma-separated HTTP header names to inject as SQLi surfaces")
|
|
190
|
+
|
|
191
|
+
# --- Output ---
|
|
192
|
+
p.add_argument("-o", "--output", default="", metavar="NAME",
|
|
193
|
+
help="Output stem — writes <name>.json and <name>.txt")
|
|
194
|
+
p.add_argument("--json", action="store_true", dest="json_output",
|
|
195
|
+
help="Output raw JSON")
|
|
196
|
+
p.add_argument("-q", "--quiet", action="store_true",
|
|
197
|
+
help="Suppress live log output")
|
|
198
|
+
p.add_argument("-v", "--verbose", action="store_true",
|
|
199
|
+
help="Show all checks including clean ones")
|
|
200
|
+
p.add_argument("--login-url", default="", dest="login_url",
|
|
201
|
+
help="Login form URL — authenticates before scanning")
|
|
202
|
+
p.add_argument("--login-user", default="", dest="login_user",
|
|
203
|
+
help="Username for form login")
|
|
204
|
+
p.add_argument("--login-pass", default="", dest="login_pass",
|
|
205
|
+
help="Password for form login")
|
|
206
|
+
p.add_argument("--login-user-field", default="username", dest="login_user_field",
|
|
207
|
+
help="Username field name (default: username)")
|
|
208
|
+
p.add_argument("--login-pass-field", default="password", dest="login_pass_field",
|
|
209
|
+
help="Password field name (default: password)")
|
|
210
|
+
p.add_argument("--openapi", default="", dest="openapi",
|
|
211
|
+
help="OpenAPI/Swagger spec file path or URL — imports endpoints to scan")
|
|
212
|
+
p.add_argument("--base-url", default="", dest="base_url",
|
|
213
|
+
help="Base URL override for OpenAPI spec")
|
|
214
|
+
p.add_argument("--browser-crawl", action="store_true", dest="browser_crawl",
|
|
215
|
+
help="Use headless Chromium for endpoint discovery (requires selenium)")
|
|
216
|
+
|
|
217
|
+
# --- Exploitation ---
|
|
218
|
+
p.add_argument("--exploit", action="store_true", default=False,
|
|
219
|
+
help="After finding SQLi, dump all tables and write <host>.txt, <host>.json, <host>.html")
|
|
220
|
+
p.add_argument("--dump", default="", metavar="TABLE",
|
|
221
|
+
help="Dump all rows from TABLE using a confirmed injection point (implies --exploit)")
|
|
222
|
+
p.add_argument("--dump-all", action="store_true", default=False, dest="dump_all",
|
|
223
|
+
help="Dump every table discovered during extraction (implies --exploit)")
|
|
224
|
+
|
|
225
|
+
# --- Reports ---
|
|
226
|
+
p.add_argument("--report-html", default="", dest="report_html", metavar="FILE",
|
|
227
|
+
help="Write a self-contained HTML report to this file")
|
|
228
|
+
|
|
229
|
+
return p
|