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.
- rimscan_cli/__init__.py +3 -0
- rimscan_cli/__main__.py +4 -0
- rimscan_cli/banner.py +48 -0
- rimscan_cli/cli.py +158 -0
- rimscan_cli/data/fingerprints.json +932 -0
- rimscan_cli/recon/__init__.py +0 -0
- rimscan_cli/recon/_common.py +62 -0
- rimscan_cli/recon/_http.py +65 -0
- rimscan_cli/recon/cert_audit.py +131 -0
- rimscan_cli/recon/dns_audit.py +196 -0
- rimscan_cli/recon/enumeration.py +192 -0
- rimscan_cli/recon/resolver.py +232 -0
- rimscan_cli/recon/secret_scan.py +241 -0
- rimscan_cli/recon/severity.py +15 -0
- rimscan_cli/recon/takeover.py +302 -0
- rimscan_cli/scanner.py +305 -0
- rimscan_cli-1.0.0.dist-info/METADATA +250 -0
- rimscan_cli-1.0.0.dist-info/RECORD +23 -0
- rimscan_cli-1.0.0.dist-info/WHEEL +5 -0
- rimscan_cli-1.0.0.dist-info/entry_points.txt +2 -0
- rimscan_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- rimscan_cli-1.0.0.dist-info/licenses/NOTICE +8 -0
- rimscan_cli-1.0.0.dist-info/top_level.txt +1 -0
rimscan_cli/__init__.py
ADDED
rimscan_cli/__main__.py
ADDED
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
|