sm0g-cli 0.1.3__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.
- sm0g_cli/__init__.py +32 -0
- sm0g_cli/ansi.py +48 -0
- sm0g_cli/ask.py +56 -0
- sm0g_cli/base.py +68 -0
- sm0g_cli/colour.py +32 -0
- sm0g_cli/emit.py +148 -0
- sm0g_cli/grade.py +60 -0
- sm0g_cli/html_render.py +50 -0
- sm0g_cli/level.py +85 -0
- sm0g_cli/load.py +71 -0
- sm0g_cli/log.py +30 -0
- sm0g_cli/prompt.py +33 -0
- sm0g_cli/render.py +46 -0
- sm0g_cli/report.py +55 -0
- sm0g_cli/risk.py +38 -0
- sm0g_cli/sarif_render.py +54 -0
- sm0g_cli/signals.py +24 -0
- sm0g_cli-0.1.3.dist-info/METADATA +55 -0
- sm0g_cli-0.1.3.dist-info/RECORD +21 -0
- sm0g_cli-0.1.3.dist-info/WHEEL +4 -0
- sm0g_cli-0.1.3.dist-info/licenses/LICENSE +661 -0
sm0g_cli/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from .emit import (
|
|
7
|
+
ok, warn, fail, note, trace,
|
|
8
|
+
emit_header, emit_footer, emit_meta,
|
|
9
|
+
emit_hit, emit_hit_graded, emit_grade_summary, emit_errors,
|
|
10
|
+
evidence_url,
|
|
11
|
+
)
|
|
12
|
+
from .colour import red, green, yellow, cyan, bold, dim, draw_banner
|
|
13
|
+
from .level import FIND, ScanLogger, ColorSink, ReportSink, get_scan_logger, setup_logging
|
|
14
|
+
from .grade import Grade, grade_colour, grade_label, grade_score, GRADE_ORDER
|
|
15
|
+
from .ask import safe_int, ask, ask_bool, section
|
|
16
|
+
from .load import load_url_list, compile_skip_patterns, parse_headers, parse_credential, validate_timeout
|
|
17
|
+
from .render import dump_table, print_banner, clip_url
|
|
18
|
+
from .base import ScanBase
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ok", "warn", "fail", "note", "trace",
|
|
22
|
+
"emit_header", "emit_footer", "emit_meta",
|
|
23
|
+
"emit_hit", "emit_hit_graded", "emit_grade_summary", "emit_errors",
|
|
24
|
+
"evidence_url",
|
|
25
|
+
"red", "green", "yellow", "cyan", "bold", "dim", "draw_banner",
|
|
26
|
+
"FIND", "ScanLogger", "ColorSink", "ReportSink", "get_scan_logger", "setup_logging",
|
|
27
|
+
"Grade", "grade_colour", "grade_label", "grade_score", "GRADE_ORDER",
|
|
28
|
+
"safe_int", "ask", "ask_bool", "section",
|
|
29
|
+
"load_url_list", "compile_skip_patterns", "parse_headers", "parse_credential", "validate_timeout",
|
|
30
|
+
"dump_table", "print_banner", "clip_url",
|
|
31
|
+
"ScanBase",
|
|
32
|
+
]
|
sm0g_cli/ansi.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
_NO_COLOR = bool(os.environ.get("NO_COLOR", ""))
|
|
11
|
+
|
|
12
|
+
_ESC = "\033["
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _wrap(code: str, text: str) -> str:
|
|
16
|
+
if _NO_COLOR:
|
|
17
|
+
return text
|
|
18
|
+
return f"{_ESC}{code}m{text}{_ESC}0m"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bold(text: str) -> str:
|
|
22
|
+
return _wrap("1", text)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dim(text: str) -> str:
|
|
26
|
+
return _wrap("2", text)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def red(text: str) -> str:
|
|
30
|
+
return _wrap("31", text)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def green(text: str) -> str:
|
|
34
|
+
return _wrap("32", text)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def yellow(text: str) -> str:
|
|
38
|
+
return _wrap("33", text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cyan(text: str) -> str:
|
|
42
|
+
return _wrap("36", text)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def strip_color(text: str) -> str:
|
|
46
|
+
"""Remove all ANSI escape sequences from text."""
|
|
47
|
+
import re
|
|
48
|
+
return re.sub(r"\033\[[0-9;]*m", "", text)
|
sm0g_cli/ask.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from sm0g_cli.colour import bold, dim
|
|
11
|
+
|
|
12
|
+
__all__ = ["safe_int", "ask", "ask_bool", "section"]
|
|
13
|
+
|
|
14
|
+
_SECTION_WIDTH = 46
|
|
15
|
+
_TRUTHY = frozenset(("y", "yes", "1", "true"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def safe_int(val: str, default: int, lo: int, hi: int) -> int:
|
|
19
|
+
try:
|
|
20
|
+
return max(lo, min(int(val), hi))
|
|
21
|
+
except (TypeError, ValueError):
|
|
22
|
+
return default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ask(label: str, default: str = "", hint: str = "") -> str:
|
|
26
|
+
prompt = bold(label)
|
|
27
|
+
if default:
|
|
28
|
+
prompt = f"{prompt} {dim('[' + default + ']')}"
|
|
29
|
+
if hint:
|
|
30
|
+
prompt = f"{prompt} {dim(hint)}"
|
|
31
|
+
prompt += ": "
|
|
32
|
+
try:
|
|
33
|
+
val = input(prompt).strip()
|
|
34
|
+
except (EOFError, KeyboardInterrupt):
|
|
35
|
+
print()
|
|
36
|
+
sys.exit(0)
|
|
37
|
+
return val if val else default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ask_bool(label: str, default: bool = False) -> bool:
|
|
41
|
+
indicator = "Y/n" if default else "y/N"
|
|
42
|
+
prompt = f"{bold(label)} {dim('[' + indicator + ']')}: "
|
|
43
|
+
try:
|
|
44
|
+
val = input(prompt).strip().lower()
|
|
45
|
+
except (EOFError, KeyboardInterrupt):
|
|
46
|
+
print()
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
if not val:
|
|
49
|
+
return default
|
|
50
|
+
return val in _TRUTHY
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def section(title: str) -> None:
|
|
54
|
+
fill = "─" * max(0, _SECTION_WIDTH - len(title))
|
|
55
|
+
print()
|
|
56
|
+
print(dim(f" ── {title} {fill}"))
|
sm0g_cli/base.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__ = ["ScanBase"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ScanBase:
|
|
18
|
+
target: str
|
|
19
|
+
started_at: float = field(default_factory=time.time)
|
|
20
|
+
finished_at: float = 0.0
|
|
21
|
+
duration_s: float = 0.0
|
|
22
|
+
waf_detected: str | None = None
|
|
23
|
+
evasion_applied: str | None = None
|
|
24
|
+
crawled_urls: int = 0
|
|
25
|
+
params_tested: int = 0
|
|
26
|
+
requests_sent: int = 0
|
|
27
|
+
log: list[str] = field(default_factory=list)
|
|
28
|
+
errors: list[str] = field(default_factory=list)
|
|
29
|
+
_lock: threading.Lock = field(
|
|
30
|
+
default_factory=threading.Lock,
|
|
31
|
+
repr=False,
|
|
32
|
+
compare=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def close_scan(self) -> "ScanBase":
|
|
36
|
+
now = time.time()
|
|
37
|
+
self.finished_at = now
|
|
38
|
+
self.duration_s = round(now - self.started_at, 2)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def _record(self, attr: str, item: Any) -> None:
|
|
42
|
+
with self._lock:
|
|
43
|
+
lst: list = getattr(self, attr)
|
|
44
|
+
lst.append(item)
|
|
45
|
+
|
|
46
|
+
def append_error(self, msg: str) -> None:
|
|
47
|
+
self._record("errors", msg)
|
|
48
|
+
|
|
49
|
+
def append_log(self, msg: str) -> None:
|
|
50
|
+
self._record("log", msg)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def success(self) -> bool:
|
|
54
|
+
return not bool(self.errors) or bool(getattr(self, "total_findings", 0))
|
|
55
|
+
|
|
56
|
+
def _summary_fields(self) -> dict[str, Any]:
|
|
57
|
+
out: dict[str, Any] = {}
|
|
58
|
+
out["target"] = self.target
|
|
59
|
+
out["duration_s"] = self.duration_s
|
|
60
|
+
out["requests_sent"] = self.requests_sent
|
|
61
|
+
out["crawled_urls"] = self.crawled_urls
|
|
62
|
+
out["params_tested"] = self.params_tested
|
|
63
|
+
out["waf_detected"] = self.waf_detected
|
|
64
|
+
out["evasion_applied"] = self.evasion_applied
|
|
65
|
+
out["success"] = self.success
|
|
66
|
+
out["errors"] = self.errors
|
|
67
|
+
out["log"] = self.log
|
|
68
|
+
return out
|
sm0g_cli/colour.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _tty_active() -> bool:
|
|
13
|
+
if os.environ.get("NO_COLOR"):
|
|
14
|
+
return False
|
|
15
|
+
stream = sys.__stdout__ or sys.stdout
|
|
16
|
+
return getattr(stream, "isatty", lambda: False)()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ansi(code: str, text: str) -> str:
|
|
20
|
+
return text if not _tty_active() else "\x1b[" + code + "m" + text + "\x1b[0m"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def red(t: str) -> str: return _ansi("31;1", t)
|
|
24
|
+
def green(t: str) -> str: return _ansi("38;5;46", t)
|
|
25
|
+
def yellow(t: str) -> str: return _ansi("33;1", t)
|
|
26
|
+
def cyan(t: str) -> str: return _ansi("36", t)
|
|
27
|
+
def bold(t: str) -> str: return _ansi("1", t)
|
|
28
|
+
def dim(t: str) -> str: return _ansi("2", t)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def draw_banner(text: str) -> str:
|
|
32
|
+
return cyan(text)
|
sm0g_cli/emit.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.parse as _up
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from sm0g_cli.colour import bold, cyan, dim, green, red, yellow
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ok", "warn", "fail", "note", "trace",
|
|
16
|
+
"emit_header", "emit_footer", "emit_meta",
|
|
17
|
+
"emit_hit", "emit_hit_graded", "emit_grade_summary", "emit_errors",
|
|
18
|
+
"evidence_url",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_META_COL = 14
|
|
22
|
+
_FIELD_COL = 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ok(msg: str) -> None:
|
|
26
|
+
print(green(f"[+] {msg}"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def warn(msg: str) -> None:
|
|
30
|
+
print(yellow(f"[!] {msg}"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fail(msg: str) -> None:
|
|
34
|
+
print(red(f"[!] {msg}"), file=sys.stderr)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def note(msg: str) -> None:
|
|
38
|
+
print(dim(f"[*] {msg}"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def trace(msg: str) -> None:
|
|
42
|
+
print(cyan(f"[~] {msg}"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def emit_header(title: str, width: int = 60) -> None:
|
|
46
|
+
print()
|
|
47
|
+
print(bold("=" * width))
|
|
48
|
+
print(bold(f" {title}"))
|
|
49
|
+
print(bold("=" * width))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def emit_footer(width: int = 60) -> None:
|
|
53
|
+
print(bold("=" * width))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _row(label: str, value: str) -> None:
|
|
57
|
+
print(f" {label:<{_META_COL}}: {value}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def emit_meta(
|
|
61
|
+
target: str,
|
|
62
|
+
duration_s: float,
|
|
63
|
+
requests_sent: int,
|
|
64
|
+
crawled_urls: int,
|
|
65
|
+
params_tested: int,
|
|
66
|
+
*,
|
|
67
|
+
waf_detected: str | None = None,
|
|
68
|
+
evasion_applied: str | None = None,
|
|
69
|
+
**extra: str,
|
|
70
|
+
) -> None:
|
|
71
|
+
rows = [
|
|
72
|
+
("Target", target),
|
|
73
|
+
("Duration", f"{duration_s}s"),
|
|
74
|
+
("Requests sent", str(requests_sent)),
|
|
75
|
+
("URLs crawled", str(crawled_urls)),
|
|
76
|
+
("Params tested", str(params_tested)),
|
|
77
|
+
("WAF detected", waf_detected or "None"),
|
|
78
|
+
("Evasion used", evasion_applied or "None"),
|
|
79
|
+
]
|
|
80
|
+
for k, v in extra.items():
|
|
81
|
+
rows.append((k, v))
|
|
82
|
+
for label, value in rows:
|
|
83
|
+
_row(label, value)
|
|
84
|
+
print()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def emit_hit(
|
|
88
|
+
index: int,
|
|
89
|
+
tag: str,
|
|
90
|
+
tag_colour_fn: Callable[[str], str],
|
|
91
|
+
fields: list[tuple[str, str]],
|
|
92
|
+
proof: str = "",
|
|
93
|
+
) -> None:
|
|
94
|
+
print(f" {index}. {tag_colour_fn(f'[{tag}]')}")
|
|
95
|
+
for label, value in fields:
|
|
96
|
+
print(f" {label:<{_FIELD_COL}}: {value}")
|
|
97
|
+
if proof:
|
|
98
|
+
print(f" {'Proof':<{_FIELD_COL}}: {cyan(proof)}")
|
|
99
|
+
print()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def emit_hit_graded(
|
|
103
|
+
index: int,
|
|
104
|
+
tag: str,
|
|
105
|
+
severity: str,
|
|
106
|
+
fields: list[tuple[str, str]],
|
|
107
|
+
proof: str = "",
|
|
108
|
+
) -> None:
|
|
109
|
+
from sm0g_cli.grade import grade_colour, grade_label
|
|
110
|
+
colour_fn = grade_colour(severity)
|
|
111
|
+
full_tag = f"{tag} [{grade_label(severity).strip()}]"
|
|
112
|
+
print(f" {index}. {colour_fn(f'[{full_tag}]')}")
|
|
113
|
+
for label, value in fields:
|
|
114
|
+
print(f" {label:<{_FIELD_COL}}: {value}")
|
|
115
|
+
if proof:
|
|
116
|
+
print(f" {'Proof':<{_FIELD_COL}}: {cyan(proof)}")
|
|
117
|
+
print()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def emit_grade_summary(counts: dict[str, int]) -> None:
|
|
121
|
+
from sm0g_cli.grade import GRADE_ORDER, grade_colour
|
|
122
|
+
parts: list[str] = []
|
|
123
|
+
for sev in GRADE_ORDER:
|
|
124
|
+
n = counts.get(sev, 0)
|
|
125
|
+
colour_fn = grade_colour(sev) if n > 0 else dim
|
|
126
|
+
parts.append(colour_fn(f"{sev.upper()} {n}"))
|
|
127
|
+
print(f" {'Severity':<{_META_COL}}: {' '.join(parts)}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def emit_errors(errors: list[str]) -> None:
|
|
131
|
+
if not errors:
|
|
132
|
+
return
|
|
133
|
+
print(red(" Errors:"))
|
|
134
|
+
for e in errors:
|
|
135
|
+
print(f" - {e}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def evidence_url(url: str, param: str, payload: str, *, append: bool = True) -> str:
|
|
139
|
+
try:
|
|
140
|
+
parsed = _up.urlparse(url)
|
|
141
|
+
if not parsed.scheme or not parsed.netloc:
|
|
142
|
+
return ""
|
|
143
|
+
qs = _up.parse_qs(parsed.query, keep_blank_values=True)
|
|
144
|
+
injected = (qs.get(param, ["1"])[0] + payload) if append else payload
|
|
145
|
+
qs[param] = [injected]
|
|
146
|
+
return _up.urlunparse(parsed._replace(query=_up.urlencode(qs, doseq=True)))
|
|
147
|
+
except (ValueError, AttributeError):
|
|
148
|
+
return ""
|
sm0g_cli/grade.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from sm0g_cli.colour import red, yellow, cyan, dim
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Grade",
|
|
14
|
+
"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO",
|
|
15
|
+
"grade_colour", "grade_label", "grade_score",
|
|
16
|
+
"GRADE_ORDER",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Grade:
|
|
21
|
+
CRITICAL = "critical"
|
|
22
|
+
HIGH = "high"
|
|
23
|
+
MEDIUM = "medium"
|
|
24
|
+
LOW = "low"
|
|
25
|
+
INFO = "info"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
CRITICAL = Grade.CRITICAL
|
|
29
|
+
HIGH = Grade.HIGH
|
|
30
|
+
MEDIUM = Grade.MEDIUM
|
|
31
|
+
LOW = Grade.LOW
|
|
32
|
+
INFO = Grade.INFO
|
|
33
|
+
|
|
34
|
+
GRADE_ORDER: list[str] = [CRITICAL, HIGH, MEDIUM, LOW, INFO]
|
|
35
|
+
|
|
36
|
+
_GRADE_SCORE: dict[str, int] = {
|
|
37
|
+
CRITICAL: 4,
|
|
38
|
+
HIGH: 3,
|
|
39
|
+
MEDIUM: 2,
|
|
40
|
+
LOW: 1,
|
|
41
|
+
INFO: 0,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def grade_colour(severity: str) -> Callable[[str], str]:
|
|
46
|
+
if severity in (CRITICAL, HIGH):
|
|
47
|
+
return red
|
|
48
|
+
if severity == MEDIUM:
|
|
49
|
+
return yellow
|
|
50
|
+
if severity == LOW:
|
|
51
|
+
return cyan
|
|
52
|
+
return dim
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def grade_label(severity: str) -> str:
|
|
56
|
+
return f"{severity.upper():<8}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def grade_score(severity: str) -> int:
|
|
60
|
+
return _GRADE_SCORE.get(severity, 0)
|
sm0g_cli/html_render.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
from .report import ProbeReport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_html_report(report: ProbeReport) -> str:
|
|
13
|
+
"""Render a ProbeReport as a self-contained HTML document string."""
|
|
14
|
+
rows = ""
|
|
15
|
+
for hit in report.hits:
|
|
16
|
+
rows += (
|
|
17
|
+
f"<tr>"
|
|
18
|
+
f"<td>{html.escape(hit.url)}</td>"
|
|
19
|
+
f"<td>{html.escape(hit.parameter)}</td>"
|
|
20
|
+
f"<td>{html.escape(hit.method)}</td>"
|
|
21
|
+
f"<td><code>{html.escape(hit.payload)}</code></td>"
|
|
22
|
+
f"<td>{html.escape(hit.risk.label())}</td>"
|
|
23
|
+
f"<td>{html.escape(hit.evidence)}</td>"
|
|
24
|
+
f"</tr>\n"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return f"""<!DOCTYPE html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="UTF-8">
|
|
31
|
+
<title>SM0G Report — {html.escape(report.tool)}</title>
|
|
32
|
+
<style>
|
|
33
|
+
body {{ font-family: monospace; background: #111; color: #eee; padding: 1rem; }}
|
|
34
|
+
h1 {{ color: #0f0; }}
|
|
35
|
+
table {{ border-collapse: collapse; width: 100%; }}
|
|
36
|
+
th, td {{ border: 1px solid #444; padding: 6px 10px; text-align: left; }}
|
|
37
|
+
th {{ background: #222; color: #0f0; }}
|
|
38
|
+
tr:nth-child(even) {{ background: #1a1a1a; }}
|
|
39
|
+
code {{ color: #f90; }}
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<h1>SM0G :: {html.escape(report.tool)}</h1>
|
|
44
|
+
<p>Target: {html.escape(report.target)} — Findings: {report.hit_count}</p>
|
|
45
|
+
<table>
|
|
46
|
+
<thead><tr><th>URL</th><th>Param</th><th>Method</th><th>Payload</th><th>Risk</th><th>Evidence</th></tr></thead>
|
|
47
|
+
<tbody>{rows}</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
</body>
|
|
50
|
+
</html>"""
|
sm0g_cli/level.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
from sm0g_cli.colour import green, yellow, cyan, bold
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"FIND",
|
|
13
|
+
"ScanLogger",
|
|
14
|
+
"ColorSink",
|
|
15
|
+
"ReportSink",
|
|
16
|
+
"get_scan_logger",
|
|
17
|
+
"setup_logging",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
FIND = 25
|
|
21
|
+
logging.addLevelName(FIND, "FIND")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScanLogger(logging.Logger):
|
|
25
|
+
def hit(self, msg: str, *args, **kwargs) -> None:
|
|
26
|
+
if self.isEnabledFor(FIND):
|
|
27
|
+
self._log(FIND, msg, args, **kwargs)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
logging.setLoggerClass(ScanLogger)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_scan_logger(name: str) -> ScanLogger:
|
|
34
|
+
return logging.getLogger(name) # type: ignore[return-value]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Console styling is data-driven: each severity selects a painter + badge.
|
|
38
|
+
# WARNING and above share the alert style and always render (even an empty
|
|
39
|
+
# message); quieter levels collapse an empty message to a bare line.
|
|
40
|
+
_ALERT_STYLE = (yellow, "[!]")
|
|
41
|
+
_PLAIN_STYLE = (bold, "[*]")
|
|
42
|
+
_LEVEL_STYLES: dict[int, tuple] = {
|
|
43
|
+
FIND: (green, "[+]"),
|
|
44
|
+
logging.DEBUG: (cyan, "[~]"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ColorSink(logging.StreamHandler):
|
|
49
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
50
|
+
try:
|
|
51
|
+
text = record.getMessage()
|
|
52
|
+
level = record.levelno
|
|
53
|
+
if level >= logging.WARNING:
|
|
54
|
+
paint, badge = _ALERT_STYLE
|
|
55
|
+
rendered: str | None = paint(f"{badge} {text}")
|
|
56
|
+
else:
|
|
57
|
+
paint, badge = _LEVEL_STYLES.get(level, _PLAIN_STYLE)
|
|
58
|
+
rendered = paint(f"{badge} {text}") if text else None
|
|
59
|
+
print(rendered if rendered is not None else "")
|
|
60
|
+
if record.exc_info:
|
|
61
|
+
traceback.print_exception(*record.exc_info)
|
|
62
|
+
self.flush()
|
|
63
|
+
except Exception:
|
|
64
|
+
self.handleError(record)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def setup_logging(verbose: bool, quiet: bool, logger_name: str) -> None:
|
|
68
|
+
root = get_scan_logger(logger_name)
|
|
69
|
+
while root.handlers:
|
|
70
|
+
root.handlers.pop().close()
|
|
71
|
+
root.propagate = False
|
|
72
|
+
if quiet:
|
|
73
|
+
root.setLevel(logging.ERROR)
|
|
74
|
+
return
|
|
75
|
+
root.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
76
|
+
root.addHandler(ColorSink())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ReportSink(logging.Handler):
|
|
80
|
+
def __init__(self, result) -> None:
|
|
81
|
+
super().__init__()
|
|
82
|
+
self._result = result
|
|
83
|
+
|
|
84
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
85
|
+
self._result._record("log", self.format(record))
|
sm0g_cli/load.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"load_url_list",
|
|
14
|
+
"compile_skip_patterns",
|
|
15
|
+
"parse_headers",
|
|
16
|
+
"parse_credential",
|
|
17
|
+
"validate_timeout",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_url_list(path: str) -> list[str]:
|
|
22
|
+
try:
|
|
23
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
24
|
+
return [ln for ln in (l.strip() for l in lines) if ln and not ln.startswith("#")]
|
|
25
|
+
except OSError as exc:
|
|
26
|
+
print(f"[!] Cannot read URL list: {exc}", file=sys.stderr)
|
|
27
|
+
sys.exit(2)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compile_skip_patterns(
|
|
31
|
+
patterns: list[str],
|
|
32
|
+
exit_on_error: bool = True,
|
|
33
|
+
) -> list[re.Pattern]:
|
|
34
|
+
out: list[re.Pattern] = []
|
|
35
|
+
for pat in patterns:
|
|
36
|
+
try:
|
|
37
|
+
out.append(re.compile(pat))
|
|
38
|
+
except re.error as exc:
|
|
39
|
+
msg = f"[!] Invalid --exclude pattern '{pat}': {exc}"
|
|
40
|
+
if exit_on_error:
|
|
41
|
+
print(msg, file=sys.stderr)
|
|
42
|
+
sys.exit(2)
|
|
43
|
+
raise ValueError(msg) from exc
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_headers(header_list: list[str]) -> dict[str, str]:
|
|
48
|
+
result: dict[str, str] = {}
|
|
49
|
+
for item in header_list:
|
|
50
|
+
if ":" not in item:
|
|
51
|
+
continue
|
|
52
|
+
name, value = item.split(":", 1)
|
|
53
|
+
result[name.strip()] = value.strip()
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_credential(cred: str) -> tuple[str, str]:
|
|
58
|
+
if not cred or ":" not in cred:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"credential must be in 'username:password' format, got {cred!r}"
|
|
61
|
+
)
|
|
62
|
+
idx = cred.index(":")
|
|
63
|
+
return cred[:idx], cred[idx + 1:]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_timeout(timeout: int, min_val: int = 5) -> None:
|
|
67
|
+
if timeout < min_val:
|
|
68
|
+
print(
|
|
69
|
+
f"[!] --timeout {timeout} is below {min_val}s minimum; raising to {min_val}s",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
sm0g_cli/log.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 SM0G-Community
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
# Copyright (c) 2026 SM0G-Community
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from typing import TextIO
|
|
11
|
+
|
|
12
|
+
FMT: str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
13
|
+
DATE_FMT: str = "%H:%M:%S"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup_log(level: int = logging.INFO, stream: TextIO = sys.stderr) -> logging.Logger:
|
|
17
|
+
"""Configure and return the root sm0g logger."""
|
|
18
|
+
logger = logging.getLogger("sm0g")
|
|
19
|
+
if logger.handlers:
|
|
20
|
+
return logger
|
|
21
|
+
handler = logging.StreamHandler(stream)
|
|
22
|
+
handler.setFormatter(logging.Formatter(FMT, datefmt=DATE_FMT))
|
|
23
|
+
logger.addHandler(handler)
|
|
24
|
+
logger.setLevel(level)
|
|
25
|
+
return logger
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_log(name: str) -> logging.Logger:
|
|
29
|
+
"""Return a child logger under the sm0g namespace."""
|
|
30
|
+
return logging.getLogger(f"sm0g.{name}")
|