Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ This guide walks you through both installation and usage.
5. [Advanced Configuration](#advanced-configuration)
5. [Platform Command](#platform-command-beta)
1. [Discovering Commands](#discovering-commands)
2. [Examples](#platform-examples)
3. [Notes & Limitations](#platform-notes--limitations)
2. [Raw API Requests](#raw-api-requests)
3. [Examples](#platform-examples)
4. [Notes & Limitations](#platform-notes--limitations)
6. [Scan Command](#scan-command)
1. [Running a Scan](#running-a-scan)
1. [Options](#options)
Expand Down Expand Up @@ -672,6 +673,35 @@ cycode platform projects --help # list actions on a resource
cycode platform projects list --help # list options/arguments for an action
```

## Raw API Requests

`cycode platform api` sends a raw authenticated request to any Cycode REST endpoint — including endpoints the spec does not expose as a generated command, and writes (`post`, `put`). It is the escape hatch for scripts: you supply the method, path, and body, and the CLI handles authentication.

```bash
cycode platform api <get|post|put> <PATH> [-q KEY=VALUE]... [-H "KEY: VALUE"]... [-d JSON]
```

Credentials come from the CLI itself — `cycode auth`, the `CYCODE_CLIENT_ID` / `CYCODE_CLIENT_SECRET` (or `CYCODE_ID_TOKEN`) environment variables, or the global `--client-id` / `--client-secret` / `--id-token` options. The CLI mints and refreshes the access token for you, so scripts never handle tokens — your credentials and access token are never printed. Note that `-v` / `--verbose` logs full response bodies to stderr, so avoid it in shared CI logs when the endpoint returns sensitive data.

```bash
# GET with query parameters (repeat a key to send multiple values)
cycode platform api get v4/projects -q page-size=5
cycode platform api get v4/violations -q severity=High -q severity=Critical

# POST a JSON body — inline, from a file, or from stdin
cycode platform api post v4/some/resource -d '{"name": "example"}'
cycode platform api post v4/some/resource -d @body.json
cat body.json | cycode platform api put v4/some/resource -d -

# Pipe through jq like any other platform command
cycode platform api get v4/projects -q page-size=100 | jq '.items[].name'
```

The response body is printed as formatted JSON (non-JSON bodies are printed as-is). On an HTTP error the status and response body go to stderr and the command exits non-zero, so `set -e` scripts fail as expected. `PATH` must be a v4 API path such as `v4/projects` (or `api/v4/...`), not a full URL — use `cycode configure` to point the CLI at a different Cycode installation. The `Authorization` header is owned by the CLI and cannot be overridden. Unlike the generated commands, `cycode platform api` never fetches the OpenAPI spec.

> [!NOTE]
> `-v` / `--verbose` logs the outgoing request and the response, which is the quickest way to debug an unexpected status code.

## Platform Examples

```bash
Expand All @@ -696,7 +726,7 @@ cycode platform projects list --page-size 100 | jq '.items[].name'

## Platform Notes & Limitations

- **Read-only today.** Only `GET` endpoints are exposed in this beta.
- **Generated commands are read-only.** Only `GET` endpoints are turned into commands in this beta. For writes, use [`cycode platform api`](#raw-api-requests) with `post` or `put`.
- **Spec-driven.** Adding a new endpoint to the API surfaces it automatically the next time the cache is refreshed.
- **No bundled spec.** The first `cycode platform` invocation after install (or after the 24h cache expires) performs a network fetch. On slow connections this first call may take a few seconds; subsequent calls are near-instant until the cache expires.
- **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=<seconds>`.
Expand Down
11 changes: 10 additions & 1 deletion cycode/cli/apps/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,19 @@ def list_commands(self, ctx: click.Context) -> list[str]:
return super().list_commands(ctx)

def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]:
# Statically registered commands (like `api`) must not trigger a spec fetch.
if cmd_name in self.commands:
return super().get_command(ctx, cmd_name)

self._ensure_loaded(ctx)
return super().get_command(ctx, cmd_name)


def get_platform_group() -> click.Group:
"""Return the top-level `platform` Click group (lazy-loading)."""
return PlatformGroup(name='platform', help=_PLATFORM_HELP, no_args_is_help=True)
from cycode.cli.apps.api.raw_api_command import build_raw_api_command

group = PlatformGroup(name='platform', help=_PLATFORM_HELP, no_args_is_help=True)
# The raw request escape hatch is registered statically: it needs no OpenAPI spec.
group.add_command(build_raw_api_command(), 'api')
return group
205 changes: 205 additions & 0 deletions cycode/cli/apps/api/raw_api_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""Raw REST passthrough: `cycode platform api <METHOD> <PATH>`.

Lets scripts call any Cycode REST endpoint without handling credentials themselves.
The CLI resolves credentials (client id/secret or OIDC, from flags, environment
variables, or `cycode auth`), mints and refreshes the access token, and prints the
response body. Tokens and secrets are never printed.
"""

import json
import re
import sys
from typing import TYPE_CHECKING, Any, Optional, Union

import click

from cycode.logger import get_logger

if TYPE_CHECKING:
from requests import Response

logger = get_logger('Raw API Command')

_SUPPORTED_METHODS = ('get', 'post', 'put')
_METHODS_WITH_BODY = ('post', 'put')
# Allowlist: a versioned API path such as `v4/projects` or `api/v4/auth/api-token`.
# Anything else (full URLs, protocol-relative paths, paths with whitespace) is rejected.
_API_PATH_RE = re.compile(r'^(api/)?v4/\S+$')

_HELP = """[BETA] Send a raw authenticated request to the Cycode API.

METHOD is one of get, post, put. PATH is an API path such as `v4/projects`
(a leading slash is optional).

Credentials come from the CLI: `cycode auth`, the CYCODE_CLIENT_ID/CYCODE_CLIENT_SECRET
(or CYCODE_ID_TOKEN) environment variables, or the global `--client-id`/`--client-secret`/
`--id-token` options. Your credentials and access token are never printed.

\b
Examples:
cycode platform api get v4/projects -q page-size=5
cycode platform api get v4/violations -q severity=High -q severity=Critical
cycode platform api post v4/sbom/import -d @body.json
cat body.json | cycode platform api put v4/some/resource -d -
"""


def _validate_path(path: str) -> str:
"""Validate that PATH is a versioned API path, so credentials only reach the configured Cycode host."""
url_path = path.lstrip('/')
if not _API_PATH_RE.match(url_path):
raise click.ClickException(f'PATH must be a versioned API path such as `v4/projects`, not `{path}`.')

return url_path


def _parse_query(query: tuple[str, ...]) -> dict[str, Union[str, list[str]]]:
"""Parse repeatable `key=value` pairs. Repeated keys collapse into a list."""
params: dict[str, Union[str, list[str]]] = {}
for item in query:
key, sep, value = item.partition('=')
if not sep or not key:
raise click.ClickException(f'Invalid query parameter "{item}". Expected format: key=value')

if key in params:
existing = params[key]
if isinstance(existing, list):
existing.append(value)
else:
params[key] = [existing, value]
else:
params[key] = value

return params


def _parse_headers(header: tuple[str, ...]) -> dict[str, str]:
"""Parse repeatable `Key: Value` pairs."""
headers: dict[str, str] = {}
for item in header:
key, sep, value = item.partition(':')
key = key.strip()
if not sep or not key:
raise click.ClickException(f'Invalid header "{item}". Expected format: "Key: Value"')

if key.lower() == 'authorization':
raise click.ClickException('The Authorization header is managed by the CLI and cannot be overridden.')

headers[key] = value.strip()

return headers


def _read_body(data: str) -> Any:
"""Read the request body from an inline JSON string, `@file`, or `-` (stdin)."""
if data == '-':
raw = sys.stdin.read()
source = 'stdin'
elif data.startswith('@'):
file_path = data[1:]
try:
with open(file_path, encoding='utf-8') as f:
raw = f.read()
except OSError as e:
raise click.ClickException(f'Could not read request body file "{file_path}": {e}') from e
source = file_path
else:
raw = data
source = 'the --data value'

try:
return json.loads(raw)
except json.JSONDecodeError as e:
raise click.ClickException(f'Could not parse JSON from {source}: {e}') from e


def _echo_response_body(response: 'Response') -> None:
try:
click.echo(json.dumps(response.json(), indent=2))
except ValueError:
# Not a JSON body (empty response, plain text, file download, etc.)
click.echo(response.text)


def _callback(
method: str,
path: str,
query: tuple[str, ...],
header: tuple[str, ...],
data: Optional[str],
timeout: Optional[int],
) -> None:
from cycode.cli.exceptions.custom_exceptions import RequestHttpError
from cycode.cli.utils.get_api_client import get_raw_api_client

method = method.lower()
url_path = _validate_path(path)
params = _parse_query(query)
headers = _parse_headers(header)

body = None
if data is not None:
if method not in _METHODS_WITH_BODY:
raise click.ClickException(f'--data is not supported for the {method} method.')
body = _read_body(data)

ctx = click.get_current_context()
client = get_raw_api_client(ctx.find_root())

kwargs: dict[str, Any] = {'headers': headers or None, 'params': params or None}
if timeout is not None:
kwargs['timeout'] = timeout

logger.debug('Sending raw API request, %s', {'method': method, 'path': url_path})

try:
if method == 'get':
response = client.get(url_path, **kwargs)
elif method == 'post':
response = client.post(url_path, body=body, **kwargs)
else:
response = client.put(url_path, body=body, **kwargs)
except RequestHttpError as e:
click.echo(f'HTTP {e.status_code}: {e.error_message}', err=True)
raise click.exceptions.Exit(1) from e
except Exception as e:
click.echo(f'Error: {e}', err=True)
raise click.exceptions.Exit(1) from e

_echo_response_body(response)


def build_raw_api_command() -> click.Command:
"""Build the `cycode platform api` raw request command."""
return click.Command(
name='api',
callback=_callback,
help=_HELP,
short_help='[BETA] Send a raw authenticated request to the Cycode API.',
params=[
click.Argument(['method'], type=click.Choice(_SUPPORTED_METHODS, case_sensitive=False), required=True),
click.Argument(['path'], type=click.STRING, required=True),
click.Option(
['-q', '--query'],
multiple=True,
metavar='KEY=VALUE',
help='Query parameter. Repeatable; repeating a key sends multiple values.',
),
click.Option(
['-H', '--header'],
multiple=True,
metavar='"KEY: VALUE"',
help='Additional request header. Repeatable. Authorization is managed by the CLI.',
),
click.Option(
['-d', '--data'],
metavar='JSON',
help='JSON request body for post/put. Use @file to read a file, or - to read stdin.',
),
click.Option(
['--timeout'],
type=click.INT,
help='Request timeout in seconds. Defaults to the CLI request timeout.',
),
],
)
18 changes: 15 additions & 3 deletions cycode/cli/utils/get_api_client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from typing import TYPE_CHECKING, Optional, Union
from typing import TYPE_CHECKING, Callable, Optional, TypeVar, Union

import click

from cycode.cli.user_settings.credentials_manager import CredentialsManager
from cycode.cyclient.client_creator import (
create_ai_security_manager_client,
create_import_sbom_client,
create_raw_api_client,
create_report_client,
create_scan_client,
)
Expand All @@ -14,18 +15,21 @@
import typer

from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient
from cycode.cyclient.cycode_client_base import CycodeClientBase
from cycode.cyclient.import_sbom_client import ImportSbomClient
from cycode.cyclient.report_client import ReportClient
from cycode.cyclient.scan_client import ScanClient

_ClientT = TypeVar('_ClientT')


def _get_cycode_client(
create_client_func: callable,
create_client_func: Callable[..., _ClientT],
client_id: Optional[str],
client_secret: Optional[str],
hide_response_log: bool,
id_token: Optional[str] = None,
) -> Union['ScanClient', 'ReportClient', 'ImportSbomClient', 'AISecurityManagerClient']:
) -> _ClientT:
if client_id and id_token:
return create_client_func(client_id, None, hide_response_log, id_token)

Expand Down Expand Up @@ -75,6 +79,14 @@ def get_ai_security_manager_client(ctx: 'typer.Context', hide_response_log: bool
return _get_cycode_client(create_ai_security_manager_client, client_id, client_secret, hide_response_log, id_token)


def get_raw_api_client(ctx: Union['typer.Context', click.Context]) -> 'CycodeClientBase':
client_id = ctx.obj.get('client_id') if ctx.obj else None
client_secret = ctx.obj.get('client_secret') if ctx.obj else None
id_token = ctx.obj.get('id_token') if ctx.obj else None
# hide_response_log is unused by create_raw_api_client: raw responses are the command's output
return _get_cycode_client(create_raw_api_client, client_id, client_secret, True, id_token)


def _get_configured_credentials() -> tuple[str, str]:
credentials_manager = CredentialsManager()
return credentials_manager.get_credentials()
Expand Down
12 changes: 12 additions & 0 deletions cycode/cyclient/client_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
)
from cycode.cyclient.config import dev_mode
from cycode.cyclient.config_dev import DEV_CYCODE_API_URL
from cycode.cyclient.cycode_client_base import CycodeClientBase
from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient
from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient
from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
Expand Down Expand Up @@ -56,6 +57,17 @@ def create_import_sbom_client(
return ImportSbomClient(client)


def create_raw_api_client(
client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None
) -> CycodeClientBase:
"""Create an authenticated client without any service wrapper, for raw API requests."""
if dev_mode:
return CycodeDevBasedClient(DEV_CYCODE_API_URL)
if id_token:
return CycodeOidcBasedClient(client_id, id_token)
return CycodeTokenBasedClient(client_id, client_secret)


def create_ai_security_manager_client(
client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None
) -> AISecurityManagerClient:
Expand Down
Loading
Loading