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,10 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""BlackOpsSQL — engine/http/injector.py"""
|
|
4
|
+
|
|
5
|
+
from blackops_core.http import HttpClient, parse_cookie_string, parse_post_data
|
|
6
|
+
|
|
7
|
+
# Alias: Injector IS HttpClient — no subclass needed, all methods already present.
|
|
8
|
+
Injector = HttpClient
|
|
9
|
+
|
|
10
|
+
__all__ = ["Injector", "HttpClient", "parse_cookie_string", "parse_post_data"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""BlackOpsSQL — WAF detection"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from blackops_payloads.waf import WafResult, detect as _detect
|
|
10
|
+
from blackops_payloads.waf.signatures import WafSignature # noqa: F401 (re-export)
|
|
11
|
+
from blackops_payloads.encoders import (
|
|
12
|
+
EVASION_NONE,
|
|
13
|
+
EVASION_CASE_MIXING,
|
|
14
|
+
EVASION_HTML_ENCODE,
|
|
15
|
+
EVASION_UNICODE,
|
|
16
|
+
EVASION_DOUBLE_ENCODE,
|
|
17
|
+
EVASION_CHUNKED_TAGS,
|
|
18
|
+
EVASION_NULL_BYTE,
|
|
19
|
+
EVASION_NEWLINE,
|
|
20
|
+
EVASION_COMMENT_BREAK,
|
|
21
|
+
EVASION_BACKTICK,
|
|
22
|
+
EVASION_SQL_COMMENT,
|
|
23
|
+
EVASION_SQL_WHITESPACE,
|
|
24
|
+
EVASION_SQL_CASE,
|
|
25
|
+
EVASION_SQL_ENCODE,
|
|
26
|
+
EVASION_SQL_MULTILINE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# SQLi probe — triggers most SQL-aware WAFs
|
|
30
|
+
_PROBE_PAYLOAD = "' OR '1'='1\"-- -"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"WafResult", "WafSignature", "detect",
|
|
34
|
+
"EVASION_NONE", "EVASION_CASE_MIXING", "EVASION_HTML_ENCODE",
|
|
35
|
+
"EVASION_UNICODE", "EVASION_DOUBLE_ENCODE", "EVASION_CHUNKED_TAGS",
|
|
36
|
+
"EVASION_NULL_BYTE", "EVASION_NEWLINE", "EVASION_COMMENT_BREAK",
|
|
37
|
+
"EVASION_BACKTICK",
|
|
38
|
+
"EVASION_SQL_COMMENT", "EVASION_SQL_WHITESPACE", "EVASION_SQL_CASE",
|
|
39
|
+
"EVASION_SQL_ENCODE", "EVASION_SQL_MULTILINE",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect(injector, url: str, param: Optional[str] = None) -> WafResult:
|
|
44
|
+
"""Probe for a WAF using the SQLi probe payload."""
|
|
45
|
+
return _detect(
|
|
46
|
+
injector.get,
|
|
47
|
+
url,
|
|
48
|
+
param,
|
|
49
|
+
probe_payload=_PROBE_PAYLOAD,
|
|
50
|
+
check_reflection=False,
|
|
51
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""BlackOpsSQL — engine/log.py — re-exported from blackops_cli.logging."""
|
|
4
|
+
|
|
5
|
+
from blackops_cli.logging import FINDING, StingLogger, get_logger, ScanResultHandler # noqa: F401
|
|
6
|
+
|
|
7
|
+
__all__ = ["FINDING", "get_logger", "ScanResultHandler"]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""BlackOpsSQL — engine/reporter.py — scan result dataclasses and serialisation."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from blackops_cli.reporter import ScanResultBase
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Enums
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
class FindingType(str, Enum):
|
|
20
|
+
ERROR_BASED = "error_based_sqli"
|
|
21
|
+
BOOLEAN = "boolean_based_sqli"
|
|
22
|
+
TIME_BASED = "time_based_sqli"
|
|
23
|
+
UNION_BASED = "union_based_sqli"
|
|
24
|
+
OOB = "oob_sqli"
|
|
25
|
+
STACKED = "stacked_sqli"
|
|
26
|
+
EXTRACTION = "extraction"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Finding dataclasses
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ErrorBasedFinding:
|
|
35
|
+
"""DB error pattern visible in response — confirmed SQLi."""
|
|
36
|
+
url: str
|
|
37
|
+
parameter: str
|
|
38
|
+
method: str
|
|
39
|
+
payload: str
|
|
40
|
+
dbms: str
|
|
41
|
+
evidence: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class BooleanFinding:
|
|
46
|
+
"""True/false response divergence — likely SQLi."""
|
|
47
|
+
url: str
|
|
48
|
+
parameter: str
|
|
49
|
+
method: str
|
|
50
|
+
payload_true: str
|
|
51
|
+
payload_false: str
|
|
52
|
+
diff_score: float
|
|
53
|
+
confirmed: bool
|
|
54
|
+
evidence: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class TimeFinding:
|
|
59
|
+
"""Response time delta exceeds threshold — time-based blind SQLi."""
|
|
60
|
+
url: str
|
|
61
|
+
parameter: str
|
|
62
|
+
method: str
|
|
63
|
+
payload: str
|
|
64
|
+
dbms: str
|
|
65
|
+
observed_delay: float
|
|
66
|
+
threshold: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class UnionFinding:
|
|
71
|
+
"""UNION SELECT reflection confirmed — union-based SQLi."""
|
|
72
|
+
url: str
|
|
73
|
+
parameter: str
|
|
74
|
+
method: str
|
|
75
|
+
payload: str
|
|
76
|
+
column_count: int
|
|
77
|
+
extracted: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class OOBFinding:
|
|
82
|
+
"""Out-of-band payload injected — callback confirmation required externally."""
|
|
83
|
+
url: str
|
|
84
|
+
parameter: str
|
|
85
|
+
method: str
|
|
86
|
+
payload: str
|
|
87
|
+
callback_url: str
|
|
88
|
+
confirmed: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class StackedFinding:
|
|
93
|
+
"""Stacked (batched) query injection confirmed — second statement was executed."""
|
|
94
|
+
url: str
|
|
95
|
+
parameter: str
|
|
96
|
+
method: str
|
|
97
|
+
payload: str
|
|
98
|
+
dbms: str
|
|
99
|
+
evidence: str = ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class ExtractionFinding:
|
|
104
|
+
"""Data extracted via blind char-by-char extraction (boolean or time mode)."""
|
|
105
|
+
url: str
|
|
106
|
+
parameter: str
|
|
107
|
+
method: str
|
|
108
|
+
expr: str
|
|
109
|
+
value: str
|
|
110
|
+
mode: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class TableDumpFinding:
|
|
115
|
+
"""Rows extracted from a table via table dump."""
|
|
116
|
+
table: str
|
|
117
|
+
columns: List[str]
|
|
118
|
+
rows: List[List[str]]
|
|
119
|
+
url: str
|
|
120
|
+
parameter: str
|
|
121
|
+
method: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Finding type → list attribute mapping
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
_FINDING_LISTS: List[tuple[str, FindingType]] = [
|
|
129
|
+
("error_based", FindingType.ERROR_BASED),
|
|
130
|
+
("boolean_based", FindingType.BOOLEAN),
|
|
131
|
+
("time_based", FindingType.TIME_BASED),
|
|
132
|
+
("union_based", FindingType.UNION_BASED),
|
|
133
|
+
("oob", FindingType.OOB),
|
|
134
|
+
("stacked", FindingType.STACKED),
|
|
135
|
+
("extracted", FindingType.EXTRACTION),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
_FINDING_SEVERITY: dict[FindingType, str] = {
|
|
139
|
+
FindingType.ERROR_BASED: "high",
|
|
140
|
+
FindingType.BOOLEAN: "high",
|
|
141
|
+
FindingType.TIME_BASED: "high",
|
|
142
|
+
FindingType.UNION_BASED: "high",
|
|
143
|
+
FindingType.OOB: "high",
|
|
144
|
+
FindingType.STACKED: "critical",
|
|
145
|
+
FindingType.EXTRACTION: "critical",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Top-level ScanResult
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class ScanResult(ScanResultBase):
|
|
155
|
+
# DBMS (auto-detected during scan) — BlackOpsSQL-specific
|
|
156
|
+
dbms_detected: Optional[str] = None
|
|
157
|
+
|
|
158
|
+
# Findings
|
|
159
|
+
error_based: List[ErrorBasedFinding] = field(default_factory=list)
|
|
160
|
+
boolean_based: List[BooleanFinding] = field(default_factory=list)
|
|
161
|
+
time_based: List[TimeFinding] = field(default_factory=list)
|
|
162
|
+
union_based: List[UnionFinding] = field(default_factory=list)
|
|
163
|
+
oob: List[OOBFinding] = field(default_factory=list)
|
|
164
|
+
stacked: List[StackedFinding] = field(default_factory=list)
|
|
165
|
+
extracted: List[ExtractionFinding] = field(default_factory=list)
|
|
166
|
+
table_dumps: List[TableDumpFinding] = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
# --- Append helpers -------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def append_error_based(self, f) -> None: self._append("error_based", f)
|
|
171
|
+
def append_boolean(self, f) -> None: self._append("boolean_based", f)
|
|
172
|
+
def append_time(self, f) -> None: self._append("time_based", f)
|
|
173
|
+
def append_union(self, f) -> None: self._append("union_based", f)
|
|
174
|
+
def append_oob(self, f) -> None: self._append("oob", f)
|
|
175
|
+
def append_stacked(self, f) -> None: self._append("stacked", f)
|
|
176
|
+
|
|
177
|
+
def append_extraction(self, f) -> None:
|
|
178
|
+
with self._lock:
|
|
179
|
+
for existing in self.extracted:
|
|
180
|
+
if existing.parameter == f.parameter and existing.expr == f.expr:
|
|
181
|
+
return
|
|
182
|
+
self.extracted.append(f)
|
|
183
|
+
|
|
184
|
+
# --- Computed properties --------------------------------------------------
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def total_findings(self) -> int:
|
|
188
|
+
return sum(len(getattr(self, attr)) for attr, _ in _FINDING_LISTS)
|
|
189
|
+
|
|
190
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
191
|
+
findings: List[Dict[str, Any]] = []
|
|
192
|
+
for attr, ftype in _FINDING_LISTS:
|
|
193
|
+
for item in getattr(self, attr):
|
|
194
|
+
d = dataclasses.asdict(item)
|
|
195
|
+
d["type"] = ftype.value
|
|
196
|
+
d["severity"] = _FINDING_SEVERITY.get(ftype, "info")
|
|
197
|
+
findings.append(d)
|
|
198
|
+
|
|
199
|
+
result = self._base_dict()
|
|
200
|
+
result["dbms_detected"] = self.dbms_detected
|
|
201
|
+
result["total_findings"] = self.total_findings
|
|
202
|
+
result["findings"] = findings
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
def dumps_to_dict(self) -> Dict[str, Any]:
|
|
206
|
+
return {
|
|
207
|
+
"table_dumps": [dataclasses.asdict(td) for td in self.table_dumps],
|
|
208
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""
|
|
4
|
+
BlackOpsSQL — engine/scanner.py
|
|
5
|
+
Top-level scan() entry point.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from .log import ScanResultHandler, get_logger
|
|
14
|
+
from .http.injector import Injector
|
|
15
|
+
from .reporter import ScanResult
|
|
16
|
+
from .http import waf_detect # noqa: F401 (patch target for tests)
|
|
17
|
+
from ._scanner.options import ScanOptions # noqa: F401 (re-exported)
|
|
18
|
+
from ._scanner.pipeline import run
|
|
19
|
+
|
|
20
|
+
logger = get_logger("blackopssql.scanner")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_OUTPUT_EXTS = {".json", ".txt", ".html"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _output_stem(path: str) -> str:
|
|
27
|
+
"""Strip extension only if it's one of our known output types (not e.g. '.1' in an IP)."""
|
|
28
|
+
root, ext = os.path.splitext(path)
|
|
29
|
+
return root if ext.lower() in _OUTPUT_EXTS else path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _unique_stem(stem: str) -> str:
|
|
33
|
+
"""Return *stem* unchanged if no collision exists, else append _1, _2, …"""
|
|
34
|
+
suffixes = (".json", ".txt", "_dump.json", ".html")
|
|
35
|
+
if not any(os.path.exists(stem + s) for s in suffixes):
|
|
36
|
+
return stem
|
|
37
|
+
counter = 1
|
|
38
|
+
while any(os.path.exists(f"{stem}_{counter}{s}") for s in suffixes):
|
|
39
|
+
counter += 1
|
|
40
|
+
return f"{stem}_{counter}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scan(url: str, options: ScanOptions | None = None) -> ScanResult:
|
|
44
|
+
"""Run a full BlackOpsSQL scan against *url* and return a ScanResult."""
|
|
45
|
+
if options is None:
|
|
46
|
+
options = ScanOptions()
|
|
47
|
+
|
|
48
|
+
result = ScanResult(target=url)
|
|
49
|
+
_root = get_logger("blackopssql")
|
|
50
|
+
_handler = ScanResultHandler(result)
|
|
51
|
+
_handler.setFormatter(__import__("logging").Formatter("%(name)s: %(message)s"))
|
|
52
|
+
_root.addHandler(_handler)
|
|
53
|
+
|
|
54
|
+
injector = Injector(
|
|
55
|
+
timeout=options.timeout,
|
|
56
|
+
proxy=options.proxy or None,
|
|
57
|
+
headers=options.headers or None,
|
|
58
|
+
cookies=options.cookies or None,
|
|
59
|
+
delay=options.delay,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
run(url, options, injector, result)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
result.append_error(f"Scan aborted: {exc}")
|
|
66
|
+
logger.exception("BlackOpsSQL scan error")
|
|
67
|
+
finally:
|
|
68
|
+
_root.removeHandler(_handler)
|
|
69
|
+
injector.close()
|
|
70
|
+
result.requests_sent = injector.request_count
|
|
71
|
+
result.finish()
|
|
72
|
+
|
|
73
|
+
if options.output:
|
|
74
|
+
stem = _output_stem(options.output)
|
|
75
|
+
try:
|
|
76
|
+
with open(stem + ".json", "w", encoding="utf-8") as fh:
|
|
77
|
+
json.dump(result.to_dict(), fh, indent=2)
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
result.append_error(f"Failed to write JSON output: {exc}")
|
|
80
|
+
try:
|
|
81
|
+
from blackopssql._cli.summary import format_summary
|
|
82
|
+
with open(stem + ".txt", "w", encoding="utf-8") as fh:
|
|
83
|
+
fh.write(format_summary(result))
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
result.append_error(f"Failed to write text output: {exc}")
|
|
86
|
+
|
|
87
|
+
if options.output and result.table_dumps:
|
|
88
|
+
stem = _output_stem(options.output)
|
|
89
|
+
try:
|
|
90
|
+
with open(stem + "_dump.json", "w", encoding="utf-8") as fh:
|
|
91
|
+
json.dump(result.dumps_to_dict(), fh, indent=2)
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
result.append_error(f"Failed to write dump file: {exc}")
|
|
94
|
+
|
|
95
|
+
return result
|