hardn-cli 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.
- hardn_cli/__init__.py +3 -0
- hardn_cli/__main__.py +4 -0
- hardn_cli/client.py +66 -0
- hardn_cli/main.py +185 -0
- hardn_cli-0.1.0.dist-info/METADATA +60 -0
- hardn_cli-0.1.0.dist-info/RECORD +8 -0
- hardn_cli-0.1.0.dist-info/WHEEL +4 -0
- hardn_cli-0.1.0.dist-info/entry_points.txt +2 -0
hardn_cli/__init__.py
ADDED
hardn_cli/__main__.py
ADDED
hardn_cli/client.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""A tiny stdlib-only HTTP client for the Hardn /v1 API, so the CLI needs no third-party packages in CI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
DEFAULT_API_URL = "https://api.hardn.si"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiError(Exception):
|
|
14
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
15
|
+
super().__init__(f"{code}: {message}")
|
|
16
|
+
self.status = status
|
|
17
|
+
self.code = code
|
|
18
|
+
self.message = message
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Client:
|
|
22
|
+
def __init__(self, api_key: str, api_url: str = DEFAULT_API_URL, *, timeout: float = 30.0) -> None:
|
|
23
|
+
self._key = api_key
|
|
24
|
+
self._base = api_url.rstrip("/")
|
|
25
|
+
self._timeout = timeout
|
|
26
|
+
|
|
27
|
+
def _request(self, method: str, path: str, body: dict | None = None) -> Any:
|
|
28
|
+
url = f"{self._base}{path}"
|
|
29
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
30
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
31
|
+
req.add_header("Authorization", f"Bearer {self._key}")
|
|
32
|
+
req.add_header("Accept", "application/json")
|
|
33
|
+
if data is not None:
|
|
34
|
+
req.add_header("Content-Type", "application/json")
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
37
|
+
raw = resp.read()
|
|
38
|
+
return json.loads(raw) if raw else None
|
|
39
|
+
except urllib.error.HTTPError as exc:
|
|
40
|
+
raw = exc.read()
|
|
41
|
+
detail = {}
|
|
42
|
+
try:
|
|
43
|
+
detail = (json.loads(raw) or {}).get("detail", {})
|
|
44
|
+
except ValueError:
|
|
45
|
+
pass
|
|
46
|
+
if isinstance(detail, dict):
|
|
47
|
+
raise ApiError(exc.code, detail.get("code", "error"),
|
|
48
|
+
detail.get("message", exc.reason or "Request failed.")) from exc
|
|
49
|
+
raise ApiError(exc.code, "error", str(detail) or (exc.reason or "Request failed.")) from exc
|
|
50
|
+
except urllib.error.URLError as exc:
|
|
51
|
+
raise ApiError(0, "network", f"Could not reach {self._base}: {exc.reason}") from exc
|
|
52
|
+
|
|
53
|
+
def start_scan(self, target_id: str, scan_type: str) -> dict:
|
|
54
|
+
return self._request("POST", "/v1/scans", {"target_id": target_id, "scan_type": scan_type})
|
|
55
|
+
|
|
56
|
+
def get_scan(self, scan_id: str) -> dict:
|
|
57
|
+
return self._request("GET", f"/v1/scans/{scan_id}")
|
|
58
|
+
|
|
59
|
+
def report(self, scan_id: str, fmt: str = "json") -> Any:
|
|
60
|
+
return self._request("GET", f"/v1/scans/{scan_id}/report?format={fmt}")
|
|
61
|
+
|
|
62
|
+
def verify(self, target_id: str) -> dict:
|
|
63
|
+
return self._request("POST", f"/v1/targets/{target_id}/verify")
|
|
64
|
+
|
|
65
|
+
def list_targets(self) -> list:
|
|
66
|
+
return self._request("GET", "/v1/targets")
|
hardn_cli/main.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""`hardn` — start a read-only scan of a site you own, wait for it, write the report, and (for CI) fail the
|
|
2
|
+
build when confirmed issues reach a severity threshold.
|
|
3
|
+
|
|
4
|
+
Auth: set HARDN_API_KEY (an hk_live_… key from Settings → API keys). HARDN_API_URL overrides the endpoint.
|
|
5
|
+
Everything the CLI triggers is the same read-only scan the dashboard runs; it never changes the target.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .client import ApiError, Client, DEFAULT_API_URL
|
|
19
|
+
|
|
20
|
+
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
21
|
+
_RANK = {s: i for i, s in enumerate(SEVERITIES)} # 0 = most severe
|
|
22
|
+
TERMINAL = {"completed", "failed", "stopped"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _err(msg: str) -> int:
|
|
26
|
+
print(f"hardn: {msg}", file=sys.stderr)
|
|
27
|
+
return 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _client(args: argparse.Namespace) -> Client:
|
|
31
|
+
key = args.api_key or os.environ.get("HARDN_API_KEY", "")
|
|
32
|
+
if not key:
|
|
33
|
+
raise SystemExit(_err("no API key. Set HARDN_API_KEY or pass --api-key."))
|
|
34
|
+
return Client(key, args.api_url or os.environ.get("HARDN_API_URL", DEFAULT_API_URL))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _counts(findings: list[dict]) -> dict[str, int]:
|
|
38
|
+
out = {s: 0 for s in SEVERITIES}
|
|
39
|
+
for f in findings:
|
|
40
|
+
if f.get("validation_status") == "rejected":
|
|
41
|
+
continue
|
|
42
|
+
out[f.get("severity", "info")] = out.get(f.get("severity", "info"), 0) + 1
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _poll(client: Client, scan_id: str, timeout: float, quiet: bool) -> dict:
|
|
47
|
+
deadline = time.monotonic() + timeout
|
|
48
|
+
last = None
|
|
49
|
+
while time.monotonic() < deadline:
|
|
50
|
+
scan = client.get_scan(scan_id)
|
|
51
|
+
status = scan.get("status")
|
|
52
|
+
if status != last and not quiet:
|
|
53
|
+
print(f" … {status} ({scan.get('progress', 0)}%)", file=sys.stderr)
|
|
54
|
+
last = status
|
|
55
|
+
if status in TERMINAL:
|
|
56
|
+
return scan
|
|
57
|
+
time.sleep(3.0)
|
|
58
|
+
raise SystemExit(_err(f"scan {scan_id} did not finish within {int(timeout)}s."))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
62
|
+
client = _client(args)
|
|
63
|
+
try:
|
|
64
|
+
scan = client.start_scan(args.target, args.type)
|
|
65
|
+
except ApiError as exc:
|
|
66
|
+
return _err(exc.message)
|
|
67
|
+
scan_id = scan["id"]
|
|
68
|
+
if not args.quiet:
|
|
69
|
+
print(f"Started {args.type} scan {scan_id} for target {args.target}.", file=sys.stderr)
|
|
70
|
+
scan = _poll(client, scan_id, args.timeout, args.quiet)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
report = client.report(scan_id, "json")
|
|
74
|
+
except ApiError as exc:
|
|
75
|
+
return _err(f"scan finished but the report could not be fetched: {exc.message}")
|
|
76
|
+
findings = report.get("findings", [])
|
|
77
|
+
counts = _counts(findings)
|
|
78
|
+
|
|
79
|
+
if args.sarif:
|
|
80
|
+
try:
|
|
81
|
+
sarif = client.report(scan_id, "sarif")
|
|
82
|
+
with open(args.sarif, "w", encoding="utf-8") as fh:
|
|
83
|
+
json.dump(sarif, fh)
|
|
84
|
+
if not args.quiet:
|
|
85
|
+
print(f"Wrote SARIF to {args.sarif}.", file=sys.stderr)
|
|
86
|
+
except (ApiError, OSError) as exc:
|
|
87
|
+
print(f"hardn: could not write SARIF: {exc}", file=sys.stderr)
|
|
88
|
+
|
|
89
|
+
if args.format == "json":
|
|
90
|
+
json.dump({"scan": scan, "counts": counts, "findings": findings}, sys.stdout, indent=2)
|
|
91
|
+
print()
|
|
92
|
+
else:
|
|
93
|
+
_print_summary(scan, counts, findings)
|
|
94
|
+
|
|
95
|
+
if scan.get("status") != "completed":
|
|
96
|
+
return _err(f"scan did not complete (status: {scan.get('status')}).")
|
|
97
|
+
|
|
98
|
+
threshold = args.fail_on.lower()
|
|
99
|
+
if threshold == "none":
|
|
100
|
+
return 0
|
|
101
|
+
if threshold not in _RANK:
|
|
102
|
+
return _err(f"--fail-on must be one of none, {', '.join(SEVERITIES)}.")
|
|
103
|
+
gating = sum(n for sev, n in counts.items() if _RANK[sev] <= _RANK[threshold])
|
|
104
|
+
if gating:
|
|
105
|
+
if not args.quiet:
|
|
106
|
+
print(f"\nFAIL: {gating} issue(s) at or above '{threshold}'.", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
if not args.quiet:
|
|
109
|
+
print(f"\nPASS: no issues at or above '{threshold}'.", file=sys.stderr)
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _print_summary(scan: dict, counts: dict[str, int], findings: list[dict]) -> None:
|
|
114
|
+
grade = scan.get("grade") or "–"
|
|
115
|
+
score = scan.get("score")
|
|
116
|
+
print(f"Grade {grade} ({score if score is not None else '–'}/100) · " +
|
|
117
|
+
" ".join(f"{counts[s]} {s}" for s in SEVERITIES if counts[s]))
|
|
118
|
+
for f in sorted(findings, key=lambda x: _RANK.get(x.get("severity", "info"), 9)):
|
|
119
|
+
if f.get("validation_status") == "rejected":
|
|
120
|
+
continue
|
|
121
|
+
owasp = f" [{f['owasp']}]" if f.get("owasp") else ""
|
|
122
|
+
where = f.get("location") or f.get("affected_asset") or ""
|
|
123
|
+
print(f" {f.get('severity', '').upper():8} {f.get('title', '')}{owasp} {where}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
127
|
+
client = _client(args)
|
|
128
|
+
try:
|
|
129
|
+
result = client.verify(args.target)
|
|
130
|
+
except ApiError as exc:
|
|
131
|
+
return _err(exc.message)
|
|
132
|
+
print(json.dumps(result) if args.format == "json" else
|
|
133
|
+
f"{'verified' if result.get('verified') else 'not verified'}: {result.get('detail', '')}")
|
|
134
|
+
return 0 if result.get("verified") else 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_targets(args: argparse.Namespace) -> int:
|
|
138
|
+
client = _client(args)
|
|
139
|
+
try:
|
|
140
|
+
targets = client.list_targets()
|
|
141
|
+
except ApiError as exc:
|
|
142
|
+
return _err(exc.message)
|
|
143
|
+
if args.format == "json":
|
|
144
|
+
json.dump(targets, sys.stdout, indent=2)
|
|
145
|
+
print()
|
|
146
|
+
else:
|
|
147
|
+
for t in targets:
|
|
148
|
+
print(f"{t['id']} {t.get('status', ''):10} {t.get('host', '')}")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
153
|
+
p = argparse.ArgumentParser(prog="hardn", description="Scan sites you own with Hardn (read-only).")
|
|
154
|
+
p.add_argument("--version", action="version", version=f"hardn {__version__}")
|
|
155
|
+
p.add_argument("--api-key", help="API key (or set HARDN_API_KEY).")
|
|
156
|
+
p.add_argument("--api-url", help=f"API base URL (default {DEFAULT_API_URL}, or HARDN_API_URL).")
|
|
157
|
+
p.add_argument("--format", choices=["text", "json"], default="text")
|
|
158
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
159
|
+
|
|
160
|
+
s = sub.add_parser("scan", help="Start a scan, wait for it, and gate on severity.")
|
|
161
|
+
s.add_argument("--target", required=True, help="A verified target id (tgt_…).")
|
|
162
|
+
s.add_argument("--type", choices=["public", "deep"], default="public")
|
|
163
|
+
s.add_argument("--fail-on", default="high",
|
|
164
|
+
help="Exit non-zero if any confirmed issue is at or above this severity (or 'none').")
|
|
165
|
+
s.add_argument("--sarif", help="Write the SARIF report to this file.")
|
|
166
|
+
s.add_argument("--timeout", type=float, default=900.0, help="Seconds to wait for the scan (default 900).")
|
|
167
|
+
s.add_argument("--quiet", action="store_true", help="Only print the summary/report.")
|
|
168
|
+
s.set_defaults(func=cmd_scan)
|
|
169
|
+
|
|
170
|
+
v = sub.add_parser("verify", help="Re-run ownership verification for a target.")
|
|
171
|
+
v.add_argument("--target", required=True)
|
|
172
|
+
v.set_defaults(func=cmd_verify)
|
|
173
|
+
|
|
174
|
+
t = sub.add_parser("targets", help="List the organisation's targets.")
|
|
175
|
+
t.set_defaults(func=cmd_targets)
|
|
176
|
+
return p
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main(argv: list[str] | None = None) -> int:
|
|
180
|
+
args = build_parser().parse_args(argv)
|
|
181
|
+
return args.func(args)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hardn-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for Hardn (hardn.si): run read-only security scans of sites you own from the terminal or CI.
|
|
5
|
+
Project-URL: Homepage, https://hardn.si
|
|
6
|
+
Project-URL: Documentation, https://hardn.si/developers#cli
|
|
7
|
+
Author: Infonex Pty Ltd
|
|
8
|
+
License: Proprietary
|
|
9
|
+
Keywords: ci,devsecops,hardn,scanner,security
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: License :: Other/Proprietary License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# hardn-cli
|
|
19
|
+
|
|
20
|
+
Run Hardn's read-only security scans of sites you own from your terminal or CI, and fail the build when a scan
|
|
21
|
+
finds problems at or above the severity you choose. Python 3.9+, no dependencies.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install hardn-cli # or: pipx install hardn-cli
|
|
27
|
+
hardn --version
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Use
|
|
31
|
+
|
|
32
|
+
Create an API key in Hardn (API keys → New key) and keep it as a secret.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
export HARDN_API_KEY=hk_live_…
|
|
36
|
+
hardn targets # your sites and their ids (tgt_…)
|
|
37
|
+
hardn scan --target tgt_… --fail-on high # start a scan, wait, exit 1 on high or critical issues
|
|
38
|
+
hardn scan --target tgt_… --sarif hardn.sarif # also write a SARIF report (GitHub code scanning)
|
|
39
|
+
hardn verify --target tgt_… # re-check ownership verification
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Options: `--fail-on none|critical|high|medium|low|info` (default `high`), `--type public|deep`,
|
|
43
|
+
`--timeout <seconds>` (default 900), `--format text|json`, `--quiet`, `--api-url` (or `HARDN_API_URL`; default
|
|
44
|
+
`https://api.hardn.si`).
|
|
45
|
+
|
|
46
|
+
Exit codes: `0` passed, `1` issues at or above `--fail-on` (for `verify`: not verified), `2` the scan didn't
|
|
47
|
+
complete or timed out, or a usage or API error.
|
|
48
|
+
|
|
49
|
+
## In GitHub Actions
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
- name: Hardn scan
|
|
53
|
+
env:
|
|
54
|
+
HARDN_API_KEY: ${{ secrets.HARDN_API_KEY }}
|
|
55
|
+
run: |
|
|
56
|
+
pip install hardn-cli
|
|
57
|
+
hardn scan --target tgt_… --fail-on high --sarif hardn.sarif
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Hardn only scans sites whose ownership you have verified. Docs: https://hardn.si/developers
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
hardn_cli/__init__.py,sha256=jaDNSMqSx8-HNHZzVHwSXwvEHc58ds0kOfnJLBURXeo,124
|
|
2
|
+
hardn_cli/__main__.py,sha256=fdA1QWBe5LNX0fbtaJXa1-dwEhrg_4sS_5UnjaajcMo,80
|
|
3
|
+
hardn_cli/client.py,sha256=3pyN-ufJZkTZNVdU8i48orgufg9p_hlfpPbFNMB5124,2674
|
|
4
|
+
hardn_cli/main.py,sha256=xOwswJ_JVDAVjtsye4Kr387L0xCClgf9BYuWiPbzTi8,7152
|
|
5
|
+
hardn_cli-0.1.0.dist-info/METADATA,sha256=sfjjNQ-03u5IT3h1pmg9LCB2Ie7KiPrXwNzp8RlS678,2145
|
|
6
|
+
hardn_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
hardn_cli-0.1.0.dist-info/entry_points.txt,sha256=DDj65hueR4z44wyFeDnxTwms2s9FhHW8Nwi06ADbrwA,46
|
|
8
|
+
hardn_cli-0.1.0.dist-info/RECORD,,
|