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 ADDED
@@ -0,0 +1,3 @@
1
+ """Hardn CLI — drive read-only scans of sites you own from the terminal or CI, via the /v1 API."""
2
+
3
+ __version__ = "0.1.0"
hardn_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hardn = hardn_cli.main:main