rimscan-cli 1.0.0__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.
@@ -0,0 +1,3 @@
1
+ """rimscan-cli: external attack-surface reconnaissance for domains you are authorized to test."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from rimscan_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
rimscan_cli/banner.py ADDED
@@ -0,0 +1,48 @@
1
+ """Terminal banner for the Rimscan CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TextIO
7
+
8
+ from . import __version__
9
+
10
+ UNICODE_BANNER = """\
11
+ ██████╗ ██╗███╗ ███╗███████╗ ██████╗ █████╗ ███╗ ██╗
12
+ ██╔══██╗██║████╗ ████║██╔════╝██╔════╝██╔══██╗████╗ ██║
13
+ ██████╔╝██║╔████╔██║███████╗██║ ███████║██╔██╗ ██║
14
+ ██╔══██╗██║██║╚██╔╝██║╚════██║██║ ██╔══██║██║╚██╗██║
15
+ ██║ ██║██║██║ ╚═╝ ██║███████║╚██████╗██║ ██║██║ ╚████║
16
+ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝"""
17
+
18
+ ASCII_BANNER = """\
19
+ ____ ___ __ __ ____ ____ _ _ _
20
+ | _ \\|_ _| \\/ / ___| / ___| / \\ | \\ | |
21
+ | |_) || || |\\/| \\___ \\| | / _ \\ | \\| |
22
+ | _ < | || | | |___) | |___ / ___ \\| |\\ |
23
+ |_| \\_\\___|_| |_|____/ \\____/_/ \\_\\_| \\_|"""
24
+
25
+ BANNER = UNICODE_BANNER
26
+
27
+
28
+ def _banner_for(output: TextIO) -> str:
29
+ """Use line art when the destination encoding supports it."""
30
+ encoding = getattr(output, "encoding", None) or "utf-8"
31
+ try:
32
+ UNICODE_BANNER.encode(encoding)
33
+ except (LookupError, UnicodeEncodeError):
34
+ return ASCII_BANNER
35
+ return UNICODE_BANNER
36
+
37
+
38
+ def render_banner(stream: TextIO | None = None) -> None:
39
+ """Print the startup banner to stderr unless another stream is supplied."""
40
+ output = stream or sys.stderr
41
+ print(_banner_for(output), file=output)
42
+ print(f"rimscan-cli v{__version__} | rimscan.cloud", file=output)
43
+ print("external attack-surface reconnaissance", file=output)
44
+ print(file=output)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ render_banner(sys.stdout)
rimscan_cli/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """Command-line interface for rimscan-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ from rimscan_cli import __version__
13
+ from rimscan_cli.banner import render_banner
14
+ from rimscan_cli.recon.enumeration import DEFAULT_SOURCES
15
+ from rimscan_cli.scanner import SEVERITY_RANK, InvalidArguments, scan_domain, severity_counts
16
+
17
+
18
+ def _positive_float(value: str) -> float:
19
+ try:
20
+ number = float(value)
21
+ except ValueError:
22
+ raise argparse.ArgumentTypeError(f"{value!r} is not a number") from None
23
+ if not number > 0 or number == float("inf"):
24
+ raise argparse.ArgumentTypeError("must be greater than 0")
25
+ return number
26
+
27
+
28
+ def _positive_int(value: str) -> int:
29
+ try:
30
+ number = int(value)
31
+ except ValueError:
32
+ raise argparse.ArgumentTypeError(f"{value!r} is not a whole number") from None
33
+ if number < 1:
34
+ raise argparse.ArgumentTypeError("must be at least 1")
35
+ return number
36
+
37
+
38
+ def _csv(value: str) -> List[str]:
39
+ """Split a comma-separated list, dropping blanks and repeats (order is kept)."""
40
+ return list(dict.fromkeys(part.strip() for part in value.split(",") if part.strip()))
41
+
42
+
43
+ def build_parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(
45
+ prog="rimscan-cli",
46
+ description="External attack-surface reconnaissance for domains you own or are authorized to test.",
47
+ epilog=(
48
+ "The target's domain is sent to public passive-DNS and certificate-transparency services "
49
+ "(crt.sh, Cert Spotter, AlienVault OTX, RapidDNS, Wayback Machine, Anubis). A default scan "
50
+ "also opens TLS connections to discovered hosts and sends at most one HTTP GET to hosts that "
51
+ "CNAME to a known third-party service. --secrets, --nuclei and --zone-transfer send further "
52
+ "probes to the target and require --authorized. Results go to stdout as JSON; progress goes "
53
+ "to stderr. Exit status: 0 ok, 1 findings reached --fail-on, 2 usage or output error, "
54
+ "3 scan incomplete (with --fail-on), 130 interrupted."
55
+ ),
56
+ )
57
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
58
+ commands = parser.add_subparsers(dest="command", required=True)
59
+ scan = commands.add_parser("scan", help="enumerate and audit a domain")
60
+ scan.add_argument("domain", help="domain to scan, e.g. example.com")
61
+ scan.add_argument("--authorized", action="store_true", help="confirm you own or may test this target")
62
+ scan.add_argument("--secrets", action="store_true", help="fetch well-known paths and look for exposed secrets (requires --authorized)")
63
+ scan.add_argument("--zone-transfer", action="store_true", help="attempt AXFR against the domain's nameservers (requires --authorized)")
64
+ scan.add_argument("--nuclei", action="store_true", help="run Nuclei with dos, fuzz and intrusive templates excluded (requires --authorized)")
65
+ scan.add_argument("--nuclei-rate-limit", type=_positive_int, default=75, metavar="N", help="Nuclei requests per second (default: 75)")
66
+ scan.add_argument("--include-third-party", action="store_true",
67
+ help="let --secrets and --nuclei also probe hosts that CNAME to third-party services (skipped by default)")
68
+ scan.add_argument("--bruteforce", action="store_true", help="also resolve a short list of common subdomain names")
69
+ scan.add_argument("--no-certs", action="store_true", help="skip the TLS certificate audit")
70
+ scan.add_argument("--sources", type=_csv, metavar="LIST",
71
+ help=f"comma-separated enumeration sources (default: {','.join(DEFAULT_SOURCES)})")
72
+ scan.add_argument("--resolver", type=_csv, metavar="IPS",
73
+ help="comma-separated DNS resolver addresses for DNS lookups (TLS and HTTP probes always use the system resolver)")
74
+ scan.add_argument("--timeout", type=_positive_float, default=10.0, metavar="SECONDS", help="network timeout (default: 10)")
75
+ scan.add_argument("-o", "--output", type=Path, help="write JSON results to this file instead of stdout")
76
+ scan.add_argument("--fail-on", choices=[*SEVERITY_RANK, "none"], default="none",
77
+ help="exit with status 1 if any finding is at or above this severity (3 if the scan was incomplete)")
78
+ scan.add_argument("-q", "--quiet", action="store_true", help="no banner, progress or summary on stderr")
79
+ scan.add_argument("--no-banner", action="store_true", help="do not print the banner")
80
+ scan.add_argument("--verbose", action="store_true", help="enable debug logging")
81
+ return parser
82
+
83
+
84
+ def main(argv: Optional[List[str]] = None) -> int:
85
+ parser = build_parser()
86
+ args = parser.parse_args(argv)
87
+ # --quiet silences stderr completely, including log warnings (--verbose still wins).
88
+ log_level = logging.DEBUG if args.verbose else (logging.CRITICAL + 1 if args.quiet else logging.WARNING)
89
+ logging.basicConfig(
90
+ force=True,
91
+ level=log_level,
92
+ format="%(levelname)s %(message)s",
93
+ stream=sys.stderr,
94
+ )
95
+ logging.getLogger("urllib3").setLevel(logging.ERROR)
96
+
97
+ if not (args.quiet or args.no_banner):
98
+ render_banner() # prints to stderr
99
+
100
+ def progress(message: str) -> None:
101
+ if not args.quiet:
102
+ print(f"[*] {message}", file=sys.stderr)
103
+
104
+ try:
105
+ result = scan_domain(
106
+ args.domain,
107
+ authorized=args.authorized,
108
+ include_zone_transfer=args.zone_transfer,
109
+ include_secrets=args.secrets,
110
+ include_certs=not args.no_certs,
111
+ include_nuclei=args.nuclei,
112
+ include_third_party=args.include_third_party,
113
+ bruteforce=args.bruteforce,
114
+ sources=args.sources,
115
+ timeout=args.timeout,
116
+ nameservers=args.resolver,
117
+ nuclei_rate_limit=args.nuclei_rate_limit,
118
+ progress=progress,
119
+ )
120
+ except InvalidArguments as exc:
121
+ parser.error(str(exc))
122
+ except KeyboardInterrupt:
123
+ print("interrupted", file=sys.stderr)
124
+ return 130
125
+
126
+ rendered = json.dumps(result, indent=2, sort_keys=True)
127
+ exit_code = 0
128
+ if args.output:
129
+ try:
130
+ args.output.parent.mkdir(parents=True, exist_ok=True)
131
+ args.output.write_text(rendered + "\n", encoding="utf-8")
132
+ except OSError as exc:
133
+ # The scan may have taken a long time; never lose its results over a bad path.
134
+ print(f"error: cannot write {args.output}: {exc.strerror}; printing results to stdout", file=sys.stderr)
135
+ print(rendered)
136
+ exit_code = 2
137
+ else:
138
+ progress(f"results written to {args.output}")
139
+ else:
140
+ print(rendered)
141
+
142
+ for warning in result.get("warnings", []):
143
+ progress(f"warning: {warning}")
144
+ counts = severity_counts(result)
145
+ if counts:
146
+ summary = ", ".join(f"{n} {level}" for level, n in sorted(counts.items(), key=lambda kv: -SEVERITY_RANK[kv[0]]))
147
+ progress(f"findings: {summary}")
148
+ else:
149
+ progress("no findings" + (" (scan incomplete)" if result.get("incomplete") else ""))
150
+
151
+ if exit_code:
152
+ return exit_code
153
+ if args.fail_on != "none":
154
+ if any(SEVERITY_RANK[level] >= SEVERITY_RANK[args.fail_on] for level in counts):
155
+ return 1
156
+ if result.get("incomplete"):
157
+ return 3
158
+ return 0