diff --git a/AGENTS.md b/AGENTS.md index b347278b..fdaae975 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,13 +38,20 @@ Target-specific workflows built on the same engine: - **Managed cloud (app.strix.ai):** no Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Use it when local infra isn't available. ```bash - # token from Settings → API Access; register the target as an asset, then: - curl -sS https://app.strix.ai/api/v1/scans -H "Authorization: Bearer $STRIX_API_TOKEN" \ - -H "Content-Type: application/json" -d '{"engagement_type":"live_test","domain_ids":[""]}' + strix cloud login --scopes scans:read scans:write billing:read # device sign-in, no prompts + strix cloud domains add --domain example.com --asset-type web_app + strix cloud scans start --engagement-type live_test --domain-ids --wait + strix cloud scans start --source . --dry-run --show-files --json # review local upload + strix cloud scans start --source . --yes --engagement-type code_review --wait + strix cloud vulns list --severity critical + strix cloud billing topup --credits 20 # buy credits when a scan returns exit code 5 ``` - - API docs: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json). + - Account setup runs from the CLI too: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe --plan strix_cloud`, `strix cloud billing portal`, `strix cloud integrations install github`, and `strix cloud domains verify `. The last four end at a person: the command prints a link or a DNS record for the user to open or add, and it never completes the payment, the installation, or the DNS change for them. + - Every REST operation has a `strix cloud ` 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. 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`. Review with `scans start --source . --dry-run --show-files --json`, then approve with `--yes`. 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. + - The REST API works directly too: https://docs.app.strix.ai (OpenAPI: https://docs.app.strix.ai/openapi.json). -- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt). +- CLI docs index for LLMs: https://docs.strix.ai/llms.txt (full: https://docs.strix.ai/llms-full.txt). Managed API docs for LLMs: https://docs.app.strix.ai/llms.txt. - Only scan targets the user is authorized to test. ## Contributing to this repo diff --git a/README.md b/README.md index 99c235af..29cbbba2 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,56 @@ strix auth status # show the active sign-in strix auth logout # forget the sign-in ``` +#### Use the managed platform: `strix cloud` + +The `strix cloud` commands drive the managed platform ([app.strix.ai](https://app.strix.ai)) from the terminal. Sign in once with the device flow. The sign-in creates your account and workspace on first use and stores a personal API token in `~/.strix/platform-auth.json`: + +```bash +strix cloud login # opens the browser, then pick a workspace and scopes +strix cloud login --scopes scans:read scans:write # request specific token scopes +strix cloud login --workspace "My Team" # select a workspace by name or ID +strix cloud whoami # show the active sign-in +strix cloud logout # forget the sign-in +``` + +Every operation of the [REST API](https://docs.app.strix.ai) has a matching command in the form `strix cloud `: + +```bash +strix cloud # list all resources +strix cloud scans # 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 --wait +strix cloud vulns list --severity critical +strix cloud credits # credit balance +strix cloud billing topup --credits 20 # buy credits (agent payment, HTTP 402) +``` + +Workspaces and account setup also work from the terminal: + +```bash +strix cloud workspaces list # your workspaces +strix cloud workspaces create --name "My Team" +strix cloud workspaces use "My Team" # store a token for another workspace +strix cloud billing subscribe --plan strix_cloud # opens the hosted checkout page +strix cloud billing portal # opens the billing portal +strix cloud integrations install github # opens the app installation page +strix cloud domains verify # 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 are agent friendly. Output is JSON when stdout is not a terminal or when you pass `--json`. There are no prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication required, `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`: + +```bash +strix cloud scans start --data '{"engagement_type":"code_review"}' # literal JSON +strix cloud scans start --data @request.json # read a file +cat request.json | strix cloud scans start --data - # read standard input +``` + +The platform enforces plan and role limits. Report downloads need the Enterprise plan, schedules need the Pro plan, and billing writes need an admin token. Those commands return the platform message and exit code `4`. + #### Connect your own MCP servers Strix can connect to Model Context Protocol (MCP) servers you list and expose their tools to the agent during a run. Create `~/.strix/mcp-servers.json` with a JSON list of servers. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server: diff --git a/docs/cloud/overview.mdx b/docs/cloud/overview.mdx index 8b6e584d..2429c7e2 100644 --- a/docs/cloud/overview.mdx +++ b/docs/cloud/overview.mdx @@ -35,6 +35,22 @@ Skip the setup. Run Strix in the cloud at [app.strix.ai](https://app.strix.ai). 2. Connect your repository or enter a target URL 3. Launch your first scan +## Scan Local Source + +Send a local working tree to the managed white-box scanner without connecting a source-control provider: + +```bash +# Review the exact file manifest first. Nothing is uploaded. +strix cloud scans start --source . --dry-run --show-files --json + +# Approve the reviewed selection, upload it, and wait for the scan. +strix cloud scans start --source . --yes --engagement-type code_review --wait +``` + +In a Git repository, Strix includes tracked files and untracked files that are not ignored. Hidden files, `.git`, symlinks, dependencies and build output, secret-like filenames, and nested archives are excluded by default. Use `.strixignore` or repeat `--exclude GLOB` for project-specific exclusions. `--include-hidden`, `--include-sensitive`, and `--include-archives` are explicit opt-ins. + +The CLI limits individual files, total expanded bytes, archive bytes, and file count. Non-interactive environments must pass `--yes`, so an agent cannot upload a workspace without an explicit approval flag. + Run your first pentest in minutes. diff --git a/docs/integrations/coding-agents.mdx b/docs/integrations/coding-agents.mdx index 59e598f1..80f24597 100644 --- a/docs/integrations/coding-agents.mdx +++ b/docs/integrations/coding-agents.mdx @@ -36,13 +36,14 @@ npx skills use usestrix/strix@penetration-testing-with-strix | claude Both use the same engine and produce the same validated findings and SARIF, so agents can pick per situation or combine them: - **Open-source CLI (self-hosted)** — runs locally in a Docker sandbox with your own LLM key. Free, fully local, air-gap capable. Best for local dev loops and full control. -- **Managed cloud** — runs on Strix's infrastructure via the [app.strix.ai REST API](https://docs.app.strix.ai). No Docker, no LLM key, no local install; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Create an API token under **Settings → API Access**; the `managed-pentesting-with-strix` skill has the full flow. +- **Managed cloud** — runs on Strix's infrastructure. Drive it with the `strix cloud` CLI (every REST operation has a `strix cloud ` command) or the [app.strix.ai REST API](https://docs.app.strix.ai) directly. No Docker, no LLM key; adds team dashboards, scheduling, PR reviews, and downloadable PDF/DOCX reports (Enterprise plan). Best in sandboxed/CI environments and for teams. Sign in with `strix cloud login` (browser device sign-in, account created on first use) or create a token in the dashboard under **Settings → API Access**. The `managed-pentesting-with-strix` skill has the full flow. ## Agent-Friendly Interfaces Everything an agent needs is machine-readable: - **Headless CLI** — `strix -n` runs without the TUI and exits with `0` (clean), `1` (error), or `2` (vulnerabilities found). +- **Cloud CLI** — `strix cloud` prints JSON when stdout is not a terminal (or with `--json`), never prompts without a TTY, and exits with `0` (success), `1` (error), `2` (usage), `4` (authentication required), or `5` (payment required). Credit top-ups pay the Stripe machine-payment challenge with an agent wallet (`strix cloud billing topup --credits N --yes`). Account setup also runs from the CLI: `strix cloud workspaces list|create|use`, `strix cloud org members invite`, `strix cloud billing subscribe`, `strix cloud billing portal`, and `strix cloud integrations install github`. The last three print a hosted link the user opens to finish the payment or approve the installation. - **REST API** — the managed platform exposes a documented [OpenAPI](https://docs.app.strix.ai/openapi.json) at `https://app.strix.ai/api/v1` (scans, vulnerabilities, assets, PR reviews, schedules, webhooks) with bearer tokens and scopes. - **Structured results** — every run writes `vulnerabilities.json`, `vulnerabilities.csv`, `findings.sarif` (SARIF 2.1.0), and per-finding Markdown under `strix_runs//`; the cloud exposes the same as JSON plus SARIF export. - **Budget controls** — `--max-budget` and `--max-turns` give agents hard cost/time caps. diff --git a/pyproject.toml b/pyproject.toml index 1a8e646f..238a085f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -229,6 +229,7 @@ ignore = [ # Test doubles use fixture tokens/passwords and match a callee signature whose # args they intentionally ignore. "tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"] +"tests/test_cloud_cli.py" = ["S105", "ARG001"] "tests/test_codex_auth.py" = ["S105", "S106", "SLF001"] # Hatchling loads the build hook by path, not as an importable package. "scripts/tui_sidecar_hook.py" = ["INP001"] diff --git a/skills/managed-pentesting-with-strix/SKILL.md b/skills/managed-pentesting-with-strix/SKILL.md index 08feeb36..a8fb70a7 100644 --- a/skills/managed-pentesting-with-strix/SKILL.md +++ b/skills/managed-pentesting-with-strix/SKILL.md @@ -1,23 +1,46 @@ --- name: managed-pentesting-with-strix -description: Run a managed pentest of a web app or API through the app.strix.ai REST API — no local Docker, LLM key, or install needed. Create an API token, register domain/repository assets, launch and poll scans, triage vulnerabilities, export SARIF, download PDF/DOCX pentest reports for SOC 2 and other compliance evidence (Enterprise plan), start PR reviews, and set up schedules and webhooks. Use when the user wants continuous or scheduled pentesting-as-a-service, an auditor-ready pentest report, scans tracked in a team dashboard, or security testing from a sandboxed agent/CI environment with no infrastructure. +description: Run a managed pentest of a web app, API, repository, or local workspace on the app.strix.ai platform with the `strix cloud` CLI or REST API — no local Docker or LLM key needed. Safely review and upload local source, register assets, launch and poll scans, triage vulnerabilities, export SARIF, download compliance reports, start PR reviews, buy credits, and set up schedules or webhooks. Use for managed, continuous, scheduled, team-tracked, or sandboxed-agent security testing. license: Apache-2.0 metadata: author: usestrix homepage: https://docs.app.strix.ai --- -# Strix Cloud API (managed, no local infra) +# Strix Cloud (managed, no local infra) Use this when you want Strix's autonomous pentesting **without running Docker or an LLM yourself** — the scan runs on Strix's infrastructure and results are tracked in a team dashboard. This is the right choice in sandboxed/hosted agent and CI environments, for teams, and for scheduled/continuous testing (downloadable PDF/DOCX reports are an Enterprise-plan feature). For fully local, free, air-gapped, or BYO-LLM runs, use the open-source CLI in the **penetration-testing-with-strix** skill instead — both share the same engine and SARIF output, so you can mix them. -Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: `https://docs.app.strix.ai/openapi.json` +There are two equivalent interfaces. Prefer the CLI: -## Setup +- **`strix cloud` CLI** — every REST operation has a command in the form `strix cloud `. Install with `curl -sSL https://strix.ai/install | bash`. Run `strix cloud` to list all resources and `strix cloud ` to list its verbs. +- **REST API** — base URL `https://app.strix.ai/api/v1`, `Authorization: Bearer ` 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`. -- **Base URL:** `https://app.strix.ai/api/v1` -- **Auth:** every request sends `Authorization: Bearer `. Tokens are **org-scoped**. -- **Get a token:** the user creates one in the dashboard at **Settings → API Access** (app.strix.ai). Ask them for it; never hardcode, log, or commit it. Store it in an env var or the CI secret store. +The CLI is agent friendly. Output is JSON when stdout is not a terminal, or when you pass `--json`. There are no interactive prompts when stdin is not a terminal. Exit codes: `0` success, `1` error, `2` invalid usage, `4` authentication required, `5` payment required. + +Write commands take request fields as flags. Every write command also accepts one JSON object with `--data`, which is the way to send fields that have no flag: + +```bash +strix cloud scans start --data '{"engagement_type":"code_review"}' # literal JSON +strix cloud scans start --data @request.json # read a file +cat request.json | strix cloud scans start --data - # read standard input +``` + +The platform enforces plan and role limits, and the CLI passes the platform message through. Report downloads need the Enterprise plan. Schedules need the Pro plan. Billing writes need an admin token. A blocked command exits with code `4`. + +## Setup: sign in + +Run the device sign-in. It creates the user's account and workspace on first use and stores a personal API token in `~/.strix/platform-auth.json`: + +```bash +strix cloud login --scopes scans:read scans:write uploads:write billing:read vulnerabilities:read assets:read assets:write +``` + +The user approves the sign-in in the browser. With `--scopes` (and optionally `--workspace `) there are no prompts, so the command works from a non-interactive agent shell. In an interactive terminal without flags, the CLI offers a workspace picker and scope presets (Recommended, Full access, Minimal, Custom). + +- `strix cloud whoami` shows the active sign-in; add `--json` when another agent will parse it. `strix cloud logout` removes it. +- Every other `strix cloud` command uses the stored token automatically. To use a different token (for example one created in the dashboard at **Settings → API Access**), pass `--token ` or set `STRIX_API_TOKEN`. +- Never hardcode, log, or commit the token. Store it in an env var or the CI secret store. - **Scopes (least-privilege):** assign only what the integration needs and rotate regularly: | Scope | Grants | @@ -29,14 +52,78 @@ Full reference: **[docs.app.strix.ai](https://docs.app.strix.ai)** · OpenAPI: ` | `pr_reviews:write` | trigger PR security reviews | | `webhooks:read` / `:write` | manage webhook subscriptions | | `tokens:write` | create/revoke API tokens | + | `billing:read` / `billing:write` | read credit balance & auto top-up settings · buy credits (admin) | + +HTTP errors map to messages and exit codes: `401` bad/expired token (exit `4`), `402` out of credits (exit `5`), `403` scope/plan-tier limit (exit `4`), `422` validation error (exit `1`). + +## 0. Credits & top-ups + +Scans consume org credits. Check the balance before a scan (`billing:read`): ```bash -export STRIX_API_TOKEN="" -BASE=https://app.strix.ai/api/v1 -auth=(-H "Authorization: Bearer $STRIX_API_TOKEN") +strix cloud credits ``` -All examples use `jq` to parse JSON. Handle HTTP errors: `401` bad/expired token, `402` out of credits, `403` scope/plan-tier limit, `422` validation error. +When the balance is too low, buy credits with `strix cloud billing topup` (`billing:write`, admin token). The server answers the first request with **HTTP 402 and a machine-payment challenge** (Stripe Machine Payments Protocol). The CLI pays the challenge with the `mppx` client when Node.js is available — the user approves the spend in their agent wallet, for example the [Link Agent Wallet](https://link.com/agents). The response returns the receipt (`credits_granted`, `duplicate`, `reference`) and the new balance. + +```bash +strix cloud billing topup --credits 20 --yes # --yes skips the confirmation prompt +strix cloud billing topup --credits 20 --no-pay # print the 402 challenge without paying +``` + +The default payment path is the Stripe agent wallet. Tell the user to set it up one time at [link.com/agents](https://link.com/agents). After setup, the user approves each payment in the Link app, and no keys or variables are necessary. + +If the user does not want a wallet, create a hosted checkout link with `strix cloud billing subscribe --plan strix_top_up` and give the link to the user. The user pays in the browser. + +Automatic top-ups (admin): `strix cloud billing auto-topup` shows the setting. Enable it with: + +```bash +strix cloud billing auto-topup update --enabled --topup-credits 20 --monthly-cap-credits 200 +``` + +An omitted `--monthly-cap-credits` keeps the stored cap. Pass `--no-monthly-cap` to remove the cap. + +### Workspaces and account setup + +Manage workspaces with a personal token from `strix cloud login`: + +```bash +strix cloud workspaces list # id, name, role, and the active one +strix cloud workspaces create --name "My Team" +strix cloud workspaces use "My Team" # store a token for that workspace +strix cloud org members invite --email dev@example.com --role analyst +``` + +`workspaces use` mints a new token for a workspace the user already belongs to, and the role in that workspace limits the scopes. Add `--scopes` to request a smaller set. + +### Handoffs a person must finish + +Four steps end at the user. The command creates the link or the record and prints it. Strix opens the browser only in an interactive terminal. Pass `--no-browser` to print the URL only. + +```bash +strix cloud billing subscribe --plan strix_cloud # hosted checkout page for the Cloud plan +strix cloud billing portal # billing portal for the card and the plan +strix cloud integrations install github # GitHub App or Slack installation page +strix cloud domains verify # DNS record to add, then run it again +``` + +Give the printed URL or DNS record to the user and wait. Do not claim that the payment, the installation, or the DNS change is complete. Confirm the result afterwards with `strix cloud credits`, `strix cloud integrations list`, or `strix cloud domains list`. All four commands need an admin token, except `domains verify`, which needs `assets:write`. + +### Organization knowledge + +Agents can manage the organization knowledge base without the dashboard (`knowledge:read` / `knowledge:write`): + +```bash +strix cloud knowledge list --search authentication +strix cloud knowledge add --title "Authentication" --content "Staging uses SSO." +strix cloud knowledge update --content "Staging uses SSO and TOTP." +strix cloud knowledge delete +strix cloud knowledge policies add --key staging-only --content "Never test production." +strix cloud knowledge policies delete staging-only +strix cloud knowledge repos entries usestrix/strix +``` + +Knowledge policy writes require an admin token. Repository names are passed as normal `owner/name` values; the CLI handles URL encoding. The `costs` and `llm-settings` commands target on-prem installations and return `404` on app.strix.ai. ## 1. Register the target as an asset @@ -44,67 +131,66 @@ Scans run against **registered assets**, not raw URLs. Register once, then reuse ```bash # Domain (black-box / live target). Requires domain verification before external scanning. -# asset_type must be one of: web_app | api | attack_surface. -curl -sS "$BASE/domains" "${auth[@]}" -H "Content-Type: application/json" \ - -d '{"domain":"staging.example.com","asset_type":"web_app"}' | jq '{id:.domain.id, status, reachable, verification}' +# --asset-type must be one of: web_app | api | attack_surface. +strix cloud domains add --domain staging.example.com --asset-type web_app # Repository (white-box / code review). `full_name` is "owner/name". -# Send one repository object, or a bare JSON array for several — not an object -# wrapping a "repositories" key (that is rejected with 400). -curl -sS "$BASE/repositories" "${auth[@]}" -H "Content-Type: application/json" \ - -d '[{"full_name":"org/app","provider":"github"}]' | jq '.repositories[] | {id, full_name}' +strix cloud repos add --data '{"full_name":"org/app","provider":"github"}' ``` -Look up existing assets instead of re-adding: `GET /domains`, `GET /repositories` (both `assets:read`, paginated with `?page=&limit=`). +Look up existing assets instead of re-adding: `strix cloud domains list`, `strix cloud repos list` (both `assets:read`). ## 2. Launch a scan -`POST /scans` (`scans:write`). Provide at least one target via `domain_ids`, `repository_ids`, or `internal_targets` (internal infra needs a network connector — see docs). +`strix cloud scans start` (`scans:write`). Provide at least one target with `--domain-ids`, `--repository-ids`, or `--internal-targets` (internal infra needs a network connector — see docs). ```bash -scan_id=$(curl -sS "$BASE/scans" "${auth[@]}" -H "Content-Type: application/json" -d '{ - "engagement_type": "live_test", - "domain_ids": [""], - "focus": "IDOR, auth bypass, SSRF", - "context": "Staging. Test account creds are configured as a test user.", - "notify_on_completion": true -}' | jq -r .scan_id) -echo "$scan_id" +strix cloud scans start \ + --engagement-type live_test \ + --domain-ids \ + --focus "IDOR, auth bypass, SSRF" \ + --context "Staging. Test account creds are configured as a test user." \ + --notify-on-completion ``` -Useful `CreateScanRequest` fields: +Useful flags (each maps to a `CreateScanRequest` field): -| Field | Purpose | +| Flag | Purpose | |---|---| -| `engagement_type` | `live_test` (default), `code_review`, `internal_infra`, `compliance_pentest` | -| `domain_ids` / `repository_ids` / `internal_targets` | targets (at least one) | -| `domain_paths` / `repository_branches` | narrow to specific paths / branches | -| `credentials` | authenticated scanning, incl. `mfa_method` (`totp`/`email_otp`/…) + `totp_secret` | -| `headers` | extra HTTP headers (API keys, for example) for the target | -| `focus` / `concerns` / `context` | steer the agents | -| `upload_ids` | attach uploaded source/docs archives for white-box context | -| `notify_on_completion` / `notification_emails` | email when done | +| `--engagement-type` | `live_test` (default), `code_review`, `internal_infra`, `compliance_pentest` | +| `--domain-ids` / `--repository-ids` / `--internal-targets` | targets (at least one) | +| `--domain-paths` / `--repository-branches` | narrow to specific paths / branches (JSON maps) | +| `--credentials` | authenticated scanning, incl. `mfa_method` (`totp`/`email_otp`/…) + `totp_secret` (JSON list) | +| `--headers` | extra HTTP headers (API keys, for example) for the target (JSON map) | +| `--focus` / `--concerns` / `--context` | steer the agents | +| `--upload-ids` | attach uploaded source/docs archives for white-box context | +| `--notify-on-completion` / `--notification-emails` | email when done | -Response is `{ scan_id, title, status }` with `status` = `pending`. +The response is `{ scan_id, title, status }` with `status` = `pending`. -## 3. Poll to completion +### Scan a local workspace in the cloud -`GET /scans/{scanId}` (`scans:read`). Status flow: `pending → running → completed` (or `failed` / `cancelled`). Poll on an interval — scans take minutes to hours. Do not block. +Review the exact local upload before sending it. An agent or CI process must never skip this review merely because it can pass `--yes`: ```bash -while :; do - s=$(curl -sS "$BASE/scans/$scan_id" "${auth[@]}" | jq -r .status) - echo "status=$s"; [[ "$s" =~ ^(completed|failed|cancelled)$ ]] && break - sleep 60 -done +strix cloud scans start --source . --dry-run --show-files --json +strix cloud scans start --source . --yes --engagement-type code_review --wait ``` +The default selection is privacy-conscious: in a Git worktree it includes tracked files plus untracked files that are not ignored; it honors `.gitignore`, excludes every hidden path component, always excludes `.git`, symlinks, dependencies/build output, secret-like filenames, and nested archives, and enforces file-count, per-file, expanded-size, and compressed-size limits. Add project exclusions to `.strixignore` (one exclude glob per line) or repeat `--exclude GLOB`. + +Only use `--include-hidden`, `--include-sensitive`, or `--include-archives` after the dry-run manifest shows that the scan needs them. Hidden and sensitive files are separate opt-ins: for example, including `.env` requires both `--include-hidden` and `--include-sensitive`. Non-interactive uploads require `--yes`; interactive terminals show one summary and confirmation prompt. The CLI deletes its temporary archive after the request and best-effort deletes the uploaded object if scan creation fails. + +## 3. Wait for completion + +Pass `--wait` to `scans start` to poll until the scan reaches a final state, or poll yourself with `strix cloud scans get ` (`scans:read`). Status flow: `pending → running → completed` (or `failed` / `cancelled`). Scans take minutes to hours — poll on an interval, do not block. + ## 4. Read findings The scan-detail response includes `executive_summary`, `methodology`, `recommendations`, a `findings` severity roll-up, and a `vulnerabilities[]` array. Each vulnerability carries `title, severity, status, cvss, cwe, endpoint, method, impact, technical_analysis, poc_description, poc_script_code`, and (for code findings) `code_file`/`code_diff`/`code_before`/`code_after`. ```bash -curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \ +strix cloud scans get --json \ | jq '["critical","high","medium","low","info"] as $order | .vulnerabilities | sort_by(.severity as $s | $order | index($s)) @@ -113,37 +199,39 @@ curl -sS "$BASE/scans/$scan_id" "${auth[@]}" \ Cloud severities are `critical | high | medium | low` and statuses are `open | in_progress | fixed | ignored`. Sort by an explicit severity order rather than `sort_by(.severity)`, which sorts alphabetically (critical, high, low, medium). -Org-wide triage across scans: `GET /vulnerabilities` (`vulnerabilities:read`; filter by severity/status). Update triage state with the vulnerabilities `:write` endpoints. To remediate, hand off to the **fix-security-vulnerabilities-with-strix** skill. +Org-wide triage across scans: `strix cloud vulns list --severity critical` (`vulnerabilities:read`, and it also filters by `--status`, `--scan-id`, and more). Update triage state with `strix cloud vulns update --status fixed`. To remediate, hand off to the **fix-security-vulnerabilities-with-strix** skill. ## 5. Export & report ```bash # SARIF 2.1.0 for GitHub code scanning / ASPM ingestion -curl -sS "$BASE/scans/$scan_id/sarif" "${auth[@]}" -o findings.sarif +strix cloud scans sarif --output findings.sarif -# Report. The format and file type are query params (`Accept` is ignored): -# format=technical (default) | retest | attestation | executive_summary -# type=pdf (default) | docx -# Any report download requires the Enterprise plan; formats beyond `technical`, +# Report. Formats: technical (default) | retest | attestation | executive_summary +# Types: pdf (default) | docx +# Any report download requires the Enterprise plan. Formats beyond `technical`, # DOCX, and white-label branding are Enterprise-only too. Scan must be completed. -curl -sS "$BASE/scans/$scan_id/report?format=technical&type=pdf" "${auth[@]}" -o strix-report.pdf +strix cloud scans report --format technical --type pdf --output strix-report.pdf ``` ## 6. PR reviews -Trigger an automated security review of a pull request (`pr_reviews:write`); results appear as PR comments and in the dashboard: +Trigger an automated security review of a pull request (`pr_reviews:write`). Read the repository's `provider` and `installation_id` with `strix cloud repos list`; both identify the installed source-control integration. The results appear as PR comments and in the dashboard: ```bash -curl -sS "$BASE/pr-reviews/start" "${auth[@]}" -H "Content-Type: application/json" \ - -d '{"repository_full_name":"org/app","pr_number":123}' +strix cloud pr-reviews start \ + --provider github \ + --installation-id \ + --repository-full-name org/app \ + --pr-number 123 ``` -List/inspect via `GET /pr-reviews` and `GET /pr-reviews/{id}`. Repo-level PR-review behavior is configured with the repository-settings endpoint. +List/inspect with `strix cloud pr-reviews list` and `strix cloud pr-reviews get `. Repo-level PR-review behavior is configured with `strix cloud pr-reviews settings`. ## 7. Continuous testing (schedules & webhooks) -- **Schedules** (`schedules:write`, Pro plan): create recurring scans and trigger them on demand — the managed equivalent of a cron-driven CLI loop. -- **Webhooks** (`webhooks:write`): subscribe to pentest/vulnerability lifecycle events such as `scan.completed` and `vulnerability.created` to push results into Slack, ticketing, or your own pipeline instead of polling. +- **Schedules** (`schedules:write`, Pro plan): `strix cloud schedules create` makes recurring scans, and `strix cloud schedules trigger ` runs one on demand — the managed equivalent of a cron-driven CLI loop. +- **Webhooks** (`webhooks:write`): `strix cloud webhooks create` subscribes to pentest/vulnerability lifecycle events such as `scan.completed` and `vulnerability.created` to push results into Slack, ticketing, or your own pipeline instead of polling. See the schedules and webhooks sections at [docs.app.strix.ai](https://docs.app.strix.ai) for payloads. diff --git a/strix/interface/cloud/__init__.py b/strix/interface/cloud/__init__.py new file mode 100644 index 00000000..7ce2ce81 --- /dev/null +++ b/strix/interface/cloud/__init__.py @@ -0,0 +1,94 @@ +"""`strix cloud` — the managed Strix platform (app.strix.ai) from the terminal. + +Every command maps to one operation of the public REST API. Output is JSON +when stdout is not a terminal, so agents can parse every result. Exit codes: +0 success, 1 error, 2 invalid usage, 4 authentication required, 5 payment +required. +""" + +from __future__ import annotations + +from rich.console import Console + +from strix.interface.cloud.runner import resolve, run +from strix.interface.cloud.spec import DEFAULT_VERBS, GROUP_HELP, SPEC +from strix.interface.cloud.workspaces import run_workspace_use +from strix.interface.platform_cli import run_login + + +_USAGE_HEADER = """[bold]Usage:[/] strix cloud [arguments] + +[bold]Session commands:[/] + login Sign in to the managed platform and store an API token + logout Remove the stored API token + whoami Show the stored account, workspace, and token state + credits Show the credit balance of the workspace + +[bold]Resource commands:[/]""" + +_USAGE_FOOTER = """ +Run [bold]strix cloud help[/] to list its verbs. Common read-only +commands may also run their default verb when no verb is given. +Every command accepts [bold]--json[/] and [bold]--token[/]. Write commands +accept [bold]--data[/] with a JSON object of extra request fields. +API reference: https://docs.app.strix.ai""" + + +def run_cloud(argv: list[str]) -> int: + """Entry point for ``strix cloud …``. Returns a process exit code.""" + console = Console() + if not argv or argv[0] in ("-h", "--help", "help"): + _print_usage(console) + return 0 + + group, rest = argv[0], argv[1:] + if group == "workspace": + group = "workspaces" + if group in ("login", "logout", "whoami"): + return _run_session(console, group, rest) + if group == "credits": + group, rest = "billing", ["credits", *rest] + if group == "workspaces" and rest and rest[0] == "use": + return run_workspace_use(rest[1:]) + + if group not in SPEC: + console.print(f"[red]Unknown command:[/] {group}") + _print_usage(console) + return 2 + group_help = bool(rest and rest[0] in ("-h", "--help", "help")) + resolved = None if group_help else resolve(group, rest) + if resolved is None: + _print_verbs(console, group) + return 0 if not rest or rest[0] in ("-h", "--help", "help") else 2 + cmd, remaining = resolved + verb_label = " ".join(rest[: len(rest) - len(remaining)]) or DEFAULT_VERBS.get(group, "") + return run(group, verb_label, cmd, remaining) + + +def _run_session(console: Console, group: str, rest: list[str]) -> int: + if rest and rest[0] in ("-h", "--help", "help"): + return run_login(["--help"]) + if group == "logout" and rest: + console.print("[red]Unknown argument for logout:[/] " + " ".join(rest)) + return 2 + session_argv = { + "login": rest, + "logout": ["logout"], + "whoami": ["status", *rest], + } + return run_login(session_argv[group]) + + +def _print_usage(console: Console) -> None: + console.print(_USAGE_HEADER) + for group in SPEC: + console.print(f" {group:<14}{GROUP_HELP.get(group, '')}") + console.print(_USAGE_FOOTER) + + +def _print_verbs(console: Console, group: str) -> None: + console.print(f"[bold]strix cloud {group}[/] verbs:") + for verb, cmd in SPEC[group].items(): + console.print(f" {verb:<28}{cmd.help}") + if group == "workspaces": + console.print(f" {'use':<28}Switch the stored token to another workspace.") diff --git a/strix/interface/cloud/http.py b/strix/interface/cloud/http.py new file mode 100644 index 00000000..14560350 --- /dev/null +++ b/strix/interface/cloud/http.py @@ -0,0 +1,151 @@ +"""HTTP client for the managed Strix platform API (app.strix.ai).""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any, cast + +import requests + +from strix.config import load_settings +from strix.interface.platform_cli import read_record + + +if TYPE_CHECKING: + from pathlib import Path + + +_DEFAULT_TIMEOUT_S = 120 +_app_url_override: str | None = None +_timeout_s: float = _DEFAULT_TIMEOUT_S + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_AUTH = 4 +EXIT_PAYMENT = 5 + + +class CloudError(Exception): + """A failed cloud command. Carries the process exit code.""" + + def __init__(self, message: str, *, exit_code: int = EXIT_ERROR, payload: Any = None) -> None: + super().__init__(message) + self.exit_code = exit_code + self.payload = payload + + +def configure(*, base_url: str | None = None, timeout: float | None = None) -> None: + """Set the platform URL and the request timeout for this process.""" + global _app_url_override, _timeout_s # noqa: PLW0603 + if base_url: + _app_url_override = base_url.rstrip("/") + if timeout: + _timeout_s = timeout + + +def app_url() -> str: + if _app_url_override: + return _app_url_override + return load_settings().viewer.app_url.rstrip("/") + + +def api_token(override: str | None = None) -> str: + token = override or os.environ.get("STRIX_API_TOKEN") + if not token: + record = read_record() + if record is not None: + stored = record.get("api_token") + if isinstance(stored, str): + token = stored + if not token or not token.strip(): + raise CloudError( + "not signed in. Run `strix cloud login`, or set STRIX_API_TOKEN.", + exit_code=EXIT_AUTH, + ) + return token.strip() + + +def request( + method: str, + path: str, + *, + token: str | None = None, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, +) -> requests.Response: + url = f"{app_url()}/api/v1{path}" + headers = {"Authorization": f"Bearer {api_token(token)}"} + try: + response = requests.request( + method, + url, + headers=headers, + params={k: v for k, v in (query or {}).items() if v is not None} or None, + json=body, + timeout=_timeout_s, + ) + except requests.RequestException as exc: + raise CloudError(f"could not reach {app_url()}: {exc}") from exc + return response + + +def upload_file(signed_url: str, upload_token: str, path: Path) -> None: + """Stream a file to a platform-issued storage URL.""" + try: + with path.open("rb") as stream: + response = requests.put( + signed_url, + data=stream, + headers={ + "Authorization": f"Bearer {upload_token}", + "Content-Type": "application/zip", + }, + timeout=_timeout_s, + ) + except (OSError, requests.RequestException) as exc: + raise CloudError(f"source upload failed: {exc}") from exc + if not response.ok: + detail = "" + try: + payload = response.json() + if isinstance(payload, dict): + fields = cast("dict[str, Any]", payload) + detail = str(fields.get("message") or fields.get("error") or "") + except ValueError: + pass + raise CloudError(detail or f"source upload failed (HTTP {response.status_code})") + + +def parsed(response: requests.Response) -> Any: + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + try: + return response.json() + except ValueError: + return response.text + return response.text + + +def check(response: requests.Response) -> Any: + data = parsed(response) + if response.ok: + content_type = response.headers.get("content-type", "").lower() + if "application/json" not in content_type: + raise CloudError( + "the server returned a non-JSON response. Check STRIX_APP_URL and preview " + "access, then retry." + ) + return data + detail = "" + if isinstance(data, dict): + raw = cast("dict[str, Any]", data) + detail = str(raw.get("detail") or raw.get("error") or "") + message = detail or f"HTTP {response.status_code}" + if response.status_code in (401, 403): + raise CloudError(message, exit_code=EXIT_AUTH, payload=data) + if response.status_code == 402: + hint = detail or ( + "not enough credits. Run `strix cloud billing topup --credits N` to buy credits." + ) + raise CloudError(hint, exit_code=EXIT_PAYMENT, payload=data) + raise CloudError(message, exit_code=EXIT_ERROR, payload=data) diff --git a/strix/interface/cloud/render.py b/strix/interface/cloud/render.py new file mode 100644 index 00000000..484ae4fe --- /dev/null +++ b/strix/interface/cloud/render.py @@ -0,0 +1,235 @@ +"""Output rendering for `strix cloud` commands.""" + +from __future__ import annotations + +import json +import sys +from typing import TYPE_CHECKING, Any + +from rich.markup import escape +from rich.table import Table + + +if TYPE_CHECKING: + from rich.console import Console + + +_MAX_TABLE_COLUMNS = 8 +_MAX_CELL_LENGTH = 60 +_NARROW_TABLE_WIDTH = 120 +_INTERNAL_COLUMNS = frozenset( + { + "organization_id", + "user_id", + "userId", + "installation_id", + "created_by", + "avatarUrl", + } +) + +_PREFERRED_KEYS = ( + "name", + "title", + "repository_full_name", + "pr_number", + "pr_title", + "head_branch", + "base_branch", + "verdict", + "domain", + "target", + "default_branch", + "branch", + "display_number", + "status", + "state", + "severity", + "cve", + "cvss", + "finding_type", + "findings_count", + "open_findings_count", + "role", + "email", + "firstName", + "lastName", + "url", + "provider", + "secret_prefix", + "scan_type", + "engagement_type", + "estimated_credits", + "cron_expression", + "timezone", + "next_run_at", + "is_active", + "created_at", + "updated_at", + "expires_at", + "last_used_at", + "id", +) + + +def json_mode(*, flag: bool) -> bool: + """JSON output is on when the flag is set or when stdout is not a terminal.""" + return flag or not sys.stdout.isatty() + + +def emit( + console: Console, + data: Any, + *, + as_json: bool, + row_numbers: bool = False, + omit_columns: frozenset[str] = frozenset(), + hint: str | None = None, +) -> None: + if as_json: + sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n") + return + rows = _list_of_dicts(data) + if rows is not None: + _print_table( + console, + rows, + row_numbers=row_numbers, + omit_columns=omit_columns, + hint=hint, + ) + return + if isinstance(data, str): + console.print(data) + return + if isinstance(data, dict): + _print_detail(console, data) + return + console.print_json(json.dumps(data, default=str)) + + +def _list_of_dicts(data: Any) -> list[dict[str, Any]] | None: + if isinstance(data, dict) and len(data) >= 1: + lists = [v for v in data.values() if isinstance(v, list)] + scalars = [v for v in data.values() if not isinstance(v, list | dict)] + if len(lists) == 1 and not scalars: + data = lists[0] + if not isinstance(data, list) or not data: + return None + if not all(isinstance(item, dict) for item in data): + return None + return data + + +def _print_table( + console: Console, + rows: list[dict[str, Any]], + *, + row_numbers: bool = False, + omit_columns: frozenset[str] = frozenset(), + hint: str | None = None, +) -> None: + omit_columns = omit_columns | _INTERNAL_COLUMNS + columns: list[str] = [ + key + for key in _PREFERRED_KEYS + if key not in omit_columns and any(key in row for row in rows) + ] + for row in rows: + for key in row: + if ( + key not in columns + and key not in omit_columns + and len(columns) < _MAX_TABLE_COLUMNS + and not isinstance(row[key], dict | list) + ): + columns.append(key) + columns = columns[:_MAX_TABLE_COLUMNS] + if console.width < _NARROW_TABLE_WIDTH: + _print_cards(console, rows, columns, row_numbers=row_numbers) + console.print(f"[dim]{len(rows)} item(s). Use --json for the full records.[/]") + if hint: + console.print(f"[dim]{hint}[/]") + return + table = Table(show_lines=False) + if row_numbers: + table.add_column("#", justify="right", style="cyan", no_wrap=True) + for column in columns: + table.add_column(column) + for index, row in enumerate(rows, start=1): + cells = [_cell(row.get(column)) for column in columns] + if row_numbers: + cells.insert(0, str(index)) + table.add_row(*cells) + console.print(table) + console.print(f"[dim]{len(rows)} item(s). Use --json for the full records.[/]") + if hint: + console.print(f"[dim]{hint}[/]") + + +def _print_cards( + console: Console, + rows: list[dict[str, Any]], + columns: list[str], + *, + row_numbers: bool, +) -> None: + """Render list rows legibly when a terminal is too narrow for a table.""" + for index, row in enumerate(rows, start=1): + parts = [ + f"[bold]{escape(_human_label(column))}:[/] {escape(_cell(row.get(column)))}" + for column in columns + if row.get(column) is not None + ] + prefix = f"[cyan]{index}.[/] " if row_numbers else "[cyan]•[/] " + console.print(prefix + " [dim]·[/] ".join(parts), soft_wrap=False) + + +def _print_detail(console: Console, data: dict[str, Any]) -> None: + """Render one API record as a readable field/value view.""" + keys = [key for key in _PREFERRED_KEYS if key in data and key not in _INTERNAL_COLUMNS] + keys.extend(key for key in data if key not in keys and key not in _INTERNAL_COLUMNS) + table = Table(show_header=False, show_edge=False, box=None, padding=(0, 2)) + table.add_column("field", style="bold cyan", no_wrap=True) + table.add_column("value", overflow="fold") + for key in keys: + value = data.get(key) + if value is None: + continue + if isinstance(value, (dict, list)): + rendered = json.dumps(value, indent=2, default=str) + else: + rendered = _cell(value) + table.add_row(_human_label(key), rendered) + console.print(table) + console.print("[dim]Use --json for the lossless machine-readable record.[/]") + + +def _human_label(column: str) -> str: + labels = { + "repository_full_name": "repo", + "pr_number": "PR", + "pr_title": "title", + "head_branch": "head", + "base_branch": "base", + "findings_count": "findings", + "open_findings_count": "open", + "display_number": "finding", + "created_at": "created", + "updated_at": "updated", + "expires_at": "expires", + "last_used_at": "last used", + "secret_prefix": "prefix", + } + return labels.get(column, column.replace("_", " ")) + + +def _cell(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "yes" if value else "no" + text = str(value) + if len(text) > _MAX_CELL_LENGTH: + return text[: _MAX_CELL_LENGTH - 1] + "…" + return text diff --git a/strix/interface/cloud/runner.py b/strix/interface/cloud/runner.py new file mode 100644 index 00000000..4b32629c --- /dev/null +++ b/strix/interface/cloud/runner.py @@ -0,0 +1,618 @@ +"""Generic command runner for `strix cloud`. + +The runner turns one entry of the command table into an argument parser, +sends the HTTP request, renders the result, and returns the exit code. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import time +import webbrowser +from contextlib import suppress +from pathlib import Path +from typing import Any, cast +from urllib.parse import quote + +from rich.console import Console + +from strix.interface.cloud import http +from strix.interface.cloud.render import emit, json_mode +from strix.interface.cloud.source_upload import SourceBundle, prepare_source, remove_bundle +from strix.interface.cloud.spec import DEFAULT_VERBS, SPEC, Cmd, P + + +_PLACEHOLDER = re.compile(r"\{([^{}]+)\}") +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") +_WAIT_POLL_S = 15 +_TERMINAL_STATUSES = frozenset( + { + "completed", + "failed", + "cancelled", + "canceled", + "stopped", + "error", + "expired", + "succeeded", + } +) + + +def _dest(name: str) -> str: + return _CAMEL_BOUNDARY.sub("_", name).lower() + + +def _metavar(name: str) -> str: + return _CAMEL_BOUNDARY.sub("_", name).upper() + + +def resolve(group: str, tokens: list[str]) -> tuple[Cmd, list[str]] | None: + """Find the command for a verb. Two-word verbs match before one-word verbs.""" + commands = SPEC.get(group) + if commands is None: + return None + if len(tokens) >= 2: + two = f"{tokens[0]} {tokens[1]}" + if two in commands: + return commands[two], tokens[2:] + if tokens and tokens[0] in commands: + return commands[tokens[0]], tokens[1:] + default = DEFAULT_VERBS.get(group) + if default is not None and (not tokens or tokens[0].startswith("-")): + return commands[default], tokens + return None + + +def run(group: str, verb_label: str, cmd: Cmd, argv: list[str]) -> int: + console = Console() + parser = _build_parser(group, verb_label, cmd) + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + + path = cmd.path + for name in _PLACEHOLDER.findall(cmd.path): + value = quote(str(getattr(args, _dest(name))), safe="") + path = path.replace("{" + name + "}", value) + + as_json = json_mode(flag=bool(getattr(args, "json", False))) + token = getattr(args, "token", None) + http.configure(base_url=getattr(args, "app_url", None), timeout=getattr(args, "timeout", None)) + + try: + query = _collect(args, cmd.query) + body = _collect(args, cmd.body) + data = getattr(args, "data", None) + if data: + body.update(_load_data(data)) + if getattr(args, "no_monthly_cap", False): + body["monthly_cap_credits"] = None + return _execute(console, cmd, args, path, query, body, as_json=as_json, token=token) + except http.CloudError as exc: + _emit_error(console, exc, as_json=as_json) + return exc.exit_code + + +def _execute( # noqa: PLR0912 + console: Console, + cmd: Cmd, + args: argparse.Namespace, + path: str, + query: dict[str, Any], + body: dict[str, Any], + *, + as_json: bool, + token: str | None, +) -> int: + if cmd.path == "/billing/topup": + return _topup(console, args, body, as_json=as_json, token=token) + source_bundle: SourceBundle | None = None + source_upload_id: str | None = None + try: + if cmd.path == "/scans" and cmd.method == "POST": + _set_default_scan_engagement( + body, + has_local_source=getattr(args, "source", None) is not None, + ) + source_bundle = _prepare_scan_source(console, args, as_json=as_json) + if source_bundle is not None and getattr(args, "dry_run", False): + emit( + console, + { + "source": source_bundle.summary( + show_files=getattr(args, "show_files", False) + ) + }, + as_json=as_json, + ) + return http.EXIT_OK + if source_bundle is not None: + source_upload_id = _upload_scan_source(source_bundle, token=token) + existing = body.get("upload_ids") + body["upload_ids"] = [ + *(existing if isinstance(existing, list) else []), + source_upload_id, + ] + + response = http.request( + cmd.method, + path, + token=token, + query=query or None, + body=body if cmd.method in ("POST", "PUT", "PATCH") else None, + ) + except BaseException: + if source_upload_id is not None: + _delete_upload(source_upload_id, token=token) + raise + finally: + if source_bundle is not None: + remove_bundle(source_bundle) + if cmd.binary: + return _emit_binary(console, response, getattr(args, "output", None)) + try: + result = http.check(response) + except BaseException: + if source_upload_id is not None: + _delete_upload(source_upload_id, token=token) + raise + if getattr(args, "wait", False): + if cmd.wait_self: + result = _poll(console, path, token=token, as_json=as_json) + elif cmd.wait_path: + result = _wait(console, cmd, result, token=token, as_json=as_json) + if source_bundle is not None: + result = { + "source": source_bundle.summary(show_files=getattr(args, "show_files", False)), + "upload_id": source_upload_id, + "scan": result, + } + if cmd.link: + return _handoff_link(console, cmd, args, result, as_json=as_json) + workspace_list = cmd.method == "GET" and cmd.path == "/workspaces" + emit( + console, + result, + as_json=as_json, + row_numbers=workspace_list, + omit_columns=frozenset({"id"}) if workspace_list else frozenset(), + hint=("Switch with `strix cloud workspaces use NUMBER`." if workspace_list else None), + ) + return http.EXIT_OK + + +def _set_default_scan_engagement( + body: dict[str, Any], *, has_local_source: bool = False +) -> None: + """Infer the scan type from its targets when the caller did not choose one.""" + if body.get("engagement_type"): + return + if body.get("internal_targets"): + body["engagement_type"] = "internal_infra" + elif body.get("domain_ids"): + body["engagement_type"] = "live_test" + elif has_local_source or body.get("repository_ids") or body.get("upload_ids"): + body["engagement_type"] = "code_review" + + +def _handoff_link( + console: Console, cmd: Cmd, args: argparse.Namespace, result: Any, *, as_json: bool +) -> int: + """Print a hosted URL a person must open, and open the browser when interactive.""" + fields = cast("dict[str, Any]", result) if isinstance(result, dict) else {} + url = fields.get(cmd.link) if cmd.link else None + if not isinstance(url, str) or not url: + emit(console, result, as_json=as_json) + return http.EXIT_OK + interactive = sys.stdout.isatty() and not getattr(args, "no_browser", False) + if as_json: + emit(console, result, as_json=True) + else: + console.print(f"Open this URL to continue:\n [bold]{url}[/]") + if interactive: + webbrowser.open(url) + return http.EXIT_OK + + +def _load_data(value: str) -> dict[str, Any]: + """Read a JSON object from a literal string, a `@file` path, or `-` for stdin.""" + if value == "-": + text = sys.stdin.read() + elif value.startswith("@"): + path = Path(value[1:]).expanduser() + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise http.CloudError(f"could not read {path}: {exc}") from exc + else: + text = value + try: + parsed_value = json.loads(text) + except ValueError as exc: + raise http.CloudError("--data must be a JSON object.") from exc + if not isinstance(parsed_value, dict): + raise http.CloudError("--data must be a JSON object.") + return cast("dict[str, Any]", parsed_value) + + +def _build_parser(group: str, verb_label: str, cmd: Cmd) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog=f"strix cloud {group} {verb_label}", description=cmd.help) + for name in _PLACEHOLDER.findall(cmd.path): + parser.add_argument(_dest(name), metavar=_metavar(name)) + for param in cmd.query + cmd.body: + _add_option(parser, param) + parser.add_argument("--json", action="store_true", help="Print the raw JSON response.") + parser.add_argument("--token", default=None, help="API token override.") + parser.add_argument("--app-url", default=None, metavar="URL", help="Platform URL override.") + parser.add_argument( + "--timeout", + default=None, + type=float, + metavar="SECONDS", + help="Request timeout in seconds.", + ) + if cmd.method in ("POST", "PUT", "PATCH"): + parser.add_argument( + "--data", + default=None, + metavar="JSON", + help="JSON object with extra request fields. Use @file to read a file, or - for stdin.", + ) + if cmd.path == "/billing/auto-topup" and cmd.method == "PUT": + parser.add_argument( + "--no-monthly-cap", + action="store_true", + help="Remove the monthly cap. Omit this flag to keep the stored cap.", + ) + if cmd.binary: + parser.add_argument("--output", default=None, metavar="FILE", help="Write to this file.") + if cmd.link: + parser.add_argument( + "--no-browser", + action="store_true", + help="Do not open the browser. Print the URL only.", + ) + if cmd.wait_path or cmd.wait_self: + parser.add_argument( + "--wait", action="store_true", help="Wait until the operation reaches a final state." + ) + if cmd.path == "/billing/topup": + parser.add_argument( + "--yes", action="store_true", help="Do not ask for confirmation before payment." + ) + parser.add_argument( + "--no-pay", + action="store_true", + help="Print the payment challenge instead of paying it.", + ) + parser.add_argument( + "--payment-method", + default=None, + metavar="PM_ID", + help=( + "Stripe payment method for the card payment. " + "Defaults to MPPX_STRIPE_PAYMENT_METHOD." + ), + ) + if cmd.path == "/scans" and cmd.method == "POST": + parser.add_argument( + "--source", + default=None, + metavar="DIRECTORY", + help="Package a local directory, upload it, and attach it to this scan.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Build and print the source manifest without uploading or starting a scan.", + ) + parser.add_argument( + "--yes", + action="store_true", + help="Approve the displayed source upload without an interactive prompt.", + ) + parser.add_argument( + "--show-files", + action="store_true", + help="Include every selected relative path in the source manifest.", + ) + parser.add_argument( + "--exclude", + action="append", + default=[], + metavar="GLOB", + help="Exclude a path glob from the upload. May be repeated.", + ) + parser.add_argument( + "--include-hidden", + action="store_true", + help="Include hidden files except .git and secret-like filenames.", + ) + parser.add_argument( + "--include-sensitive", + action="store_true", + help="Include files with secret-like names. Use only after reviewing --dry-run.", + ) + parser.add_argument( + "--include-archives", + action="store_true", + help="Include nested archives. Use only when they are required source inputs.", + ) + return parser + + +def _prepare_scan_source( + console: Console, args: argparse.Namespace, *, as_json: bool +) -> SourceBundle | None: + source = getattr(args, "source", None) + source_flags = ( + "dry_run", + "show_files", + "include_hidden", + "include_sensitive", + "include_archives", + ) + if source is None: + if any(getattr(args, name, False) for name in source_flags) or getattr(args, "exclude", []): + raise http.CloudError("source upload options require --source DIRECTORY.") + return None + bundle = prepare_source( + source, + include_hidden=bool(getattr(args, "include_hidden", False)), + include_sensitive=bool(getattr(args, "include_sensitive", False)), + include_archives=bool(getattr(args, "include_archives", False)), + exclude=cast("list[str]", getattr(args, "exclude", [])), + ) + if getattr(args, "dry_run", False): + return bundle + if getattr(args, "yes", False): + return bundle + if as_json or not (sys.stdin.isatty() and sys.stdout.isatty()): + remove_bundle(bundle) + raise http.CloudError( + "source upload requires explicit approval in non-interactive mode. " + "Review with --dry-run --show-files, then rerun with --yes." + ) + console.print( + "[bold]Local source upload[/]\n" + f" {len(bundle.manifest.files):,} file(s), " + f"{_format_bytes(bundle.manifest.total_bytes)} " + f"({_format_bytes(bundle.archive_bytes)} compressed)\n" + f" {sum(bundle.manifest.excluded.values()):,} path(s) excluded\n" + " Only the selected files will be sent to Strix Cloud." + ) + answer = console.input("Upload this source and start the scan? [y/N]: ").strip().lower() + if answer not in ("y", "yes"): + remove_bundle(bundle) + raise http.CloudError("source upload cancelled.") + return bundle + + +def _upload_scan_source(bundle: SourceBundle, *, token: str | None) -> str: + file_name = f"strix-source-{bundle.archive_sha256[:12]}.zip" + requested = http.check( + http.request( + "POST", + "/uploads/request", + token=token, + body={ + "file_name": file_name, + "file_size": bundle.archive_bytes, + "category": "repository", + }, + ) + ) + if not isinstance(requested, dict): + raise http.CloudError("the platform returned an invalid source upload response.") + fields = cast("dict[str, Any]", requested) + upload_id = fields.get("upload_id") + signed_url = fields.get("signed_url") + upload_token = fields.get("token") + if not all(isinstance(value, str) and value for value in (upload_id, signed_url, upload_token)): + raise http.CloudError("the platform did not return complete source upload credentials.") + try: + http.upload_file(cast("str", signed_url), cast("str", upload_token), bundle.archive_path) + http.check( + http.request( + "POST", + "/uploads/complete", + token=token, + body={"upload_id": upload_id}, + ) + ) + except BaseException: + _delete_upload(cast("str", upload_id), token=token) + raise + return cast("str", upload_id) + + +def _delete_upload(upload_id: str, *, token: str | None) -> None: + with suppress(http.CloudError): + http.request("DELETE", f"/uploads/{quote(upload_id, safe='')}", token=token) + + +def _format_bytes(value: int) -> str: + if value < 1024: + return f"{value} B" + if value < 1024 * 1024: + return f"{value / 1024:.1f} KB" + return f"{value / (1024 * 1024):.1f} MB" + + +def _add_option(parser: argparse.ArgumentParser, param: P) -> None: + flag = "--" + (param.flag or param.name.replace("_", "-")) + if param.kind == "bool": + parser.add_argument( + flag, + dest=param.name, + action=argparse.BooleanOptionalAction, + default=None, + required=param.required, + help=param.help, + ) + elif param.kind == "list": + parser.add_argument( + flag, + dest=param.name, + nargs="+", + default=None, + required=param.required, + help=param.help, + ) + elif param.kind in ("int", "float"): + parser.add_argument( + flag, + dest=param.name, + type=int if param.kind == "int" else float, + default=None, + required=param.required, + help=param.help, + ) + else: + parser.add_argument( + flag, dest=param.name, default=None, required=param.required, help=param.help + ) + + +def _collect(args: argparse.Namespace, params: tuple[P, ...]) -> dict[str, Any]: + values: dict[str, Any] = {} + for param in params: + value = getattr(args, param.name, None) + if value is None: + continue + if param.kind == "json" and isinstance(value, str): + try: + value = json.loads(value) + except ValueError as exc: + raise http.CloudError(f"--{param.name.replace('_', '-')} must be JSON") from exc + values[param.name] = value + return values + + +def _emit_binary(console: Console, response: Any, output: str | None) -> int: + if not response.ok: + http.check(response) + if output: + Path(output).write_bytes(response.content) + console.print(f"Saved to [bold]{output}[/]") + return http.EXIT_OK + sys.stdout.buffer.write(response.content) + return http.EXIT_OK + + +def _emit_error(console: Console, exc: http.CloudError, *, as_json: bool) -> None: + if as_json: + payload = {"error": str(exc)} + if exc.payload is not None: + payload["detail"] = exc.payload + sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n") + return + console.print(f"[red]Error:[/] {exc}") + + +def _created_id(created: Any) -> str | None: + """Read the identifier of a created item. The API names it `id` or `_id`.""" + if not isinstance(created, dict): + 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): + return value + return None + + +def _wait(console: Console, cmd: Cmd, created: Any, *, token: str | None, as_json: bool) -> Any: + item_id = _created_id(created) + if not item_id or not cmd.wait_path: + return created + path = cmd.wait_path.replace("{id}", str(item_id)) + if not as_json: + console.print(f"[dim]Waiting for {item_id} to reach a final state…[/]") + return _poll(console, path, token=token, as_json=as_json) + + +def _poll(console: Console, path: str, *, token: str | None, as_json: bool) -> Any: + """Poll a GET path until its status is final. Returns the last response.""" + while True: + time.sleep(_WAIT_POLL_S) + current: Any = http.check(http.request("GET", path, token=token)) + fields = cast("dict[str, Any]", current) if isinstance(current, dict) else {} + status = str(fields.get("status", "")) + if status.lower() in _TERMINAL_STATUSES: + return fields if isinstance(current, dict) else current + if not as_json: + console.print(f"[dim] status: {status or 'unknown'}[/]") + + +def _topup( + console: Console, + args: argparse.Namespace, + body: dict[str, Any], + *, + as_json: bool, + token: str | None, +) -> int: + response = http.request("POST", "/billing/topup", token=token, body=body) + if response.status_code != 402: + emit(console, http.check(response), as_json=as_json) + return http.EXIT_OK + + challenge = http.parsed(response) + if getattr(args, "no_pay", False): + emit( + console, + {"error": "Payment required", "challenge": challenge}, + as_json=as_json, + ) + return http.EXIT_PAYMENT + + npx = shutil.which("npx") + if npx is None: + emit(console, challenge, as_json=as_json) + console.print( + "[yellow]Payment required.[/] Install Node.js and run the command again, " + "or pay the challenge above with an MPP wallet client." + ) + return http.EXIT_PAYMENT + + credit_count = body.get("credits") + if not getattr(args, "yes", False) and sys.stdin.isatty(): + answer = console.input(f"Buy {credit_count} credit(s) now? [y/N]: ").strip().lower() + if answer not in ("y", "yes"): + console.print("[yellow]Payment cancelled.[/]") + return http.EXIT_PAYMENT + + url = f"{http.app_url()}/api/v1/billing/topup" + auth_header = f"X-Strix-Authorization: Bearer {http.api_token(token)}" + command = [ + npx, + "--yes", + "mppx", + url, + "-J", + json.dumps(body), + "-H", + auth_header, + ] + payment_method = getattr(args, "payment_method", None) or os.environ.get( + "MPPX_STRIPE_PAYMENT_METHOD" + ) + if payment_method: + command += ["-M", f"paymentMethod={payment_method}"] + elif not os.environ.get("MPPX_ACCOUNT") and not os.environ.get("MPPX_STRIPE_SECRET_KEY"): + console.print( + "[dim]Tip: payments need a wallet. Set up a Stripe agent wallet at " + "https://link.com/agents, and the user approves each payment in the Link app. " + "If the user does not want a wallet, run " + "`strix cloud billing subscribe --plan strix_top_up` for a hosted checkout link.[/]" + ) + result = subprocess.run(command, check=False) # noqa: S603 + return http.EXIT_OK if result.returncode == 0 else http.EXIT_PAYMENT diff --git a/strix/interface/cloud/source_upload.py b/strix/interface/cloud/source_upload.py new file mode 100644 index 00000000..dc9e801c --- /dev/null +++ b/strix/interface/cloud/source_upload.py @@ -0,0 +1,382 @@ +"""Privacy-conscious local source packaging for managed scans.""" + +from __future__ import annotations + +import fnmatch +import hashlib +import os +import shutil +import stat +import subprocess # nosec B404 +import tempfile +import zipfile +from collections import Counter +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from strix.interface.cloud import http + + +MAX_FILES = 20_000 +MAX_FILE_BYTES = 25 * 1024 * 1024 +MAX_TOTAL_BYTES = 250 * 1024 * 1024 +MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 + +_ALWAYS_EXCLUDED_DIRS = frozenset( + { + ".git", + ".hg", + ".svn", + "node_modules", + "vendor", + "venv", + "__pycache__", + "dist", + "build", + "coverage", + "target", + } +) +_SENSITIVE_NAMES = frozenset( + { + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "credentials.json", + "service-account.json", + "service_account.json", + ".env", + ".npmrc", + ".pypirc", + ".netrc", + } +) +_SENSITIVE_PATTERNS = ( + "*.pem", + "*.key", + "*.p12", + "*.pfx", + "*.keystore", + "*.jks", + "secrets.*", + "secret.*", + ".env.*", +) +_ARCHIVE_SUFFIXES = ( + ".zip", + ".tar", + ".tgz", + ".tar.gz", + ".tar.bz2", + ".tar.xz", + ".7z", + ".rar", + ".gz", + ".bz2", + ".xz", +) + + +@dataclass(frozen=True) +class SelectedFile: + path: Path + archive_name: str + size: int + + +@dataclass(frozen=True) +class SourceManifest: + source: Path + files: tuple[SelectedFile, ...] + excluded: Counter[str] + include_hidden: bool + include_sensitive: bool + include_archives: bool + + @property + def total_bytes(self) -> int: + return sum(item.size for item in self.files) + + def as_dict( + self, + *, + show_files: bool, + archive_bytes: int | None = None, + archive_sha256: str | None = None, + ) -> dict[str, object]: + result: dict[str, object] = { + "source": str(self.source), + "file_count": len(self.files), + "uncompressed_bytes": self.total_bytes, + "excluded_count": sum(self.excluded.values()), + "excluded_by_reason": dict(sorted(self.excluded.items())), + "include_hidden": self.include_hidden, + "include_sensitive": self.include_sensitive, + "include_archives": self.include_archives, + } + if archive_bytes is not None: + result["archive_bytes"] = archive_bytes + if archive_sha256 is not None: + result["archive_sha256"] = archive_sha256 + if show_files: + result["files"] = [item.archive_name for item in self.files] + return result + + +@dataclass(frozen=True) +class SourceBundle: + manifest: SourceManifest + archive_path: Path + archive_bytes: int + archive_sha256: str + + def summary(self, *, show_files: bool) -> dict[str, object]: + return self.manifest.as_dict( + show_files=show_files, + archive_bytes=self.archive_bytes, + archive_sha256=self.archive_sha256, + ) + + +def prepare_source( + value: str, + *, + include_hidden: bool, + include_sensitive: bool, + include_archives: bool, + exclude: list[str], +) -> SourceBundle: + """Select safe source files and build a bounded temporary ZIP archive.""" + source = Path(value).expanduser().resolve() + if not source.is_dir(): + raise http.CloudError(f"--source must be a directory: {source}") + manifest = select_source( + source, + include_hidden=include_hidden, + include_sensitive=include_sensitive, + include_archives=include_archives, + exclude=exclude, + ) + if not manifest.files: + raise http.CloudError("no files remain after applying source upload exclusions.") + + with tempfile.NamedTemporaryFile(prefix="strix-source-", suffix=".zip", delete=False) as handle: + archive_path = Path(handle.name) + try: + _write_archive(archive_path, manifest.files) + except BaseException: + archive_path.unlink(missing_ok=True) + raise + archive_bytes = archive_path.stat().st_size + if archive_bytes > MAX_ARCHIVE_BYTES: + archive_path.unlink(missing_ok=True) + raise http.CloudError( + "source archive is larger than the 50 MB upload limit; narrow --source or " + "add --exclude patterns." + ) + digest = _sha256(archive_path) + return SourceBundle(manifest, archive_path, archive_bytes, digest) + + +def select_source( + source: Path, + *, + include_hidden: bool = False, + include_sensitive: bool = False, + include_archives: bool = False, + exclude: list[str] | None = None, +) -> SourceManifest: + excluded: Counter[str] = Counter() + selected: list[SelectedFile] = [] + patterns = [*_load_ignore_patterns(source), *(exclude or [])] + total_bytes = 0 + for relative in _candidate_paths(source): + archive_name = relative.as_posix() + reason = _exclusion_reason( + relative, + include_hidden=include_hidden, + include_sensitive=include_sensitive, + include_archives=include_archives, + patterns=patterns, + ) + if reason: + excluded[reason] += 1 + continue + path = source / relative + try: + info = path.lstat() + except OSError: + excluded["unreadable"] += 1 + continue + if not stat.S_ISREG(info.st_mode): + excluded["symlink_or_non_file"] += 1 + continue + if info.st_size > MAX_FILE_BYTES: + raise http.CloudError( + f"{archive_name} is larger than the 25 MB per-file limit; exclude it explicitly." + ) + selected.append(SelectedFile(path, archive_name, info.st_size)) + total_bytes += info.st_size + if len(selected) > MAX_FILES: + raise http.CloudError( + f"source contains more than {MAX_FILES:,} files; narrow --source or add exclusions." + ) + if total_bytes > MAX_TOTAL_BYTES: + raise http.CloudError( + "selected source is larger than the 250 MB expanded-size limit; narrow --source " + "or add --exclude patterns." + ) + selected.sort(key=lambda item: item.archive_name) + return SourceManifest( + source, + tuple(selected), + excluded, + include_hidden, + include_sensitive, + include_archives, + ) + + +def remove_bundle(bundle: SourceBundle) -> None: + bundle.archive_path.unlink(missing_ok=True) + + +def _candidate_paths(source: Path) -> list[Path]: + git_root = _git_root(source) + if git_root is not None: + git = shutil.which("git") + if git is None: + return [path.relative_to(source) for path in source.rglob("*")] + relative_source = source.relative_to(git_root) + command = [ + git, + "-C", + str(git_root), + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--", + ] + if relative_source != Path(): + command.append(relative_source.as_posix()) + result = subprocess.run( # noqa: S603 # nosec B603 + command, check=False, capture_output=True + ) + if result.returncode == 0: + paths: list[Path] = [] + for raw in result.stdout.split(b"\0"): + if not raw: + continue + repo_relative = Path(os.fsdecode(raw)) + try: + paths.append(repo_relative.relative_to(relative_source)) + except ValueError: + continue + return paths + return [path.relative_to(source) for path in source.rglob("*")] + + +def _git_root(source: Path) -> Path | None: + git = shutil.which("git") + if git is None: + return None + result = subprocess.run( # noqa: S603 # nosec B603 + [git, "-C", str(source), "rev-parse", "--show-toplevel"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + try: + return Path(result.stdout.strip()).resolve() + except OSError: + return None + + +def _exclusion_reason( # noqa: PLR0911 + relative: Path, + *, + include_hidden: bool, + include_sensitive: bool, + include_archives: bool, + patterns: list[str], +) -> str | None: + parts = relative.parts + if any(part == ".git" for part in parts): + return "git_metadata" + if any(part in _ALWAYS_EXCLUDED_DIRS for part in parts[:-1]): + return "dependency_or_build_output" + if not include_hidden and any(part.startswith(".") for part in parts): + return "hidden" + posix = PurePosixPath(relative.as_posix()) + if any( + posix.match(pattern) or fnmatch.fnmatch(relative.as_posix(), pattern) + for pattern in patterns + ): + return "user_pattern" + name = relative.name.lower() + if not include_sensitive and ( + name in _SENSITIVE_NAMES + or any(fnmatch.fnmatch(name, pattern) for pattern in _SENSITIVE_PATTERNS) + ): + return "sensitive_filename" + if not include_archives and name.endswith(_ARCHIVE_SUFFIXES): + return "nested_archive" + return None + + +def _write_archive(destination: Path, files: tuple[SelectedFile, ...]) -> None: + with zipfile.ZipFile( + destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6 + ) as archive: + for item in files: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(item.path, flags) + except OSError as exc: + raise http.CloudError(f"could not safely read {item.archive_name}: {exc}") from exc + with os.fdopen(descriptor, "rb") as source_file: + current = os.fstat(source_file.fileno()) + if not stat.S_ISREG(current.st_mode) or current.st_size != item.size: + raise http.CloudError( + f"{item.archive_name} changed while the source archive was being built; " + "retry." + ) + info = zipfile.ZipInfo(item.archive_name) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + with archive.open(info, "w", force_zip64=True) as target: + shutil.copyfileobj(source_file, target, length=1024 * 1024) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _load_ignore_patterns(source: Path) -> list[str]: + path = source / ".strixignore" + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return [] + except OSError as exc: + raise http.CloudError(f"could not read {path}: {exc}") from exc + patterns: list[str] = [] + for line_number, raw in enumerate(lines, start=1): + value = raw.strip() + if not value or value.startswith("#"): + continue + if value.startswith("!"): + raise http.CloudError( + f"{path}:{line_number}: negated patterns are not supported; use exclude-only globs." + ) + patterns.append(value) + return patterns diff --git a/strix/interface/cloud/spec.py b/strix/interface/cloud/spec.py new file mode 100644 index 00000000..fbbabbf1 --- /dev/null +++ b/strix/interface/cloud/spec.py @@ -0,0 +1,929 @@ +"""Declarative command table for `strix cloud`. + +Each command maps one CLI verb to one managed API operation. The runner +builds the argument parser and the HTTP request from this table, so the +CLI surface stays aligned with the OpenAPI specification. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class P: + """One command parameter. + + ``kind`` is one of ``str``, ``int``, ``float``, ``bool``, ``list``, or ``json``. + """ + + name: str + kind: str = "str" + required: bool = False + help: str = "" + # Command-line name when the field name collides with a common option. + flag: str | None = None + + +@dataclass(frozen=True) +class Cmd: + method: str + path: str + help: str + query: tuple[P, ...] = () + body: tuple[P, ...] = () + binary: bool = False + wait_path: str | None = None + # When true, `--wait` polls GET on this same path until the status is final. + wait_self: bool = False + # Response field that holds a URL a person must open, for example a hosted + # checkout page. The runner opens the browser for an interactive terminal + # and always prints the URL. + link: str | None = None + + +def _q(*names: str) -> tuple[P, ...]: + return tuple(P(name) for name in names) + + +_SCAN_START_BODY = ( + P("engagement_type", help="Test category, for example live_test or code_review."), + P("domain_ids", "list", help="Domain asset IDs to test."), + P("domain_paths", "json", help="JSON map of domain ID to start paths."), + P("repository_ids", "list", help="Repository asset IDs to test."), + P("repository_branches", "json", help="JSON map of repository ID to branch."), + P("credentials", "json", help="JSON list of credential objects."), + P("headers", "json", help="JSON map of extra HTTP headers for the target."), + P("concerns", "list", help="Vulnerability classes to focus on."), + P("focus", help="Free-form focus instructions for the agents."), + P("context", help="Extra context about the target."), + P("upload_ids", "list", help="Upload IDs to attach to the scan."), + P("connector_id", help="Network connector ID for internal targets."), + P("internal_targets", "list", help="Internal IP addresses or ranges."), + P("org_knowledge_enabled", "bool", help="Use the organization knowledge base."), + P("notify_on_completion", "bool", help="Send an email when the scan completes."), + P("notification_emails", "list", help="Extra notification email addresses."), + P("scan_tier", help="Scan tier, for example lite, pro, or max."), + P("model_config_id", help="Model configuration ID to run with."), + P("max_budget_usd", "float", help="Budget limit for the scan in USD."), +) + +_TEST_USER_ADD_BODY = ( + P("label", required=True, help="Display label for the test user."), + P("username", required=True, help="Sign-in username or email address."), + P("password", help="Sign-in password."), + P("notes", help="Free-form notes for the agents."), + P("login_url", help="URL of the sign-in page."), + P("mfa_method", help="MFA method, for example totp or email."), + P("totp_secret", help="TOTP secret for MFA sign-in."), + P("mfa_email", help="Email address that receives MFA codes."), + P("scope_domain_ids", "list", help="Domain IDs where this user applies."), +) + +_TEST_USER_BODY = ( + P("label", help="Display label for the test user."), + P("username", help="Sign-in username or email address."), + P("password", help="Sign-in password."), + P("notes", help="Free-form notes for the agents."), + P("login_url", help="URL of the sign-in page."), + P("mfa_method", help="MFA method, for example totp or email."), + P("totp_secret", help="TOTP secret for MFA sign-in."), + P("mfa_email", help="Email address that receives MFA codes."), + P("scope_domain_ids", "list", help="Domain IDs where this user applies."), +) + +_GIT_TOKEN_BODY = ( + P("token", required=True, help="Provider access token.", flag="provider-token"), + P("instance_url", help="Base URL for a self-hosted instance."), + P("workspace", help="Bitbucket workspace name."), +) + + +SPEC: dict[str, dict[str, Cmd]] = { + "scans": { + "list": Cmd( + "GET", + "/scans", + "List scans.", + query=_q( + "status", + "scan_type", + "date_from", + "date_to", + "domain_id", + "repository_id", + "search", + "include_retests", + ), + ), + "start": Cmd( + "POST", + "/scans", + "Start a scan.", + body=_SCAN_START_BODY, + wait_path="/scans/{id}", + ), + "get": Cmd("GET", "/scans/{scanId}", "Get one scan."), + "delete": Cmd("DELETE", "/scans/{scanId}", "Delete a scan."), + "agents": Cmd("GET", "/scans/{scanId}/agents", "List the agents of a scan."), + "cancel": Cmd("POST", "/scans/{scanId}/cancel", "Cancel a running scan."), + "message": Cmd( + "POST", + "/scans/{scanId}/message", + "Send a message to the scan agents.", + body=( + P("message", required=True, help="Message text for the agents."), + P("cancel_current", "bool", help="Stop the current task first."), + P("agent_id", help="Target one agent instead of the root agent."), + ), + ), + "report": Cmd( + "GET", + "/scans/{scanId}/report", + "Download the scan report.", + query=_q("format", "type"), + binary=True, + ), + "rerun": Cmd("POST", "/scans/{scanId}/rerun", "Run the scan again.", wait_path=None), + "retest-all": Cmd( + "POST", + "/scans/{scanId}/retest-all", + "Retest all open findings of a scan.", + body=( + P("scope", help="Retest scope."), + P("upload_ids", "list", help="Upload IDs with updated code."), + ), + ), + "retests": Cmd("GET", "/scans/{scanId}/retests", "List the retests of a scan."), + "sarif": Cmd( + "GET", + "/scans/{scanId}/sarif", + "Download the scan findings as SARIF.", + query=_q("repository"), + binary=True, + ), + "sarif-upload": Cmd( + "POST", + "/scans/{scanId}/sarif", + "Upload the scan findings to GitHub code scanning.", + body=( + P("repository", help="Repository full name."), + P("ref", help="Git ref for the upload."), + P("commit_sha", help="Commit SHA for the upload."), + P("checkout_uri", help="Checkout URI for the upload."), + P("github_api_base_url", help="GitHub API base URL."), + ), + ), + "template": Cmd("GET", "/scans/{scanId}/template", "Get the scan configuration template."), + "trace": Cmd( + "GET", + "/scans/{scanId}/trace", + "List trace events for one agent of a scan.", + query=( + P("agent_id", required=True, help="Agent ID to read the trace for."), + P("cursor"), + P("limit", "int"), + P("tool_name"), + ), + ), + "trace-event": Cmd( + "GET", "/scans/{scanId}/trace/{eventId}", "Get one trace event of a scan." + ), + }, + "vulns": { + "list": Cmd( + "GET", + "/vulnerabilities", + "List vulnerabilities.", + query=_q( + "scan_id", + "severity", + "status", + "search", + "from", + "to", + "domain_id", + "repository_id", + "finding_type", + "dependency_relation", + "reachability", + "sort_by", + ), + ), + "get": Cmd("GET", "/vulnerabilities/{vulnerabilityId}", "Get one vulnerability."), + "history": Cmd( + "GET", + "/vulnerabilities/{vulnerabilityId}/history", + "Get the change history of a vulnerability.", + ), + "update": Cmd( + "PATCH", + "/vulnerabilities/{vulnerabilityId}", + "Update the status or severity of a vulnerability.", + body=( + P("status", help="New status, for example triaged or false_positive."), + P("note", help="Note that explains the change."), + P("severity", help="New severity."), + P("severity_reason", help="Reason for the severity change."), + ), + ), + "retest": Cmd( + "POST", + "/vulnerabilities/{vulnerabilityId}/retest", + "Retest one vulnerability.", + body=(P("upload_ids", "list", help="Upload IDs with updated code."),), + ), + "fix-pr": Cmd( + "POST", + "/vulnerabilities/{vulnerabilityId}/create-fix-pr", + "Create a fix pull request for a vulnerability.", + ), + "push": Cmd( + "POST", + "/vulnerabilities/{vulnerabilityId}/push", + "Push one vulnerability to an issue tracker.", + body=( + P("provider", required=True, help="Tracker provider, for example jira or linear."), + P("collection_id", help="Tracker project or collection ID."), + ), + ), + "push-bulk": Cmd( + "POST", + "/vulnerabilities/bulk-push", + "Push many vulnerabilities to an issue tracker.", + body=( + P("provider", required=True, help="Tracker provider, for example jira or linear."), + P("vulnerability_ids", "list", required=True, help="Vulnerability IDs to push."), + P("collection_id", help="Tracker project or collection ID."), + ), + ), + }, + "domains": { + "list": Cmd( + "GET", + "/domains", + "List domain assets.", + query=_q("limit", "search", "verified", "business_unit", "tags", "sort_by"), + ), + "add": Cmd( + "POST", + "/domains", + "Add a domain asset.", + body=( + P("domain", required=True, help="Domain name or URL."), + P("asset_type", required=True, help="Asset type, for example web_app or api."), + P("context", help="Extra context about the asset."), + P("tags", "list", help="Tags for the asset."), + P("business_unit", help="Business unit for the asset."), + ), + ), + "update": Cmd( + "PATCH", + "/domains/{domainId}", + "Update a domain asset.", + body=( + P("context", help="Extra context about the asset."), + P("tags", "list", help="Tags for the asset."), + P("business_unit", help="Business unit for the asset."), + ), + ), + "remove": Cmd("DELETE", "/domains/{domainId}", "Remove a domain asset."), + "verify": Cmd("POST", "/domains/{domainId}/verify", "Verify domain ownership."), + "auto-verify": Cmd( + "POST", + "/domains/{domainId}/auto-verify", + "Verify domain ownership through a DNS provider.", + body=(P("provider", required=True, help="DNS provider name."),), + ), + "test-users list": Cmd( + "GET", "/domains/{domainId}/test-users", "List the test users of a domain." + ), + "test-users add": Cmd( + "POST", + "/domains/{domainId}/test-users", + "Add a test user to a domain.", + body=_TEST_USER_ADD_BODY, + ), + "test-users update": Cmd( + "PATCH", + "/domains/{domainId}/test-users/{userId}", + "Update a test user.", + body=_TEST_USER_BODY, + ), + "test-users remove": Cmd( + "DELETE", "/domains/{domainId}/test-users/{userId}", "Remove a test user." + ), + "test-users provision-inbox": Cmd( + "POST", + "/domains/{domainId}/test-users/provision-inbox", + "Create a test user with a managed email inbox.", + body=(P("label", help="Display label for the test user."),), + ), + "test-users inbox": Cmd( + "GET", + "/domains/{domainId}/test-users/{userId}/inbox", + "List the inbox messages of a test user.", + query=(P("limit", "int"),), + ), + "test-users inbox-message": Cmd( + "GET", + "/domains/{domainId}/test-users/{userId}/inbox/{messageId}", + "Get one inbox message of a test user.", + ), + "test-users verify": Cmd( + "POST", + "/domains/{domainId}/test-users/{userId}/verify", + "Verify that the test user credentials work.", + query=(P("force"),), + wait_self=True, + ), + "test-users verify-status": Cmd( + "GET", + "/domains/{domainId}/test-users/{userId}/verify", + "Get the verification status of a test user.", + ), + }, + "repos": { + "list": Cmd( + "GET", + "/repositories", + "List repository assets.", + query=_q("limit", "search", "business_unit", "tags", "sort_by"), + ), + "add": Cmd("POST", "/repositories", "Add a repository asset. Use --data for the fields."), + "update": Cmd( + "PATCH", + "/repositories/{repositoryId}", + "Update a repository asset.", + body=( + P("pr_review_enabled", "bool", help="Turn PR reviews on or off."), + P("pr_review_approvals_enabled", "bool", help="Let reviews approve clean PRs."), + P("pr_review_non_blocking", "bool", help="Make review verdicts non-blocking."), + P("pr_review_on_push", "bool", help="Review new pushes to open PRs."), + P("tags", "list", help="Tags for the asset."), + P("business_unit", help="Business unit for the asset."), + ), + ), + "remove": Cmd("DELETE", "/repositories/{repositoryId}", "Remove a repository asset."), + "supply-chain scan": Cmd( + "POST", + "/repositories/{repositoryId}/supply-chain/scan", + "Start a supply-chain scan for a repository.", + ), + "supply-chain summary": Cmd( + "GET", + "/repositories/{repositoryId}/supply-chain/summary", + "Get the supply-chain summary of a repository.", + query=_q("job_id", "snapshot_id"), + ), + "supply-chain findings": Cmd( + "GET", + "/repositories/{repositoryId}/supply-chain/findings", + "List the supply-chain findings of a repository.", + query=_q("job_id", "snapshot_id", "component_id"), + ), + "supply-chain components": Cmd( + "GET", + "/repositories/{repositoryId}/supply-chain/components", + "List the dependency components of a repository.", + query=_q( + "job_id", + "snapshot_id", + "component_id", + "ecosystem", + "status", + "relationship", + "source_file", + "q", + "changed", + "limit", + "offset", + ), + ), + "supply-chain sbom": Cmd( + "GET", + "/repositories/{repositoryId}/supply-chain/sbom", + "Download the SBOM of a repository.", + query=_q("job_id", "snapshot_id", "format"), + binary=True, + ), + "supply-chain policy": Cmd( + "PATCH", + "/repositories/{repositoryId}/supply-chain/policy", + "Update the supply-chain policy of a repository.", + body=( + P("supply_chain_enabled", "bool", help="Turn supply-chain scans on or off."), + P("supply_chain_pr_checks_enabled", "bool", help="Run checks on pull requests."), + P("supply_chain_policy_mode", help="Policy mode for new findings."), + ), + ), + }, + "supply-chain": { + "summary": Cmd( + "GET", "/supply-chain/summary", "Get the organization supply-chain summary." + ), + }, + "schedules": { + "list": Cmd("GET", "/schedules", "List scan schedules."), + "create": Cmd("POST", "/schedules", "Create a scan schedule. Use --data for the fields."), + "get": Cmd("GET", "/schedules/{scheduleId}", "Get one schedule."), + "update": Cmd( + "PATCH", + "/schedules/{scheduleId}", + "Update a schedule. Use --data for fields that have no option.", + body=( + P("action", help="Lifecycle action, for example pause or resume."), + P("cron_expression", help="Cron expression for the schedule."), + P("timezone", help="Time zone for the cron expression."), + P("name", help="Display name of the schedule."), + P("max_budget_usd", "int", help="Budget limit per run in USD."), + P("scan_tier", help="Scan tier for scheduled runs."), + ), + ), + "delete": Cmd("DELETE", "/schedules/{scheduleId}", "Delete a schedule."), + "template": Cmd( + "GET", "/schedules/{scheduleId}/template", "Get the schedule configuration template." + ), + "trigger": Cmd("POST", "/schedules/{scheduleId}/trigger", "Run a schedule now."), + }, + "pr-reviews": { + "list": Cmd( + "GET", + "/pr-reviews", + "List PR reviews.", + query=_q( + "search", + "status", + "group", + "pr_state", + "repository_full_name", + "date_from", + "date_to", + "sort_by", + "sort_order", + "include_counts", + ), + ), + "get": Cmd("GET", "/pr-reviews/{prReviewId}", "Get one PR review."), + "findings": Cmd( + "GET", + "/pr-reviews/findings", + "List PR review findings.", + query=_q("severity", "pr_state", "search", "repository_full_name", "include_stats"), + ), + "start": Cmd( + "POST", + "/pr-reviews/start", + "Start a PR review.", + body=( + P("provider", required=True, help="Git provider: github, gitlab, or bitbucket."), + P("installation_id", "int", required=True, help="Provider installation ID."), + P("repository_full_name", required=True, help="Repository full name."), + P("pr_number", "int", required=True, help="Pull request number."), + ), + ), + "settings": Cmd("GET", "/pr-reviews/settings", "Get the PR review settings."), + "settings update": Cmd( + "PATCH", + "/pr-reviews/settings", + "Update the PR review settings. Use --data for fields that have no option.", + body=( + P("review_on_push", "bool", help="Review new pushes to open PRs."), + P("block_on_findings", "bool", help="Block PRs that have findings."), + P("blocking_severities", "list", help="Severities that block a PR."), + P("approve_clean_prs", "bool", help="Approve PRs without findings."), + P("target_branches", "list", help="Branches that get reviews."), + ), + ), + }, + "billing": { + "credits": Cmd("GET", "/billing/credits", "Get the credit balance of the workspace."), + "topup": Cmd( + "POST", + "/billing/topup", + "Buy credits with an agent payment (HTTP 402 flow).", + body=(P("credits", "int", required=True, help="Number of credits to buy."),), + ), + "subscribe": Cmd( + "POST", + "/billing/checkout", + "Create a checkout link for a plan or a credit pack. A person completes the payment.", + body=( + P( + "product", + required=True, + flag="plan", + help="Product to buy: strix_cloud, strix_startup, or strix_top_up.", + ), + P("success_url", help="Page to open after the payment."), + ), + link="checkout_url", + ), + "portal": Cmd( + "POST", + "/billing/portal", + "Create a billing portal link. A person manages the card and the plan there.", + link="portal_url", + ), + "auto-topup": Cmd("GET", "/billing/auto-topup", "Get the automatic top-up settings."), + "auto-topup update": Cmd( + "PUT", + "/billing/auto-topup", + "Update the automatic top-up settings.", + body=( + P("enabled", "bool", required=True, help="Turn automatic top-up on or off."), + P("topup_credits", "int", required=True, help="Credits to buy on each top-up."), + P("monthly_cap_credits", "int", help="Monthly credit cap for automatic top-ups."), + ), + ), + }, + "chat": { + "list": Cmd("GET", "/chat", "List chat sessions."), + "start": Cmd( + "POST", + "/chat", + "Start a chat session.", + body=( + P("message", required=True, help="First message of the session."), + P("repos", "list", help="Repository full names for context."), + P("domain_ids", "list", help="Domain asset IDs for context."), + ), + ), + "get": Cmd("GET", "/chat/{chatId}", "Get one chat session."), + "send": Cmd( + "POST", + "/chat/{chatId}/message", + "Send a message in a chat session.", + body=( + P("message", required=True, help="Message text."), + P("cancel_current", "bool", help="Stop the current task first."), + P("stop_agent", "bool", help="Stop the agent."), + P("repos", "list", help="Repository full names for context."), + P("agent_id", help="Target one agent."), + ), + ), + "findings": Cmd("GET", "/chat/{chatId}/findings", "List the findings of a chat session."), + "finding": Cmd( + "GET", "/chat/{chatId}/findings/{findingId}", "Get one finding of a chat session." + ), + "finding file": Cmd( + "POST", + "/chat/{chatId}/findings/{findingId}/file", + "File a chat finding into the organization issue list.", + ), + "files": Cmd("GET", "/chat/{chatId}/files", "List the files of a chat session."), + "files download": Cmd( + "GET", + "/chat/{chatId}/files/download", + "Download one file of a chat session.", + query=(P("path", required=True, help="File path inside the session."),), + binary=True, + ), + "files archive": Cmd( + "GET", + "/chat/{chatId}/files/archive", + "Download all files of a chat session as an archive.", + binary=True, + ), + "credentials": Cmd( + "GET", + "/chat/{chatId}/credentials", + "Get the credentials of a chat session.", + query=_q("scan_ids"), + ), + "credentials set": Cmd( + "POST", + "/chat/{chatId}/credentials", + "Set the credentials of a chat session.", + body=( + P("test_user_ids", "list", help="Test user IDs to attach."), + P("credentials", "json", help="JSON list of credential objects."), + P("scan_ids", "list", help="Scan IDs that use the credentials."), + ), + ), + "credentials clear": Cmd( + "DELETE", "/chat/{chatId}/credentials", "Remove the credentials of a chat session." + ), + "domains set": Cmd( + "PUT", + "/chat/{chatId}/domains", + "Set the domains of a chat session.", + body=(P("domain_ids", "list", help="Domain asset IDs."),), + ), + "terminal": Cmd( + "POST", + "/chat/{chatId}/terminal", + "Run a command in the chat session sandbox.", + body=( + P("command", required=True, help="Shell command to run."), + P("cwd", help="Working directory for the command."), + ), + ), + "share": Cmd("POST", "/chat/{chatId}/share", "Create a share link for a chat session."), + }, + "knowledge": { + "list": Cmd( + "GET", + "/knowledge", + "List knowledge documents.", + query=_q("source_type", "search", "limit"), + ), + "add": Cmd( + "POST", + "/knowledge", + "Add a knowledge document.", + body=( + P("title", required=True, help="Document title."), + P("content", required=True, help="Document content."), + P("tags", "list", help="Tags for the document."), + P("metadata", "json", help="JSON metadata for the document."), + ), + ), + "update": Cmd( + "PATCH", + "/knowledge/{documentId}", + "Update a knowledge document.", + body=( + P("title", help="Document title."), + P("content", help="Document content."), + P("tags", "list", help="Tags for the document."), + P("metadata", "json", help="JSON metadata for the document."), + ), + ), + "delete": Cmd("DELETE", "/knowledge/{documentId}", "Delete a knowledge document."), + "policies": Cmd("GET", "/knowledge/policies", "List knowledge policies."), + "policies add": Cmd( + "POST", + "/knowledge/policies", + "Add a knowledge policy.", + body=( + P("policy_key", required=True, flag="key", help="Policy key."), + P("policy_value", required=True, flag="content", help="Policy content."), + P("policy_type", help="Policy type. Defaults to constraint."), + P("is_active", "bool", flag="enabled", help="Turn the policy on or off."), + P("metadata", "json", help="JSON metadata for the policy."), + ), + ), + "policies delete": Cmd( + "DELETE", "/knowledge/policies/{policyKey}", "Delete a knowledge policy." + ), + "repos": Cmd("GET", "/knowledge/repos", "List repositories with knowledge entries."), + "repos entries": Cmd( + "GET", "/knowledge/repos/{repo}/entries", "List the knowledge entries of a repository." + ), + "repos profile": Cmd( + "PATCH", + "/knowledge/repos/{repo}/profile", + "Update the knowledge profile of a repository. Use --data for the fields.", + ), + }, + "org": { + "get": Cmd("GET", "/organization", "Get the organization."), + "update": Cmd( + "PATCH", + "/organization", + "Update the organization.", + body=(P("name", required=True, help="Organization name."),), + ), + "members": Cmd("GET", "/organization/members", "List the organization members."), + "members invite": Cmd( + "POST", + "/organization/members", + "Invite a member to the organization.", + body=( + P("email", required=True, help="Email address of the new member."), + P("role", help="Member role, for example admin, analyst, or viewer."), + P("scopes", "list", help="RBAC scopes for the member."), + ), + ), + "members update": Cmd( + "PATCH", + "/organization/members/{membershipId}", + "Update a member of the organization.", + body=( + P("role", required=True, help="Member role."), + P("scopes", "list", help="RBAC scopes for the member."), + ), + ), + "members remove": Cmd("DELETE", "/organization/members/{membershipId}", "Remove a member."), + "invitations": Cmd("GET", "/organization/invitations", "List open invitations."), + "invitations revoke": Cmd( + "DELETE", "/organization/invitations/{invitationId}", "Revoke an invitation." + ), + }, + "integrations": { + "list": Cmd("GET", "/integrations", "List the connected integrations."), + "connect": Cmd( + "POST", + "/integrations/{provider}/connect", + "Connect a Git provider. The provider is gitlab or bitbucket.", + body=_GIT_TOKEN_BODY, + ), + "validate": Cmd( + "POST", + "/integrations/{provider}/validate", + "Validate a Git provider token. The provider is gitlab or bitbucket.", + body=_GIT_TOKEN_BODY, + ), + "install": Cmd( + "POST", + "/integrations/{provider}/install-url", + "Create an installation link. The provider is github or slack. A person approves it.", + link="url", + ), + "disconnect": Cmd("DELETE", "/integrations/{provider}", "Disconnect an integration."), + }, + "connectors": { + "list": Cmd("GET", "/connectors", "List network connectors."), + "create": Cmd( + "POST", + "/connectors", + "Create a network connector.", + body=(P("name", required=True, help="Connector name."),), + ), + "get": Cmd( + "GET", + "/connectors/{connectorId}", + "Get one network connector.", + query=_q("include_command"), + ), + "status": Cmd( + "GET", "/connectors/{connectorId}/status", "Get the status of a network connector." + ), + "delete": Cmd("DELETE", "/connectors/{connectorId}", "Delete a network connector."), + }, + "webhooks": { + "list": Cmd("GET", "/webhooks", "List webhooks."), + "create": Cmd( + "POST", + "/webhooks", + "Create a webhook.", + body=( + P("url", required=True, help="Delivery URL."), + P("events", "list", required=True, help="Event names to deliver."), + P("business_unit", help="Business unit filter."), + P("is_active", "bool", help="Turn the webhook on or off."), + ), + ), + "get": Cmd("GET", "/webhooks/{webhookId}", "Get one webhook."), + "update": Cmd( + "PATCH", + "/webhooks/{webhookId}", + "Update a webhook.", + body=( + P("url", help="Delivery URL."), + P("events", "list", help="Event names to deliver."), + P("business_unit", help="Business unit filter."), + P("is_active", "bool", help="Turn the webhook on or off."), + P("rotate_secret", "bool", help="Create a new signing secret."), + ), + ), + "delete": Cmd("DELETE", "/webhooks/{webhookId}", "Delete a webhook."), + "deliveries": Cmd( + "GET", "/webhooks/{webhookId}/deliveries", "List the deliveries of a webhook." + ), + }, + "analytics": { + "overview": Cmd( + "GET", + "/analytics/overview", + "Get the analytics overview.", + query=_q("range", "from", "to"), + ), + "stats": Cmd("GET", "/analytics/stats", "Get the analytics statistics."), + "scan-frequency": Cmd( + "GET", "/analytics/scan-frequency", "Get the scan frequency data.", query=_q("tz") + ), + }, + "audit": { + "list": Cmd( + "GET", + "/audit", + "List audit log entries.", + query=_q( + "action", "resource_type", "actor_id", "date_from", "date_to", "format", "all" + ), + ), + }, + "costs": { + "overview": Cmd( + "GET", "/llm-costs", "Show the LLM cost overview.", query=_q("range", "from", "to") + ), + "run": Cmd("GET", "/llm-costs/runs/{runType}/{runId}", "Get the LLM costs of one run."), + }, + "llm-settings": { + "get": Cmd("GET", "/llm-settings", "Get the LLM settings."), + "update": Cmd( + "PUT", + "/llm-settings", + "Update the LLM settings.", + body=( + P( + "modelConfigs", + "json", + required=True, + flag="model-configs", + help="JSON list of model configurations.", + ), + P("assignments", "json", required=True, help="JSON map of model assignments."), + ), + ), + }, + "settings": { + "notifications": Cmd("GET", "/settings/notifications", "Get the notification settings."), + "notifications update": Cmd( + "PATCH", + "/settings/notifications", + "Update the notification settings.", + body=( + P("sla_reminders_enabled", "bool", help="Turn SLA reminders on or off."), + P("sla_reminder_email", "bool", help="Send SLA reminders by email."), + P("sla_reminder_slack", "bool", help="Send SLA reminders to Slack."), + P("sla_warning_days", "int", help="Days before an SLA warning."), + ), + ), + }, + "license": { + "show": Cmd("GET", "/license", "Get the license information."), + }, + "tokens": { + "list": Cmd("GET", "/tokens", "List API tokens.", query=_q("type")), + "create": Cmd( + "POST", + "/tokens", + "Create an API token.", + body=( + P("type", required=True, help="Token type, personal or service."), + P("name", required=True, help="Token name."), + P("scopes", "list", help="API scopes for the token."), + P("expires_in_days", "int", help="Days until the token expires."), + ), + ), + "revoke": Cmd("DELETE", "/tokens/{tokenId}", "Revoke an API token."), + }, + "uploads": { + "request": Cmd( + "POST", + "/uploads/request", + "Request an upload URL.", + body=( + P("file_name", required=True, help="File name."), + P("file_size", "int", required=True, help="File size in bytes."), + P("category", help="Upload category."), + ), + ), + "complete": Cmd( + "POST", + "/uploads/complete", + "Mark an upload as complete.", + body=(P("upload_id", required=True, help="Upload ID."),), + ), + "delete": Cmd("DELETE", "/uploads/{uploadId}", "Delete an upload."), + }, + "workspaces": { + "list": Cmd("GET", "/workspaces", "List the workspaces of your account."), + "create": Cmd( + "POST", + "/workspaces", + "Create a workspace and become its admin.", + body=(P("name", required=True, help="Workspace name."),), + ), + }, +} + + +# Default verbs let a bare group name run its most common read command. +DEFAULT_VERBS: dict[str, str] = { + "workspaces": "list", + "costs": "overview", + "audit": "list", + "license": "show", + "supply-chain": "summary", +} + + +GROUP_HELP: dict[str, str] = { + "scans": "Start, watch, and manage scans", + "vulns": "Triage and remediate vulnerabilities", + "domains": "Manage domain assets and test users", + "repos": "Manage repository assets and supply-chain scans", + "supply-chain": "Organization supply-chain summary", + "schedules": "Manage scan schedules", + "pr-reviews": "Manage pull request reviews", + "billing": "Credits, top-ups, and automatic top-up", + "chat": "Interactive pentest chat sessions", + "knowledge": "Manage the knowledge base", + "org": "Manage the organization and its members", + "integrations": "Connect Git providers and other integrations", + "workspaces": "List, create, and switch workspaces", + "connectors": "Manage network connectors", + "webhooks": "Manage webhooks", + "analytics": "Read analytics data", + "audit": "Read the audit log", + "costs": "Read LLM cost data", + "llm-settings": "Manage LLM model settings", + "settings": "Manage notification settings", + "license": "Read license information", + "tokens": "Manage API tokens", + "uploads": "Upload files for scans", +} diff --git a/strix/interface/cloud/workspaces.py b/strix/interface/cloud/workspaces.py new file mode 100644 index 00000000..6df14a5a --- /dev/null +++ b/strix/interface/cloud/workspaces.py @@ -0,0 +1,153 @@ +"""`strix cloud workspaces use` — switch the stored token to another workspace. + +The command lists the workspaces of the account, finds the requested one by +ID or by exact name, asks the platform for a token in that workspace, and +stores the token in the credential file. The role of the account in the new +workspace limits the granted scopes. +""" + +from __future__ import annotations + +import argparse +from typing import Any, cast + +from rich.console import Console + +from strix.interface.cloud import http +from strix.interface.cloud.render import emit, json_mode +from strix.interface.platform_cli import AUTH_PATH, read_record, save_record + + +def run_workspace_use(argv: list[str]) -> int: + """Entry point for ``strix cloud workspaces use``. Returns an exit code.""" + console = Console() + parser = argparse.ArgumentParser( + prog="strix cloud workspaces use", + description="Switch the stored API token to another workspace.", + ) + parser.add_argument( + "workspace", + metavar="WORKSPACE", + help="Workspace number from `workspaces list`, ID, or exact name.", + ) + parser.add_argument( + "--scopes", + nargs="+", + metavar="SCOPE", + default=None, + help=( + "API scopes for the new token. Without this option, preserve the stored token's scopes." + ), + ) + parser.add_argument("--json", action="store_true", help="Print the raw JSON response.") + parser.add_argument("--token", default=None, help="API token override.") + parser.add_argument("--app-url", default=None, metavar="URL", help="Platform URL override.") + parser.add_argument( + "--timeout", default=None, type=float, metavar="SECONDS", help="Request timeout in seconds." + ) + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + + as_json = json_mode(flag=bool(args.json)) + http.configure(base_url=args.app_url, timeout=args.timeout) + try: + return _use(console, args, as_json=as_json) + except http.CloudError as exc: + if as_json: + emit(console, {"error": str(exc)}, as_json=True) + else: + console.print(f"[red]Error:[/] {exc}") + return exc.exit_code + + +def _use(console: Console, args: argparse.Namespace, *, as_json: bool) -> int: + workspace = _find_workspace(args.workspace, token=args.token) + record = read_record() or {} + body: dict[str, Any] = {} + if args.scopes: + body["scopes"] = args.scopes + elif args.token is None: + stored_scopes = record.get("scopes") + if ( + isinstance(stored_scopes, list) + and stored_scopes + and all(isinstance(scope, str) for scope in stored_scopes) + ): + body["scopes"] = stored_scopes + minted = http.check( + http.request( + "POST", + f"/workspaces/{workspace['id']}/token", + token=args.token, + body=body or None, + ) + ) + if not isinstance(minted, dict) or not minted.get("api_token"): + raise http.CloudError("the platform did not return a token.") + minted_record = cast("dict[str, Any]", minted) + + record.update( + { + "api_token": minted_record["api_token"], + "organization_id": minted_record.get("organization_id", workspace["id"]), + "organization_name": minted_record.get("organization_name", workspace.get("name", "")), + "expires_at": minted_record.get("expires_at"), + "scopes": minted_record.get("scopes", []), + } + ) + if minted_record.get("email"): + record["email"] = minted_record["email"] + save_record(record) + + result = { + "workspace_id": record["organization_id"], + "workspace_name": record["organization_name"], + "scopes": record["scopes"], + } + if as_json: + emit(console, result, as_json=True) + return http.EXIT_OK + console.print(f"[green]✓ Switched to workspace [bold]{record['organization_name']}[/].[/]") + scopes = record.get("scopes") + if isinstance(scopes, list) and scopes: + console.print(f" Scopes: [dim]{' '.join(str(s) for s in scopes)}[/]") + console.print(f" Token: stored in [dim]{AUTH_PATH}[/]") + return http.EXIT_OK + + +def _find_workspace(selector: str, *, token: str | None) -> dict[str, Any]: + listed = http.check(http.request("GET", "/workspaces", token=token)) + items = listed.get("workspaces") if isinstance(listed, dict) else None + workspaces = [cast("dict[str, Any]", item) for item in (items or []) if isinstance(item, dict)] + if not workspaces: + raise http.CloudError("no workspaces found for this account.") + wanted = selector.strip() + if wanted.isdigit(): + index = int(wanted) + if 1 <= index <= len(workspaces): + return workspaces[index - 1] + raise http.CloudError( + f"workspace number must be between 1 and {len(workspaces)}. " + "Run `strix cloud workspaces` to see the numbered list." + ) + by_id = [w for w in workspaces if w.get("id") == wanted] + if by_id: + return by_id[0] + by_name = [w for w in workspaces if str(w.get("name", "")).casefold() == wanted.casefold()] + if len(by_name) == 1: + return by_name[0] + if len(by_name) > 1: + numbers = ", ".join( + str(index) + for index, workspace in enumerate(workspaces, start=1) + if workspace in by_name + ) + raise http.CloudError( + f"multiple workspaces are named {wanted!r}. Use its list number: {numbers}" + ) + names = ", ".join( + f"{index}: {workspace.get('name')}" for index, workspace in enumerate(workspaces, start=1) + ) + raise http.CloudError(f"no workspace matches {wanted!r}. Your workspaces: {names}") diff --git a/strix/interface/completions.py b/strix/interface/completions.py new file mode 100644 index 00000000..83a53026 --- /dev/null +++ b/strix/interface/completions.py @@ -0,0 +1,171 @@ +"""Shell completion scripts and candidates for the Strix CLI.""" + +from __future__ import annotations + +import sys +from typing import Any + +from strix.interface.cloud.spec import SPEC, Cmd + + +_ROOT_COMMANDS = ("cloud", "auth", "view", "completions", "completion") +_SESSION_COMMANDS = ("login", "logout", "whoami", "credits") +_COMMON_FLAGS = ("--json", "--token", "--app-url", "--timeout", "--help") + + +def run_completions(argv: list[str]) -> int: + """Print a shell integration script or hidden completion candidates.""" + if argv and argv[0] == "--candidates": + for candidate in completion_candidates(argv[1:]): + sys.stdout.write(candidate + "\n") + return 0 + if not argv or argv[0] in ("-h", "--help", "help"): + sys.stdout.write( + "Usage: strix completions \n\n" + "Enable tab completion for the current shell:\n" + " zsh: source <(strix completions zsh)\n" + " bash: source <(strix completions bash)\n" + " fish: strix completions fish | source\n" + ) + return 0 + shell = argv[0].lower() + scripts = {"zsh": _zsh_script, "bash": _bash_script, "fish": _fish_script} + generator = scripts.get(shell) + if generator is None: + sys.stderr.write(f"Unknown shell: {shell}. Choose zsh, bash, or fish.\n") + return 2 + sys.stdout.write(generator()) + return 0 + + +def completion_candidates(words: list[str]) -> list[str]: + """Return candidates for words after the ``strix`` executable.""" + prior, current = _split_cursor(words) + if not prior: + return _matching(_ROOT_COMMANDS, current) + if prior[0] != "cloud": + return [] + return _cloud_candidates(prior[1:], current) + + +def _split_cursor(words: list[str]) -> tuple[list[str], str]: + if not words: + return [], "" + return words[:-1], words[-1] + + +def _cloud_candidates(prior: list[str], current: str) -> list[str]: + groups = (*_SESSION_COMMANDS, *SPEC, "workspace") + if not prior: + return _matching(groups, current) + group = "workspaces" if prior[0] == "workspace" else prior[0] + rest = prior[1:] + if group in _SESSION_COMMANDS: + return _matching(_session_flags(group), current) + commands = SPEC.get(group) + if commands is None: + return _matching(groups, current) + + verb_paths = [verb.split() for verb in commands] + if group == "workspaces": + verb_paths.append(["use"]) + matching_paths = [path for path in verb_paths if path[: len(rest)] == rest] + if not matching_paths: + return [] + next_words = sorted({path[len(rest)] for path in matching_paths if len(path) > len(rest)}) + exact_verbs = [" ".join(path) for path in matching_paths if len(path) == len(rest)] + candidates: list[str] = list(next_words) + for verb in exact_verbs: + if verb == "use" and group == "workspaces": + candidates.extend(_COMMON_FLAGS) + else: + candidates.extend(_command_flags(commands[verb])) + return _matching(candidates, current) + + +def _session_flags(group: str) -> tuple[str, ...]: + if group == "login": + return ("--no-browser", "--scopes", "--workspace", "--help") + if group == "whoami": + return ("--json", "--help") + return ("--help",) + + +def _command_flags(cmd: Cmd) -> tuple[str, ...]: + flags: list[str] = list(_COMMON_FLAGS) + for param in cmd.query + cmd.body: + flag = "--" + (param.flag or _kebab(param.name)) + flags.append(flag) + if param.kind == "bool": + flags.append("--no-" + flag.removeprefix("--")) + if cmd.method in ("POST", "PUT", "PATCH"): + flags.append("--data") + if cmd.binary: + flags.append("--output") + if cmd.link: + flags.append("--no-browser") + if cmd.wait_path or cmd.wait_self: + flags.append("--wait") + if cmd.path == "/billing/topup": + flags.extend(("--yes", "--no-pay", "--payment-method")) + if cmd.path == "/scans" and cmd.method == "POST": + flags.extend( + ( + "--source", + "--dry-run", + "--yes", + "--show-files", + "--exclude", + "--include-hidden", + "--include-sensitive", + "--include-archives", + ) + ) + if cmd.path == "/billing/auto-topup" and cmd.method == "PUT": + flags.append("--no-monthly-cap") + return tuple(dict.fromkeys(flags)) + + +def _kebab(value: str) -> str: + output: list[str] = [] + for char in value: + if char.isupper(): + output.extend(("-", char.lower())) + else: + output.append("-" if char == "_" else char) + return "".join(output) + + +def _matching(candidates: Any, prefix: str) -> list[str]: + return sorted({str(candidate) for candidate in candidates if str(candidate).startswith(prefix)}) + + +def _zsh_script() -> str: + return r"""#compdef strix +_strix() { + local -a candidates + candidates=("${(@f)$($words[1] completions --candidates "${words[@]:2}")}") + _describe 'strix' candidates +} +compdef _strix strix +""" + + +def _bash_script() -> str: + return r"""_strix_completion() { + local -a candidates + mapfile -t candidates < <(strix completions --candidates "${COMP_WORDS[@]:1:$COMP_CWORD}") + COMPREPLY=( $(compgen -W "${candidates[*]}" -- "${COMP_WORDS[$COMP_CWORD]}") ) +} +complete -F _strix_completion strix +""" + + +def _fish_script() -> str: + return r"""function __strix_candidates + set -l words (commandline -opc) + set -e words[1] + command strix completions --candidates $words (commandline -ct) +end +complete -c strix -f -a '(__strix_candidates)' +""" diff --git a/strix/interface/main.py b/strix/interface/main.py index 96459978..e7dfc4ab 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -431,6 +431,19 @@ def main() -> None: sys.exit(run_auth(sys.argv[2:])) + # Generate native shell completion scripts before scan argument parsing. + if len(sys.argv) > 1 and sys.argv[1] in ("completion", "completions"): + from strix.interface.completions import run_completions + + sys.exit(run_completions(sys.argv[2:])) + + # `strix cloud …` drives the managed platform (app.strix.ai) and exits; + # it needs no target, Docker, or scan setup. + if len(sys.argv) > 1 and sys.argv[1] == "cloud": + from strix.interface.cloud import run_cloud + + sys.exit(run_cloud(sys.argv[2:])) + from strix.llm.warmup import start_import_warmup start_import_warmup() diff --git a/strix/interface/platform_cli.py b/strix/interface/platform_cli.py new file mode 100644 index 00000000..65dd067c --- /dev/null +++ b/strix/interface/platform_cli.py @@ -0,0 +1,518 @@ +"""`strix cloud login` — managed platform sign-in (app.strix.ai). + +Signing in runs an OAuth 2.0 device authorization flow in the browser, creates +the Strix account and workspace when they do not exist yet, and stores a +personal API token in ``~/.strix/platform-auth.json``. The token drives the +managed REST API (scans, credits, top-ups) without a dashboard visit. +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import sys +import time +import webbrowser +from pathlib import Path +from typing import Any, cast +from urllib.parse import urlparse + +import requests +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +from strix.config import load_settings +from strix.utils.secret_files import write_secret_text + + +AUTH_PATH = Path.home() / ".strix" / "platform-auth.json" + +_HTTP_TIMEOUT_S = 30 +_DEFAULT_POLL_INTERVAL_S = 5 +_MAX_POLL_INTERVAL_S = 60 +_MAX_EXPIRES_IN_S = 30 * 60 + +_LOGIN_USAGE = ( + "Usage:\n" + " strix cloud login [--no-browser] [--scopes SCOPE ...] [--workspace WORKSPACE]\n" + " strix cloud whoami\n strix cloud logout" +) + +_ROLE_RANK = {"viewer": 0, "analyst": 1, "admin": 2} + + +class PlatformAuthError(Exception): + """Raised when the device authorization flow fails.""" + + +def _app_url() -> str: + return load_settings().viewer.app_url.rstrip("/") + + +def read_record() -> dict[str, Any] | None: + try: + data = json.loads(AUTH_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + record = cast("dict[str, Any]", data) + if not record.get("api_token"): + return None + return record + + +def save_record(record: dict[str, Any]) -> None: + write_secret_text(AUTH_PATH, json.dumps(record, indent=2)) + + +def logout() -> bool: + try: + AUTH_PATH.unlink() + except FileNotFoundError: + return True + except OSError: + return False + return True + + +def run_login(argv: list[str]) -> int: + """Entry point for ``strix cloud login``. Returns a process exit code.""" + console = Console() + subcommand = argv[0] if argv else None + + if subcommand in ("-h", "--help", "help"): + console.print(_LOGIN_USAGE) + return 0 + if subcommand == "status": + return _status(console, argv[1:]) + if subcommand == "logout": + return _logout(console) + return _login(console, argv) + + +def _login(console: Console, argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="strix cloud login", add_help=True) + parser.add_argument( + "--no-browser", + action="store_true", + help="Do not open the browser. Print the verification URL instead.", + ) + parser.add_argument( + "--scopes", + nargs="+", + metavar="SCOPE", + default=None, + help=( + "API scopes for the token, for example scans:read billing:write. " + "The server always includes a minimum scope set. " + "Without this option, an interactive picker opens after the browser step." + ), + ) + parser.add_argument( + "--workspace", + metavar="WORKSPACE", + default=None, + help=( + "Workspace that receives the token, by ID or by exact name. " + "Without this option, an interactive picker opens when you have " + "more than one workspace." + ), + ) + try: + args = parser.parse_args(argv) + except SystemExit as exc: # argparse already printed the message + return exc.code if isinstance(exc.code, int) else 2 + + console.print() + host = urlparse(_app_url()).netloc or _app_url() + console.print(f"[bold]Signing in to the Strix platform[/] [dim]({host})[/]") + console.print( + "[dim]This creates your account and workspace when needed, and stores an API token.[/]" + ) + console.print() + + try: + record = _run_device_flow( + console, + open_browser=not args.no_browser, + scopes=args.scopes, + workspace=args.workspace, + ) + except PlatformAuthError as exc: + console.print(f"[red]Sign-in failed:[/] {exc}") + return 1 + except KeyboardInterrupt: + console.print("\n[yellow]Sign-in cancelled.[/]") + return 130 + + try: + save_record(record) + except OSError as exc: + console.print(f"[red]Sign-in succeeded, but the token could not be stored:[/] {exc}") + console.print( + f"[dim]Check that {AUTH_PATH.parent} is writable, " + "then run `strix cloud login` again.[/]" + ) + return 1 + _print_success(console, record) + return 0 + + +def _run_device_flow( + console: Console, + *, + open_browser: bool, + scopes: list[str] | None = None, + workspace: str | None = None, +) -> dict[str, Any]: + app_url = _app_url() + interactive = workspace is not None or (sys.stdin.isatty() and scopes is None) + + try: + response = requests.post(f"{app_url}/api/v1/cli/login", timeout=_HTTP_TIMEOUT_S) + except requests.RequestException as exc: + raise PlatformAuthError(f"could not reach {app_url}: {exc}") from exc + if not response.ok: + raise PlatformAuthError(_error_detail(response)) + authorization = _json_object(response) + + user_code = str(authorization.get("user_code") or "") + verification_uri = str( + authorization.get("verification_uri_complete") + or authorization.get("verification_uri") + or "" + ) + device_code = str(authorization.get("device_code") or "") + expires_in = _as_positive_int( + authorization.get("expires_in"), default=300, maximum=_MAX_EXPIRES_IN_S + ) + interval = _as_positive_int( + authorization.get("interval"), + default=_DEFAULT_POLL_INTERVAL_S, + maximum=_MAX_POLL_INTERVAL_S, + ) + if not device_code or not verification_uri: + raise PlatformAuthError("the server returned an incomplete device authorization") + + console.print( + Panel.fit( + Text.assemble( + ("Confirmation code: ", "dim"), + (user_code, "bold cyan"), + ("\n\nOpen this URL in your browser and confirm the code:\n", "dim"), + (verification_uri, "underline"), + ), + title="Verify this device", + ) + ) + + if open_browser: + with contextlib.suppress(Exception): + webbrowser.open(verification_uri) + + console.print("[dim]Waiting for browser confirmation…[/]") + + poll_body: dict[str, Any] = {"device_code": device_code} + if interactive: + poll_body["interactive"] = True + elif scopes: + poll_body["scopes"] = scopes + + deadline = time.monotonic() + expires_in + while time.monotonic() < deadline: + time.sleep(interval) + try: + poll = requests.post( + f"{app_url}/api/v1/cli/login/poll", + json=poll_body, + timeout=_HTTP_TIMEOUT_S, + ) + except requests.RequestException: + continue + if poll.ok: + return _finish_login(console, app_url, poll, scopes=scopes, workspace=workspace) + delta = _handle_poll_error(poll) + if delta is None: + break + interval += delta + + raise PlatformAuthError("the sign-in request expired. Run `strix cloud login` again.") + + +def _handle_poll_error(poll: requests.Response) -> int | None: + """Return the interval increase, or None when the device code expired.""" + error = "" + with contextlib.suppress(ValueError, AttributeError): + error = str(poll.json().get("error", "")) + if error == "authorization_pending": + return 0 + if error == "slow_down": + return 5 + if error == "access_denied": + raise PlatformAuthError("the sign-in request was denied in the browser") + if error == "expired_token": + return None + raise PlatformAuthError(_error_detail(poll)) + + +def _finish_login( + console: Console, + app_url: str, + poll: requests.Response, + *, + scopes: list[str] | None, + workspace: str | None, +) -> dict[str, Any]: + result = _json_object(poll) + if result.get("selection_required"): + return _complete_selection(console, app_url, result, scopes=scopes, workspace=workspace) + return _require_api_token(result) + + +def _signed_in_record(response: requests.Response) -> dict[str, Any]: + return _require_api_token(_json_object(response)) + + +def _require_api_token(record: dict[str, Any]) -> dict[str, Any]: + api_token = record.get("api_token") + if not isinstance(api_token, str) or not api_token.strip(): + raise PlatformAuthError("the server returned a sign-in response without an API token") + return record + + +def _complete_selection( + console: Console, + app_url: str, + selection: dict[str, Any], + *, + scopes: list[str] | None, + workspace: str | None, +) -> dict[str, Any]: + organizations = _dict_items(selection.get("organizations")) + catalog = _dict_items(selection.get("scopes")) + selection_token = str(selection.get("selection_token") or "") + if not selection_token or not organizations: + raise PlatformAuthError("the server returned an incomplete selection response") + + chosen_org = _choose_workspace(console, organizations, workspace) + role = str(chosen_org.get("role") or "admin") + chosen_scopes = scopes + if chosen_scopes is None and sys.stdin.isatty(): + chosen_scopes = _choose_scopes(console, catalog, role) + + body: dict[str, Any] = { + "selection_token": selection_token, + "organization_id": chosen_org.get("id"), + } + if chosen_scopes is not None: + body["scopes"] = chosen_scopes + try: + response = requests.post( + f"{app_url}/api/v1/cli/login/complete", + json=body, + timeout=_HTTP_TIMEOUT_S, + ) + except requests.RequestException as exc: + raise PlatformAuthError(f"could not reach {app_url}: {exc}") from exc + if not response.ok: + raise PlatformAuthError(_error_detail(response)) + return _signed_in_record(response) + + +def _dict_items(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _choose_workspace( + console: Console, organizations: list[dict[str, Any]], workspace: str | None +) -> dict[str, Any]: + if workspace is not None: + wanted = workspace.strip().lower() + for org in organizations: + if wanted in (str(org.get("id", "")).lower(), str(org.get("name", "")).lower()): + return org + names = ", ".join(str(org.get("name", "")) for org in organizations) + raise PlatformAuthError(f"no workspace matches {workspace!r}. Your workspaces: {names}") + if len(organizations) == 1: + return organizations[0] + + console.print() + console.print("[bold]Select a workspace for the API token:[/]") + for index, org in enumerate(organizations, start=1): + console.print(f" [cyan]{index}[/]. {org.get('name', '')} [dim]({org.get('role', '')})[/]") + while True: + answer = console.input(f"Workspace [1-{len(organizations)}] (1): ").strip() or "1" + if answer.isdigit() and 1 <= int(answer) <= len(organizations): + return organizations[int(answer) - 1] + console.print("[yellow]Enter a number from the list.[/]") + + +def _choose_scopes(console: Console, catalog: list[dict[str, Any]], role: str) -> list[str] | None: + """Prompt for token scopes. Returns None to accept the server defaults.""" + rank = _ROLE_RANK.get(role, 2) + allowed = [ + item for item in catalog if _ROLE_RANK.get(str(item.get("min_role", "viewer")), 0) <= rank + ] + if not allowed: + return None + + console.print() + console.print("[bold]Select token scopes:[/]") + console.print( + " [cyan]1[/]. Recommended [dim](scans, vulnerabilities, schedules, assets, billing)[/]" + ) + console.print(" [cyan]2[/]. Full access [dim](every scope your role allows)[/]") + console.print(" [cyan]3[/]. Minimal [dim](scans and billing read only)[/]") + console.print(" [cyan]4[/]. Custom [dim](pick individual scopes)[/]") + while True: + answer = console.input("Scopes [1-4] (1): ").strip() or "1" + if answer == "1": + return None + if answer == "2": + return [str(item["scope"]) for item in allowed if item.get("scope")] + if answer == "3": + return [ + str(item["scope"]) for item in allowed if item.get("scope") and item.get("minimum") + ] + if answer == "4": + return _choose_custom_scopes(console, allowed) + console.print("[yellow]Enter a number from 1 to 4.[/]") + + +def _choose_custom_scopes(console: Console, allowed: list[dict[str, Any]]) -> list[str]: + selected = { + str(item["scope"]) + for item in allowed + if item.get("scope") and (item.get("default") or item.get("minimum")) + } + while True: + console.print() + for index, item in enumerate(allowed, start=1): + scope = str(item.get("scope", "")) + mark = "[green]x[/]" if scope in selected else " " + required = " [dim](always included)[/]" if item.get("minimum") else "" + console.print( + f" [{mark}] [cyan]{index:>2}[/]. {scope}{required}" + f"\n [dim]{item.get('description', '')}[/]" + ) + answer = console.input( + "Toggle scopes by number (comma separated), or press Enter to confirm: " + ).strip() + if not answer: + return sorted(selected) + for part in answer.replace(",", " ").split(): + if not part.isdigit() or not 1 <= int(part) <= len(allowed): + console.print(f"[yellow]Ignored {part!r}: not a number from the list.[/]") + continue + item = allowed[int(part) - 1] + scope = str(item.get("scope", "")) + if item.get("minimum"): + console.print(f"[yellow]{scope} is always included.[/]") + continue + if scope in selected: + selected.discard(scope) + else: + selected.add(scope) + + +def _json_object(response: requests.Response) -> dict[str, Any]: + try: + data = response.json() + except ValueError as exc: + raise PlatformAuthError("the server returned a response that is not JSON") from exc + if not isinstance(data, dict): + raise PlatformAuthError("the server returned an unexpected response shape") + return cast("dict[str, Any]", data) + + +def _as_positive_int(value: Any, *, default: int, maximum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError, OverflowError): + return default + if parsed <= 0: + return default + return min(parsed, maximum) + + +def _error_detail(response: requests.Response) -> str: + with contextlib.suppress(ValueError, AttributeError): + detail = response.json().get("detail") + if detail: + return str(detail) + return f"HTTP {response.status_code}" + + +def _print_success(console: Console, record: dict[str, Any]) -> None: + email = record.get("email", "") + organization = record.get("organization_name") or record.get("organization_id", "") + console.print() + console.print("[green]✓ Signed in to the Strix platform.[/]") + if email: + console.print(f" Account: [bold]{email}[/]") + if organization: + console.print(f" Workspace: [bold]{organization}[/]") + scopes = record.get("scopes") + if isinstance(scopes, list) and scopes: + console.print(f" Scopes: [dim]{' '.join(str(s) for s in scopes)}[/]") + console.print(f" Token: stored in [dim]{AUTH_PATH}[/]") + console.print() + console.print( + "[dim]The managed platform is ready. Run `strix cloud` to list the commands. " + "See https://docs.app.strix.ai for the API reference.[/]" + ) + + +def _status(console: Console, argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="strix cloud whoami") + parser.add_argument("--json", action="store_true", help="Print the session as JSON.") + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 2 + + record = read_record() + if record is None: + if args.json: + sys.stdout.write(json.dumps({"signed_in": False, "error": "Not signed in"}) + "\n") + return 1 + console.print("[yellow]Not signed in.[/] Run [bold]strix cloud login[/] to sign in.") + return 1 + email = record.get("email", "unknown") + organization = record.get("organization_name") or record.get("organization_id", "") + expires_at = record.get("expires_at", "") + if args.json: + payload = { + "signed_in": True, + "email": email, + "organization_id": record.get("organization_id"), + "organization_name": record.get("organization_name"), + "scopes": record.get("scopes", []), + "expires_at": expires_at or None, + } + sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n") + return 0 + console.print(f"[green]Signed in[/] as [bold]{email}[/]") + if organization: + console.print(f" Workspace: {organization}") + if expires_at: + console.print(f" Token expires: {expires_at}") + return 0 + + +def _logout(console: Console) -> int: + if read_record() is None and not AUTH_PATH.exists(): + console.print("[yellow]Not signed in.[/]") + return 0 + if not logout(): + console.print( + f"[red]Could not remove the stored API token.[/] Delete {AUTH_PATH} manually." + ) + return 1 + console.print("[green]Signed out.[/] The stored API token was removed from this machine.") + return 0 diff --git a/strix/utils/secret_files.py b/strix/utils/secret_files.py index b2170bf9..023aa87f 100644 --- a/strix/utils/secret_files.py +++ b/strix/utils/secret_files.py @@ -23,9 +23,26 @@ def write_secret_text(path: Path, text: str) -> None: try: with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(text) - except BaseException: - with contextlib.suppress(OSError): - tmp.unlink() + except BaseException as exc: + _cleanup_tmp(tmp, exc) raise - tmp.replace(path) + try: + tmp.replace(path) + except BaseException as exc: + _cleanup_tmp(tmp, exc) + raise + + +def _cleanup_tmp(tmp: Path, cause: BaseException) -> None: + """Delete the temporary secret file. A failed delete must not stay silent.""" + try: + tmp.unlink() + except FileNotFoundError: + pass + except OSError: + message = ( + f"could not store the secret, and the temporary file {tmp} " + f"still holds it. Delete the file manually." + ) + raise OSError(message) from cause diff --git a/tests/test_cli_target_list.py b/tests/test_cli_target_list.py index 6ba4ca22..d20230b1 100644 --- a/tests/test_cli_target_list.py +++ b/tests/test_cli_target_list.py @@ -228,7 +228,10 @@ def test_resume_still_requires_targets_or_a_workspace( assert "has no targets_info" in capsys.readouterr().err -def test_resume_non_object_run_json_exits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + +def test_resume_non_object_run_json_exits( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: monkeypatch.chdir(tmp_path) run_dir = tmp_path / "strix_runs" / "pentest_abcd" run_dir.mkdir(parents=True) diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py new file mode 100644 index 00000000..46dc88c6 --- /dev/null +++ b/tests/test_cloud_cli.py @@ -0,0 +1,811 @@ +"""Tests for the `strix cloud` CLI: routing, request building, and output.""" + +from __future__ import annotations + +import io +import json +import shutil +import subprocess +import webbrowser +from typing import TYPE_CHECKING, Any + +import pytest +import requests + +from strix.interface import cloud, platform_cli +from strix.interface.cloud import http, render, runner, workspaces +from strix.interface.cloud.spec import SPEC + + +if TYPE_CHECKING: + from pathlib import Path + + +class FakeResponse: + def __init__( + self, + status_code: int = 200, + payload: Any = None, + text: str = "", + content: bytes = b"", + ) -> None: + self.status_code = status_code + self._payload = payload + self.text = text if payload is None else json.dumps(payload) + self.content = content + self.ok = 200 <= status_code < 400 + self.headers = {"content-type": "application/json" if payload is not None else "text/plain"} + + def json(self) -> Any: + if self._payload is None: + raise ValueError("no JSON") + return self._payload + + +@pytest.fixture(autouse=True) +def _token_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_API_TOKEN", "test-token") + + +def test_help_returns_zero() -> None: + assert cloud.run_cloud([]) == 0 + assert cloud.run_cloud(["--help"]) == 0 + + +def test_unknown_group_returns_usage_error() -> None: + assert cloud.run_cloud(["bogus"]) == 2 + + +def test_unknown_verb_returns_usage_error() -> None: + assert cloud.run_cloud(["scans", "bogus"]) == 2 + + +def test_successful_html_response_is_reported_without_dumping_html( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(text="preview gate"), + ) + + assert cloud.run_cloud(["workspaces", "list", "--json"]) == 1 + output = capsys.readouterr().out + assert "non-JSON response" in output + assert "STRIX_APP_URL" in output + assert " None: + assert cloud.run_cloud(["scans"]) == 0 + + +def test_resolve_prefers_two_word_verbs() -> None: + resolved = runner.resolve("billing", ["auto-topup", "update", "--enabled"]) + assert resolved is not None + cmd, remaining = resolved + assert cmd.path == "/billing/auto-topup" + assert cmd.method == "PUT" + assert remaining == ["--enabled"] + + +def test_resolve_default_verb() -> None: + resolved = runner.resolve("audit", []) + assert resolved is not None + cmd, remaining = resolved + assert cmd.method == "GET" + assert remaining == [] + + +def test_dest_converts_camel_case() -> None: + assert runner._dest("scanId") == "scan_id" + assert runner._dest("chatId") == "chat_id" + assert runner._metavar("findingId") == "FINDING_ID" + + +def test_placeholder_substitution(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None: + seen: dict[str, Any] = {} + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + seen.update(method=method, path=path, query=kwargs.get("query")) + return FakeResponse(payload={"id": "abc"}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["scans", "get", "abc-123", "--json"]) + assert code == 0 + assert seen["method"] == "GET" + assert seen["path"] == "/scans/abc-123" + assert json.loads(capsys.readouterr().out) == {"id": "abc"} + + +def test_placeholder_substitution_percent_encodes_path_segments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse: + seen["path"] = path + return FakeResponse(payload={"entries": []}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["knowledge", "repos", "entries", "usestrix/.github", "--json"]) + assert code == 0 + assert seen["path"] == "/knowledge/repos/usestrix%2F.github/entries" + + +def test_query_and_body_collection(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen.update(query=kwargs.get("query"), body=kwargs.get("body")) + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["scans", "list", "--status", "running", "--json"]) == 0 + assert seen["query"] == {"status": "running"} + + assert ( + cloud.run_cloud( + [ + "scans", + "start", + "--engagement-type", + "live_test", + "--domain-ids", + "d1", + "d2", + "--json", + ] + ) + == 0 + ) + assert seen["body"] == {"engagement_type": "live_test", "domain_ids": ["d1", "d2"]} + + +def test_data_merges_extra_fields(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + ["scans", "start", "--data", '{"engagement_type": "code_review"}', "--json"] + ) + assert code == 0 + assert seen["body"] == {"engagement_type": "code_review"} + + +def test_data_reads_a_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + request_file = tmp_path / "request.json" + request_file.write_text('{"focus": "IDOR"}', encoding="utf-8") + assert cloud.run_cloud(["scans", "start", "--data", f"@{request_file}", "--json"]) == 0 + assert seen["body"] == {"focus": "IDOR"} + + +def test_data_reads_stdin(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr("sys.stdin", io.StringIO('{"context": "staging"}')) + assert cloud.run_cloud(["scans", "start", "--data", "-", "--json"]) == 0 + assert seen["body"] == {"context": "staging"} + + +def test_data_reports_a_missing_file(tmp_path: Path) -> None: + assert cloud.run_cloud(["scans", "start", "--data", f"@{tmp_path / 'nope.json'}"]) == 1 + + +def test_auto_topup_removes_the_monthly_cap(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + [ + "billing", + "auto-topup", + "update", + "--enabled", + "--topup-credits", + "20", + "--no-monthly-cap", + "--json", + ] + ) + assert code == 0 + assert seen["body"] == { + "enabled": True, + "topup_credits": 20, + "monthly_cap_credits": None, + } + + +def test_costs_default_verb_is_the_overview(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse: + seen["path"] = path + return FakeResponse(payload={"total_cost": 1}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["costs", "--json"]) == 0 + assert seen["path"] == "/llm-costs" + + +def test_binary_download_writes_a_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=200, content=b"%PDF-1.7") + ) + target = tmp_path / "report.pdf" + assert cloud.run_cloud(["scans", "report", "scan-1", "--output", str(target)]) == 0 + assert target.read_bytes() == b"%PDF-1.7" + + +def test_wait_polls_until_the_status_is_final(monkeypatch: pytest.MonkeyPatch) -> None: + statuses = iter(["running", "completed"]) + + def fake_request(method: str, _path: str, **_kwargs: Any) -> FakeResponse: + if method == "POST": + return FakeResponse(payload={"id": "scan-1", "status": "pending"}) + return FakeResponse(payload={"id": "scan-1", "status": next(statuses)}) + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr(runner, "_WAIT_POLL_S", 0) + assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1", "--wait", "--json"]) == 0 + assert next(statuses, None) is None + + +def test_insufficient_credits_exits_with_payment_code(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload={}) + ) + assert cloud.run_cloud(["scans", "start", "--domain-ids", "d1"]) == http.EXIT_PAYMENT + + +def test_data_rejects_non_object() -> None: + assert cloud.run_cloud(["scans", "start", "--data", "[1,2]"]) == 1 + assert cloud.run_cloud(["scans", "start", "--data", "not json"]) == 1 + + +def test_missing_token_exits_with_auth_code( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(platform_cli, "AUTH_PATH", tmp_path / "platform-auth.json") + assert cloud.run_cloud(["credits"]) == http.EXIT_AUTH + + +def test_http_error_exit_codes(monkeypatch: pytest.MonkeyPatch) -> None: + for status, expected in ((401, http.EXIT_AUTH), (403, http.EXIT_AUTH), (500, http.EXIT_ERROR)): + monkeypatch.setattr( + http, + "request", + lambda *_a, _s=status, **_k: FakeResponse(status_code=_s, payload={"error": "x"}), + ) + assert cloud.run_cloud(["scans", "list"]) == expected + + +def test_credits_alias_routes_to_billing(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse: + seen["path"] = path + return FakeResponse(payload={"balance": 3}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["credits", "--json"]) == 0 + assert seen["path"] == "/billing/credits" + + +def test_whoami_json_is_machine_readable_and_omits_the_token( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any +) -> None: + monkeypatch.delenv("STRIX_API_TOKEN", raising=False) + monkeypatch.setattr(platform_cli, "AUTH_PATH", tmp_path / "platform-auth.json") + platform_cli.save_record( + { + "api_token": "strix_pat_secret", + "email": "agent@example.test", + "organization_id": "org_1", + "organization_name": "Example", + "scopes": ["scans:read"], + "expires_at": "2026-09-01T00:00:00Z", + } + ) + + assert cloud.run_cloud(["whoami", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == { + "signed_in": True, + "email": "agent@example.test", + "organization_id": "org_1", + "organization_name": "Example", + "scopes": ["scans:read"], + "expires_at": "2026-09-01T00:00:00Z", + } + assert "api_token" not in payload + + +def test_topup_no_pay_prints_challenge(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None: + challenge = {"payment_requirements": [{"amount": 500}]} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge) + ) + code = cloud.run_cloud(["billing", "topup", "--credits", "5", "--no-pay", "--json"]) + assert code == http.EXIT_PAYMENT + assert json.loads(capsys.readouterr().out) == { + "error": "Payment required", + "challenge": challenge, + } + + +def test_topup_success_without_payment(monkeypatch: pytest.MonkeyPatch, capsys: Any) -> None: + receipt = {"credits_granted": 5, "duplicate": False, "balance": 5} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=200, payload=receipt) + ) + code = cloud.run_cloud(["billing", "topup", "--credits", "5", "--json"]) + assert code == 0 + assert json.loads(capsys.readouterr().out) == receipt + + +def test_topup_passes_payment_method_to_wallet(monkeypatch: pytest.MonkeyPatch) -> None: + challenge = {"payment_requirements": [{"amount": 500}]} + monkeypatch.setattr( + http, "request", lambda *_a, **_k: FakeResponse(status_code=402, payload=challenge) + ) + monkeypatch.setattr(http, "api_token", lambda *_a, **_k: "tok") + monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/npx") + commands: list[list[str]] = [] + + def fake_run(command: list[str], **_kwargs: Any) -> Any: + commands.append(command) + return type("Result", (), {"returncode": 0})() + + monkeypatch.setattr(subprocess, "run", fake_run) + code = cloud.run_cloud( + ["billing", "topup", "--credits", "5", "--yes", "--payment-method", "pm_card_visa"] + ) + assert code == 0 + assert "X-Strix-Authorization: Bearer tok" in commands[0] + assert "Authorization: Bearer tok" not in commands[0] + assert "-M" in commands[0] + assert "paymentMethod=pm_card_visa" in commands[0] + + +def test_render_json_mode_when_not_a_tty() -> None: + assert render.json_mode(flag=True) is True + # Under pytest, stdout is captured and is not a terminal. + assert render.json_mode(flag=False) is True + + +def test_render_list_extraction() -> None: + rows = render._list_of_dicts({"scans": [{"id": "a"}, {"id": "b"}]}) + assert rows == [{"id": "a"}, {"id": "b"}] + assert render._list_of_dicts({"scans": [], "total": 1}) is None + assert render._list_of_dicts([{"id": "a"}, "x"]) is None + + +def test_spec_paths_are_well_formed() -> None: + for group, commands in SPEC.items(): + for verb, cmd in commands.items(): + assert cmd.path.startswith("/"), f"{group} {verb}" + assert cmd.method in ("GET", "POST", "PUT", "PATCH", "DELETE"), f"{group} {verb}" + assert cmd.help, f"{group} {verb} has no help text" + for param in cmd.query + cmd.body: + assert param.kind in ("str", "int", "float", "bool", "list", "json"), ( + f"{group} {verb} {param.name}" + ) + + +def test_every_command_builds_a_parser() -> None: + for group, commands in SPEC.items(): + for verb, cmd in commands.items(): + parser = runner._build_parser(group, verb, cmd) + assert parser.prog == f"strix cloud {group} {verb}" + + +def test_app_url_and_timeout_overrides(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, url: str, **kwargs: Any) -> FakeResponse: + seen["url"] = url + seen["timeout"] = kwargs.get("timeout") + return FakeResponse(status_code=200, payload={"balance": 1}) + + monkeypatch.setattr(http, "api_token", lambda _override=None: "t") + monkeypatch.setattr(requests, "request", fake_request) + code = cloud.run_cloud( + ["credits", "--app-url", "https://example.test/", "--timeout", "7", "--json"] + ) + assert code == 0 + assert seen["url"] == "https://example.test/api/v1/billing/credits" + assert seen["timeout"] == 7 + + +def test_created_id_reads_resource_id() -> None: + assert runner._created_id({"scan_id": "abc", "status": "pending"}) == "abc" + assert runner._created_id({"id": "xyz"}) == "xyz" + assert runner._created_id({"status": "pending"}) is None + + +def test_billing_subscribe_prints_checkout_url( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + seen: dict[str, Any] = {} + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + seen["method"], seen["path"] = method, path + seen["body"] = kwargs.get("body") + return FakeResponse(status_code=200, payload={"checkout_url": "https://pay.test/session"}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["billing", "subscribe", "--plan", "strix_cloud", "--json"]) + assert code == 0 + assert seen["method"] == "POST" + assert seen["path"] == "/billing/checkout" + assert seen["body"] == {"product": "strix_cloud"} + assert "https://pay.test/session" in capsys.readouterr().out + + +def test_knowledge_policy_flags_use_the_api_field_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"success": True}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + [ + "knowledge", + "policies", + "add", + "--key", + "no-production-data", + "--content", + "Never test production data.", + "--policy-type", + "constraint", + "--no-enabled", + "--metadata", + '{"owner":"security"}', + "--json", + ] + ) + assert code == 0 + assert seen["body"] == { + "policy_key": "no-production-data", + "policy_value": "Never test production data.", + "policy_type": "constraint", + "is_active": False, + "metadata": {"owner": "security"}, + } + + +def test_pr_review_start_sends_provider_installation_and_pull_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"review_id": "review-1", "status": "pending"}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + [ + "pr-reviews", + "start", + "--provider", + "github", + "--installation-id", + "123", + "--repository-full-name", + "org/app", + "--pr-number", + "42", + "--json", + ] + ) + assert code == 0 + assert seen["body"] == { + "provider": "github", + "installation_id": 123, + "repository_full_name": "org/app", + "pr_number": 42, + } + + +def test_llm_settings_uses_kebab_case_flag_for_camel_case_api_field( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + + def fake_request(_method: str, _path: str, **kwargs: Any) -> FakeResponse: + seen["body"] = kwargs.get("body") + return FakeResponse(payload={"ok": True}) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud( + [ + "llm-settings", + "update", + "--model-configs", + "[]", + "--assignments", + "{}", + "--json", + ] + ) + assert code == 0 + assert seen["body"] == {"modelConfigs": [], "assignments": {}} + + +def test_integration_install_url_does_not_open_browser( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + opened: list[str] = [] + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse(status_code=200, payload={"url": "https://github.test/app"}), + ) + + def fake_open(url: str) -> bool: + opened.append(url) + return True + + monkeypatch.setattr(webbrowser, "open", fake_open) + code = cloud.run_cloud(["integrations", "install", "github", "--json"]) + assert code == 0 + assert opened == [] + assert "https://github.test/app" in capsys.readouterr().out + + +def test_workspaces_use_switches_stored_token( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any +) -> None: + auth_path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) + monkeypatch.setattr(workspaces, "AUTH_PATH", auth_path) + platform_cli.save_record( + { + "api_token": "old", + "email": "a@b.test", + "scopes": ["scans:read", "organizations:read", "tokens:write"], + } + ) + + calls: list[tuple[str, str]] = [] + token_body: dict[str, Any] | None = None + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + nonlocal token_body + calls.append((method, path)) + if path == "/workspaces": + return FakeResponse( + status_code=200, + payload={"workspaces": [{"id": "org_1", "name": "Team One", "role": "admin"}]}, + ) + token_body = kwargs.get("body") + return FakeResponse( + status_code=201, + payload={ + "api_token": "new-token", + "organization_id": "org_1", + "organization_name": "Team One", + "scopes": ["scans:read"], + }, + ) + + monkeypatch.setattr(http, "request", fake_request) + code = cloud.run_cloud(["workspaces", "use", "team one", "--json"]) + assert code == 0 + assert calls == [("GET", "/workspaces"), ("POST", "/workspaces/org_1/token")] + assert token_body == {"scopes": ["scans:read", "organizations:read", "tokens:write"]} + record = platform_cli.read_record() + assert record is not None + assert record["api_token"] == "new-token" + assert record["organization_name"] == "Team One" + assert record["email"] == "a@b.test" + assert "org_1" in capsys.readouterr().out + + +def test_workspaces_use_reports_unknown_workspace(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + status_code=200, payload={"workspaces": [{"id": "org_1", "name": "Team One"}]} + ), + ) + assert cloud.run_cloud(["workspaces", "use", "missing", "--json"]) == 1 + + +def test_group_help_lists_all_verbs_instead_of_default_verb_help(capsys: Any) -> None: + assert cloud.run_cloud(["workspaces", "-h"]) == 0 + output = capsys.readouterr().out + assert "workspaces verbs" in output + assert "list" in output + assert "create" in output + assert "use" in output + + +def test_workspace_alias_routes_to_workspaces(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, str] = {} + + def fake_request(method: str, path: str, **_kwargs: Any) -> FakeResponse: + seen.update(method=method, path=path) + return FakeResponse(payload={"workspaces": []}) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["workspace", "list", "--json"]) == 0 + assert seen == {"method": "GET", "path": "/workspaces"} + + +def test_workspace_human_list_is_numbered_and_hides_ids( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "workspaces": [ + {"id": "org_secret", "name": "Team One", "role": "admin", "current": True} + ] + } + ), + ) + + assert cloud.run_cloud(["workspaces", "list"]) == 0 + output = capsys.readouterr().out + assert "1." in output + assert "Team One" in output + assert "yes" in output + assert "org_secret" not in output + assert "workspaces use NUMBER" in output + + +def test_pr_review_human_list_prioritizes_actionable_fields( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "items": [ + { + "id": "review-id", + "organization_id": "org-id", + "user_id": "user-id", + "installation_id": 42, + "repository_full_name": "usestrix/strix", + "pr_number": 1177, + "pr_title": "Improve cloud CLI", + "head_branch": "feature", + "base_branch": "main", + "verdict": "pass", + "status": "posted", + "findings_count": 0, + } + ], + "meta": {"total": 1}, + } + ), + ) + + assert cloud.run_cloud(["pr-reviews", "list"]) == 0 + output = capsys.readouterr().out + for value in ("usestrix/strix", "1177", "Improve cloud CLI", "feature", "main", "pass"): + assert value in output + for value in ("org-id", "user-id", "installation_id"): + assert value not in output + + +def test_human_get_prioritizes_details_and_hides_internal_identity_fields( + monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + monkeypatch.setattr(render.sys.stdout, "isatty", lambda: True) + monkeypatch.setattr( + http, + "request", + lambda *_a, **_k: FakeResponse( + payload={ + "id": "review-id", + "organization_id": "org-id", + "user_id": "user-id", + "repository_full_name": "usestrix/strix", + "pr_number": 1177, + "pr_title": "Improve cloud CLI", + "verdict": "pass", + "findings": [{"severity": "high", "title": "Example"}], + } + ), + ) + + assert cloud.run_cloud(["pr-reviews", "get", "review-id"]) == 0 + output = capsys.readouterr().out + for value in ("usestrix/strix", "1177", "Improve cloud CLI", "pass", "Example"): + assert value in output + assert "org-id" not in output + assert "user-id" not in output + assert "lossless machine-readable" in output + + +def test_workspace_use_accepts_list_number(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + auth_path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) + monkeypatch.setattr(workspaces, "AUTH_PATH", auth_path) + platform_cli.save_record({"api_token": "old", "scopes": ["organizations:read", "tokens:write"]}) + called_paths: list[str] = [] + + def fake_request(_method: str, path: str, **_kwargs: Any) -> FakeResponse: + called_paths.append(path) + if path == "/workspaces": + return FakeResponse( + payload={ + "workspaces": [ + {"id": "org_1", "name": "One"}, + {"id": "org_2", "name": "Two"}, + ] + } + ) + return FakeResponse( + status_code=201, + payload={ + "api_token": "new", + "organization_id": "org_2", + "organization_name": "Two", + "scopes": ["organizations:read", "tokens:write"], + }, + ) + + monkeypatch.setattr(http, "request", fake_request) + assert cloud.run_cloud(["workspaces", "use", "2", "--json"]) == 0 + assert called_paths == ["/workspaces", "/workspaces/org_2/token"] + + +def test_logout_help_does_not_remove_stored_auth( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: Any +) -> None: + auth_path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) + platform_cli.save_record({"api_token": "keep-me"}) + + assert cloud.run_cloud(["logout", "--help"]) == 0 + assert platform_cli.read_record() == {"api_token": "keep-me"} + assert "Usage:" in capsys.readouterr().out + + +def test_logout_rejects_unknown_arguments_without_removing_stored_auth( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + auth_path = tmp_path / "platform-auth.json" + monkeypatch.setattr(platform_cli, "AUTH_PATH", auth_path) + platform_cli.save_record({"api_token": "keep-me"}) + + assert cloud.run_cloud(["logout", "--bogus"]) == 2 + assert platform_cli.read_record() == {"api_token": "keep-me"} diff --git a/tests/test_cloud_source_upload.py b/tests/test_cloud_source_upload.py new file mode 100644 index 00000000..eeba69a6 --- /dev/null +++ b/tests/test_cloud_source_upload.py @@ -0,0 +1,282 @@ +"""Local-source packaging and scan upload tests.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import zipfile +from typing import TYPE_CHECKING, Any + +import pytest + +from strix.interface import cloud +from strix.interface.cloud import http, source_upload + + +if TYPE_CHECKING: + from pathlib import Path + + +class FakeResponse: + def __init__(self, payload: Any, status_code: int = 200) -> None: + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) + self.content = b"" + self.ok = 200 <= status_code < 400 + self.headers = {"content-type": "application/json"} + + def json(self) -> Any: + return self._payload + + +@pytest.fixture(autouse=True) +def _token_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_API_TOKEN", "test-token") + + +def _git_source(tmp_path: Path) -> Path: + git = shutil.which("git") + assert git is not None + subprocess.run([git, "init", "-q", str(tmp_path)], check=True) # noqa: S603 + (tmp_path / "app.py").write_text("print('hello')\n", encoding="utf-8") + (tmp_path / "README.md").write_text("hello\n", encoding="utf-8") + (tmp_path / ".gitignore").write_text("ignored.log\n", encoding="utf-8") + (tmp_path / "ignored.log").write_text("ignored\n", encoding="utf-8") + subprocess.run( # noqa: S603 + [git, "-C", str(tmp_path), "add", "app.py", ".gitignore"], check=True + ) + return tmp_path + + +def test_source_defaults_are_private_and_git_aware(tmp_path: Path) -> None: + source = _git_source(tmp_path) + (source / ".hidden.py").write_text("hidden\n", encoding="utf-8") + (source / ".env").write_text("TOKEN=secret\n", encoding="utf-8") + (source / "private.pem").write_text("secret\n", encoding="utf-8") + (source / "fixture.zip").write_bytes(b"not really a zip") + (source / "node_modules").mkdir() + (source / "node_modules" / "dep.js").write_text("dep\n", encoding="utf-8") + (source / "linked.py").symlink_to(source / "app.py") + + bundle = source_upload.prepare_source( + str(source), + include_hidden=False, + include_sensitive=False, + include_archives=False, + exclude=[], + ) + try: + names = [item.archive_name for item in bundle.manifest.files] + assert names == ["README.md", "app.py"] + assert bundle.manifest.total_bytes > 0 + assert bundle.archive_bytes <= source_upload.MAX_ARCHIVE_BYTES + assert bundle.manifest.excluded["hidden"] == 3 + assert bundle.manifest.excluded["sensitive_filename"] == 1 + assert bundle.manifest.excluded["nested_archive"] == 1 + assert bundle.manifest.excluded["dependency_or_build_output"] == 1 + assert bundle.manifest.excluded["symlink_or_non_file"] == 1 + with zipfile.ZipFile(bundle.archive_path) as archive: + assert archive.namelist() == names + finally: + source_upload.remove_bundle(bundle) + + +def test_hidden_and_sensitive_files_need_separate_opt_ins(tmp_path: Path) -> None: + source = _git_source(tmp_path) + (source / ".env").write_text("TOKEN=secret\n", encoding="utf-8") + (source / ".github").mkdir() + (source / ".github" / "workflow.yml").write_text("name: test\n", encoding="utf-8") + + hidden = source_upload.select_source(source, include_hidden=True) + hidden_names = {item.archive_name for item in hidden.files} + assert ".github/workflow.yml" in hidden_names + assert ".env" not in hidden_names + + sensitive = source_upload.select_source(source, include_hidden=True, include_sensitive=True) + assert ".env" in {item.archive_name for item in sensitive.files} + assert all( + not name.startswith(".git/") for name in (item.archive_name for item in sensitive.files) + ) + + +def test_strixignore_and_cli_excludes_are_applied(tmp_path: Path) -> None: + (tmp_path / "keep.py").write_text("keep\n", encoding="utf-8") + (tmp_path / "generated.py").write_text("generated\n", encoding="utf-8") + (tmp_path / "test_app.py").write_text("test\n", encoding="utf-8") + (tmp_path / ".strixignore").write_text("generated.py\n", encoding="utf-8") + + manifest = source_upload.select_source(tmp_path, exclude=["test_*.py"]) + assert [item.archive_name for item in manifest.files] == ["keep.py"] + assert manifest.excluded["user_pattern"] == 2 + + +def test_source_limits_expanded_bytes_before_compression( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(source_upload, "MAX_TOTAL_BYTES", 5) + (tmp_path / "large.py").write_bytes(b"a" * 6) + with pytest.raises(http.CloudError, match="expanded-size limit"): + source_upload.select_source(tmp_path) + + +def test_source_dry_run_never_calls_the_api( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + + def fail_request(*_args: Any, **_kwargs: Any) -> Any: + raise AssertionError("dry-run must not make an API request") + + monkeypatch.setattr(http, "request", fail_request) + assert ( + cloud.run_cloud( + ["scans", "start", "--source", str(tmp_path), "--dry-run", "--show-files", "--json"] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["source"]["files"] == ["app.py"] + assert payload["source"]["archive_sha256"] + + +def test_noninteractive_source_upload_requires_yes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + monkeypatch.setattr( + http, + "request", + lambda *_args, **_kwargs: pytest.fail("approval must happen before any API request"), + ) + assert cloud.run_cloud(["scans", "start", "--source", str(tmp_path), "--json"]) == 1 + assert "requires explicit approval" in capsys.readouterr().out + + +def test_source_upload_is_completed_and_attached_to_scan( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + calls: list[tuple[str, str, dict[str, Any]]] = [] + uploaded_path: Path | None = None + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + calls.append((method, path, kwargs)) + if path == "/uploads/request": + return FakeResponse( + { + "upload_id": "upload-1", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/complete": + return FakeResponse({"id": "upload-1"}) + if path == "/scans": + return FakeResponse({"scan_id": "scan-1", "status": "pending"}) + raise AssertionError(path) + + def fake_upload(_url: str, _token: str, path: Path) -> None: + nonlocal uploaded_path + uploaded_path = path + assert path.exists() + + monkeypatch.setattr(http, "request", fake_request) + monkeypatch.setattr(http, "upload_file", fake_upload) + + assert ( + cloud.run_cloud( + ["scans", "start", "--source", str(tmp_path), "--yes", "--show-files", "--json"] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["upload_id"] == "upload-1" + assert payload["scan"]["scan_id"] == "scan-1" + assert payload["source"]["files"] == ["app.py"] + scan_call = next(call for call in calls if call[1] == "/scans") + assert scan_call[2]["body"] == { + "engagement_type": "code_review", + "upload_ids": ["upload-1"], + } + assert uploaded_path is not None and not uploaded_path.exists() + + +def test_source_upload_with_domain_is_a_live_test( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: Any +) -> None: + (tmp_path / "app.py").write_text("print('safe')\n", encoding="utf-8") + calls: list[tuple[str, str, dict[str, Any]]] = [] + + def fake_request(method: str, path: str, **kwargs: Any) -> FakeResponse: + calls.append((method, path, kwargs)) + if path == "/uploads/request": + return FakeResponse( + { + "upload_id": "upload-1", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/complete": + return FakeResponse({"id": "upload-1"}) + if path == "/scans": + return FakeResponse({"scan_id": "scan-1", "status": "pending"}) + 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), + "--domain-ids", + "domain-1", + "--yes", + "--json", + ] + ) + == 0 + ) + scan_call = next(call for call in calls if call[1] == "/scans") + assert scan_call[2]["body"] == { + "engagement_type": "live_test", + "domain_ids": ["domain-1"], + "upload_ids": ["upload-1"], + } + assert json.loads(capsys.readouterr().out)["scan"]["scan_id"] == "scan-1" + + +def test_failed_scan_deletes_completed_source_upload( + 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-1", + "signed_url": "https://storage.test/object", + "token": "signed", + } + ) + if path == "/uploads/complete": + return FakeResponse({"id": "upload-1"}) + if path == "/scans": + return FakeResponse({"detail": "not enough credits"}, status_code=402) + if path == "/uploads/upload-1": + return FakeResponse({"ok": True}) + 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", "--json"]) == 5 + assert ("DELETE", "/uploads/upload-1") in paths diff --git a/tests/test_completions.py b/tests/test_completions.py new file mode 100644 index 00000000..67c67c6f --- /dev/null +++ b/tests/test_completions.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any + +from strix.interface.completions import completion_candidates, run_completions + + +def test_root_completion_candidates() -> None: + assert completion_candidates(["cl"]) == ["cloud"] + assert "completions" in completion_candidates([""]) + + +def test_cloud_group_and_alias_candidates() -> None: + candidates = completion_candidates(["cloud", "work"]) + assert candidates == ["workspace", "workspaces"] + + +def test_cloud_verb_candidates_include_multiword_prefixes() -> None: + assert "test-users" in completion_candidates(["cloud", "domains", ""]) + assert completion_candidates(["cloud", "domains", "test-users", "in"]) == [ + "inbox", + "inbox-message", + ] + + +def test_cloud_leaf_flag_candidates_come_from_command_spec() -> None: + candidates = completion_candidates(["cloud", "scans", "start", "--"]) + assert "--domain-ids" in candidates + assert "--json" in candidates + assert "--wait" in candidates + assert "--source" in candidates + assert "--dry-run" in candidates + assert "--include-hidden" in candidates + + +def test_boolean_completion_includes_positive_and_negative_flags() -> None: + candidates = completion_candidates(["cloud", "billing", "auto-topup", "update", "--"]) + assert "--enabled" in candidates + assert "--no-enabled" in candidates + assert "--no-monthly-cap" in candidates + + +def test_completion_scripts_cover_supported_shells(capsys: Any) -> None: + for shell in ("zsh", "bash", "fish"): + assert run_completions([shell]) == 0 + output = capsys.readouterr().out + assert "completions --candidates" in output + + +def test_completion_rejects_unknown_shell(capsys: Any) -> None: + assert run_completions(["powershell"]) == 2 + assert "Choose zsh, bash, or fish" in capsys.readouterr().err