openhack-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.
@@ -0,0 +1,3 @@
1
+ """OpenHack CLI — programmatic, authenticated access to the OpenHack platform."""
2
+
3
+ __version__ = "0.1.0"
openhack_cli/cli.py ADDED
@@ -0,0 +1,88 @@
1
+ """OpenHack CLI entrypoint.
2
+
3
+ Wires the command groups together, sets up the shared Click context (config +
4
+ output mode), and provides uniform error handling for API/auth failures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ import click
12
+
13
+ from . import __version__, output
14
+ from .client import APIError, AuthError
15
+ from .commands.auth import auth
16
+ from .commands.config_cmd import config
17
+ from .commands.orgs import orgs
18
+ from .commands.pentest import pentest
19
+ from .commands.projects import projects
20
+ from .commands.scans import scans
21
+ from .commands.vulns import vulns
22
+ from .config import Config, LOCAL_APP_URL
23
+
24
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
25
+
26
+
27
+ @click.group(context_settings=CONTEXT_SETTINGS)
28
+ @click.version_option(__version__, "-v", "--version", prog_name="openhack-cli")
29
+ @click.option("--json", "json_output", is_flag=True,
30
+ help="Output raw JSON (for scripts and agents).")
31
+ @click.option("--app-url", default=None, help="Override the OpenHack app URL.")
32
+ @click.option("--local", "use_local", is_flag=True,
33
+ help=f"Target the local dev server ({LOCAL_APP_URL}).")
34
+ @click.option("--token", default=None,
35
+ help="Override the API token (else uses stored credentials).")
36
+ @click.pass_context
37
+ def cli(ctx: click.Context, json_output: bool, app_url: str | None,
38
+ use_local: bool, token: str | None) -> None:
39
+ """OpenHack CLI — authenticated, programmatic access to OpenHack.
40
+
41
+ Start with `openhack-cli auth login`, then explore `orgs`, `projects`,
42
+ `scans`, `vulns`, and `pentest`.
43
+ """
44
+ cfg = Config.load()
45
+ # --local is a shorthand for the dev server; --app-url wins if both given.
46
+ # These are runtime overrides (beat OPENHACK_DEV / OPENHACK_APP_URL / config).
47
+ if use_local:
48
+ cfg.set_override_app_url(LOCAL_APP_URL)
49
+ if app_url:
50
+ cfg.set_override_app_url(app_url.rstrip("/"))
51
+ if token:
52
+ cfg.set("token", token)
53
+ ctx.obj = {"config": cfg, "json": json_output}
54
+
55
+
56
+ cli.add_command(auth)
57
+ cli.add_command(orgs)
58
+ cli.add_command(projects)
59
+ cli.add_command(scans)
60
+ cli.add_command(vulns)
61
+ cli.add_command(pentest)
62
+ cli.add_command(config)
63
+
64
+
65
+ def main() -> None:
66
+ try:
67
+ cli(standalone_mode=False)
68
+ except AuthError as exc:
69
+ output.error(f"Authentication failed ({exc.status}): {exc.message}")
70
+ output.info("Run `openhack-cli auth login` to (re)authorize, or check "
71
+ "that this endpoint accepts CLI tokens.")
72
+ sys.exit(2)
73
+ except APIError as exc:
74
+ output.error(exc.message if exc.status else str(exc))
75
+ sys.exit(1)
76
+ except click.ClickException as exc:
77
+ exc.show()
78
+ sys.exit(exc.exit_code)
79
+ except click.Abort:
80
+ output.error("Aborted.")
81
+ sys.exit(130)
82
+ except KeyboardInterrupt:
83
+ output.error("Interrupted.")
84
+ sys.exit(130)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
openhack_cli/client.py ADDED
@@ -0,0 +1,124 @@
1
+ """HTTP client for the OpenHack API.
2
+
3
+ Wraps ``requests`` with the CLI's Bearer-token auth and uniform error
4
+ handling. Every authenticated endpoint expects::
5
+
6
+ Authorization: Bearer openhack_<hex>
7
+
8
+ which is the token issued by the device-code login flow.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Optional
14
+
15
+ import requests
16
+
17
+ from . import __version__
18
+
19
+ # Endpoints that the CLI talks to are JSON unless noted (e.g. PDF reports).
20
+ USER_AGENT = f"openhack-cli/{__version__}"
21
+ DEFAULT_TIMEOUT = 30
22
+
23
+
24
+ class APIError(Exception):
25
+ """Raised when the API returns a non-2xx response."""
26
+
27
+ def __init__(self, status: int, message: str, body: Any = None):
28
+ self.status = status
29
+ self.message = message
30
+ self.body = body
31
+ super().__init__(f"[{status}] {message}")
32
+
33
+
34
+ class AuthError(APIError):
35
+ """Raised on 401/403 so the CLI can prompt the user to re-login."""
36
+
37
+
38
+ class Client:
39
+ def __init__(self, app_url: str, token: Optional[str] = None,
40
+ timeout: int = DEFAULT_TIMEOUT):
41
+ self.app_url = app_url.rstrip("/")
42
+ self.token = token
43
+ self.timeout = timeout
44
+ self._session = requests.Session()
45
+
46
+ # ----- low-level --------------------------------------------------------
47
+ def _headers(self, extra: Optional[dict] = None, auth: bool = True) -> dict:
48
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
49
+ if auth and self.token:
50
+ headers["Authorization"] = f"Bearer {self.token}"
51
+ if extra:
52
+ headers.update(extra)
53
+ return headers
54
+
55
+ def request(
56
+ self,
57
+ method: str,
58
+ path: str,
59
+ *,
60
+ json: Any = None,
61
+ params: Optional[dict] = None,
62
+ auth: bool = True,
63
+ raw: bool = False,
64
+ ) -> Any:
65
+ """Perform a request and return parsed JSON (or the raw Response).
66
+
67
+ ``raw=True`` returns the underlying ``requests.Response`` so callers can
68
+ stream binary payloads (e.g. PDF reports).
69
+ """
70
+ url = path if path.startswith("http") else f"{self.app_url}{path}"
71
+ try:
72
+ resp = self._session.request(
73
+ method,
74
+ url,
75
+ json=json,
76
+ params=params,
77
+ headers=self._headers(auth=auth),
78
+ timeout=self.timeout,
79
+ )
80
+ except requests.RequestException as exc:
81
+ raise APIError(0, f"Network error: {exc}") from exc
82
+
83
+ if resp.status_code >= 400:
84
+ self._raise_for_status(resp)
85
+
86
+ if raw:
87
+ return resp
88
+ if not resp.content:
89
+ return None
90
+ ctype = resp.headers.get("Content-Type", "")
91
+ if "application/json" in ctype:
92
+ return resp.json()
93
+ return resp.content
94
+
95
+ @staticmethod
96
+ def _raise_for_status(resp: requests.Response) -> None:
97
+ message = resp.reason or "Request failed"
98
+ body: Any = None
99
+ try:
100
+ body = resp.json()
101
+ if isinstance(body, dict):
102
+ message = body.get("error") or body.get("message") or message
103
+ if body.get("message") and body.get("error"):
104
+ message = f"{body['error']}: {body['message']}"
105
+ except ValueError:
106
+ text = resp.text.strip()
107
+ if text:
108
+ message = text[:300]
109
+ if resp.status_code in (401, 403):
110
+ raise AuthError(resp.status_code, message, body)
111
+ raise APIError(resp.status_code, message, body)
112
+
113
+ # ----- verb helpers -----------------------------------------------------
114
+ def get(self, path: str, **kw) -> Any:
115
+ return self.request("GET", path, **kw)
116
+
117
+ def post(self, path: str, **kw) -> Any:
118
+ return self.request("POST", path, **kw)
119
+
120
+ def patch(self, path: str, **kw) -> Any:
121
+ return self.request("PATCH", path, **kw)
122
+
123
+ def delete(self, path: str, **kw) -> Any:
124
+ return self.request("DELETE", path, **kw)
@@ -0,0 +1 @@
1
+ """Command groups for the OpenHack CLI."""
@@ -0,0 +1,185 @@
1
+ """`openhack-cli auth` — device-code login, logout, and status.
2
+
3
+ Implements the same device-code flow the app exposes for the CLI:
4
+
5
+ 1. POST /api/cli/auth -> { device_code, user_code, verification_url }
6
+ 2. open verification_url in the browser; user signs in + picks an org + approves
7
+ 3. POST /api/cli/auth/poll -> { status: "pending" } ... until
8
+ { status: "approved", token, org, user }
9
+
10
+ The returned ``token`` (``openhack_<hex>``) is a long-lived, org-scoped API key
11
+ sent as ``Authorization: Bearer`` on every later request.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ import webbrowser
18
+
19
+ import click
20
+
21
+ from .. import output
22
+ from ..client import APIError, Client
23
+ from ..config import Config
24
+ from ..context import get_config
25
+
26
+ POLL_INTERVAL = 2.0 # seconds between poll attempts
27
+
28
+
29
+ @click.group()
30
+ def auth() -> None:
31
+ """Log in to OpenHack and manage CLI credentials."""
32
+
33
+
34
+ @auth.command()
35
+ @click.option("--no-browser", is_flag=True,
36
+ help="Don't auto-open the browser; just print the URL.")
37
+ @click.option("--app-url", default=None,
38
+ help="Override the OpenHack app URL for this login.")
39
+ @click.pass_context
40
+ def login(ctx: click.Context, no_browser: bool, app_url: str | None) -> None:
41
+ """Authorize this CLI against your OpenHack account."""
42
+ cfg: Config = get_config(ctx)
43
+ base_url = (app_url or cfg.app_url).rstrip("/")
44
+ # The auth-start + poll endpoints are public (no token needed).
45
+ client = Client(base_url, token=None)
46
+
47
+ if cfg.is_authenticated:
48
+ output.warn("Already logged in. Re-authorizing will replace the current token.")
49
+
50
+ try:
51
+ start = client.post("/api/cli/auth", auth=False)
52
+ except APIError as exc:
53
+ raise click.ClickException(f"Failed to start login: {exc.message}")
54
+
55
+ device_code = start["device_code"]
56
+ user_code = start["user_code"]
57
+ verification_url = start["verification_url"]
58
+ expires_in = int(start.get("expires_in", 900))
59
+
60
+ output.console.print()
61
+ output.console.print("To authorize the OpenHack CLI, visit:\n")
62
+ output.console.print(f" [bold cyan]{verification_url}[/bold cyan]\n")
63
+ output.console.print("and confirm this code:\n")
64
+ output.console.print(f" [bold]{user_code}[/bold]\n")
65
+
66
+ if not no_browser:
67
+ try:
68
+ webbrowser.open(verification_url)
69
+ except Exception:
70
+ pass
71
+
72
+ output.info("Waiting for approval in the browser… (Ctrl-C to cancel)")
73
+
74
+ deadline = time.time() + expires_in
75
+ result = None
76
+ while time.time() < deadline:
77
+ time.sleep(POLL_INTERVAL)
78
+ try:
79
+ poll = client.post(
80
+ "/api/cli/auth/poll", json={"device_code": device_code}, auth=False
81
+ )
82
+ except APIError as exc:
83
+ if exc.status == 410:
84
+ raise click.ClickException(
85
+ "Login session expired. Run `openhack-cli auth login` again."
86
+ )
87
+ raise click.ClickException(f"Login failed: {exc.message}")
88
+ if poll.get("status") == "approved":
89
+ result = poll
90
+ break
91
+
92
+ if not result:
93
+ raise click.ClickException("Login timed out before approval.")
94
+
95
+ # Persist the token + bound org/user context.
96
+ cfg.set("app_url", base_url)
97
+ cfg.set("token", result["token"])
98
+ cfg.set("user", result.get("user"))
99
+ cfg.set("org", result.get("org"))
100
+ cfg.save()
101
+
102
+ org = result.get("org") or {}
103
+ user = result.get("user") or {}
104
+ name = _display_name(user)
105
+ output.console.print()
106
+ output.success(
107
+ f"Logged in as [bold]{name}[/bold]"
108
+ + (f" — org [bold]{org.get('name')}[/bold]" if org.get("name") else "")
109
+ )
110
+
111
+
112
+ @auth.command()
113
+ @click.pass_context
114
+ def logout(ctx: click.Context) -> None:
115
+ """Remove stored credentials from this machine."""
116
+ cfg: Config = get_config(ctx)
117
+ if not cfg.is_authenticated:
118
+ output.warn("Not logged in.")
119
+ return
120
+ cfg.clear_auth()
121
+ cfg.save()
122
+ output.success("Logged out. Token removed from this machine.")
123
+ output.info("(The API key still exists server-side; revoke it in org settings.)")
124
+
125
+
126
+ @auth.command()
127
+ @click.pass_context
128
+ def status(ctx: click.Context) -> None:
129
+ """Show the current login status and active context."""
130
+ cfg: Config = get_config(ctx)
131
+ payload = {
132
+ "authenticated": cfg.is_authenticated,
133
+ "app_url": cfg.app_url,
134
+ "user": cfg.user,
135
+ "org": cfg.org,
136
+ "project": cfg.project,
137
+ }
138
+
139
+ def render(_data):
140
+ if not cfg.is_authenticated:
141
+ output.warn("Not logged in. Run `openhack-cli auth login`.")
142
+ output.console.print(f"App URL: [dim]{cfg.app_url}[/dim]")
143
+ return
144
+ user = cfg.user or {}
145
+ output.success(f"Logged in as [bold]{_display_name(user)}[/bold]")
146
+ if user.get("email"):
147
+ output.console.print(f" Email: {user['email']}")
148
+ output.console.print(f" App URL: {cfg.app_url}")
149
+ if cfg.org:
150
+ output.console.print(
151
+ f" Org: {cfg.org.get('name')} "
152
+ f"[dim]({cfg.org.get('slug') or cfg.org.get('id')})[/dim]"
153
+ )
154
+ if cfg.project:
155
+ output.console.print(
156
+ f" Project: {cfg.project.get('name')} "
157
+ f"[dim]({cfg.project.get('slug') or cfg.project.get('id')})[/dim]"
158
+ )
159
+
160
+ output.emit(ctx.obj, payload, render)
161
+
162
+
163
+ @auth.command(name="whoami")
164
+ @click.pass_context
165
+ def whoami(ctx: click.Context) -> None:
166
+ """Alias for `auth status`."""
167
+ ctx.invoke(status)
168
+
169
+
170
+ @auth.command()
171
+ @click.pass_context
172
+ def token(ctx: click.Context) -> None:
173
+ """Print the raw API token (for scripting). Keep it secret."""
174
+ cfg: Config = get_config(ctx)
175
+ if not cfg.token:
176
+ raise click.ClickException("Not logged in.")
177
+ click.echo(cfg.token)
178
+
179
+
180
+ def _display_name(user: dict) -> str:
181
+ if not user:
182
+ return "unknown"
183
+ parts = [user.get("firstName"), user.get("lastName")]
184
+ name = " ".join(p for p in parts if p).strip()
185
+ return name or user.get("email") or "unknown"
@@ -0,0 +1,56 @@
1
+ """`openhack-cli config` — inspect and set CLI configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from .. import output
8
+ from ..config import config_path
9
+ from ..context import get_config
10
+
11
+ # Keys the user is allowed to set directly. `token` is managed by auth login.
12
+ SETTABLE = {"app_url"}
13
+
14
+
15
+ @click.group()
16
+ def config() -> None:
17
+ """View and change CLI configuration."""
18
+
19
+
20
+ @config.command(name="path")
21
+ def show_path() -> None:
22
+ """Print the path to the config file."""
23
+ click.echo(str(config_path()))
24
+
25
+
26
+ @config.command(name="show")
27
+ @click.pass_context
28
+ def show(ctx: click.Context) -> None:
29
+ """Show the current configuration (token redacted)."""
30
+ cfg = get_config(ctx)
31
+ data = cfg.as_dict()
32
+ if data.get("token"):
33
+ data["token"] = data["token"][:14] + "…"
34
+ data["app_url"] = cfg.app_url # resolved value (env may override)
35
+
36
+ def render(d):
37
+ for key, value in d.items():
38
+ output.console.print(f" [bold]{key}[/bold]: {value}")
39
+
40
+ output.emit(ctx.obj, data, render)
41
+
42
+
43
+ @config.command(name="set")
44
+ @click.argument("key")
45
+ @click.argument("value")
46
+ @click.pass_context
47
+ def set_value(ctx: click.Context, key: str, value: str) -> None:
48
+ """Set a config value (e.g. `config set app_url https://...`)."""
49
+ if key not in SETTABLE:
50
+ raise click.ClickException(
51
+ f"'{key}' is not settable. Allowed: {', '.join(sorted(SETTABLE))}."
52
+ )
53
+ cfg = get_config(ctx)
54
+ cfg.set(key, value.rstrip("/") if key == "app_url" else value)
55
+ cfg.save()
56
+ output.success(f"Set {key} = {value}")
@@ -0,0 +1,71 @@
1
+ """`openhack-cli orgs` — list organizations and pick the active one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from .. import output
8
+ from ..context import get_client, get_config, match_resource
9
+
10
+
11
+ @click.group()
12
+ def orgs() -> None:
13
+ """List and select organizations."""
14
+
15
+
16
+ def _fetch_orgs(ctx: click.Context) -> list[dict]:
17
+ client = get_client(ctx)
18
+ data = client.get("/api/orgs")
19
+ return data.get("orgs", []) if isinstance(data, dict) else (data or [])
20
+
21
+
22
+ @orgs.command(name="list")
23
+ @click.pass_context
24
+ def list_orgs(ctx: click.Context) -> None:
25
+ """List organizations you belong to."""
26
+ items = _fetch_orgs(ctx)
27
+ cfg = get_config(ctx)
28
+ active_id = (cfg.org or {}).get("id")
29
+
30
+ def render(rows):
31
+ if not rows:
32
+ output.warn("No organizations found.")
33
+ return
34
+ table = output.make_table("Organizations",
35
+ ["", "Name", "Slug", "Type", "Plan", "ID"])
36
+ for o in rows:
37
+ marker = "[green]●[/green]" if o.get("id") == active_id else ""
38
+ table.add_row(
39
+ marker,
40
+ o.get("name", "-"),
41
+ o.get("slug") or "-",
42
+ o.get("orgType") or "-",
43
+ o.get("subscriptionPlan") or "-",
44
+ output.short(o.get("id"), 38),
45
+ )
46
+ output.console.print(table)
47
+
48
+ output.emit(ctx.obj, items, render)
49
+
50
+
51
+ @orgs.command()
52
+ @click.argument("identifier")
53
+ @click.pass_context
54
+ def use(ctx: click.Context, identifier: str) -> None:
55
+ """Set the active organization (by id, slug, or name)."""
56
+ items = _fetch_orgs(ctx)
57
+ match = match_resource(items, identifier)
58
+ if not match:
59
+ raise click.ClickException(
60
+ f"No organization matching '{identifier}'. Try `openhack-cli orgs list`."
61
+ )
62
+ cfg = get_config(ctx)
63
+ cfg.set("org", {
64
+ "id": match.get("id"),
65
+ "slug": match.get("slug"),
66
+ "name": match.get("name"),
67
+ })
68
+ # Switching orgs invalidates the previously-selected project.
69
+ cfg.set("project", None)
70
+ cfg.save()
71
+ output.success(f"Active org set to [bold]{match.get('name')}[/bold]")