blackops-cli 0.1.5__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_cli/__init__.py +62 -0
- blackops_cli/colour.py +29 -0
- blackops_cli/entrypoint.py +94 -0
- blackops_cli/logging.py +88 -0
- blackops_cli/output.py +202 -0
- blackops_cli/prompts.py +56 -0
- blackops_cli/report_html.py +358 -0
- blackops_cli/report_sarif.py +208 -0
- blackops_cli/reporter.py +92 -0
- blackops_cli/severity.py +95 -0
- blackops_cli-0.1.5.dist-info/METADATA +19 -0
- blackops_cli-0.1.5.dist-info/RECORD +14 -0
- blackops_cli-0.1.5.dist-info/WHEEL +4 -0
- blackops_cli-0.1.5.dist-info/licenses/LICENSE +661 -0
blackops_cli/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""
|
|
4
|
+
blackops-cli — shared CLI/terminal UX primitives for BlackOpsSQL tools.
|
|
5
|
+
|
|
6
|
+
Quick imports:
|
|
7
|
+
|
|
8
|
+
from blackops_cli.output import success, warning, error
|
|
9
|
+
from blackops_cli.logging import setup_logging, get_logger
|
|
10
|
+
from blackops_cli.reporter import ScanResultBase
|
|
11
|
+
from blackops_cli.prompts import prompt, prompt_bool, section
|
|
12
|
+
from blackops_cli.entrypoint import load_url_list, parse_headers
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from blackops_cli.colour import (
|
|
16
|
+
RED, GREEN, YELLOW, CYAN, BOLD, DIM,
|
|
17
|
+
render_banner,
|
|
18
|
+
)
|
|
19
|
+
from blackops_cli.logging import (
|
|
20
|
+
FINDING, StingLogger, get_logger, setup_logging, ScanResultHandler,
|
|
21
|
+
)
|
|
22
|
+
from blackops_cli.output import (
|
|
23
|
+
success, warning, error, info, debug,
|
|
24
|
+
print_header, print_footer, print_scan_meta,
|
|
25
|
+
print_finding, print_finding_severity, print_severity_summary, print_errors,
|
|
26
|
+
proof_url,
|
|
27
|
+
)
|
|
28
|
+
from blackops_cli.severity import (
|
|
29
|
+
Severity,
|
|
30
|
+
CRITICAL, HIGH, MEDIUM, LOW, INFO,
|
|
31
|
+
severity_colour, severity_label, severity_score,
|
|
32
|
+
SEVERITY_ORDER,
|
|
33
|
+
)
|
|
34
|
+
from blackops_cli.prompts import safe_int, prompt, prompt_bool, section
|
|
35
|
+
from blackops_cli.reporter import ScanResultBase
|
|
36
|
+
from blackops_cli.entrypoint import (
|
|
37
|
+
load_url_list, compile_exclude_patterns, parse_headers, validate_timeout,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.5"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"__version__",
|
|
44
|
+
# colour
|
|
45
|
+
"RED", "GREEN", "YELLOW", "CYAN", "BOLD", "DIM", "render_banner",
|
|
46
|
+
# logging
|
|
47
|
+
"FINDING", "StingLogger", "get_logger", "setup_logging", "ScanResultHandler",
|
|
48
|
+
# output
|
|
49
|
+
"success", "warning", "error", "info", "debug",
|
|
50
|
+
"print_header", "print_footer", "print_scan_meta",
|
|
51
|
+
"print_finding", "print_finding_severity", "print_severity_summary",
|
|
52
|
+
"print_errors", "proof_url",
|
|
53
|
+
# severity
|
|
54
|
+
"Severity", "CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO",
|
|
55
|
+
"severity_colour", "severity_label", "severity_score", "SEVERITY_ORDER",
|
|
56
|
+
# prompts
|
|
57
|
+
"safe_int", "prompt", "prompt_bool", "section",
|
|
58
|
+
# reporter
|
|
59
|
+
"ScanResultBase",
|
|
60
|
+
# entrypoint
|
|
61
|
+
"load_url_list", "compile_exclude_patterns", "parse_headers", "validate_timeout",
|
|
62
|
+
]
|
blackops_cli/colour.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _use_colour() -> bool:
|
|
9
|
+
"""Evaluated at call time so stdout redirections are always respected."""
|
|
10
|
+
return sys.stdout.isatty()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _c(code: str, text: str) -> str:
|
|
14
|
+
if not _use_colour():
|
|
15
|
+
return text
|
|
16
|
+
return f"\033[{code}m{text}\033[0m"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
RED = lambda t: _c("31;1", t) # noqa: E731
|
|
20
|
+
GREEN = lambda t: _c("38;5;46", t) # noqa: E731
|
|
21
|
+
YELLOW = lambda t: _c("33;1", t) # noqa: E731
|
|
22
|
+
CYAN = lambda t: _c("36", t) # noqa: E731
|
|
23
|
+
BOLD = lambda t: _c("1", t) # noqa: E731
|
|
24
|
+
DIM = lambda t: _c("2", t) # noqa: E731
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_banner(text: str) -> str:
|
|
28
|
+
"""Wrap a banner string in CYAN, ready for print()."""
|
|
29
|
+
return CYAN(text)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"load_url_list",
|
|
10
|
+
"compile_exclude_patterns",
|
|
11
|
+
"parse_headers",
|
|
12
|
+
"parse_auth_cred",
|
|
13
|
+
"validate_timeout",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_url_list(path: str) -> list[str]:
|
|
18
|
+
"""Read a file of target URLs, one per line. Skips blanks and # comments.
|
|
19
|
+
|
|
20
|
+
Exits with code 2 on I/O error.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
with open(path) as fh:
|
|
24
|
+
return [
|
|
25
|
+
line.strip()
|
|
26
|
+
for line in fh
|
|
27
|
+
if line.strip() and not line.strip().startswith("#")
|
|
28
|
+
]
|
|
29
|
+
except OSError as e:
|
|
30
|
+
print(f"[!] Cannot read URL list: {e}", file=sys.stderr)
|
|
31
|
+
sys.exit(2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compile_exclude_patterns(
|
|
35
|
+
patterns: list[str],
|
|
36
|
+
exit_on_error: bool = True,
|
|
37
|
+
) -> list[re.Pattern]:
|
|
38
|
+
"""Compile a list of regex strings into Pattern objects.
|
|
39
|
+
|
|
40
|
+
exit_on_error=True (default): prints to stderr and sys.exit(2) on bad regex.
|
|
41
|
+
exit_on_error=False: raises ValueError instead (useful in tests).
|
|
42
|
+
"""
|
|
43
|
+
compiled: list[re.Pattern] = []
|
|
44
|
+
for pat in patterns:
|
|
45
|
+
try:
|
|
46
|
+
compiled.append(re.compile(pat))
|
|
47
|
+
except re.error as e:
|
|
48
|
+
msg = f"[!] Invalid --exclude pattern '{pat}': {e}"
|
|
49
|
+
if exit_on_error:
|
|
50
|
+
print(msg, file=sys.stderr)
|
|
51
|
+
sys.exit(2)
|
|
52
|
+
raise ValueError(msg) from e
|
|
53
|
+
return compiled
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_headers(header_list: list[str]) -> dict[str, str]:
|
|
57
|
+
"""Convert a list of "KEY:VALUE" strings into a dict.
|
|
58
|
+
|
|
59
|
+
Splits on the first colon only so header values may contain colons
|
|
60
|
+
(e.g. "Authorization: Bearer token:extra").
|
|
61
|
+
Entries without a colon are silently ignored.
|
|
62
|
+
"""
|
|
63
|
+
headers: dict[str, str] = {}
|
|
64
|
+
for h in header_list:
|
|
65
|
+
if ":" in h:
|
|
66
|
+
k, _, v = h.partition(":")
|
|
67
|
+
headers[k.strip()] = v.strip()
|
|
68
|
+
return headers
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_auth_cred(cred: str) -> tuple[str, str]:
|
|
72
|
+
"""Split ``"username:password"`` into ``(username, password)``.
|
|
73
|
+
|
|
74
|
+
Splits on the first colon only, so passwords that themselves contain
|
|
75
|
+
colons are handled correctly.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: *cred* is empty or contains no colon.
|
|
79
|
+
"""
|
|
80
|
+
if not cred or ":" not in cred:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"auth_cred must be in 'username:password' format, got {cred!r}"
|
|
83
|
+
)
|
|
84
|
+
username, _, password = cred.partition(":")
|
|
85
|
+
return username, password
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_timeout(timeout: int, min_val: int = 5) -> None:
|
|
89
|
+
"""Warn to stderr if *timeout* is below *min_val*. Does not clamp."""
|
|
90
|
+
if timeout < min_val:
|
|
91
|
+
print(
|
|
92
|
+
f"[!] --timeout {timeout} is below minimum; clamping to {min_val}s",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
blackops_cli/logging.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
from blackops_cli.colour import GREEN, YELLOW, CYAN, DIM
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"FINDING",
|
|
12
|
+
"StingLogger",
|
|
13
|
+
"get_logger",
|
|
14
|
+
"setup_logging",
|
|
15
|
+
"ScanResultHandler",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Custom level for confirmed findings — sits between INFO (20) and WARNING (30).
|
|
19
|
+
FINDING = 25
|
|
20
|
+
logging.addLevelName(FINDING, "FINDING")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StingLogger(logging.Logger):
|
|
24
|
+
"""Logger subclass that adds a .finding() convenience method."""
|
|
25
|
+
|
|
26
|
+
def finding(self, msg: str, *args, **kwargs) -> None:
|
|
27
|
+
if self.isEnabledFor(FINDING):
|
|
28
|
+
self._log(FINDING, msg, args, **kwargs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Called once at import so all tool logger hierarchies use StingLogger.
|
|
32
|
+
logging.setLoggerClass(StingLogger)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_logger(name: str) -> StingLogger:
|
|
36
|
+
"""Return (or create) a StingLogger for *name*."""
|
|
37
|
+
return logging.getLogger(name) # type: ignore[return-value]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _ColorHandler(logging.StreamHandler):
|
|
41
|
+
"""Writes log records to stdout with ANSI colour based on level."""
|
|
42
|
+
|
|
43
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
44
|
+
try:
|
|
45
|
+
msg = record.getMessage()
|
|
46
|
+
if record.levelno >= logging.WARNING:
|
|
47
|
+
print(YELLOW(f"[!] {msg}"))
|
|
48
|
+
elif record.levelno == FINDING:
|
|
49
|
+
print(GREEN(f"[+] {msg}"))
|
|
50
|
+
elif record.levelno == logging.DEBUG:
|
|
51
|
+
print(CYAN(f"[~] {msg}"))
|
|
52
|
+
else:
|
|
53
|
+
print(DIM(f"[*] {msg}"))
|
|
54
|
+
if record.exc_info:
|
|
55
|
+
traceback.print_exception(*record.exc_info)
|
|
56
|
+
self.flush()
|
|
57
|
+
except Exception:
|
|
58
|
+
self.handleError(record)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def setup_logging(verbose: bool, quiet: bool, logger_name: str) -> None:
|
|
62
|
+
"""Configure the *logger_name* hierarchy with colour output.
|
|
63
|
+
|
|
64
|
+
Each tool passes its own logger_name (e.g. "blackops-sql", "stingxss") so
|
|
65
|
+
namespaces remain isolated even when both are imported in the same process.
|
|
66
|
+
"""
|
|
67
|
+
root = get_logger(logger_name)
|
|
68
|
+
for h in root.handlers[:]:
|
|
69
|
+
h.close()
|
|
70
|
+
root.handlers.remove(h)
|
|
71
|
+
root.propagate = False
|
|
72
|
+
if quiet:
|
|
73
|
+
root.setLevel(logging.ERROR)
|
|
74
|
+
return
|
|
75
|
+
handler = _ColorHandler()
|
|
76
|
+
root.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
77
|
+
root.addHandler(handler)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ScanResultHandler(logging.Handler):
|
|
81
|
+
"""Appends formatted log messages to a ScanResult.log list."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, result) -> None:
|
|
84
|
+
super().__init__()
|
|
85
|
+
self._result = result
|
|
86
|
+
|
|
87
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
88
|
+
self._result.append_log(self.format(record))
|
blackops_cli/output.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
"""Terminal output helpers: status printers, summary blocks, and PoC URL builder."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.parse as _up
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from blackops_cli.colour import BOLD, CYAN, DIM, GREEN, RED, YELLOW
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"success", "warning", "error", "info", "debug",
|
|
15
|
+
"print_header", "print_footer", "print_scan_meta",
|
|
16
|
+
"print_finding", "print_finding_severity", "print_severity_summary", "print_errors",
|
|
17
|
+
"proof_url",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# One-liner status printers
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def success(msg: str) -> None:
|
|
26
|
+
"""Print a GREEN [+] confirmation line."""
|
|
27
|
+
print(GREEN(f"[+] {msg}"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def warning(msg: str) -> None:
|
|
31
|
+
"""Print a YELLOW [!] warning line."""
|
|
32
|
+
print(YELLOW(f"[!] {msg}"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def error(msg: str) -> None:
|
|
36
|
+
"""Print a RED [!] error line to stderr."""
|
|
37
|
+
print(RED(f"[!] {msg}"), file=sys.stderr)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def info(msg: str) -> None:
|
|
41
|
+
"""Print a DIM [*] informational line."""
|
|
42
|
+
print(DIM(f"[*] {msg}"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def debug(msg: str) -> None:
|
|
46
|
+
"""Print a CYAN [~] debug line."""
|
|
47
|
+
print(CYAN(f"[~] {msg}"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Summary block helpers
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def print_header(title: str, width: int = 60) -> None:
|
|
55
|
+
"""Print a BOLD === title === header block."""
|
|
56
|
+
print()
|
|
57
|
+
print(BOLD("=" * width))
|
|
58
|
+
print(BOLD(f" {title}"))
|
|
59
|
+
print(BOLD("=" * width))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_footer(width: int = 60) -> None:
|
|
63
|
+
"""Print a BOLD === closing separator."""
|
|
64
|
+
print(BOLD("=" * width))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def print_scan_meta(
|
|
68
|
+
target: str,
|
|
69
|
+
duration_s: float,
|
|
70
|
+
requests_sent: int,
|
|
71
|
+
crawled_urls: int,
|
|
72
|
+
params_tested: int,
|
|
73
|
+
*,
|
|
74
|
+
waf_detected: str | None = None,
|
|
75
|
+
evasion_applied: str | None = None,
|
|
76
|
+
**extra: str,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Print the standard scan metadata block.
|
|
79
|
+
|
|
80
|
+
The five required positional args cover fields common to every tool.
|
|
81
|
+
Pass tool-specific rows as keyword arguments, e.g.:
|
|
82
|
+
print_scan_meta(..., **{"DBMS detected": result.dbms_detected or "Unknown"})
|
|
83
|
+
"""
|
|
84
|
+
print(f" Target : {target}")
|
|
85
|
+
print(f" Duration : {duration_s}s")
|
|
86
|
+
print(f" Requests sent : {requests_sent}")
|
|
87
|
+
print(f" URLs crawled : {crawled_urls}")
|
|
88
|
+
print(f" Params tested : {params_tested}")
|
|
89
|
+
print(f" WAF detected : {waf_detected or 'None'}")
|
|
90
|
+
print(f" Evasion used : {evasion_applied or 'None'}")
|
|
91
|
+
for key, val in extra.items():
|
|
92
|
+
print(f" {key:<14}: {val}")
|
|
93
|
+
print()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def print_finding(
|
|
97
|
+
index: int,
|
|
98
|
+
tag: str,
|
|
99
|
+
tag_colour_fn: Callable[[str], str],
|
|
100
|
+
fields: list[tuple[str, str]],
|
|
101
|
+
proof: str = "",
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Print a single numbered finding block.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
index: 1-based finding number.
|
|
107
|
+
tag: Finding label, e.g. "ERROR-BASED SQLi".
|
|
108
|
+
tag_colour_fn: Colour function to wrap the tag, e.g. RED or YELLOW.
|
|
109
|
+
fields: Ordered list of (label, value) rows.
|
|
110
|
+
proof: Optional PoC URL; omitted when empty.
|
|
111
|
+
"""
|
|
112
|
+
print(f" {index}. {tag_colour_fn(f'[{tag}]')}")
|
|
113
|
+
for label, value in fields:
|
|
114
|
+
print(f" {label:<10}: {value}")
|
|
115
|
+
if proof:
|
|
116
|
+
print(f" {'Proof':<10}: {CYAN(proof)}")
|
|
117
|
+
print()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def print_finding_severity(
|
|
121
|
+
index: int,
|
|
122
|
+
tag: str,
|
|
123
|
+
severity: str,
|
|
124
|
+
fields: list[tuple[str, str]],
|
|
125
|
+
proof: str = "",
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Like print_finding but colours the tag by severity automatically.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
index: 1-based finding number.
|
|
131
|
+
tag: Finding label, e.g. "REFLECTED XSS".
|
|
132
|
+
severity: Severity string from ``blackops_cli.severity.Severity``.
|
|
133
|
+
fields: Ordered list of (label, value) rows.
|
|
134
|
+
proof: Optional PoC URL; omitted when empty.
|
|
135
|
+
"""
|
|
136
|
+
from blackops_cli.severity import severity_colour, severity_label
|
|
137
|
+
colour_fn = severity_colour(severity)
|
|
138
|
+
full_tag = f"{tag} [{severity_label(severity).strip()}]"
|
|
139
|
+
print(f" {index}. {colour_fn(f'[{full_tag}]')}")
|
|
140
|
+
for label, value in fields:
|
|
141
|
+
print(f" {label:<10}: {value}")
|
|
142
|
+
if proof:
|
|
143
|
+
print(f" {'Proof':<10}: {CYAN(proof)}")
|
|
144
|
+
print()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def print_severity_summary(counts: dict[str, int]) -> None:
|
|
148
|
+
"""Print a compact severity count summary line.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
counts: Dict mapping severity string → number of findings.
|
|
152
|
+
Unknown severity strings are grouped under "other".
|
|
153
|
+
|
|
154
|
+
Example output::
|
|
155
|
+
|
|
156
|
+
Severity : CRITICAL 0 HIGH 3 MEDIUM 1 LOW 0 INFO 2
|
|
157
|
+
"""
|
|
158
|
+
from blackops_cli.severity import SEVERITY_ORDER, severity_colour
|
|
159
|
+
parts: list[str] = []
|
|
160
|
+
for sev in SEVERITY_ORDER:
|
|
161
|
+
n = counts.get(sev, 0)
|
|
162
|
+
colour_fn = severity_colour(sev) if n > 0 else DIM
|
|
163
|
+
parts.append(colour_fn(f"{sev.upper()} {n}"))
|
|
164
|
+
print(f" {'Severity':<14}: {' '.join(parts)}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def print_errors(errors: list[str]) -> None:
|
|
168
|
+
"""Print a RED errors block; no-ops on an empty list."""
|
|
169
|
+
if not errors:
|
|
170
|
+
return
|
|
171
|
+
print(RED(" Errors:"))
|
|
172
|
+
for e in errors:
|
|
173
|
+
print(f" - {e}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# PoC URL builder
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def proof_url(url: str, param: str, payload: str, *, append: bool = True) -> str:
|
|
181
|
+
"""Build a percent-encoded PoC URL with *payload* injected into *param*.
|
|
182
|
+
|
|
183
|
+
append=True (default): appends payload to the existing param value.
|
|
184
|
+
Matches SQLi scanner injection style.
|
|
185
|
+
append=False: replaces the param value with the raw payload.
|
|
186
|
+
Matches XSS scanner injection style.
|
|
187
|
+
|
|
188
|
+
Returns "" if the URL has no scheme/netloc or cannot be reconstructed.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
parsed = _up.urlparse(url)
|
|
192
|
+
if not parsed.scheme or not parsed.netloc:
|
|
193
|
+
return ""
|
|
194
|
+
qs = _up.parse_qs(parsed.query, keep_blank_values=True)
|
|
195
|
+
if append:
|
|
196
|
+
orig = qs.get(param, ["1"])[0]
|
|
197
|
+
qs[param] = [orig + payload]
|
|
198
|
+
else:
|
|
199
|
+
qs[param] = [payload]
|
|
200
|
+
return _up.urlunparse(parsed._replace(query=_up.urlencode(qs, doseq=True)))
|
|
201
|
+
except (ValueError, AttributeError):
|
|
202
|
+
return ""
|
blackops_cli/prompts.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 CommonHuman-Lab
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from blackops_cli.colour import BOLD, DIM, YELLOW
|
|
8
|
+
|
|
9
|
+
__all__ = ["safe_int", "prompt", "prompt_bool", "section"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def safe_int(val: str, default: int, lo: int, hi: int) -> int:
|
|
13
|
+
"""Parse *val* as int, clamped to [lo, hi]. Returns *default* on error."""
|
|
14
|
+
try:
|
|
15
|
+
return max(lo, min(int(val), hi))
|
|
16
|
+
except (TypeError, ValueError):
|
|
17
|
+
return default
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def prompt(label: str, default: str = "", hint: str = "") -> str:
|
|
21
|
+
"""Display a labelled input prompt and return the entered value.
|
|
22
|
+
|
|
23
|
+
Returns *default* if the user presses Enter with no input.
|
|
24
|
+
Exits cleanly on Ctrl+C or EOF.
|
|
25
|
+
"""
|
|
26
|
+
hint_str = f" {DIM(hint)}" if hint else ""
|
|
27
|
+
if default:
|
|
28
|
+
display = f"{BOLD(label)} {DIM(f'[{default}]')}{hint_str}: "
|
|
29
|
+
else:
|
|
30
|
+
display = f"{BOLD(label)}{hint_str}: "
|
|
31
|
+
try:
|
|
32
|
+
val = input(display).strip()
|
|
33
|
+
except (EOFError, KeyboardInterrupt):
|
|
34
|
+
print()
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
return val if val else default
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def prompt_bool(label: str, default: bool = False) -> bool:
|
|
40
|
+
"""Display a Y/n prompt and return the boolean result."""
|
|
41
|
+
default_str = "Y/n" if default else "y/N"
|
|
42
|
+
display = f"{BOLD(label)} {DIM(f'[{default_str}]')}: "
|
|
43
|
+
try:
|
|
44
|
+
val = input(display).strip().lower()
|
|
45
|
+
except (EOFError, KeyboardInterrupt):
|
|
46
|
+
print()
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
if not val:
|
|
49
|
+
return default
|
|
50
|
+
return val in ("y", "yes", "1", "true")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def section(title: str) -> None:
|
|
54
|
+
"""Print a dimmed section divider for interactive mode."""
|
|
55
|
+
print()
|
|
56
|
+
print(DIM(" ─── " + title + " " + "─" * max(0, 40 - len(title))))
|