cycode 3.12.3.dev5__py3-none-any.whl → 3.13.1.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cycode/__init__.py +1 -1
- cycode/cli/app.py +23 -0
- cycode/cli/apps/api/__init__.py +69 -0
- cycode/cli/apps/api/api_command.py +271 -0
- cycode/cli/apps/api/openapi_spec.py +182 -0
- {cycode-3.12.3.dev5.dist-info → cycode-3.13.1.dev1.dist-info}/METADATA +64 -2
- {cycode-3.12.3.dev5.dist-info → cycode-3.13.1.dev1.dist-info}/RECORD +10 -7
- {cycode-3.12.3.dev5.dist-info → cycode-3.13.1.dev1.dist-info}/WHEEL +0 -0
- {cycode-3.12.3.dev5.dist-info → cycode-3.13.1.dev1.dist-info}/entry_points.txt +0 -0
- {cycode-3.12.3.dev5.dist-info → cycode-3.13.1.dev1.dist-info}/licenses/LICENCE +0 -0
cycode/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '3.
|
|
1
|
+
__version__ = '3.13.1.dev1' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
|
cycode/cli/app.py
CHANGED
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
import sys
|
|
3
3
|
from typing import Annotated, Optional
|
|
4
4
|
|
|
5
|
+
import click
|
|
5
6
|
import typer
|
|
6
7
|
from typer import rich_utils
|
|
7
8
|
from typer._completion_classes import completion_init
|
|
@@ -10,6 +11,7 @@ from typer.completion import install_callback, show_callback
|
|
|
10
11
|
|
|
11
12
|
from cycode import __version__
|
|
12
13
|
from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status
|
|
14
|
+
from cycode.cli.apps.api import get_platform_group
|
|
13
15
|
|
|
14
16
|
if sys.version_info >= (3, 10):
|
|
15
17
|
from cycode.cli.apps import mcp
|
|
@@ -56,6 +58,27 @@ app.add_typer(status.app)
|
|
|
56
58
|
if sys.version_info >= (3, 10):
|
|
57
59
|
app.add_typer(mcp.app)
|
|
58
60
|
|
|
61
|
+
# Register the `platform` command group (dynamically built from the OpenAPI spec).
|
|
62
|
+
# The group itself is constructed cheaply at import time; the spec is only fetched
|
|
63
|
+
# when the user actually invokes `cycode platform ...`. Unrelated commands like
|
|
64
|
+
# `cycode scan` and `cycode status` never trigger a spec fetch.
|
|
65
|
+
#
|
|
66
|
+
# Typer doesn't support adding native Click groups directly, so we monkey-patch
|
|
67
|
+
# typer.main.get_group to inject our `platform` group into the resolved Click group.
|
|
68
|
+
# The `app_typer is app` guard ensures we only modify our own app.
|
|
69
|
+
_platform_group = get_platform_group()
|
|
70
|
+
_original_get_group = typer.main.get_group
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_group_with_platform(app_typer: typer.Typer) -> click.Group:
|
|
74
|
+
group = _original_get_group(app_typer)
|
|
75
|
+
if app_typer is app and _platform_group.name not in group.commands:
|
|
76
|
+
group.add_command(_platform_group, _platform_group.name)
|
|
77
|
+
return group
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
typer.main.get_group = _get_group_with_platform
|
|
81
|
+
|
|
59
82
|
|
|
60
83
|
def check_latest_version_on_close(ctx: typer.Context) -> None:
|
|
61
84
|
output = ctx.obj.get('output')
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Cycode platform API CLI commands.
|
|
2
|
+
|
|
3
|
+
Dynamically builds CLI command groups from the Cycode API v4 OpenAPI spec.
|
|
4
|
+
The spec is fetched lazily — only when the user invokes `cycode platform ...` —
|
|
5
|
+
and cached locally for 24 hours.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from cycode.logger import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger('Platform')
|
|
15
|
+
|
|
16
|
+
_PLATFORM_HELP = (
|
|
17
|
+
'[BETA] Access the Cycode platform.\n\n'
|
|
18
|
+
'Commands are generated dynamically from the Cycode API spec and may change '
|
|
19
|
+
'between releases. The spec is fetched on first use and cached for 24 hours.'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PlatformGroup(click.Group):
|
|
24
|
+
"""Lazy-loading Click group for `cycode platform` subcommands.
|
|
25
|
+
|
|
26
|
+
The OpenAPI spec is only fetched when the user actually invokes
|
|
27
|
+
`cycode platform ...` (or asks for its help). Unrelated commands like
|
|
28
|
+
`cycode scan` or `cycode status` never trigger a spec fetch.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
self._loaded: bool = False
|
|
34
|
+
|
|
35
|
+
def _ensure_loaded(self, ctx: Optional[click.Context]) -> None:
|
|
36
|
+
if self._loaded:
|
|
37
|
+
return
|
|
38
|
+
self._loaded = True # set first to avoid re-entrancy on errors
|
|
39
|
+
|
|
40
|
+
client_id = client_secret = None
|
|
41
|
+
if ctx is not None:
|
|
42
|
+
root = ctx.find_root()
|
|
43
|
+
if root.obj:
|
|
44
|
+
client_id = root.obj.get('client_id')
|
|
45
|
+
client_secret = root.obj.get('client_secret')
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
from cycode.cli.apps.api.api_command import build_api_command_groups
|
|
49
|
+
|
|
50
|
+
for sub_group, name in build_api_command_groups(client_id, client_secret):
|
|
51
|
+
if name not in self.commands:
|
|
52
|
+
self.add_command(sub_group, name)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.debug('Could not load platform commands: %s', e)
|
|
55
|
+
# Surface the error to the user only when they're inside `platform`
|
|
56
|
+
click.echo(f'Error loading Cycode platform commands: {e}', err=True)
|
|
57
|
+
|
|
58
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
59
|
+
self._ensure_loaded(ctx)
|
|
60
|
+
return super().list_commands(ctx)
|
|
61
|
+
|
|
62
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]:
|
|
63
|
+
self._ensure_loaded(ctx)
|
|
64
|
+
return super().get_command(ctx, cmd_name)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_platform_group() -> click.Group:
|
|
68
|
+
"""Return the top-level `platform` Click group (lazy-loading)."""
|
|
69
|
+
return PlatformGroup(name='platform', help=_PLATFORM_HELP, no_args_is_help=True)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""OpenAPI-to-Typer translator: dynamically builds CLI commands from the Cycode API v4 spec."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from cycode.cli.apps.api.openapi_spec import OpenAPISpecError, get_openapi_spec, parse_spec_commands
|
|
10
|
+
from cycode.logger import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger('API Command')
|
|
13
|
+
|
|
14
|
+
# Map OpenAPI parameter types to Click types
|
|
15
|
+
_CLICK_TYPE_MAP: dict[str, click.ParamType] = {
|
|
16
|
+
'string': click.STRING,
|
|
17
|
+
'integer': click.INT,
|
|
18
|
+
'number': click.FLOAT,
|
|
19
|
+
'boolean': click.BOOL,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _normalize_tag(tag: str) -> str:
|
|
24
|
+
"""Normalize an OpenAPI tag to a CLI-friendly command name.
|
|
25
|
+
|
|
26
|
+
'Scan Statistics' -> 'scan-statistics'
|
|
27
|
+
'CLI scan statistics' -> 'cli-scan-statistics'
|
|
28
|
+
"""
|
|
29
|
+
return re.sub(r'[^a-z0-9]+', '-', tag.lower()).strip('-')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _find_common_prefix(paths: list[str]) -> str:
|
|
33
|
+
"""Find the longest common path prefix shared by all paths."""
|
|
34
|
+
if not paths:
|
|
35
|
+
return ''
|
|
36
|
+
if len(paths) == 1:
|
|
37
|
+
# For single-path tags, use the parent directory as prefix
|
|
38
|
+
return '/'.join(paths[0].split('/')[:-1])
|
|
39
|
+
|
|
40
|
+
common = paths[0]
|
|
41
|
+
for p in paths[1:]:
|
|
42
|
+
while not p.startswith(common + '/') and common != p:
|
|
43
|
+
common = '/'.join(common.split('/')[:-1])
|
|
44
|
+
return common
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _path_to_command_name(path: str, common_prefix: str, has_path_params: bool) -> str:
|
|
48
|
+
"""Derive a CLI command name from an API path relative to the tag's common prefix.
|
|
49
|
+
|
|
50
|
+
Rules:
|
|
51
|
+
1. Strip the common prefix shared by all endpoints in the tag
|
|
52
|
+
2. Remove path parameter segments ({id})
|
|
53
|
+
3. If nothing remains: 'list' (no path params) or 'view' (has path params)
|
|
54
|
+
4. Otherwise: use remaining segments joined with hyphens
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
/v4/projects (prefix=/v4/projects) -> list
|
|
58
|
+
/v4/projects/{id} (prefix=/v4/projects) -> view
|
|
59
|
+
/v4/projects/assets (prefix=/v4/projects) -> assets
|
|
60
|
+
/v4/violations/count (prefix=/v4/violations) -> count
|
|
61
|
+
"""
|
|
62
|
+
# Strip common prefix
|
|
63
|
+
relative = path[len(common_prefix) :] if path.startswith(common_prefix) else path
|
|
64
|
+
relative = relative.strip('/')
|
|
65
|
+
|
|
66
|
+
# Remove path parameter segments and empty parts
|
|
67
|
+
parts = [p for p in relative.split('/') if p and not p.startswith('{')]
|
|
68
|
+
|
|
69
|
+
if not parts:
|
|
70
|
+
return 'view' if has_path_params else 'list'
|
|
71
|
+
|
|
72
|
+
# Join remaining segments with hyphens, normalize to kebab-case
|
|
73
|
+
return re.sub(r'[^a-z0-9]+', '-', '-'.join(parts).lower()).strip('-')
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _param_to_option_name(name: str) -> str:
|
|
77
|
+
"""Convert an OpenAPI parameter name to a CLI option name.
|
|
78
|
+
|
|
79
|
+
'page_size' -> '--page-size'
|
|
80
|
+
'pageSize' -> '--page-size'
|
|
81
|
+
'filter.status' -> '--filter-status'
|
|
82
|
+
"""
|
|
83
|
+
s = re.sub(r'([a-z])([A-Z])', r'\1-\2', name)
|
|
84
|
+
# Replace any non-alphanumeric characters with hyphens
|
|
85
|
+
s = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-')
|
|
86
|
+
return f'--{s}'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _make_api_request(
|
|
90
|
+
endpoint_path: str,
|
|
91
|
+
method: str,
|
|
92
|
+
path_params: dict[str, str],
|
|
93
|
+
query_params: dict[str, Any],
|
|
94
|
+
client_id: Optional[str] = None,
|
|
95
|
+
client_secret: Optional[str] = None,
|
|
96
|
+
) -> dict:
|
|
97
|
+
"""Execute an API request using the CLI's standard auth client."""
|
|
98
|
+
from urllib.parse import quote
|
|
99
|
+
|
|
100
|
+
from cycode.cli.apps.api.openapi_spec import resolve_credentials
|
|
101
|
+
from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
|
|
102
|
+
|
|
103
|
+
cid, csecret = resolve_credentials(client_id, client_secret)
|
|
104
|
+
client = CycodeTokenBasedClient(cid, csecret)
|
|
105
|
+
|
|
106
|
+
# Substitute path parameters (URL-encoded to prevent path traversal)
|
|
107
|
+
url_path = endpoint_path
|
|
108
|
+
for param_name, param_value in path_params.items():
|
|
109
|
+
url_path = url_path.replace(f'{{{param_name}}}', quote(str(param_value), safe=''))
|
|
110
|
+
|
|
111
|
+
filtered_query = {k: v for k, v in query_params.items() if v is not None}
|
|
112
|
+
|
|
113
|
+
response = client.get(url_path.lstrip('/'), params=filtered_query)
|
|
114
|
+
return response.json()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_api_command_groups(
|
|
118
|
+
client_id: Optional[str] = None,
|
|
119
|
+
client_secret: Optional[str] = None,
|
|
120
|
+
) -> list[tuple[click.Group, str]]:
|
|
121
|
+
"""Build Click command groups from the OpenAPI spec.
|
|
122
|
+
|
|
123
|
+
Returns a list of (click_group, command_name) tuples.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
spec = get_openapi_spec(client_id, client_secret)
|
|
127
|
+
except OpenAPISpecError as e:
|
|
128
|
+
logger.warning('Could not load OpenAPI spec: %s', e)
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
groups = parse_spec_commands(spec)
|
|
132
|
+
result = []
|
|
133
|
+
|
|
134
|
+
for tag, endpoints in groups.items():
|
|
135
|
+
tag_name = _normalize_tag(tag)
|
|
136
|
+
|
|
137
|
+
group = click.Group(name=tag_name, help=f'[BETA] {tag}')
|
|
138
|
+
|
|
139
|
+
# Compute common prefix from all GET (non-deprecated) endpoint paths in this tag
|
|
140
|
+
get_endpoints = [ep for ep in endpoints if ep['method'] == 'get' and not ep.get('deprecated')]
|
|
141
|
+
if not get_endpoints:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
clean_paths = [re.sub(r'/\{[^}]+\}', '', ep['path']) for ep in get_endpoints]
|
|
145
|
+
common_prefix = _find_common_prefix(clean_paths)
|
|
146
|
+
|
|
147
|
+
used_names: dict[str, int] = {}
|
|
148
|
+
|
|
149
|
+
for endpoint in get_endpoints:
|
|
150
|
+
has_path_params = bool(endpoint['path_params'])
|
|
151
|
+
cmd_name = _path_to_command_name(endpoint['path'], common_prefix, has_path_params)
|
|
152
|
+
|
|
153
|
+
# Fix redundancy: if command name matches the tag name, use list/view
|
|
154
|
+
# e.g. "cycode groups groups" -> "cycode groups list"
|
|
155
|
+
if cmd_name == tag_name:
|
|
156
|
+
cmd_name = 'view' if has_path_params else 'list'
|
|
157
|
+
|
|
158
|
+
# Handle duplicate names (e.g. deprecated + new endpoint for same resource)
|
|
159
|
+
if cmd_name in used_names:
|
|
160
|
+
used_names[cmd_name] += 1
|
|
161
|
+
cmd_name = f'{cmd_name}-v{used_names[cmd_name]}'
|
|
162
|
+
else:
|
|
163
|
+
used_names[cmd_name] = 1
|
|
164
|
+
|
|
165
|
+
cmd = _build_endpoint_command(cmd_name, endpoint)
|
|
166
|
+
group.add_command(cmd, cmd_name)
|
|
167
|
+
|
|
168
|
+
result.append((group, tag_name))
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_click_params(endpoint: dict) -> list[click.Parameter]:
|
|
174
|
+
"""Build Click parameters from OpenAPI endpoint definition."""
|
|
175
|
+
params: list[click.Parameter] = []
|
|
176
|
+
|
|
177
|
+
# Path parameters -> required arguments
|
|
178
|
+
for p in endpoint['path_params']:
|
|
179
|
+
param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING)
|
|
180
|
+
params.append(
|
|
181
|
+
click.Argument(
|
|
182
|
+
[p['name'].replace('-', '_')],
|
|
183
|
+
type=param_type,
|
|
184
|
+
required=True,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Query parameters -> --option flags
|
|
189
|
+
for p in endpoint['query_params']:
|
|
190
|
+
param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING)
|
|
191
|
+
option_name = _param_to_option_name(p['name'])
|
|
192
|
+
required = p.get('required', False)
|
|
193
|
+
default = p.get('schema', {}).get('default')
|
|
194
|
+
|
|
195
|
+
schema = p.get('schema', {})
|
|
196
|
+
if 'enum' in schema:
|
|
197
|
+
param_type = click.Choice(schema['enum'])
|
|
198
|
+
|
|
199
|
+
params.append(
|
|
200
|
+
click.Option(
|
|
201
|
+
[option_name],
|
|
202
|
+
type=param_type,
|
|
203
|
+
required=required,
|
|
204
|
+
default=default,
|
|
205
|
+
help=p.get('description', ''),
|
|
206
|
+
show_default=default is not None,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return params
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _build_endpoint_command(cmd_name: str, endpoint: dict) -> click.Command:
|
|
214
|
+
"""Build a Click command for an API endpoint.
|
|
215
|
+
|
|
216
|
+
Path parameters become required CLI arguments.
|
|
217
|
+
Query parameters become --option flags with proper types.
|
|
218
|
+
"""
|
|
219
|
+
ep_path = endpoint['path']
|
|
220
|
+
ep_method = endpoint['method']
|
|
221
|
+
ep_path_params = list(endpoint['path_params'])
|
|
222
|
+
ep_query_params = list(endpoint['query_params'])
|
|
223
|
+
ep_description = endpoint['description'] or endpoint['summary']
|
|
224
|
+
|
|
225
|
+
# Build a mapping from Click's normalized kwarg name to original OpenAPI param name
|
|
226
|
+
_path_param_map = {p['name'].replace('-', '_').lower(): p['name'] for p in ep_path_params}
|
|
227
|
+
_query_param_map = {re.sub(r'[^a-z0-9]+', '_', p['name'].lower()).strip('_'): p['name'] for p in ep_query_params}
|
|
228
|
+
|
|
229
|
+
def _callback(**kwargs: Any) -> None:
|
|
230
|
+
ctx = click.get_current_context()
|
|
231
|
+
|
|
232
|
+
# Extract path param values using the mapping
|
|
233
|
+
path_values = {}
|
|
234
|
+
for kwarg_key, original_name in _path_param_map.items():
|
|
235
|
+
if kwarg_key in kwargs and kwargs[kwarg_key] is not None:
|
|
236
|
+
path_values[original_name] = kwargs[kwarg_key]
|
|
237
|
+
|
|
238
|
+
# Extract query param values (skip None)
|
|
239
|
+
query_values = {}
|
|
240
|
+
for kwarg_key, original_name in _query_param_map.items():
|
|
241
|
+
value = kwargs.get(kwarg_key)
|
|
242
|
+
if value is not None:
|
|
243
|
+
query_values[original_name] = value
|
|
244
|
+
|
|
245
|
+
# Get auth from root context (set by app_callback)
|
|
246
|
+
root_ctx = ctx.find_root()
|
|
247
|
+
client_id = root_ctx.obj.get('client_id') if root_ctx.obj else None
|
|
248
|
+
client_secret = root_ctx.obj.get('client_secret') if root_ctx.obj else None
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
result = _make_api_request(
|
|
252
|
+
ep_path,
|
|
253
|
+
ep_method,
|
|
254
|
+
path_values,
|
|
255
|
+
query_values,
|
|
256
|
+
client_id=client_id,
|
|
257
|
+
client_secret=client_secret,
|
|
258
|
+
)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
click.echo(f'Error: {e}', err=True)
|
|
261
|
+
raise click.Abort from e
|
|
262
|
+
|
|
263
|
+
click.echo(json.dumps(result, indent=2))
|
|
264
|
+
|
|
265
|
+
return click.Command(
|
|
266
|
+
name=cmd_name,
|
|
267
|
+
callback=_callback,
|
|
268
|
+
help=ep_description,
|
|
269
|
+
short_help=endpoint['summary'],
|
|
270
|
+
params=_build_click_params(endpoint),
|
|
271
|
+
)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""OpenAPI spec manager: fetch, cache, and parse the Cycode API v4 spec."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY
|
|
10
|
+
from cycode.cli.user_settings.credentials_manager import CredentialsManager
|
|
11
|
+
from cycode.cyclient import config as cyclient_config
|
|
12
|
+
from cycode.logger import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger('OpenAPI Spec')
|
|
15
|
+
|
|
16
|
+
_CACHE_DIR = Path.home() / CYCODE_CONFIGURATION_DIRECTORY
|
|
17
|
+
_CACHE_FILE = _CACHE_DIR / 'openapi-spec.json'
|
|
18
|
+
_CACHE_TTL_SECONDS = int(os.getenv('CYCODE_SPEC_CACHE_TTL', str(24 * 60 * 60))) # 24h default
|
|
19
|
+
|
|
20
|
+
_OPENAPI_SPEC_PATH = '/v4/api-docs/cycode-api-swagger.json'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_openapi_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict:
|
|
24
|
+
"""Get the OpenAPI spec, using cache if fresh, otherwise fetching from API.
|
|
25
|
+
|
|
26
|
+
The spec is only fetched when the user actually invokes `cycode platform ...`.
|
|
27
|
+
Fetch uses the HTTP client's default timeout; on a slow connection the first
|
|
28
|
+
invocation will block accordingly. Once cached, subsequent invocations within
|
|
29
|
+
the TTL are near-instant.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
client_id: Optional client ID override (from CLI flags).
|
|
33
|
+
client_secret: Optional client secret override (from CLI flags).
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Parsed OpenAPI specification dictionary.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
OpenAPISpecError: If spec cannot be loaded from cache or API.
|
|
40
|
+
"""
|
|
41
|
+
cached = _load_cached_spec()
|
|
42
|
+
if cached is not None:
|
|
43
|
+
return cached
|
|
44
|
+
|
|
45
|
+
return _fetch_and_cache_spec(client_id, client_secret)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_cached_spec() -> Optional[dict]:
|
|
49
|
+
"""Load spec from local cache if it exists and is fresh."""
|
|
50
|
+
if not _CACHE_FILE.exists():
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
mtime = _CACHE_FILE.stat().st_mtime
|
|
55
|
+
if time.time() - mtime > _CACHE_TTL_SECONDS:
|
|
56
|
+
logger.debug('Cached OpenAPI spec is stale (age > %ds)', _CACHE_TTL_SECONDS)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
spec = json.loads(_CACHE_FILE.read_text(encoding='utf-8'))
|
|
60
|
+
logger.debug('Using cached OpenAPI spec from %s', _CACHE_FILE)
|
|
61
|
+
return spec
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logger.warning('Failed to load cached OpenAPI spec: %s', e)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_credentials(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> tuple[str, str]:
|
|
68
|
+
"""Resolve credentials from args or the CLI's standard credential chain."""
|
|
69
|
+
if not client_id or not client_secret:
|
|
70
|
+
credentials_manager = CredentialsManager()
|
|
71
|
+
cred_id, cred_secret = credentials_manager.get_credentials()
|
|
72
|
+
client_id = client_id or cred_id
|
|
73
|
+
client_secret = client_secret or cred_secret
|
|
74
|
+
|
|
75
|
+
if not client_id or not client_secret:
|
|
76
|
+
raise OpenAPISpecError(
|
|
77
|
+
'Cycode credentials not found. Run `cycode auth` first, '
|
|
78
|
+
'or set CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET environment variables.'
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return client_id, client_secret
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fetch_and_cache_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict:
|
|
85
|
+
"""Fetch OpenAPI spec from API and cache to disk.
|
|
86
|
+
|
|
87
|
+
Uses CycodeTokenBasedClient for auth and retries. The spec is served from the app URL,
|
|
88
|
+
so we create a client with app_url as base instead of the default api_url.
|
|
89
|
+
"""
|
|
90
|
+
from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
|
|
91
|
+
|
|
92
|
+
cid, csecret = resolve_credentials(client_id, client_secret)
|
|
93
|
+
|
|
94
|
+
# The spec is served from app.cycode.com, but token refresh POSTs to api.cycode.com.
|
|
95
|
+
# Ensure the token is fresh BEFORE overriding the base URL so that refresh
|
|
96
|
+
# targets the correct host.
|
|
97
|
+
client = CycodeTokenBasedClient(cid, csecret)
|
|
98
|
+
client.get_access_token()
|
|
99
|
+
client.api_url = cyclient_config.cycode_app_url
|
|
100
|
+
|
|
101
|
+
spec_path = _OPENAPI_SPEC_PATH.lstrip('/')
|
|
102
|
+
logger.info('Fetching OpenAPI spec from %s/%s', cyclient_config.cycode_app_url, spec_path)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
response = client.get(spec_path)
|
|
106
|
+
spec = response.json()
|
|
107
|
+
except Exception as e:
|
|
108
|
+
raise OpenAPISpecError(
|
|
109
|
+
f'Failed to fetch OpenAPI spec. Check your authentication and network connectivity. Error: {e}'
|
|
110
|
+
) from e
|
|
111
|
+
|
|
112
|
+
if not isinstance(spec, dict) or 'paths' not in spec:
|
|
113
|
+
raise OpenAPISpecError('Response does not look like a valid OpenAPI spec (missing "paths" key).')
|
|
114
|
+
|
|
115
|
+
# Override server URL with API URL (supports on-premise installations)
|
|
116
|
+
spec['servers'] = [{'url': cyclient_config.cycode_api_url}]
|
|
117
|
+
|
|
118
|
+
# Cache to disk
|
|
119
|
+
_cache_spec(spec)
|
|
120
|
+
|
|
121
|
+
return spec
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cache_spec(spec: dict) -> None:
|
|
125
|
+
"""Write spec to local cache file atomically (write to temp file, then rename)."""
|
|
126
|
+
try:
|
|
127
|
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
tmp_file = _CACHE_FILE.with_suffix('.json.tmp')
|
|
129
|
+
tmp_file.write_text(json.dumps(spec), encoding='utf-8')
|
|
130
|
+
tmp_file.replace(_CACHE_FILE) # atomic on POSIX and Windows
|
|
131
|
+
logger.debug('Cached OpenAPI spec to %s', _CACHE_FILE)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.warning('Failed to cache OpenAPI spec: %s', e)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_spec_commands(spec: dict) -> dict[str, list[dict]]:
|
|
137
|
+
"""Parse OpenAPI spec into resource groups with their endpoints.
|
|
138
|
+
|
|
139
|
+
Groups endpoints by their first tag, returning a dict of:
|
|
140
|
+
{tag_name: [endpoint_info, ...]}
|
|
141
|
+
|
|
142
|
+
Each endpoint_info contains:
|
|
143
|
+
- path: API path (e.g., '/v4/projects/{projectId}')
|
|
144
|
+
- method: HTTP method (e.g., 'get')
|
|
145
|
+
- summary: Human-readable summary
|
|
146
|
+
- description: Detailed description
|
|
147
|
+
- operation_id: Unique operation ID
|
|
148
|
+
- path_params: List of path parameter definitions
|
|
149
|
+
- query_params: List of query parameter definitions
|
|
150
|
+
"""
|
|
151
|
+
groups: dict[str, list[dict]] = {}
|
|
152
|
+
|
|
153
|
+
for path, methods in spec.get('paths', {}).items():
|
|
154
|
+
for method, details in methods.items():
|
|
155
|
+
tags = details.get('tags', ['other'])
|
|
156
|
+
tag = tags[0] if tags else 'other'
|
|
157
|
+
|
|
158
|
+
# Separate path and query parameters
|
|
159
|
+
parameters = details.get('parameters', [])
|
|
160
|
+
path_params = [p for p in parameters if p.get('in') == 'path']
|
|
161
|
+
query_params = [p for p in parameters if p.get('in') == 'query']
|
|
162
|
+
|
|
163
|
+
endpoint_info = {
|
|
164
|
+
'path': path,
|
|
165
|
+
'method': method,
|
|
166
|
+
'summary': details.get('summary', ''),
|
|
167
|
+
'description': details.get('description', ''),
|
|
168
|
+
'operation_id': details.get('operationId', ''),
|
|
169
|
+
'path_params': path_params,
|
|
170
|
+
'query_params': query_params,
|
|
171
|
+
'deprecated': details.get('deprecated', False),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if tag not in groups:
|
|
175
|
+
groups[tag] = []
|
|
176
|
+
groups[tag].append(endpoint_info)
|
|
177
|
+
|
|
178
|
+
return groups
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class OpenAPISpecError(Exception):
|
|
182
|
+
"""Raised when the OpenAPI spec cannot be loaded."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cycode
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.13.1.dev1
|
|
4
4
|
Summary: Boost security in your dev lifecycle via SAST, SCA, Secrets & IaC scanning.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENCE
|
|
@@ -62,7 +62,11 @@ This guide walks you through both installation and usage.
|
|
|
62
62
|
2. [Available Options](#available-options)
|
|
63
63
|
3. [MCP Tools](#mcp-tools)
|
|
64
64
|
4. [Usage Examples](#usage-examples)
|
|
65
|
-
5. [
|
|
65
|
+
5. [Platform Command](#platform-command-beta)
|
|
66
|
+
1. [Discovering Commands](#discovering-commands)
|
|
67
|
+
2. [Examples](#platform-examples)
|
|
68
|
+
3. [Notes & Limitations](#platform-notes--limitations)
|
|
69
|
+
6. [Scan Command](#scan-command)
|
|
66
70
|
1. [Running a Scan](#running-a-scan)
|
|
67
71
|
1. [Options](#options)
|
|
68
72
|
1. [Severity Threshold](#severity-option)
|
|
@@ -646,6 +650,64 @@ This information can be helpful when:
|
|
|
646
650
|
- Debugging transport-specific issues
|
|
647
651
|
|
|
648
652
|
|
|
653
|
+
# Platform Command \[BETA\]
|
|
654
|
+
|
|
655
|
+
> [!WARNING]
|
|
656
|
+
> The `platform` command is in **beta**. Commands, arguments, and output formats are generated dynamically from the Cycode API spec and may change between releases without notice. Do not rely on them in production automation yet.
|
|
657
|
+
|
|
658
|
+
The `cycode platform` command exposes the Cycode platform's read APIs as CLI commands. It groups endpoints by resource (e.g. `projects`, `violations`, `workflows`) and turns each endpoint's parameters into typed CLI arguments and `--option` flags.
|
|
659
|
+
|
|
660
|
+
```bash
|
|
661
|
+
cycode platform projects list --page-size 50
|
|
662
|
+
cycode platform violations count
|
|
663
|
+
cycode platform workflows view <workflow-id>
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
The OpenAPI spec is fetched from the Cycode API on first use and cached at `~/.cycode/openapi-spec.json` for 24 hours. Unrelated commands (`cycode scan`, `cycode status`, etc.) do not trigger a fetch.
|
|
667
|
+
|
|
668
|
+
> [!NOTE]
|
|
669
|
+
> You must be authenticated (`cycode auth` or `CYCODE_CLIENT_ID` / `CYCODE_CLIENT_SECRET` environment variables) for `cycode platform` to discover and run commands. Other Cycode CLI commands work without authentication.
|
|
670
|
+
|
|
671
|
+
## Discovering Commands
|
|
672
|
+
|
|
673
|
+
Because commands are generated from the spec, the source of truth for what's available is `--help`:
|
|
674
|
+
|
|
675
|
+
```bash
|
|
676
|
+
cycode platform --help # list all resource groups
|
|
677
|
+
cycode platform projects --help # list actions on a resource
|
|
678
|
+
cycode platform projects list --help # list options/arguments for an action
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
## Platform Examples
|
|
682
|
+
|
|
683
|
+
```bash
|
|
684
|
+
# List projects with pagination
|
|
685
|
+
cycode platform projects list --page-size 25
|
|
686
|
+
|
|
687
|
+
# View a single project by ID
|
|
688
|
+
cycode platform projects view <project-id>
|
|
689
|
+
|
|
690
|
+
# Count violations across the tenant
|
|
691
|
+
cycode platform violations count
|
|
692
|
+
|
|
693
|
+
# Filter using query parameters (see `--help` for what each endpoint supports)
|
|
694
|
+
cycode platform violations list --severity CRITICAL
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
All output is JSON by default — pipe it through `jq` for ad-hoc filtering:
|
|
698
|
+
|
|
699
|
+
```bash
|
|
700
|
+
cycode platform projects list --page-size 100 | jq '.items[].name'
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
## Platform Notes & Limitations
|
|
704
|
+
|
|
705
|
+
- **Read-only today.** Only `GET` endpoints are exposed in this beta.
|
|
706
|
+
- **Spec-driven.** Adding a new endpoint to the API surfaces it automatically the next time the cache is refreshed.
|
|
707
|
+
- **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.
|
|
708
|
+
- **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=<seconds>`.
|
|
709
|
+
|
|
710
|
+
|
|
649
711
|
# Scan Command
|
|
650
712
|
|
|
651
713
|
## Running a Scan
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
cycode/__init__.py,sha256=
|
|
1
|
+
cycode/__init__.py,sha256=XwAvpXUqHDlbSaLpeRzrQIJsv9a29URD8wVPjXnmVVo,115
|
|
2
2
|
cycode/__main__.py,sha256=Z3bD5yrA7yPvAChcADQrqCaZd0ChGI1gdiwALwbWJ6U,104
|
|
3
3
|
cycode/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
cycode/cli/app.py,sha256=
|
|
4
|
+
cycode/cli/app.py,sha256=7ReEcVkRX9IaQ2I7jAj7Sl9smbtvxiuK8-9bitMEQik,7491
|
|
5
5
|
cycode/cli/apps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
cycode/cli/apps/activation_manager.py,sha256=Hz9PDJFB-ZmYi4HSG8iYC-fR8j5v25VuUU-l95Otsdk,1678
|
|
7
7
|
cycode/cli/apps/ai_guardrails/__init__.py,sha256=R2l1CRRMOY4bAeJkndio81Sc4v0upURGo5s14Hejh-I,1120
|
|
@@ -26,6 +26,9 @@ cycode/cli/apps/ai_remediation/__init__.py,sha256=8vYthY9RQeJqEni3AIF5sryz8n-XJQ
|
|
|
26
26
|
cycode/cli/apps/ai_remediation/ai_remediation_command.py,sha256=u1EdebaKCEmzv9fXmnIN0xDSLcCmGyjueYKvYfLOj_8,1549
|
|
27
27
|
cycode/cli/apps/ai_remediation/apply_fix.py,sha256=9zgqiqF9HBQXi7Oz9ZIiANIAuKAMTji1PlNncCEOf5Q,817
|
|
28
28
|
cycode/cli/apps/ai_remediation/print_remediation.py,sha256=nEVkR7gnGIryGEo0NOKzrmqsh4CjLr2QfVt9elsrzGY,590
|
|
29
|
+
cycode/cli/apps/api/__init__.py,sha256=1Re_qLdTwSt9H4OR-Cl5jxBvhNI4nEhPBzJ3-V1AMCw,2523
|
|
30
|
+
cycode/cli/apps/api/api_command.py,sha256=iZKOegd0-NQ8dOwxuQUFdem39iXd9nBGQr2FD-s4nyw,9539
|
|
31
|
+
cycode/cli/apps/api/openapi_spec.py,sha256=_Le9FN5JxL5XElEnBpKtdFLGZH2iSNVjXgQblxwFEXE,6877
|
|
29
32
|
cycode/cli/apps/auth/__init__.py,sha256=rjf_rEBS1aS6rzY4Qh75BzOOX9SEHPdJMah-1FJM4DY,447
|
|
30
33
|
cycode/cli/apps/auth/auth_command.py,sha256=lI5lXuyGD9_OOr-kdzVxu_3gDZasOHJ0mgMXSIt8-cs,1448
|
|
31
34
|
cycode/cli/apps/auth/auth_common.py,sha256=bfQXqfv5bcYmc7njWOnG1VGzRU-C7spBv48gxHROCGU,2420
|
|
@@ -199,8 +202,8 @@ cycode/cyclient/report_client.py,sha256=Scq30NeJPzgXv0hPLO1U05AdE9i_2iu6cIrSKpEJ
|
|
|
199
202
|
cycode/cyclient/scan_client.py,sha256=6TK5FQkfrvV7PHqRnUzEn1PBNd2oPYVamvIixcUfe3c,16755
|
|
200
203
|
cycode/cyclient/scan_config_base.py,sha256=mXsPZGYCtp85rv5GIige40yQZXuRcEKUW-VQJ0vgFzk,1201
|
|
201
204
|
cycode/logger.py,sha256=EfZGRK6VC5rE_LAjIcRrHFiQCueylCDXoG6bvGkrIME,2111
|
|
202
|
-
cycode-3.
|
|
203
|
-
cycode-3.
|
|
204
|
-
cycode-3.
|
|
205
|
-
cycode-3.
|
|
206
|
-
cycode-3.
|
|
205
|
+
cycode-3.13.1.dev1.dist-info/METADATA,sha256=GxY_ST16ZL_6hONlPt3R_zxgdajGap5DW7jIbKfDUWg,87082
|
|
206
|
+
cycode-3.13.1.dev1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
207
|
+
cycode-3.13.1.dev1.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
|
|
208
|
+
cycode-3.13.1.dev1.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
|
|
209
|
+
cycode-3.13.1.dev1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|