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 +8 -0
- secably/__main__.py +4 -0
- secably/api.py +79 -0
- secably/checks/dns.py +89 -0
- secably/checks/headers.py +79 -0
- secably/checks/subdomains.py +53 -0
- secably/checks/tls.py +129 -0
- secably/cli.py +200 -0
- secably/output.py +51 -0
- secably-0.1.0.dist-info/METADATA +94 -0
- secably-0.1.0.dist-info/RECORD +15 -0
- secably-0.1.0.dist-info/WHEEL +5 -0
- secably-0.1.0.dist-info/entry_points.txt +2 -0
- secably-0.1.0.dist-info/licenses/LICENSE +21 -0
- secably-0.1.0.dist-info/top_level.txt +1 -0
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
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,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
|