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.
openhack_cli/config.py ADDED
@@ -0,0 +1,137 @@
1
+ """Persistent CLI configuration: app URL, auth token, and active org/project context.
2
+
3
+ Stored as JSON at ``$XDG_CONFIG_HOME/openhack/config.json`` (falling back to
4
+ ``~/.config/openhack/config.json``). The file is written with ``0600`` perms
5
+ since it holds a long-lived API token.
6
+
7
+ Every value can be overridden at runtime by an environment variable so the CLI
8
+ stays scriptable for agents and CI:
9
+
10
+ OPENHACK_APP_URL -> app_url
11
+ OPENHACK_TOKEN -> token
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ from typing import Any, Optional
20
+
21
+ DEFAULT_APP_URL = "https://app.openhack.com"
22
+ LOCAL_APP_URL = "http://localhost:9080"
23
+
24
+ # Environment overrides take precedence over the on-disk config.
25
+ ENV_APP_URL = "OPENHACK_APP_URL"
26
+ ENV_TOKEN = "OPENHACK_TOKEN"
27
+ # Export OPENHACK_DEV=1 to target the local dev server without passing --local.
28
+ ENV_DEV = "OPENHACK_DEV"
29
+
30
+ _TRUTHY = {"1", "true", "yes", "on"}
31
+
32
+
33
+ def _truthy(value: Optional[str]) -> bool:
34
+ return bool(value) and value.strip().lower() in _TRUTHY
35
+
36
+
37
+ def config_dir() -> Path:
38
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(
39
+ os.path.expanduser("~"), ".config"
40
+ )
41
+ return Path(base) / "openhack"
42
+
43
+
44
+ def config_path() -> Path:
45
+ return config_dir() / "config.json"
46
+
47
+
48
+ class Config:
49
+ """Thin wrapper over the JSON config file with env-var overrides."""
50
+
51
+ def __init__(self, data: Optional[dict[str, Any]] = None):
52
+ self._data: dict[str, Any] = data or {}
53
+ # Set by CLI flags (--app-url/--local); wins over env and saved config.
54
+ self._override_app_url: Optional[str] = None
55
+
56
+ # ----- loading / saving -------------------------------------------------
57
+ @classmethod
58
+ def load(cls) -> "Config":
59
+ path = config_path()
60
+ if path.exists():
61
+ try:
62
+ data = json.loads(path.read_text())
63
+ except (json.JSONDecodeError, OSError):
64
+ data = {}
65
+ else:
66
+ data = {}
67
+ return cls(data)
68
+
69
+ def save(self) -> None:
70
+ path = config_path()
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ # Write atomically-ish, then lock down perms (token lives here).
73
+ tmp = path.with_suffix(".json.tmp")
74
+ tmp.write_text(json.dumps(self._data, indent=2) + "\n")
75
+ os.chmod(tmp, 0o600)
76
+ tmp.replace(path)
77
+ os.chmod(path, 0o600)
78
+
79
+ # ----- resolved accessors (env wins) -----------------------------------
80
+ def set_override_app_url(self, url: str) -> None:
81
+ """Force the app URL for this invocation (from a CLI flag)."""
82
+ self._override_app_url = url.rstrip("/")
83
+
84
+ @property
85
+ def app_url(self) -> str:
86
+ # Precedence (highest first):
87
+ # 1. CLI flag (--app-url / --local)
88
+ # 2. OPENHACK_APP_URL env
89
+ # 3. OPENHACK_DEV=1 -> local dev server
90
+ # 4. saved config (last login / `config set`)
91
+ # 5. built-in production default
92
+ if self._override_app_url:
93
+ return self._override_app_url
94
+ explicit = os.environ.get(ENV_APP_URL)
95
+ if explicit:
96
+ return explicit.rstrip("/")
97
+ if _truthy(os.environ.get(ENV_DEV)):
98
+ return LOCAL_APP_URL
99
+ return (self._data.get("app_url") or DEFAULT_APP_URL).rstrip("/")
100
+
101
+ @property
102
+ def token(self) -> Optional[str]:
103
+ return os.environ.get(ENV_TOKEN) or self._data.get("token")
104
+
105
+ @property
106
+ def user(self) -> Optional[dict[str, Any]]:
107
+ return self._data.get("user")
108
+
109
+ @property
110
+ def org(self) -> Optional[dict[str, Any]]:
111
+ return self._data.get("org")
112
+
113
+ @property
114
+ def project(self) -> Optional[dict[str, Any]]:
115
+ return self._data.get("project")
116
+
117
+ # ----- mutators ---------------------------------------------------------
118
+ def set(self, key: str, value: Any) -> None:
119
+ if value is None:
120
+ self._data.pop(key, None)
121
+ else:
122
+ self._data[key] = value
123
+
124
+ def get(self, key: str, default: Any = None) -> Any:
125
+ return self._data.get(key, default)
126
+
127
+ def as_dict(self) -> dict[str, Any]:
128
+ return dict(self._data)
129
+
130
+ def clear_auth(self) -> None:
131
+ """Remove credentials and bound context (logout)."""
132
+ for key in ("token", "user", "org", "project"):
133
+ self._data.pop(key, None)
134
+
135
+ @property
136
+ def is_authenticated(self) -> bool:
137
+ return bool(self.token)
@@ -0,0 +1,76 @@
1
+ """Helpers that bridge Click context, config, and the API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ import click
8
+
9
+ from .client import Client
10
+ from .config import Config
11
+
12
+
13
+ def get_config(ctx: click.Context) -> Config:
14
+ return ctx.obj["config"]
15
+
16
+
17
+ def get_client(ctx: click.Context, require_auth: bool = True) -> Client:
18
+ """Return a configured API client, optionally requiring a stored token."""
19
+ cfg: Config = ctx.obj["config"]
20
+ if require_auth and not cfg.token:
21
+ raise click.ClickException(
22
+ "Not logged in. Run `openhack-cli auth login` first."
23
+ )
24
+ if "client" not in ctx.obj:
25
+ ctx.obj["client"] = Client(cfg.app_url, token=cfg.token)
26
+ return ctx.obj["client"]
27
+
28
+
29
+ def is_json(ctx: click.Context) -> bool:
30
+ return bool(ctx.obj.get("json"))
31
+
32
+
33
+ def resolve_org_id(ctx: click.Context, org: Optional[str]) -> str:
34
+ """Resolve an org id from an explicit arg or the active context.
35
+
36
+ ``org`` may be an org id (passed through as-is). When omitted we fall back
37
+ to the org bound to the active context (set via `orgs use`, or the org the
38
+ login token is bound to).
39
+ """
40
+ cfg: Config = ctx.obj["config"]
41
+ if org:
42
+ return org
43
+ active = cfg.org
44
+ if active and active.get("id"):
45
+ return active["id"]
46
+ raise click.ClickException(
47
+ "No organization selected. Pass --org <id> or run `openhack-cli orgs use <id|slug>`."
48
+ )
49
+
50
+
51
+ def resolve_project_id(ctx: click.Context, project: Optional[str]) -> str:
52
+ """Resolve a project id from an explicit arg or the active context."""
53
+ cfg: Config = ctx.obj["config"]
54
+ if project:
55
+ return project
56
+ active = cfg.project
57
+ if active and active.get("id"):
58
+ return active["id"]
59
+ raise click.ClickException(
60
+ "No project selected. Pass a project id or run `openhack-cli projects use <id|slug>`."
61
+ )
62
+
63
+
64
+ def match_resource(items: list[dict], needle: str,
65
+ keys: tuple[str, ...] = ("id", "slug", "name")) -> Optional[dict]:
66
+ """Find a resource by id/slug/name (case-insensitive for slug/name)."""
67
+ for item in items:
68
+ if item.get("id") == needle:
69
+ return item
70
+ low = needle.lower()
71
+ for item in items:
72
+ for key in keys:
73
+ val = item.get(key)
74
+ if val and str(val).lower() == low:
75
+ return item
76
+ return None
openhack_cli/output.py ADDED
@@ -0,0 +1,104 @@
1
+ """Rendering helpers shared by all commands.
2
+
3
+ Every command supports two modes:
4
+ - human: Rich tables / panels (default, when stdout is a TTY or not --json)
5
+ - json: raw machine-readable JSON for agents and scripts (--json)
6
+
7
+ The active mode is carried on the Click context object (``ctx.obj``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json as _json
13
+ import sys
14
+ from typing import Any, Optional, Sequence
15
+
16
+ import click
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ console = Console()
21
+ err_console = Console(stderr=True)
22
+
23
+ # Severity ordering + colors reused across scans / findings / pentest output.
24
+ SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
25
+ SEVERITY_COLORS = {
26
+ "critical": "bright_red",
27
+ "high": "red",
28
+ "medium": "yellow",
29
+ "low": "cyan",
30
+ "info": "blue",
31
+ }
32
+ STATUS_COLORS = {
33
+ "completed": "green",
34
+ "approved": "green",
35
+ "in_progress": "yellow",
36
+ "pending": "yellow",
37
+ "draft": "dim",
38
+ "failed": "red",
39
+ "cancelled": "red",
40
+ }
41
+
42
+
43
+ def severity_label(sev: Optional[str]) -> str:
44
+ if not sev:
45
+ return "[dim]-[/dim]"
46
+ color = SEVERITY_COLORS.get(sev.lower(), "white")
47
+ return f"[{color}]{sev.upper()}[/{color}]"
48
+
49
+
50
+ def status_label(status: Optional[str]) -> str:
51
+ if not status:
52
+ return "[dim]-[/dim]"
53
+ color = STATUS_COLORS.get(status.lower(), "white")
54
+ return f"[{color}]{status}[/{color}]"
55
+
56
+
57
+ def print_json(data: Any) -> None:
58
+ """Emit raw JSON to stdout (machine mode)."""
59
+ click.echo(_json.dumps(data, indent=2, default=str))
60
+
61
+
62
+ def emit(ctx_obj: dict, data: Any, render) -> None:
63
+ """Print JSON when --json is set, otherwise call ``render(data)``."""
64
+ if ctx_obj.get("json"):
65
+ print_json(data)
66
+ else:
67
+ render(data)
68
+
69
+
70
+ def make_table(title: Optional[str], columns: Sequence[str]) -> Table:
71
+ table = Table(title=title, title_justify="left", header_style="bold",
72
+ expand=False, pad_edge=False)
73
+ for col in columns:
74
+ table.add_column(col, overflow="fold")
75
+ return table
76
+
77
+
78
+ def info(message: str) -> None:
79
+ err_console.print(message)
80
+
81
+
82
+ def success(message: str) -> None:
83
+ err_console.print(f"[green]✓[/green] {message}")
84
+
85
+
86
+ def warn(message: str) -> None:
87
+ err_console.print(f"[yellow]![/yellow] {message}")
88
+
89
+
90
+ def error(message: str) -> None:
91
+ err_console.print(f"[red]✗[/red] {message}")
92
+
93
+
94
+ def fail(message: str, code: int = 1) -> "click.ClickException":
95
+ """Print an error and exit with a non-zero code."""
96
+ error(message)
97
+ sys.exit(code)
98
+
99
+
100
+ def short(value: Optional[str], length: int = 36) -> str:
101
+ if not value:
102
+ return "-"
103
+ value = str(value)
104
+ return value if len(value) <= length else value[: length - 1] + "…"
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: openhack-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for the OpenHack security platform.
5
+ Author: OpenHack
6
+ License: MIT
7
+ Project-URL: Homepage, https://openhack.com
8
+ Project-URL: Repository, https://github.com/openhackai/cli
9
+ Project-URL: Issues, https://github.com/openhackai/cli/issues
10
+ Keywords: openhack,security,pentest,cli,sast,vulnerability
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Security
24
+ Classifier: Topic :: Software Development :: Quality Assurance
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: click>=8.1
29
+ Requires-Dist: requests>=2.31
30
+ Requires-Dist: rich>=13.7
31
+ Dynamic: license-file
32
+
33
+ # OpenHack CLI
34
+
35
+ `openhack-cli` is the command-line interface for the [OpenHack](https://openhack.com)
36
+ security platform. It does one thing: talk to the OpenHack app in an
37
+ authenticated way, so that **humans and agents** can drive the platform from a
38
+ terminal or a script. No scanning happens in the CLI itself — it's a thin,
39
+ authenticated client over the OpenHack API.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pipx install openhack-cli # recommended (isolated)
45
+ # or
46
+ pip install openhack-cli
47
+ ```
48
+
49
+ From source:
50
+
51
+ ```bash
52
+ git clone <this repo> && cd openhack-cli
53
+ pip install -e .
54
+ ```
55
+
56
+ Requires Python 3.9+.
57
+
58
+ > **Driving the CLI from an automated agent?** See [`AGENT.md`](./AGENT.md) — an
59
+ > exhaustive, copy-pasteable command + field reference covering every command
60
+ > group, with allowed enum values and `--json` output for automation.
61
+
62
+ ## Quick start
63
+
64
+ ```bash
65
+ openhack-cli auth login # device-code login in your browser
66
+ openhack-cli orgs list # list your organizations
67
+ openhack-cli orgs use acme # pick the active org
68
+ openhack-cli projects list # list projects
69
+ openhack-cli projects use web # pick the active project
70
+ openhack-cli scans list # scans for the active project
71
+ openhack-cli vulns list # vulnerabilities across recent scans
72
+ ```
73
+
74
+ ## Authentication
75
+
76
+ Login uses the same **device-code flow** as the OpenHack web app:
77
+
78
+ 1. `auth login` calls `POST /api/cli/auth` and prints a short code + URL.
79
+ 2. Your browser opens; you sign in, choose an organization, and approve.
80
+ 3. The CLI polls `POST /api/cli/auth/poll` until approval, then stores the
81
+ issued token.
82
+
83
+ The token is a long-lived, **org-scoped** API key sent as
84
+ `Authorization: Bearer openhack_…` on every request. It is stored at
85
+ `$XDG_CONFIG_HOME/openhack/config.json` (default `~/.config/openhack/config.json`)
86
+ with `0600` permissions.
87
+
88
+ ```bash
89
+ openhack-cli auth status # who am I / active context
90
+ openhack-cli auth token # print the raw token (scripting)
91
+ openhack-cli auth logout # remove local credentials
92
+ ```
93
+
94
+ ## Commands
95
+
96
+ | Group | Command | Description |
97
+ |-------|---------|-------------|
98
+ | `auth` | `login`, `logout`, `status`/`whoami`, `token` | Manage credentials |
99
+ | `orgs` | `list`, `use <id\|slug>` | List / select organizations |
100
+ | `projects` | `list`, `get`, `use`, `create` | Manage projects |
101
+ | `scans` | `list`, `get <scan_id>`, `trigger-full` | View / trigger scans |
102
+ | `vulns` | `list`, `groups`, `report`, `get`, `edit` | View, report, and edit project vulnerabilities |
103
+ | `pentest` | `list`, `get`, `create` | Pentesting engagements |
104
+ | `pentest findings` | `[engagement]` | Findings in an engagement (defaults to latest) |
105
+ | `pentest finding` | `get`, `create`, `update`, `delete`, `link`, `unlink` | Single finding: view / create / patch / delete / cross-reference |
106
+ | `config` | `show`, `set`, `path` | CLI configuration |
107
+
108
+ For an exhaustive, example-driven command reference (aimed at automation and
109
+ agents), see [`AGENT.md`](./AGENT.md).
110
+
111
+ ## Scripting & agents
112
+
113
+ Pass `--json` (global flag) to get machine-readable output from any command:
114
+
115
+ ```bash
116
+ openhack-cli --json scans list
117
+ openhack-cli --json vulns list --severity critical
118
+ ```
119
+
120
+ Configuration can be driven entirely by environment variables (handy in CI):
121
+
122
+ | Variable | Purpose |
123
+ |----------|---------|
124
+ | `OPENHACK_TOKEN` | API token (overrides stored credentials) |
125
+ | `OPENHACK_APP_URL` | App base URL (default `https://app.openhack.com`) |
126
+ | `OPENHACK_DEV` | Set to `1` to target the local dev server (`http://localhost:9080`) |
127
+ | `XDG_CONFIG_HOME` | Where the config file lives |
128
+
129
+ ### Targeting an environment
130
+
131
+ Production (`https://app.openhack.com`) is the default. For local dev work
132
+ (`http://localhost:9080`), the easiest option is to export `OPENHACK_DEV` once —
133
+ then every command targets localhost with no flags:
134
+
135
+ ```bash
136
+ export OPENHACK_DEV=1 # add to your ~/.zshrc for permanent dev mode
137
+ openhack-cli auth login # now logs in against localhost:9080
138
+ openhack-cli scans list
139
+ ```
140
+
141
+ Or use the `--local` flag per-command (shorthand for `--app-url
142
+ http://localhost:9080`):
143
+
144
+ ```bash
145
+ openhack-cli --local auth login
146
+ ```
147
+
148
+ The app URL is resolved with this precedence (highest first):
149
+
150
+ 1. `--app-url` / `--local` flag
151
+ 2. `OPENHACK_APP_URL` env var
152
+ 3. `OPENHACK_DEV=1` → `http://localhost:9080`
153
+ 4. saved config (last login, or `openhack-cli config set app_url <url>`)
154
+ 5. built-in default `https://app.openhack.com`
155
+
156
+ Exit codes: `0` success, `1` API/usage error, `2` auth error, `130` interrupted.
157
+
158
+ ## Configuration
159
+
160
+ ```bash
161
+ openhack-cli config show
162
+ openhack-cli config set app_url https://your-openhack-host
163
+ openhack-cli config path
164
+ ```
@@ -0,0 +1,20 @@
1
+ openhack_cli/__init__.py,sha256=6nfu9_udx4lsS141GD_R5QAtLAYXHcFhoWDWw63odBQ,107
2
+ openhack_cli/cli.py,sha256=BDl-T3qbilOtPAxUT-8ohnA64EVewPqTs5FzZNRRpDU,2901
3
+ openhack_cli/client.py,sha256=sJB_2IyI1Pym7c4vbC3Ka3wCa6PuEKFeZDeS9pLbG8Y,3980
4
+ openhack_cli/config.py,sha256=nXHcsAlCFcE_WG9mDkop26Gxt9bnGvCqeUZXebEXRy0,4421
5
+ openhack_cli/context.py,sha256=JcGn3riPW3I1Y9wNllihxNOJLLj0A5iYsxfjSSyaSk0,2396
6
+ openhack_cli/output.py,sha256=99Rt9e9kE_2nW33Qob_Cy237NeorDJ0XJwEuDoR99M8,2745
7
+ openhack_cli/commands/__init__.py,sha256=9NftJ5ZmxEB-ahGxqevwt2mWSvoIhYqWN1qNiafgl2Q,43
8
+ openhack_cli/commands/auth.py,sha256=4XUOOLZexNjJkWwCbz-DoYGCbtU7nJRH9iP0Dw4Nu9k,6115
9
+ openhack_cli/commands/config_cmd.py,sha256=EZLtRJXYLQkW-foGDgeBLTDQU-sYj7gfcZQ9b-WnzbM,1577
10
+ openhack_cli/commands/orgs.py,sha256=Jt2EJA8dXdPNnZBdVwNpGRhenV-bn_RPiZAMU4k-hqE,2158
11
+ openhack_cli/commands/pentest.py,sha256=5WeNRDvYzqWtgkb8B9o_cBEC0-IhbCfixGeiJ9F1-dM,24970
12
+ openhack_cli/commands/projects.py,sha256=1N-EBtqA9dwzlwurkuC-FvuquSkLwyqpVGe8inPoPkk,4504
13
+ openhack_cli/commands/scans.py,sha256=ExnYWOHzuIfGuBtSMtzNET1nCOWC-D7IZ0630Xibpe4,5993
14
+ openhack_cli/commands/vulns.py,sha256=-6sx0Y8i5o_JBwrQatESua25T5UACY5Yf10bRtsg53o,13934
15
+ openhack_cli-0.1.0.dist-info/licenses/LICENSE,sha256=0tVdhGTtdQjkkFnwg6Lpg6OBdMhd8wA-9Ml_lZrIAt0,1065
16
+ openhack_cli-0.1.0.dist-info/METADATA,sha256=zHhMd_Vs5zYp-R6_m7JxWd3rhI0SVADaTkoAVcsblx8,5905
17
+ openhack_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
18
+ openhack_cli-0.1.0.dist-info/entry_points.txt,sha256=Whtj5Zn_-rVj3hbrBRDWvqGEM0I2d9-xX2Zg29FTdpA,55
19
+ openhack_cli-0.1.0.dist-info/top_level.txt,sha256=vV5GLcxelf8nE1-fcKcAawtKzfILLvrfmfr-bSt6Gc8,13
20
+ openhack_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ openhack-cli = openhack_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenHack
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
+ openhack_cli