mirror of
https://github.com/usestrix/strix.git
synced 2026-09-08 22:21:05 +00:00
Make cloud CLI workflows actionable and safe
This commit is contained in:
parent
b074e0f632
commit
d265724bfa
11 changed files with 2481 additions and 120 deletions
|
|
@ -51,7 +51,7 @@ Target-specific workflows built on the same engine:
|
|||
- Every REST operation has a `strix cloud <resource> <verb>` command. Run `strix cloud` to list them. Output is JSON when stdout is not a terminal (or with `--json`), and there are no prompts without a TTY. Binary downloads are the exception: redirect raw bytes intentionally, or combine `--output FILE --json` for structured download metadata. Exit codes: `0` success, `1` error, `2` usage, `4` auth or plan limit, `5` payment required. `--token` or `STRIX_API_TOKEN` overrides the stored sign-in. `--data` adds extra request fields as JSON, and accepts `@file` or `-` for standard input.
|
||||
- Local source uploads require `uploads:write`. For an agent/CI handoff, review `scans start --source . --dry-run --show-files --json`, capture `source.archive_sha256`, then rerun with the same `--source`, `--exclude`, and `--include-*` selection flags plus `--approve-sha256 HASH`. A changed snapshot is rejected. `--yes` approves only the snapshot built in that invocation, so reserve it for a deliberate human or one-shot approval rather than a digest-bound two-step handoff.
|
||||
- Git ignores, hidden files, `.git`, symlinks, dependency/build output, secret-like filenames, and nested archives are excluded by default; `.strixignore` and `--exclude` narrow the manifest further (a trailing `/` excludes a directory subtree). Limits: 20,000 files, 25 MiB/file, 250 MiB expanded, 50 MiB compressed. Source-only infers `code_review`; source plus a domain infers `live_test`.
|
||||
- The temporary local archive is always removed. A staged upload is deleted after a definitive rejection, but retained when a network error, `5xx`, or interruption leaves the scan launch ambiguous. JSON reports its `upload_id` with `launch_outcome_unknown: true`, or with `cleanup_unknown: true` when automatic deletion cannot be confirmed. Check `scans list` before retrying; if no scan is linked, run `uploads delete UPLOAD_ID`.
|
||||
- The temporary local archive is always removed. A staged upload is deleted after a definitive rejection, but retained when a network error, `5xx`, malformed success response, or interruption leaves the scan launch ambiguous. JSON reports its `upload_id` with `launch_outcome_unknown: true`, or with `cleanup_unknown: true` when automatic deletion cannot be confirmed. Check `scans list` before retrying; if no scan is linked, run `uploads delete UPLOAD_ID`.
|
||||
- Non-Enterprise scans consume the scope estimate (a default-tier source-only review currently starts at 60 credits); Enterprise scans are plan-included. A rejected launch does not consume credits.
|
||||
- Human output is compact and numbered; non-TTY output and `--json` retain full records. Enable tab completion with `source <(strix completions zsh)` (or `bash`), or `strix completions fish | source`.
|
||||
- The REST API works directly too: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json).
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -339,7 +339,8 @@ Every operation of the [REST API](https://docs.app.strix.ai) has a matching comm
|
|||
|
||||
```bash
|
||||
strix cloud # list all resources
|
||||
strix cloud scans # list the verbs of a resource
|
||||
strix cloud scans # run the safe default (`scans list`)
|
||||
strix cloud scans help # list the verbs of a resource
|
||||
strix cloud domains add --domain example.com --asset-type web_app
|
||||
strix cloud scans start --engagement-type live_test --domain-ids <uuid> --wait
|
||||
strix cloud scans start --source . --dry-run --show-files --json # review + capture source.archive_sha256
|
||||
|
|
@ -354,7 +355,7 @@ Workspaces and account setup also work from the terminal:
|
|||
|
||||
```bash
|
||||
strix cloud workspaces list # numbered list; `workspace` is also accepted
|
||||
strix cloud workspaces create --name "My Team"
|
||||
strix cloud workspaces create --name "My Team" # admin + organizations:write
|
||||
strix cloud workspaces use 2 # switch by list number, exact name, or ID
|
||||
strix cloud billing subscribe --plan strix_cloud # opens the hosted checkout page
|
||||
strix cloud billing portal # opens the billing portal
|
||||
|
|
@ -364,7 +365,7 @@ strix cloud domains verify <domain-id> # prints the DNS record to add
|
|||
|
||||
The last four commands end at a person. Strix creates the link, opens the browser for an interactive terminal, and always prints the URL. The user enters the card, approves the installation, or adds the DNS record. Pass `--no-browser` to print the URL only.
|
||||
|
||||
The commands work for humans and agents: terminal output favors names, branches, lifecycle states, and numbered selectors, while redirected output (or `--json`) preserves complete machine-readable records and IDs. Human asset, knowledge, test-user, and token lists retain the record IDs needed by follow-up commands but omit internal organization/user IDs; token lists label credentials as active, expired, or revoked. Binary downloads are the exception: intentionally redirect their raw bytes, or use `--output FILE --json` to write the file and receive structured download metadata. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication or plan limit, `5` payment required. Set the token with `--token` or `STRIX_API_TOKEN` to skip the stored sign-in.
|
||||
The commands work for humans and agents: terminal output favors names, branches, lifecycle states, and numbered selectors, while redirected output (or `--json`) preserves complete machine-readable records and IDs. Human lists retain the selectors needed by follow-up commands but omit internal organization/user IDs; a selector too long for the compact table is repeated losslessly in a copyable block. Paginated lists print the next `--page` or `--offset`, and detail views preserve useful prose within a safe terminal bound; use `--json` for the complete record. Token lists label credentials as active, expired, or revoked. Binary downloads are the exception: intentionally redirect their raw bytes, or use `--output FILE --json` to write the file and receive structured download metadata. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication or plan limit, `5` payment required. Set the token with `--token` or `STRIX_API_TOKEN` to skip the stored sign-in.
|
||||
|
||||
Write commands take request fields as flags, and every write command also accepts one JSON object with `--data`:
|
||||
|
||||
|
|
@ -389,8 +390,8 @@ the service independently validates the archive. Source alone infers a code revi
|
|||
domain infers a live test. You can always pass `--engagement-type` explicitly.
|
||||
|
||||
Strix removes the temporary local archive after every invocation. It deletes a staged remote
|
||||
upload after a definitive scan rejection. If a network error, `5xx` response, or interruption
|
||||
makes the launch outcome ambiguous, it retains the upload and reports its `upload_id` with
|
||||
upload after a definitive scan rejection. If a network error, `5xx` response, malformed
|
||||
success response, or interruption makes the launch outcome ambiguous, it retains the upload and reports its `upload_id` with
|
||||
`launch_outcome_unknown: true`; if automatic deletion cannot be confirmed, it reports the ID
|
||||
with `cleanup_unknown: true`. Check `strix cloud scans list` before retrying. If no scan is
|
||||
linked to the retained upload, delete it with `strix cloud uploads delete UPLOAD_ID`.
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ Use this when you want Strix's autonomous pentesting **without running Docker or
|
|||
|
||||
There are two equivalent interfaces. Prefer the CLI:
|
||||
|
||||
- **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud <resource> <verb>`. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud <resource>` to list its verbs.
|
||||
- **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud <resource> <verb>`. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud <resource> help` (or `-h`) to list a resource's verbs; a bare resource with a safe read operation runs its documented default.
|
||||
- **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer <token>` on every request. Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · agent index: `https://docs.app.strix.ai/llms.txt` · OpenAPI: `https://docs.app.strix.ai/openapi.json`.
|
||||
|
||||
The CLI is equally usable by agents and people. Output is complete JSON when stdout is not a terminal, or when you pass `--json`; terminal tables favor names, branches, lifecycle states, and numbered selectors. Human asset, knowledge, test-user, and token lists retain the record IDs needed by follow-up commands but omit internal organization/user IDs; token lists label credentials as active, expired, or revoked. Binary downloads are the exception: redirect raw bytes intentionally, or use `--output FILE --json` to write the file and receive structured metadata. There are no interactive prompts when stdin is not a terminal. Exit codes: `0` success, `1` request/runtime error, `2` invalid usage, `4` authentication or plan limit, `5` payment required.
|
||||
The CLI is equally usable by agents and people. Output is complete JSON when stdout is not a terminal, or when you pass `--json`; terminal tables favor names, branches, lifecycle states, and numbered selectors. Human lists retain the selectors needed by follow-up commands but omit internal organization/user IDs; a selector too long for the compact table is repeated losslessly in a copyable block. Paginated lists print the next `--page` or `--offset`, and detail views preserve useful prose within a safe terminal bound; use `--json` for the complete record. Token lists label credentials as active, expired, or revoked. Binary downloads are the exception: redirect raw bytes intentionally, or use `--output FILE --json` to write the file and receive structured metadata. There are no interactive prompts when stdin is not a terminal. Exit codes: `0` success, `1` request/runtime error, `2` invalid usage, `4` authentication or plan limit, `5` payment required.
|
||||
|
||||
Every resource group has a useful default list/read action, and `-h` or `help` always shows its verbs. Native tab completion includes resources, verbs, flags, workspace commands, and local paths:
|
||||
Every resource group with a safe read operation has a useful default action, and `-h` or `help` always shows its verbs. Native tab completion includes resources, verbs, flags, workspace commands, and local paths:
|
||||
|
||||
```bash
|
||||
source <(strix completions zsh) # current zsh session
|
||||
|
|
@ -62,8 +62,9 @@ The user approves the sign-in in the browser. With `--scopes` (and optionally `-
|
|||
| `pr_reviews:write` | trigger PR security reviews |
|
||||
| `webhooks:read` / `:write` | manage webhook subscriptions |
|
||||
| `uploads:write` | upload local source or documents for a scan |
|
||||
| `organizations:read` | list and switch workspaces |
|
||||
| `tokens:write` | create/revoke API tokens |
|
||||
| `organizations:read` | list workspaces |
|
||||
| `organizations:write` | create/update workspaces (admin) |
|
||||
| `tokens:write` | switch workspaces · create/revoke API tokens |
|
||||
| `knowledge:read` / `:write` | read/update organization knowledge |
|
||||
| `audit:read` | read/export the Enterprise audit log |
|
||||
| `billing:read` / `billing:write` | read credit balance & auto top-up settings · buy credits (admin) |
|
||||
|
|
@ -121,13 +122,13 @@ Manage workspaces with a personal token from `strix cloud login`:
|
|||
|
||||
```bash
|
||||
strix cloud workspaces list # numbered name/role/current list
|
||||
strix cloud workspaces create --name "My Team"
|
||||
strix cloud workspaces create --name "My Team" # admin + organizations:write
|
||||
strix cloud workspaces use 2 # displayed number, exact name, or ID
|
||||
strix cloud workspace use "My Team" # singular `workspace` alias also works
|
||||
strix cloud org members invite --email dev@example.com --role analyst
|
||||
```
|
||||
|
||||
`workspaces use` rotates the current personal token to a workspace the user already belongs to and stores the updated workspace metadata; the bearer secret stays unchanged. The role in the target workspace limits the scopes. Add `--scopes` to request a smaller set.
|
||||
`workspaces use` retargets the current personal token to a workspace the user already belongs to and stores the updated workspace metadata; the bearer secret and expiry stay unchanged. The role in the target workspace limits the scopes. Add `--scopes` to request a smaller set.
|
||||
|
||||
### Handoffs a person must finish
|
||||
|
||||
|
|
@ -199,7 +200,10 @@ Useful flags (each maps to a `CreateScanRequest` field):
|
|||
| `--upload-ids` | attach uploaded source/docs archives for white-box context |
|
||||
| `--notify-on-completion` / `--notification-emails` | email when done |
|
||||
|
||||
The response is `{ scan_id, title, status }` with `status` = `pending`.
|
||||
Without `--source`, the response is `{ scan_id, title, status }` with `status` = `pending`.
|
||||
Local-source success wraps that platform response as
|
||||
`{ source, upload_id, scan: { scan_id, title, status } }`, so automation can retain the exact
|
||||
approved manifest and staged-upload identifier alongside the created scan.
|
||||
|
||||
### Scan a local workspace in the cloud
|
||||
|
||||
|
|
|
|||
|
|
@ -300,7 +300,13 @@ def check(response: requests.Response) -> Any:
|
|||
"the server returned a non-JSON response. Check STRIX_APP_URL and preview "
|
||||
"access, then retry."
|
||||
)
|
||||
return data
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise CloudError(
|
||||
"the server returned malformed JSON. Check STRIX_APP_URL and preview "
|
||||
"access, then retry."
|
||||
) from exc
|
||||
detail = ""
|
||||
error_code = ""
|
||||
if isinstance(data, dict):
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -377,7 +377,10 @@ def _execute( # noqa: PLR0912, PLR0915
|
|||
return http.EXIT_OK
|
||||
|
||||
source_workflow.mark_launch_started()
|
||||
scan_request_started = cmd.idempotent
|
||||
# Every wait-path mutation creates a scan, even when the endpoint has
|
||||
# not yet adopted idempotency keys (for example vulnerability retests).
|
||||
# Once sent, transport and malformed-success failures are ambiguous.
|
||||
scan_request_started = cmd.idempotent or cmd.wait_path is not None
|
||||
response = _request_with_idempotency(
|
||||
cmd,
|
||||
path,
|
||||
|
|
@ -414,7 +417,7 @@ def _execute( # noqa: PLR0912, PLR0915
|
|||
json_metadata=binary_json_metadata,
|
||||
)
|
||||
try:
|
||||
result = http.check(response)
|
||||
result = _validated_operation_result(http.check(response), cmd)
|
||||
except BaseException as exc:
|
||||
source_workflow.handle_response_failure(
|
||||
exc,
|
||||
|
|
@ -1044,11 +1047,20 @@ def _created_id(created: Any) -> str | None:
|
|||
return None
|
||||
fields = cast("dict[str, Any]", created)
|
||||
for key, value in fields.items():
|
||||
if (key == "id" or key.endswith("_id")) and isinstance(value, str):
|
||||
if (key == "id" or key.endswith("_id")) and isinstance(value, str) and value.strip():
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _validated_operation_result(result: Any, cmd: Cmd) -> Any:
|
||||
"""Reject malformed success bodies for mutations that create a scan."""
|
||||
if cmd.wait_path and _created_id(result) is None:
|
||||
raise http.CloudError(
|
||||
"the platform returned a successful operation response without an operation ID."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _wait(
|
||||
console: Console,
|
||||
cmd: Cmd,
|
||||
|
|
@ -1059,8 +1071,12 @@ def _wait(
|
|||
wait_timeout: float,
|
||||
) -> Any:
|
||||
item_id = _created_id(created)
|
||||
if not item_id or not cmd.wait_path:
|
||||
if not cmd.wait_path:
|
||||
return created
|
||||
if not item_id:
|
||||
raise http.CloudError(
|
||||
"cannot wait because the platform response did not include an operation ID."
|
||||
)
|
||||
path = cmd.wait_path.replace("{id}", str(item_id))
|
||||
if not as_json:
|
||||
console.print(
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str:
|
|||
raise error
|
||||
try:
|
||||
http.upload_file(cast("str", signed_url), cast("str", upload_token), bundle.archive_path)
|
||||
http.check(
|
||||
completed = http.check(
|
||||
http.request(
|
||||
"POST",
|
||||
"/uploads/complete",
|
||||
|
|
@ -259,6 +259,7 @@ def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str:
|
|||
body={"upload_id": upload_id},
|
||||
)
|
||||
)
|
||||
_validate_completed_upload(completed, expected_id=cast("str", upload_id))
|
||||
except BaseException as error:
|
||||
try:
|
||||
_delete_upload(cast("str", upload_id), token=token)
|
||||
|
|
@ -273,6 +274,12 @@ def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str:
|
|||
return cast("str", upload_id)
|
||||
|
||||
|
||||
def _validate_completed_upload(completed: Any, *, expected_id: str) -> None:
|
||||
fields = cast("dict[str, Any]", completed) if isinstance(completed, dict) else {}
|
||||
if fields.get("id") != expected_id:
|
||||
raise http.CloudError("the platform returned an invalid source upload completion response.")
|
||||
|
||||
|
||||
def _delete_upload(upload_id: str, *, token: str | None) -> None:
|
||||
response = http.request("DELETE", f"/uploads/{quote(upload_id, safe='')}", token=token)
|
||||
if response.status_code == 404 or 200 <= response.status_code < 300:
|
||||
|
|
|
|||
|
|
@ -300,8 +300,7 @@ SPEC: dict[str, dict[str, Cmd]] = {
|
|||
P(
|
||||
"status",
|
||||
help=(
|
||||
"New status: open, in_progress, snoozed, fixed, ignored, or "
|
||||
"not_affected."
|
||||
"New status: open, in_progress, snoozed, fixed, ignored, or not_affected."
|
||||
),
|
||||
),
|
||||
P("note", help="Note that explains the change."),
|
||||
|
|
@ -1096,10 +1095,27 @@ SPEC: dict[str, dict[str, Cmd]] = {
|
|||
|
||||
# Default verbs let a bare group name run its most common read command.
|
||||
DEFAULT_VERBS: dict[str, str] = {
|
||||
"scans": "list",
|
||||
"vulns": "list",
|
||||
"domains": "list",
|
||||
"repos": "list",
|
||||
"workspaces": "list",
|
||||
"schedules": "list",
|
||||
"pr-reviews": "list",
|
||||
"billing": "credits",
|
||||
"chat": "list",
|
||||
"knowledge": "list",
|
||||
"org": "get",
|
||||
"integrations": "list",
|
||||
"connectors": "list",
|
||||
"webhooks": "list",
|
||||
"analytics": "overview",
|
||||
"costs": "overview",
|
||||
"audit": "list",
|
||||
"llm-settings": "get",
|
||||
"settings": "notifications",
|
||||
"license": "show",
|
||||
"tokens": "list",
|
||||
"supply-chain": "summary",
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -940,10 +940,10 @@ def test_device_flow_accepts_external_authkit_url_and_binds_token_origin(
|
|||
|
||||
|
||||
def test_missing_verb_json_is_structured(capsys: Any) -> None:
|
||||
assert cloud.run_cloud(["scans", "--json"]) == 0
|
||||
assert cloud.run_cloud(["uploads", "--json"]) == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["command"] == "strix cloud scans"
|
||||
assert any(item["name"] == "start" for item in payload["verbs"])
|
||||
assert payload["command"] == "strix cloud uploads"
|
||||
assert any(item["name"] == "request" for item in payload["verbs"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -981,8 +981,8 @@ def test_non_tty_dispatcher_always_emits_structured_json(
|
|||
assert cloud.run_cloud([]) == 0
|
||||
assert json.loads(capsys.readouterr().out)["command"] == "strix cloud"
|
||||
|
||||
assert cloud.run_cloud(["scans"]) == 0
|
||||
assert json.loads(capsys.readouterr().out)["command"] == "strix cloud scans"
|
||||
assert cloud.run_cloud(["uploads"]) == 0
|
||||
assert json.loads(capsys.readouterr().out)["command"] == "strix cloud uploads"
|
||||
|
||||
assert cloud.run_cloud(["does-not-exist"]) == http.EXIT_USAGE
|
||||
assert json.loads(capsys.readouterr().out) == {"error": "unknown command: does-not-exist"}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ class FakeResponse:
|
|||
return self._payload
|
||||
|
||||
|
||||
class MalformedJsonResponse(FakeResponse):
|
||||
def json(self) -> Any:
|
||||
raise ValueError("malformed JSON")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _token_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("STRIX_API_TOKEN", "test-token")
|
||||
|
|
@ -467,7 +472,10 @@ def test_failed_scan_deletes_completed_source_upload(
|
|||
assert ("DELETE", "/uploads/upload-1") in paths
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["network", "server", "malformed_success"])
|
||||
@pytest.mark.parametrize(
|
||||
"failure",
|
||||
["network", "server", "malformed_success", "malformed_json_success", "wrong_shape_success"],
|
||||
)
|
||||
def test_ambiguous_scan_launch_retains_completed_source_upload(
|
||||
failure: str,
|
||||
tmp_path: Path,
|
||||
|
|
@ -494,6 +502,10 @@ def test_ambiguous_scan_launch_retains_completed_source_upload(
|
|||
raise http.CloudError("connection closed before a response")
|
||||
if failure == "server":
|
||||
return FakeResponse({"detail": "temporary failure"}, status_code=500)
|
||||
if failure == "malformed_json_success":
|
||||
return MalformedJsonResponse("accepted")
|
||||
if failure == "wrong_shape_success":
|
||||
return FakeResponse({})
|
||||
response = FakeResponse("accepted")
|
||||
response.headers = {"content-type": "text/html"}
|
||||
return response
|
||||
|
|
@ -516,6 +528,37 @@ def test_ambiguous_scan_launch_retains_completed_source_upload(
|
|||
assert ("DELETE", "/uploads/upload-ambiguous") not in paths
|
||||
|
||||
|
||||
def test_mismatched_upload_completion_response_is_cleaned_before_launch(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
(tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8")
|
||||
paths: list[tuple[str, str]] = []
|
||||
|
||||
def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse:
|
||||
paths.append((method, path))
|
||||
if path == "/uploads/request":
|
||||
return FakeResponse(
|
||||
{
|
||||
"upload_id": "upload-expected",
|
||||
"signed_url": "https://storage.test/object",
|
||||
"token": "signed",
|
||||
}
|
||||
)
|
||||
if path == "/uploads/complete":
|
||||
return FakeResponse({"id": "upload-different"})
|
||||
if path == "/uploads/upload-expected":
|
||||
return FakeResponse({"ok": True})
|
||||
if path == "/scans":
|
||||
pytest.fail("a scan must not launch before upload completion is confirmed")
|
||||
raise AssertionError(path)
|
||||
|
||||
monkeypatch.setattr(http, "request", fake_request)
|
||||
monkeypatch.setattr(http, "upload_file", lambda *_args, **_kwargs: None)
|
||||
|
||||
assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--yes"]) == 1
|
||||
assert ("DELETE", "/uploads/upload-expected") in paths
|
||||
|
||||
|
||||
def test_interrupted_scan_launch_retains_completed_source_upload(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue