secably 0.1.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.
secably/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """secably — a small command-line toolkit for quick security checks.
2
+
3
+ Free, local checks (TLS, security headers, DNS/email posture, subdomains) run
4
+ on your machine with no account. Heavier server-side scans use the free Secably
5
+ API. https://secably.com
6
+ """
7
+
8
+ __version__ = "0.1.0"
secably/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
secably/api.py ADDED
@@ -0,0 +1,79 @@
1
+ """Thin client for Secably's hosted scan API.
2
+
3
+ Only the heavier, server-side scans (full website vulnerability scan, port
4
+ scan) go through here — the SSL / headers / DNS / subdomain commands run
5
+ entirely on your machine and need no account. A free API key from
6
+ https://secably.com/dashboard/api-keys/ raises the daily limit.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+ import urllib.request
14
+ import urllib.error
15
+
16
+ DEFAULT_BASE = os.environ.get("SECABLY_API_URL", "https://secably.com")
17
+ UTM = "utm_source=cli&utm_medium=github&utm_campaign=secably-cli"
18
+
19
+
20
+ class SecablyAPIError(RuntimeError):
21
+ pass
22
+
23
+
24
+ class NeedsKey(SecablyAPIError):
25
+ """Raised when the server rejects the request for missing/exhausted quota."""
26
+
27
+
28
+ def _request(method: str, path: str, api_key: str | None, payload: dict | None = None,
29
+ timeout: float = 30.0) -> dict:
30
+ url = f"{DEFAULT_BASE}{path}"
31
+ data = json.dumps(payload).encode() if payload is not None else None
32
+ headers = {"Content-Type": "application/json", "User-Agent": "secably-cli"}
33
+ if api_key:
34
+ headers["X-API-Key"] = api_key
35
+
36
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
37
+ try:
38
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
39
+ return json.loads(resp.read().decode("utf-8", "replace"))
40
+ except urllib.error.HTTPError as exc:
41
+ body = exc.read().decode("utf-8", "replace")
42
+ if exc.code == 429:
43
+ raise NeedsKey(
44
+ "Daily scan limit reached. Get a free API key at "
45
+ f"https://secably.com/dashboard/api-keys/?{UTM} and pass it with "
46
+ "--api-key or the SECABLY_API_KEY environment variable."
47
+ )
48
+ if exc.code in (401, 403):
49
+ raise NeedsKey(
50
+ "This scan needs a valid Secably API key. Create one free at "
51
+ f"https://secably.com/dashboard/api-keys/?{UTM}"
52
+ )
53
+ raise SecablyAPIError(f"HTTP {exc.code}: {body[:300]}")
54
+ except urllib.error.URLError as exc:
55
+ raise SecablyAPIError(f"Cannot reach Secably API: {exc.reason}")
56
+
57
+
58
+ def run_scan(tool: str, target: str, api_key: str | None = None,
59
+ parameters: dict | None = None, poll_interval: float = 3.0,
60
+ max_wait: float = 300.0) -> dict:
61
+ """Create a scan and poll until it finishes. Returns the completed result."""
62
+ created = _request("POST", "/api/v1/tools/scan/", api_key,
63
+ {"tool": tool, "target": target, "parameters": parameters or {}})
64
+ scan_id = created.get("id")
65
+ if not scan_id:
66
+ raise SecablyAPIError(f"Unexpected response: {created}")
67
+
68
+ waited = 0.0
69
+ while waited < max_wait:
70
+ status = _request("GET", f"/api/v1/tools/scan/{scan_id}/", api_key)
71
+ state = status.get("status")
72
+ if state == "completed":
73
+ return status
74
+ if state == "failed":
75
+ raise SecablyAPIError(f"Scan failed: {status.get('error', 'unknown error')}")
76
+ time.sleep(poll_interval)
77
+ waited += poll_interval
78
+
79
+ raise SecablyAPIError(f"Scan {scan_id} did not finish within {max_wait:.0f}s")
secably/checks/dns.py ADDED
@@ -0,0 +1,89 @@
1
+ """DNS record lookup + email-security posture (SPF / DMARC / DNSSEC).
2
+
3
+ Uses dnspython when available; otherwise degrades to the standard library for
4
+ the basic record types so the command still works without the optional dep.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ try:
9
+ import dns.resolver
10
+ import dns.dnssec # noqa: F401 (import proves DNSSEC support is present)
11
+ _HAVE_DNSPYTHON = True
12
+ except ImportError: # pragma: no cover
13
+ _HAVE_DNSPYTHON = False
14
+
15
+ _RECORD_TYPES = ["A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA"]
16
+
17
+
18
+ def _query(resolver, name: str, rdtype: str) -> list[str]:
19
+ try:
20
+ answers = resolver.resolve(name, rdtype)
21
+ return [r.to_text() for r in answers]
22
+ except Exception: # noqa: BLE001 - NXDOMAIN/NoAnswer/timeout all mean "nothing"
23
+ return []
24
+
25
+
26
+ def check_dns(domain: str, timeout: float = 10.0) -> dict:
27
+ domain = domain.strip().rstrip(".").replace("https://", "").replace("http://", "").split("/")[0]
28
+
29
+ if not _HAVE_DNSPYTHON:
30
+ return _fallback(domain)
31
+
32
+ resolver = dns.resolver.Resolver()
33
+ resolver.lifetime = timeout
34
+ resolver.timeout = timeout
35
+
36
+ records = {rt: _query(resolver, domain, rt) for rt in _RECORD_TYPES}
37
+
38
+ txt = records.get("TXT", [])
39
+ spf = [t for t in txt if "v=spf1" in t.lower()]
40
+ dmarc = _query(resolver, f"_dmarc.{domain}", "TXT")
41
+ dmarc = [t for t in dmarc if "v=dmarc1" in t.lower()]
42
+
43
+ # DNSSEC: presence of a DNSKEY at the zone apex.
44
+ dnssec = bool(_query(resolver, domain, "DNSKEY"))
45
+
46
+ warnings = []
47
+ if not spf:
48
+ warnings.append("No SPF record — sender spoofing is easier without one.")
49
+ if not dmarc:
50
+ warnings.append("No DMARC record — no policy for handling spoofed mail.")
51
+ if not dnssec:
52
+ warnings.append("DNSSEC not enabled — responses are not cryptographically signed.")
53
+
54
+ return {
55
+ "domain": domain,
56
+ "records": records,
57
+ "spf": spf,
58
+ "dmarc": dmarc,
59
+ "dnssec": dnssec,
60
+ "warnings": warnings,
61
+ "engine": "dnspython",
62
+ }
63
+
64
+
65
+ def _fallback(domain: str) -> dict:
66
+ """Minimal A/AAAA lookup via the stdlib when dnspython is missing."""
67
+ import socket
68
+
69
+ a, aaaa = [], []
70
+ try:
71
+ for info in socket.getaddrinfo(domain, None):
72
+ family, addr = info[0], info[4][0]
73
+ if family == socket.AF_INET:
74
+ a.append(addr)
75
+ elif family == socket.AF_INET6:
76
+ aaaa.append(addr)
77
+ except socket.gaierror:
78
+ pass
79
+
80
+ return {
81
+ "domain": domain,
82
+ "records": {"A": sorted(set(a)), "AAAA": sorted(set(aaaa))},
83
+ "spf": [],
84
+ "dmarc": [],
85
+ "dnssec": False,
86
+ "warnings": ["Install 'dnspython' for MX/TXT/SPF/DMARC/DNSSEC checks "
87
+ "(pip install secably[dns])."],
88
+ "engine": "stdlib",
89
+ }
@@ -0,0 +1,79 @@
1
+ """HTTP security-header grading — mirrors the logic behind Secably's HTTP Headers tool."""
2
+ from __future__ import annotations
3
+
4
+ import urllib.request
5
+ import urllib.error
6
+ import ssl
7
+
8
+ # (header, weight, short advice) — weights sum to 100.
9
+ _SCORED = [
10
+ ("strict-transport-security", 25, "Add HSTS to force HTTPS and prevent downgrade attacks."),
11
+ ("content-security-policy", 25, "Add a Content-Security-Policy to mitigate XSS and injection."),
12
+ ("x-frame-options", 15, "Set X-Frame-Options (or CSP frame-ancestors) to block clickjacking."),
13
+ ("x-content-type-options", 15, "Set X-Content-Type-Options: nosniff."),
14
+ ("referrer-policy", 10, "Set a Referrer-Policy to limit referrer leakage."),
15
+ ("permissions-policy", 10, "Set a Permissions-Policy to restrict powerful browser features."),
16
+ ]
17
+
18
+ # Headers that leak stack details — flagged but not scored against the grade.
19
+ _LEAKY = ["server", "x-powered-by", "x-aspnet-version", "x-generator"]
20
+
21
+
22
+ def _grade(score: int) -> str:
23
+ if score >= 90:
24
+ return "A"
25
+ if score >= 75:
26
+ return "B"
27
+ if score >= 60:
28
+ return "C"
29
+ if score >= 40:
30
+ return "D"
31
+ if score >= 20:
32
+ return "E"
33
+ return "F"
34
+
35
+
36
+ def check_headers(url: str, timeout: float = 10.0) -> dict:
37
+ if not url.startswith(("http://", "https://")):
38
+ url = "https://" + url
39
+
40
+ ctx = ssl.create_default_context()
41
+ ctx.check_hostname = False
42
+ ctx.verify_mode = ssl.CERT_NONE
43
+
44
+ req = urllib.request.Request(
45
+ url,
46
+ method="GET",
47
+ headers={"User-Agent": "secably-cli (+https://secably.com)"},
48
+ )
49
+ try:
50
+ resp = urllib.request.urlopen(req, timeout=timeout, context=ctx)
51
+ status = resp.status
52
+ raw = {k.lower(): v for k, v in resp.headers.items()}
53
+ final_url = resp.geturl()
54
+ except urllib.error.HTTPError as exc:
55
+ # Still inspect headers on 4xx/5xx responses.
56
+ status = exc.code
57
+ raw = {k.lower(): v for k, v in exc.headers.items()}
58
+ final_url = url
59
+
60
+ present, missing = [], []
61
+ score = 0
62
+ for name, weight, advice in _SCORED:
63
+ if name in raw:
64
+ score += weight
65
+ present.append({"header": name, "value": raw[name]})
66
+ else:
67
+ missing.append({"header": name, "advice": advice})
68
+
69
+ leaks = [{"header": h, "value": raw[h]} for h in _LEAKY if h in raw]
70
+
71
+ return {
72
+ "url": final_url,
73
+ "status": status,
74
+ "score": score,
75
+ "grade": _grade(score),
76
+ "present": present,
77
+ "missing": missing,
78
+ "info_leaks": leaks,
79
+ }
@@ -0,0 +1,53 @@
1
+ """Subdomain enumeration via Certificate Transparency logs (crt.sh).
2
+
3
+ This is the same passive technique behind Secably's Subdomain Finder: query
4
+ public CT logs, which record every TLS certificate issued for a domain, then
5
+ deduplicate the names. No brute force, no traffic to the target.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import socket
11
+ import urllib.parse
12
+ import urllib.request
13
+
14
+ _CRT_SH = "https://crt.sh/?q=%25.{domain}&output=json"
15
+
16
+
17
+ def _fetch_crtsh(domain: str, timeout: float) -> list[str]:
18
+ url = _CRT_SH.format(domain=urllib.parse.quote(domain))
19
+ req = urllib.request.Request(url, headers={"User-Agent": "secably-cli (+https://secably.com)"})
20
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
21
+ data = json.loads(resp.read().decode("utf-8", "replace"))
22
+
23
+ names: set[str] = set()
24
+ for row in data:
25
+ for field in ("name_value", "common_name"):
26
+ value = row.get(field, "")
27
+ for name in value.split("\n"):
28
+ name = name.strip().lower().lstrip("*.")
29
+ if name.endswith(domain) and name != domain:
30
+ names.add(name)
31
+ return sorted(names)
32
+
33
+
34
+ def check_subdomains(domain: str, resolve: bool = False, timeout: float = 30.0) -> dict:
35
+ domain = domain.strip().rstrip(".").lower().replace("https://", "").replace("http://", "").split("/")[0]
36
+
37
+ found = _fetch_crtsh(domain, timeout)
38
+
39
+ resolved = {}
40
+ if resolve:
41
+ for name in found:
42
+ try:
43
+ resolved[name] = socket.gethostbyname(name)
44
+ except socket.gaierror:
45
+ resolved[name] = None
46
+
47
+ return {
48
+ "domain": domain,
49
+ "count": len(found),
50
+ "subdomains": found,
51
+ "resolved": resolved,
52
+ "source": "crt.sh (Certificate Transparency)",
53
+ }
secably/checks/tls.py ADDED
@@ -0,0 +1,129 @@
1
+ """TLS/SSL certificate inspection — pure standard library, no network service needed."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import socket
6
+ import ssl
7
+ import tempfile
8
+ from datetime import datetime, timezone
9
+
10
+ # Protocols considered obsolete/insecure if the server still negotiates them.
11
+ _WEAK_PROTOCOLS = {"SSLv2", "SSLv3", "TLSv1", "TLSv1.1"}
12
+
13
+
14
+ _MONTHS = {m: i for i, m in enumerate(
15
+ ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
16
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], start=1)}
17
+
18
+
19
+ def _parse_cert_time(value: str) -> datetime:
20
+ # OpenSSL format: 'Jun 1 12:00:00 2026 GMT' — always English month names
21
+ # regardless of system locale, so parse the fields by hand rather than via
22
+ # strptime('%b'), which is locale-dependent and breaks on non-English hosts.
23
+ parts = value.split() # ['Jun', '1', '12:00:00', '2026', 'GMT']
24
+ if len(parts) < 4:
25
+ raise ValueError(f"Unrecognized certificate time: {value!r}")
26
+ month = _MONTHS[parts[0][:3]]
27
+ day = int(parts[1])
28
+ hour, minute, second = (int(x) for x in parts[2].split(":"))
29
+ year = int(parts[3])
30
+ return datetime(year, month, day, hour, minute, second, tzinfo=timezone.utc)
31
+
32
+
33
+ def _flatten_name(rdn_sequence) -> dict:
34
+ out = {}
35
+ for rdn in rdn_sequence:
36
+ for key, val in rdn:
37
+ out[key] = val
38
+ return out
39
+
40
+
41
+ def _decode_der(der: bytes) -> dict:
42
+ """Decode a DER certificate into the same dict shape as getpeercert().
43
+
44
+ Needed because getpeercert() returns {} under CERT_NONE, but we still want
45
+ to inspect expired/self-signed certs. Uses the stdlib's own decoder.
46
+ """
47
+ pem = ssl.DER_cert_to_PEM_cert(der)
48
+ tmp = tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False)
49
+ try:
50
+ tmp.write(pem)
51
+ tmp.close()
52
+ return ssl._ssl._test_decode_cert(tmp.name) # noqa: SLF001 - stable stdlib helper
53
+ finally:
54
+ os.unlink(tmp.name)
55
+
56
+
57
+ def check_tls(host: str, port: int = 443, timeout: float = 10.0) -> dict:
58
+ """Connect, complete the TLS handshake and describe the certificate + protocol.
59
+
60
+ Returns a dict with cert subject/issuer, validity window, days_left, the
61
+ negotiated protocol/cipher, and a list of human-readable warnings.
62
+ """
63
+ host = host.strip().replace("https://", "").replace("http://", "").split("/")[0]
64
+ if ":" in host:
65
+ host, _, maybe_port = host.partition(":")
66
+ if maybe_port.isdigit():
67
+ port = int(maybe_port)
68
+
69
+ ctx = ssl.create_default_context()
70
+ ctx.check_hostname = False
71
+ ctx.verify_mode = ssl.CERT_NONE # we inspect, we don't reject
72
+
73
+ warnings: list[str] = []
74
+ with socket.create_connection((host, port), timeout=timeout) as sock:
75
+ with ctx.wrap_socket(sock, server_hostname=host) as tls:
76
+ der = tls.getpeercert(binary_form=True)
77
+ protocol = tls.version()
78
+ cipher = tls.cipher()
79
+ cert = _decode_der(der) if der else {}
80
+
81
+ # A verifying context is also tried so we can report trust separately.
82
+ trusted = True
83
+ trust_error = None
84
+ try:
85
+ vctx = ssl.create_default_context()
86
+ with socket.create_connection((host, port), timeout=timeout) as sock:
87
+ with vctx.wrap_socket(sock, server_hostname=host):
88
+ pass
89
+ except ssl.SSLCertVerificationError as exc:
90
+ trusted = False
91
+ trust_error = exc.verify_message or str(exc)
92
+ except Exception: # noqa: BLE001 - hostname mismatch etc. still means "not trusted as-is"
93
+ trusted = False
94
+
95
+ subject = _flatten_name(cert.get("subject", []))
96
+ issuer = _flatten_name(cert.get("issuer", []))
97
+ not_after = _parse_cert_time(cert["notAfter"])
98
+ not_before = _parse_cert_time(cert["notBefore"])
99
+ now = datetime.now(timezone.utc)
100
+ days_left = (not_after - now).days
101
+
102
+ sans = [v for k, v in cert.get("subjectAltName", []) if k == "DNS"]
103
+
104
+ if days_left < 0:
105
+ warnings.append(f"Certificate EXPIRED {abs(days_left)} day(s) ago")
106
+ elif days_left <= 14:
107
+ warnings.append(f"Certificate expires in {days_left} day(s)")
108
+ if now < not_before:
109
+ warnings.append("Certificate is not valid yet (notBefore in the future)")
110
+ if protocol in _WEAK_PROTOCOLS:
111
+ warnings.append(f"Server negotiated obsolete protocol {protocol}")
112
+ if not trusted:
113
+ warnings.append("Certificate chain does not verify against system trust store"
114
+ + (f" ({trust_error})" if trust_error else ""))
115
+
116
+ return {
117
+ "host": host,
118
+ "port": port,
119
+ "subject_cn": subject.get("commonName"),
120
+ "issuer_cn": issuer.get("commonName") or issuer.get("organizationName"),
121
+ "not_before": not_before.isoformat(),
122
+ "not_after": not_after.isoformat(),
123
+ "days_left": days_left,
124
+ "protocol": protocol,
125
+ "cipher": cipher[0] if cipher else None,
126
+ "san": sans,
127
+ "trusted": trusted,
128
+ "warnings": warnings,
129
+ }
secably/cli.py ADDED
@@ -0,0 +1,200 @@
1
+ """Command-line entry point for secably."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import sys
7
+
8
+ from . import __version__
9
+ from . import output as out
10
+
11
+
12
+ def _add_common(p: argparse.ArgumentParser) -> None:
13
+ p.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
14
+ p.add_argument("--timeout", type=float, default=None, help="Network timeout in seconds.")
15
+ p.add_argument("--no-upsell", action="store_true", help=argparse.SUPPRESS)
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="secably",
21
+ description="Quick security checks from your terminal. "
22
+ "TLS, headers, DNS and subdomains run locally; port and "
23
+ "website scans use the free Secably API.",
24
+ epilog="Docs & source: https://github.com/secably/secably-cli",
25
+ )
26
+ parser.add_argument("--version", action="version", version=f"secably {__version__}")
27
+ sub = parser.add_subparsers(dest="command", required=True)
28
+
29
+ p = sub.add_parser("ssl", help="Inspect a host's TLS certificate and protocol.")
30
+ p.add_argument("host")
31
+ p.add_argument("--port", type=int, default=443)
32
+ p.add_argument("--fail-if-expires", type=int, metavar="DAYS",
33
+ help="Exit non-zero if the cert expires within DAYS (for CI).")
34
+ _add_common(p)
35
+
36
+ p = sub.add_parser("headers", help="Grade a site's HTTP security headers (A-F).")
37
+ p.add_argument("url")
38
+ p.add_argument("--fail-below", metavar="GRADE",
39
+ help="Exit non-zero if the grade is below GRADE, e.g. B (for CI).")
40
+ _add_common(p)
41
+
42
+ p = sub.add_parser("dns", help="Look up DNS records + SPF/DMARC/DNSSEC posture.")
43
+ p.add_argument("domain")
44
+ _add_common(p)
45
+
46
+ p = sub.add_parser("subdomains", help="Enumerate subdomains via Certificate Transparency.")
47
+ p.add_argument("domain")
48
+ p.add_argument("--resolve", action="store_true", help="Resolve each subdomain to an IP.")
49
+ _add_common(p)
50
+
51
+ p = sub.add_parser("scan", help="Run a hosted port or website scan (Secably API).")
52
+ p.add_argument("target")
53
+ p.add_argument("--type", choices=["website", "port"], default="website",
54
+ help="Scan type (default: website).")
55
+ p.add_argument("--api-key", help="Secably API key (or set SECABLY_API_KEY).")
56
+ _add_common(p)
57
+
58
+ return parser
59
+
60
+
61
+ # ---- command handlers -------------------------------------------------------
62
+
63
+ def _cmd_ssl(args) -> int:
64
+ from .checks.tls import check_tls
65
+ res = check_tls(args.host, port=args.port, timeout=args.timeout or 10.0)
66
+ if args.json:
67
+ out.emit_json(res)
68
+ else:
69
+ out.heading(f"TLS — {res['host']}:{res['port']}")
70
+ out.kv("Subject", res["subject_cn"])
71
+ out.kv("Issuer", res["issuer_cn"])
72
+ out.kv("Valid until", f"{res['not_after']} ({res['days_left']} days left)")
73
+ out.kv("Protocol", res["protocol"])
74
+ out.kv("Cipher", res["cipher"])
75
+ out.kv("Trusted", "yes" if res["trusted"] else "no")
76
+ if res["san"]:
77
+ out.kv("SAN", ", ".join(res["san"][:8]) + (" …" if len(res["san"]) > 8 else ""))
78
+ for w in res["warnings"]:
79
+ out.bullet(w, marker="!")
80
+ rc = 0
81
+ if args.fail_if_expires is not None and res["days_left"] < args.fail_if_expires:
82
+ print(f"\nFAIL: certificate expires in {res['days_left']} day(s) "
83
+ f"(threshold {args.fail_if_expires}).", file=sys.stderr)
84
+ rc = 2
85
+ if not args.no_upsell and not args.json:
86
+ out.footer()
87
+ return rc
88
+
89
+
90
+ def _cmd_headers(args) -> int:
91
+ from .checks.headers import check_headers
92
+ res = check_headers(args.url, timeout=args.timeout or 10.0)
93
+ if args.json:
94
+ out.emit_json(res)
95
+ else:
96
+ out.heading(f"Security headers — {res['url']}")
97
+ print(f" Grade: {out.color_grade(res['grade'])} ({res['score']}/100, HTTP {res['status']})")
98
+ if res["present"]:
99
+ print(" Present:")
100
+ for h in res["present"]:
101
+ out.bullet(h["header"])
102
+ if res["missing"]:
103
+ print(" Missing:")
104
+ for h in res["missing"]:
105
+ out.bullet(f"{h['header']} — {h['advice']}", marker="✗")
106
+ if res["info_leaks"]:
107
+ print(" Info leaks:")
108
+ for h in res["info_leaks"]:
109
+ out.bullet(f"{h['header']}: {h['value']}", marker="!")
110
+ rc = 0
111
+ order = "FEDCBA"
112
+ if args.fail_below and res["grade"] in order and args.fail_below.upper() in order:
113
+ if order.index(res["grade"]) < order.index(args.fail_below.upper()):
114
+ print(f"\nFAIL: grade {res['grade']} is below {args.fail_below.upper()}.",
115
+ file=sys.stderr)
116
+ rc = 2
117
+ if not args.no_upsell and not args.json:
118
+ out.footer()
119
+ return rc
120
+
121
+
122
+ def _cmd_dns(args) -> int:
123
+ from .checks.dns import check_dns
124
+ res = check_dns(args.domain, timeout=args.timeout or 10.0)
125
+ if args.json:
126
+ out.emit_json(res)
127
+ else:
128
+ out.heading(f"DNS — {res['domain']}")
129
+ for rtype, values in res["records"].items():
130
+ if values:
131
+ out.kv(rtype, values[0] if len(values) == 1 else f"{len(values)} records")
132
+ for extra in values[1:]:
133
+ print(f" {'':<16} {extra}")
134
+ out.kv("SPF", res["spf"][0] if res["spf"] else "—")
135
+ out.kv("DMARC", res["dmarc"][0] if res["dmarc"] else "—")
136
+ out.kv("DNSSEC", "enabled" if res["dnssec"] else "—")
137
+ for w in res["warnings"]:
138
+ out.bullet(w, marker="!")
139
+ if not args.no_upsell and not args.json:
140
+ out.footer()
141
+ return 0
142
+
143
+
144
+ def _cmd_subdomains(args) -> int:
145
+ from .checks.subdomains import check_subdomains
146
+ res = check_subdomains(args.domain, resolve=args.resolve, timeout=args.timeout or 30.0)
147
+ if args.json:
148
+ out.emit_json(res)
149
+ else:
150
+ out.heading(f"Subdomains — {res['domain']} ({res['count']} found)")
151
+ print(f" {out.dim(res['source'])}")
152
+ for name in res["subdomains"]:
153
+ if args.resolve:
154
+ ip = res["resolved"].get(name)
155
+ out.bullet(f"{name:<40} {ip or '(no A record)'}")
156
+ else:
157
+ out.bullet(name)
158
+ if not args.no_upsell and not args.json:
159
+ out.footer()
160
+ return 0
161
+
162
+
163
+ def _cmd_scan(args) -> int:
164
+ from .api import run_scan, SecablyAPIError, NeedsKey
165
+ tool = "website_scanner" if args.type == "website" else "port_scanner"
166
+ api_key = args.api_key or os.environ.get("SECABLY_API_KEY")
167
+ try:
168
+ res = run_scan(tool, args.target, api_key=api_key)
169
+ except NeedsKey as exc:
170
+ print(str(exc), file=sys.stderr)
171
+ return 3
172
+ except SecablyAPIError as exc:
173
+ print(f"Error: {exc}", file=sys.stderr)
174
+ return 1
175
+ if args.json:
176
+ out.emit_json(res)
177
+ else:
178
+ out.heading(f"{args.type.title()} scan — {args.target}")
179
+ out.emit_json(res.get("result", res))
180
+ return 0
181
+
182
+
183
+ _HANDLERS = {
184
+ "ssl": _cmd_ssl,
185
+ "headers": _cmd_headers,
186
+ "dns": _cmd_dns,
187
+ "subdomains": _cmd_subdomains,
188
+ "scan": _cmd_scan,
189
+ }
190
+
191
+
192
+ def main(argv=None) -> int:
193
+ args = build_parser().parse_args(argv)
194
+ try:
195
+ return _HANDLERS[args.command](args)
196
+ except KeyboardInterrupt:
197
+ return 130
198
+ except (OSError, ValueError) as exc:
199
+ print(f"Error: {exc}", file=sys.stderr)
200
+ return 1
secably/output.py ADDED
@@ -0,0 +1,51 @@
1
+ """Terminal formatting helpers — plain, dependency-free, and CI-friendly."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+
7
+ UPSELL = (
8
+ "secably.com — free online security tools + continuous domain monitoring. "
9
+ "Port & full website vulnerability scans and always-on alerts: "
10
+ "https://secably.com/?utm_source=cli&utm_medium=github&utm_campaign=secably-cli"
11
+ )
12
+
13
+ _COLORS = {"A": "92", "B": "92", "C": "93", "D": "93", "E": "91", "F": "91"}
14
+
15
+
16
+ def _tty() -> bool:
17
+ return sys.stdout.isatty()
18
+
19
+
20
+ def dim(text: str) -> str:
21
+ return f"\033[2m{text}\033[0m" if _tty() else text
22
+
23
+
24
+ def color_grade(grade: str) -> str:
25
+ if not _tty():
26
+ return grade
27
+ code = _COLORS.get(grade, "0")
28
+ return f"\033[1;{code}m{grade}\033[0m"
29
+
30
+
31
+ def emit_json(data: dict) -> None:
32
+ json.dump(data, sys.stdout, indent=2, default=str)
33
+ sys.stdout.write("\n")
34
+
35
+
36
+ def heading(text: str) -> None:
37
+ print(f"\n{text}")
38
+ print("-" * len(text))
39
+
40
+
41
+ def kv(key: str, value) -> None:
42
+ print(f" {key:<16} {value}")
43
+
44
+
45
+ def bullet(text: str, marker: str = "•") -> None:
46
+ print(f" {marker} {text}")
47
+
48
+
49
+ def footer() -> None:
50
+ print()
51
+ print(dim(UPSELL))
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: secably
3
+ Version: 0.1.0
4
+ Summary: Command-line security checks: TLS, HTTP security headers, DNS/email posture and subdomain discovery — run locally, no account needed.
5
+ Author-email: Secably <hello@secably.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://secably.com
8
+ Project-URL: Source, https://github.com/secably/secably-cli
9
+ Project-URL: Issues, https://github.com/secably/secably-cli/issues
10
+ Keywords: security,tls,ssl,http-headers,dns,subdomain,recon,devsecops,cli
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
19
+ Classifier: Topic :: System :: Networking :: Monitoring
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dns
24
+ Requires-Dist: dnspython>=2.0; extra == "dns"
25
+ Provides-Extra: all
26
+ Requires-Dist: dnspython>=2.0; extra == "all"
27
+ Dynamic: license-file
28
+
29
+ # secably
30
+
31
+ Quick security checks from your terminal — TLS certificates, HTTP security
32
+ headers, DNS/email posture, and subdomain discovery. The everyday checks run
33
+ **locally on your machine, with no account and no rate limit**. Heavier scans
34
+ (full website vulnerability scan, port scan) use the free
35
+ [Secably](https://secably.com) API.
36
+
37
+ ```
38
+ pip install secably # core checks (zero dependencies)
39
+ pip install "secably[dns]" # adds MX/TXT/SPF/DMARC/DNSSEC via dnspython
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ # TLS certificate + negotiated protocol, with a CI-friendly expiry gate
46
+ secably ssl example.com
47
+ secably ssl example.com --fail-if-expires 14
48
+
49
+ # Grade the HTTP security headers A–F (fail a build below B)
50
+ secably headers https://example.com
51
+ secably headers example.com --fail-below B
52
+
53
+ # DNS records + email spoofing posture (SPF / DMARC / DNSSEC)
54
+ secably dns example.com
55
+
56
+ # Passive subdomain discovery via Certificate Transparency (crt.sh)
57
+ secably subdomains example.com --resolve
58
+
59
+ # Hosted port / website vulnerability scan (needs a free API key)
60
+ secably scan example.com --type website
61
+ ```
62
+
63
+ Add `--json` to any command for machine-readable output — handy in pipelines.
64
+
65
+ ## Local vs. hosted
66
+
67
+ | Command | Where it runs | Account needed |
68
+ |---|---|---|
69
+ | `ssl`, `headers`, `dns`, `subdomains` | your machine | no |
70
+ | `scan` (website / port) | Secably API | free API key |
71
+
72
+ The local commands never touch Secably's servers, so there's no quota to hit.
73
+ The `scan` command runs deeper, server-side checks; create a free key at
74
+ **https://secably.com/dashboard/api-keys/** and pass it via `--api-key` or the
75
+ `SECABLY_API_KEY` environment variable. Free keys include a daily allowance;
76
+ paid plans lift it.
77
+
78
+ ## Use in CI
79
+
80
+ Both `ssl --fail-if-expires` and `headers --fail-below` exit non-zero on
81
+ failure, so they drop straight into a pipeline:
82
+
83
+ ```yaml
84
+ - run: pipx run secably ssl your-domain.com --fail-if-expires 21
85
+ - run: pipx run secably headers https://your-domain.com --fail-below B
86
+ ```
87
+
88
+ For always-on monitoring of certificate expiry, new subdomains and open ports
89
+ between deploys, Secably runs the same checks continuously and alerts you:
90
+ <https://secably.com/?utm_source=cli&utm_medium=github&utm_campaign=secably-cli>
91
+
92
+ ## License
93
+
94
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ secably/__init__.py,sha256=dhTtm1-vm8a04nupFnkKMVPN4Jh6-Z37mA0h5nT4Bi0,282
2
+ secably/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ secably/api.py,sha256=ajzQoQe8Mvntpojkj4G0fgVs5Jw5GZikR52YgaFUHFs,3165
4
+ secably/cli.py,sha256=_GDAQhDC1mXSG7OHg7pDRpTPuDC3Ci6PowkRL_9tPto,7424
5
+ secably/output.py,sha256=YqhFWEjJwEyoCIry67Z7DqLKoTwk2fZ4bl0SqVj9Bmg,1176
6
+ secably/checks/dns.py,sha256=q1thV4eeRVLVHQBWtsNirVXDCu6v1NHZ-SPd-NhiJ20,2843
7
+ secably/checks/headers.py,sha256=YY7zZtyg0QCRee3AGerSrmtgDoJn_-duPbx_YjGoQXk,2582
8
+ secably/checks/subdomains.py,sha256=wfeOpG5edDyihEBLAzlHN04Cdd0Vt6sGotngwsUe7-o,1811
9
+ secably/checks/tls.py,sha256=BsD8sxyEgT4lZmBiffiwXGw6XDlu9JGElh3ebg8370Q,4920
10
+ secably-0.1.0.dist-info/licenses/LICENSE,sha256=MBwhq1iAKIxrs_xsCXdfRjPa6hsN7IVMUCB_PqxLdKk,1064
11
+ secably-0.1.0.dist-info/METADATA,sha256=y9XilIYiw6RQr9X3Ya5XTrCtUoOPE3hSFec5r_2WgbI,3426
12
+ secably-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ secably-0.1.0.dist-info/entry_points.txt,sha256=X1dxct9UnRdXEz2bRGZw3ZtQsVIsdjeO717vLP69Teg,45
14
+ secably-0.1.0.dist-info/top_level.txt,sha256=HwH4lyDAOlAfkIolRjx7_no02Rd_gskrJ6bB6w1Bwi4,8
15
+ secably-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ secably = secably.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Secably
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ secably