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 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)
@@ -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)} &mdash; 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}")