apisec-code-bolt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apisec_code_bolt/__init__.py +42 -0
- apisec_code_bolt/__main__.py +11 -0
- apisec_code_bolt/analysis/__init__.py +96 -0
- apisec_code_bolt/analysis/analyzer.py +2309 -0
- apisec_code_bolt/analysis/binding_tracker.py +341 -0
- apisec_code_bolt/analysis/call_graph.py +1197 -0
- apisec_code_bolt/analysis/call_graph_types.py +332 -0
- apisec_code_bolt/analysis/call_resolver.py +988 -0
- apisec_code_bolt/analysis/capability_tagger.py +322 -0
- apisec_code_bolt/analysis/config_scanner.py +197 -0
- apisec_code_bolt/analysis/data_flow.py +1883 -0
- apisec_code_bolt/analysis/dependency_extractor.py +959 -0
- apisec_code_bolt/analysis/flow_analysis.py +1406 -0
- apisec_code_bolt/analysis/hof_catalog.py +61 -0
- apisec_code_bolt/analysis/integration_detector.py +1399 -0
- apisec_code_bolt/analysis/literal_scanner.py +300 -0
- apisec_code_bolt/analysis/path_normalizer.py +55 -0
- apisec_code_bolt/analysis/read_site_detector.py +310 -0
- apisec_code_bolt/analysis/request_patterns.py +162 -0
- apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
- apisec_code_bolt/analysis/sink_evidence.py +333 -0
- apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
- apisec_code_bolt/cli/__init__.py +5 -0
- apisec_code_bolt/cli/exit_codes.py +17 -0
- apisec_code_bolt/cli/main.py +1069 -0
- apisec_code_bolt/cloud/__init__.py +1 -0
- apisec_code_bolt/cloud/apisec_client.py +118 -0
- apisec_code_bolt/cloud/client.py +255 -0
- apisec_code_bolt/core/__init__.py +75 -0
- apisec_code_bolt/core/config.py +528 -0
- apisec_code_bolt/core/credentials.py +65 -0
- apisec_code_bolt/core/discovery.py +433 -0
- apisec_code_bolt/core/log_format.py +115 -0
- apisec_code_bolt/core/manifest.py +1009 -0
- apisec_code_bolt/core/repo.py +280 -0
- apisec_code_bolt/core/state.py +59 -0
- apisec_code_bolt/core/telemetry.py +451 -0
- apisec_code_bolt/core/types.py +587 -0
- apisec_code_bolt/fingerprinting/__init__.py +1 -0
- apisec_code_bolt/frameworks/__init__.py +29 -0
- apisec_code_bolt/frameworks/_jwt_common.py +50 -0
- apisec_code_bolt/frameworks/auth_helpers.py +437 -0
- apisec_code_bolt/frameworks/base.py +608 -0
- apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
- apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
- apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
- apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
- apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
- apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
- apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
- apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
- apisec_code_bolt/frameworks/java/__init__.py +6 -0
- apisec_code_bolt/frameworks/java/_annotations.py +167 -0
- apisec_code_bolt/frameworks/java/_constraints.py +128 -0
- apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
- apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
- apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
- apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
- apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
- apisec_code_bolt/frameworks/js/__init__.py +8 -0
- apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
- apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
- apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
- apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
- apisec_code_bolt/frameworks/python/__init__.py +19 -0
- apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
- apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
- apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
- apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
- apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
- apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
- apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
- apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
- apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
- apisec_code_bolt/parsing/__init__.py +62 -0
- apisec_code_bolt/parsing/base.py +554 -0
- apisec_code_bolt/parsing/csharp/__init__.py +5 -0
- apisec_code_bolt/parsing/csharp/language_services.py +203 -0
- apisec_code_bolt/parsing/csharp/literals.py +72 -0
- apisec_code_bolt/parsing/csharp/parser.py +1158 -0
- apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
- apisec_code_bolt/parsing/js/__init__.py +5 -0
- apisec_code_bolt/parsing/js/language_services.py +118 -0
- apisec_code_bolt/parsing/js/parser.py +622 -0
- apisec_code_bolt/parsing/jvm/__init__.py +7 -0
- apisec_code_bolt/parsing/jvm/language_services.py +270 -0
- apisec_code_bolt/parsing/jvm/parser.py +774 -0
- apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
- apisec_code_bolt/parsing/python/__init__.py +150 -0
- apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
- apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
- apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
- apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
- apisec_code_bolt/parsing/python/expression_utils.py +221 -0
- apisec_code_bolt/parsing/python/extraction_types.py +271 -0
- apisec_code_bolt/parsing/python/language_services.py +487 -0
- apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
- apisec_code_bolt/parsing/python/parser.py +719 -0
- apisec_code_bolt/parsing/python/path_resolver.py +576 -0
- apisec_code_bolt/parsing/python/router_registry.py +806 -0
- apisec_code_bolt/parsing/python/type_resolver.py +730 -0
- apisec_code_bolt/parsing/python/visitors.py +1544 -0
- apisec_code_bolt/parsing/services.py +544 -0
- apisec_code_bolt/query/__init__.py +1 -0
- apisec_code_bolt/query/ast_cache.py +182 -0
- apisec_code_bolt/query/executor.py +283 -0
- apisec_code_bolt/query/handlers.py +832 -0
- apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
- apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
- apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
- apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for apisec-code-bolt.
|
|
3
|
+
|
|
4
|
+
This module defines the CLI commands using Click.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from .. import __version__
|
|
20
|
+
from ..core.config import DEFAULT_API_URL, CodeBoltConfig
|
|
21
|
+
from ..core.log_format import analyzer_summary, log_error, log_info, log_warning
|
|
22
|
+
from .exit_codes import ExitCode
|
|
23
|
+
|
|
24
|
+
# Rich console for pretty output
|
|
25
|
+
console = Console()
|
|
26
|
+
error_console = Console(stderr=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# CLI Group
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.group()
|
|
35
|
+
@click.version_option(version=__version__, prog_name="apisec-code-bolt")
|
|
36
|
+
@click.option(
|
|
37
|
+
"-v",
|
|
38
|
+
"--verbose",
|
|
39
|
+
is_flag=True,
|
|
40
|
+
help="Enable verbose output.",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"-q",
|
|
44
|
+
"--quiet",
|
|
45
|
+
is_flag=True,
|
|
46
|
+
help="Suppress non-essential output.",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--debug",
|
|
50
|
+
is_flag=True,
|
|
51
|
+
help="Enable debug mode.",
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--log-format",
|
|
55
|
+
"log_format",
|
|
56
|
+
type=click.Choice(["text", "json"]),
|
|
57
|
+
default="text",
|
|
58
|
+
envvar="APISEC_LOG_FORMAT",
|
|
59
|
+
help="Log output format. 'json' emits NDJSON to stderr for pipeline use.",
|
|
60
|
+
)
|
|
61
|
+
@click.pass_context
|
|
62
|
+
def cli(ctx: click.Context, verbose: bool, quiet: bool, debug: bool, log_format: str) -> None:
|
|
63
|
+
"""
|
|
64
|
+
apisec-code-bolt — Extract architectural metadata from your codebase.
|
|
65
|
+
|
|
66
|
+
Analyze source code to extract routes, data flows, authentication patterns,
|
|
67
|
+
and more. Output is a structured manifest that can be uploaded to the
|
|
68
|
+
APIsec cloud for vulnerability analysis.
|
|
69
|
+
"""
|
|
70
|
+
ctx.ensure_object(dict)
|
|
71
|
+
ctx.obj["verbose"] = verbose
|
|
72
|
+
ctx.obj["quiet"] = quiet
|
|
73
|
+
ctx.obj["debug"] = debug
|
|
74
|
+
ctx.obj["log_format"] = log_format
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# =============================================================================
|
|
78
|
+
# Analyze Command
|
|
79
|
+
# =============================================================================
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@cli.command()
|
|
83
|
+
@click.argument(
|
|
84
|
+
"path",
|
|
85
|
+
type=click.Path(exists=True, file_okay=False, resolve_path=True, path_type=Path),
|
|
86
|
+
default=".",
|
|
87
|
+
)
|
|
88
|
+
@click.option(
|
|
89
|
+
"-o",
|
|
90
|
+
"--output",
|
|
91
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
92
|
+
help="Save manifest to file instead of uploading.",
|
|
93
|
+
)
|
|
94
|
+
@click.option(
|
|
95
|
+
"--no-upload",
|
|
96
|
+
is_flag=True,
|
|
97
|
+
help="Skip uploading to cloud (implies --output if not set).",
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--cloud-url",
|
|
101
|
+
type=str,
|
|
102
|
+
default=None,
|
|
103
|
+
help="Reasoning engine URL (e.g., http://localhost:8100). Legacy: direct connection for local dev.",
|
|
104
|
+
)
|
|
105
|
+
@click.option(
|
|
106
|
+
"--api-key",
|
|
107
|
+
"api_key_override",
|
|
108
|
+
type=str,
|
|
109
|
+
help="Override stored API key.",
|
|
110
|
+
)
|
|
111
|
+
@click.option(
|
|
112
|
+
"--api-url",
|
|
113
|
+
"api_url_override",
|
|
114
|
+
type=str,
|
|
115
|
+
help="Override stored API URL.",
|
|
116
|
+
)
|
|
117
|
+
@click.option(
|
|
118
|
+
"--reasoning-url",
|
|
119
|
+
type=str,
|
|
120
|
+
help="Reasoning engine URL (if different from API URL).",
|
|
121
|
+
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"--format",
|
|
124
|
+
"output_format",
|
|
125
|
+
type=click.Choice(["json", "yaml"]),
|
|
126
|
+
default="json",
|
|
127
|
+
help="Output format.",
|
|
128
|
+
)
|
|
129
|
+
@click.option(
|
|
130
|
+
"--config",
|
|
131
|
+
"config_file",
|
|
132
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
133
|
+
help="Path to configuration file.",
|
|
134
|
+
)
|
|
135
|
+
@click.option(
|
|
136
|
+
"--frameworks",
|
|
137
|
+
type=str,
|
|
138
|
+
help="Comma-separated framework hints (e.g., fastapi,sqlalchemy).",
|
|
139
|
+
)
|
|
140
|
+
@click.option(
|
|
141
|
+
"--exclude",
|
|
142
|
+
multiple=True,
|
|
143
|
+
help="Glob patterns to exclude. Can be repeated.",
|
|
144
|
+
)
|
|
145
|
+
@click.option(
|
|
146
|
+
"--max-files",
|
|
147
|
+
type=int,
|
|
148
|
+
help="Maximum files to analyze.",
|
|
149
|
+
)
|
|
150
|
+
@click.option(
|
|
151
|
+
"--timeout",
|
|
152
|
+
type=int,
|
|
153
|
+
help="Analysis timeout in seconds.",
|
|
154
|
+
)
|
|
155
|
+
@click.option(
|
|
156
|
+
"--dry-run",
|
|
157
|
+
is_flag=True,
|
|
158
|
+
help="Analyse project and print summary, but do not write or upload manifest.",
|
|
159
|
+
)
|
|
160
|
+
@click.option(
|
|
161
|
+
"--stdout",
|
|
162
|
+
"output_stdout",
|
|
163
|
+
is_flag=True,
|
|
164
|
+
help="Write manifest JSON to stdout instead of a file (for pipeline use).",
|
|
165
|
+
)
|
|
166
|
+
@click.pass_context
|
|
167
|
+
def analyze(
|
|
168
|
+
ctx: click.Context,
|
|
169
|
+
path: Path,
|
|
170
|
+
output: Path | None,
|
|
171
|
+
no_upload: bool,
|
|
172
|
+
cloud_url: str | None,
|
|
173
|
+
api_key_override: str | None,
|
|
174
|
+
api_url_override: str | None,
|
|
175
|
+
reasoning_url: str | None,
|
|
176
|
+
output_format: str,
|
|
177
|
+
config_file: Path | None,
|
|
178
|
+
frameworks: str | None,
|
|
179
|
+
exclude: tuple[str, ...],
|
|
180
|
+
max_files: int | None,
|
|
181
|
+
timeout: int | None,
|
|
182
|
+
dry_run: bool = False,
|
|
183
|
+
output_stdout: bool = False,
|
|
184
|
+
) -> None:
|
|
185
|
+
log_format: str = ctx.obj.get("log_format", "text")
|
|
186
|
+
"""
|
|
187
|
+
Analyze a codebase and generate a manifest.
|
|
188
|
+
|
|
189
|
+
PATH is the root directory of the project to analyze.
|
|
190
|
+
Defaults to the current directory.
|
|
191
|
+
|
|
192
|
+
\b
|
|
193
|
+
Examples:
|
|
194
|
+
# Analyze current directory and save locally
|
|
195
|
+
apisec-code-bolt analyze . --no-upload
|
|
196
|
+
|
|
197
|
+
# Analyze and save to specific file
|
|
198
|
+
apisec-code-bolt analyze . --output manifest.json --no-upload
|
|
199
|
+
|
|
200
|
+
# Analyze and run verification against local reasoning engine
|
|
201
|
+
apisec-code-bolt analyze . --cloud-url http://localhost:8100
|
|
202
|
+
|
|
203
|
+
# Analyze with framework hints
|
|
204
|
+
apisec-code-bolt analyze . --frameworks fastapi,sqlalchemy
|
|
205
|
+
"""
|
|
206
|
+
verbose = ctx.obj.get("verbose", False)
|
|
207
|
+
quiet = ctx.obj.get("quiet", False)
|
|
208
|
+
|
|
209
|
+
if api_key_override and not quiet:
|
|
210
|
+
error_console.print(
|
|
211
|
+
"[yellow]Warning: passing API keys via CLI arguments exposes them in "
|
|
212
|
+
"process listings. Prefer the CODEBOLT_API_KEY env var or the "
|
|
213
|
+
"'apisec-code-bolt auth' command.[/yellow]"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Build configuration
|
|
217
|
+
config_overrides: dict[str, Any] = {}
|
|
218
|
+
|
|
219
|
+
if frameworks:
|
|
220
|
+
config_overrides["analysis.framework_hints"] = frameworks.split(",")
|
|
221
|
+
if exclude:
|
|
222
|
+
config_overrides["analysis.file_discovery.exclude_patterns"] = list(exclude)
|
|
223
|
+
if max_files:
|
|
224
|
+
config_overrides["analysis.file_discovery.max_files"] = max_files
|
|
225
|
+
if timeout:
|
|
226
|
+
config_overrides["analysis.timeout_seconds"] = timeout
|
|
227
|
+
if no_upload:
|
|
228
|
+
config_overrides["cloud.enabled"] = False
|
|
229
|
+
if output:
|
|
230
|
+
config_overrides["output.output_file"] = output
|
|
231
|
+
config_overrides["output.format"] = output_format
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
config = CodeBoltConfig.from_cli(
|
|
235
|
+
project_root=path,
|
|
236
|
+
config_file=config_file,
|
|
237
|
+
**config_overrides,
|
|
238
|
+
)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
error_console.print(f"[red]Configuration error:[/red] {e}")
|
|
241
|
+
sys.exit(ExitCode.CONFIG_ERROR)
|
|
242
|
+
|
|
243
|
+
# Registration code — required on first run, stored and never asked again.
|
|
244
|
+
# Subsequent runs use the installation ID hash only.
|
|
245
|
+
from ..core.telemetry import (
|
|
246
|
+
check_first_run_notice,
|
|
247
|
+
emit_install_event,
|
|
248
|
+
needs_registration,
|
|
249
|
+
set_registration_code,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if needs_registration():
|
|
253
|
+
import os
|
|
254
|
+
|
|
255
|
+
env_code = os.environ.get("APISEC_REGISTRATION_CODE", "").strip()
|
|
256
|
+
if env_code:
|
|
257
|
+
code = env_code
|
|
258
|
+
elif quiet or log_format == "json":
|
|
259
|
+
error_console.print(
|
|
260
|
+
"[red]Error:[/red] No registration code found. "
|
|
261
|
+
"Set APISEC_REGISTRATION_CODE or run interactively."
|
|
262
|
+
)
|
|
263
|
+
sys.exit(ExitCode.CONFIG_ERROR)
|
|
264
|
+
else:
|
|
265
|
+
console.print(
|
|
266
|
+
"\n[bold]This is an installation prompt.[/bold]\n"
|
|
267
|
+
"[dim]Your registration code was provided by APIsec during onboarding.[/dim]"
|
|
268
|
+
)
|
|
269
|
+
code = click.prompt("Please enter code (###-###)").strip()
|
|
270
|
+
|
|
271
|
+
if not code:
|
|
272
|
+
error_console.print("[red]Error:[/red] Registration code cannot be empty.")
|
|
273
|
+
sys.exit(ExitCode.CONFIG_ERROR)
|
|
274
|
+
|
|
275
|
+
set_registration_code(code)
|
|
276
|
+
|
|
277
|
+
# Fire the install event — includes code + hash, happens exactly once
|
|
278
|
+
import contextlib
|
|
279
|
+
|
|
280
|
+
with contextlib.suppress(Exception):
|
|
281
|
+
emit_install_event(probe_version=__version__)
|
|
282
|
+
|
|
283
|
+
# Opt-out notice (shown once, after registration)
|
|
284
|
+
check_first_run_notice(quiet=quiet, json_mode=(log_format == "json"))
|
|
285
|
+
|
|
286
|
+
if log_format == "json":
|
|
287
|
+
log_info("scan_start", version=__version__, project=str(path))
|
|
288
|
+
elif not quiet:
|
|
289
|
+
console.print(
|
|
290
|
+
Panel(
|
|
291
|
+
f"[bold]Analyzing:[/bold] {path}\n[dim]Version: {__version__}[/dim]",
|
|
292
|
+
title="apisec-code-bolt",
|
|
293
|
+
border_style="blue",
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Run analysis
|
|
298
|
+
from ..analysis import analyze_project
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
if not quiet and log_format != "json":
|
|
302
|
+
with Progress(
|
|
303
|
+
SpinnerColumn(),
|
|
304
|
+
TextColumn("[progress.description]{task.description}"),
|
|
305
|
+
console=console,
|
|
306
|
+
) as progress:
|
|
307
|
+
progress.add_task("Analyzing project...", total=None)
|
|
308
|
+
result = analyze_project(config)
|
|
309
|
+
else:
|
|
310
|
+
result = analyze_project(config)
|
|
311
|
+
|
|
312
|
+
# Emit telemetry (fire-and-forget, opt-in only)
|
|
313
|
+
try:
|
|
314
|
+
from ..core.telemetry import emit_analyze_event
|
|
315
|
+
|
|
316
|
+
emit_analyze_event(
|
|
317
|
+
probe_version=__version__,
|
|
318
|
+
files_analyzed=result.files_analyzed,
|
|
319
|
+
files_failed=result.files_failed,
|
|
320
|
+
files_skipped=getattr(result, "files_skipped", 0),
|
|
321
|
+
frameworks=result.manifest.project.frameworks_detected,
|
|
322
|
+
languages=result.manifest.project.languages_detected,
|
|
323
|
+
routes_found=len(result.manifest.entry_points),
|
|
324
|
+
routes_by_framework=getattr(result, "routes_by_framework", {}),
|
|
325
|
+
analysis_time_ms=result.total_time_ms,
|
|
326
|
+
parse_time_ms=result.parse_time_ms,
|
|
327
|
+
extraction_time_ms=result.extraction_time_ms,
|
|
328
|
+
stage_times_ms=getattr(result, "stage_times_ms", {}),
|
|
329
|
+
extractor_times_ms=getattr(result, "extractor_times_ms", {}),
|
|
330
|
+
has_errors=bool(result.parse_errors),
|
|
331
|
+
)
|
|
332
|
+
except Exception:
|
|
333
|
+
pass # Telemetry must never break the CLI
|
|
334
|
+
|
|
335
|
+
# --stdout: write manifest JSON to stdout and return
|
|
336
|
+
if output_stdout:
|
|
337
|
+
sys.stdout.write(result.manifest.to_json(pretty=False))
|
|
338
|
+
sys.stdout.write("\n")
|
|
339
|
+
sys.stdout.flush()
|
|
340
|
+
if log_format == "json":
|
|
341
|
+
_emit_json_summary(path, result)
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
# --dry-run: print summary and return without writing
|
|
345
|
+
if dry_run:
|
|
346
|
+
if log_format == "json":
|
|
347
|
+
_emit_json_summary(path, result)
|
|
348
|
+
elif not quiet:
|
|
349
|
+
console.print()
|
|
350
|
+
console.print("[bold yellow]Dry run — manifest not written.[/bold yellow]")
|
|
351
|
+
console.print(f" Routes: {len(result.manifest.entry_points)}")
|
|
352
|
+
console.print(f" Files analyzed: {result.files_analyzed}")
|
|
353
|
+
console.print(
|
|
354
|
+
f" Frameworks: {', '.join(result.manifest.project.frameworks_detected) or 'None'}"
|
|
355
|
+
)
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
# Save manifest locally
|
|
359
|
+
if output:
|
|
360
|
+
output_path = output
|
|
361
|
+
else:
|
|
362
|
+
apisec_dir = path / "apisec"
|
|
363
|
+
apisec_dir.mkdir(exist_ok=True)
|
|
364
|
+
output_path = apisec_dir / "manifest.json"
|
|
365
|
+
manifest_json = result.manifest.to_json(pretty=True)
|
|
366
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
output_path.write_text(manifest_json)
|
|
368
|
+
|
|
369
|
+
if log_format == "json":
|
|
370
|
+
_emit_json_summary(path, result, output_path=output_path)
|
|
371
|
+
elif not quiet:
|
|
372
|
+
console.print(f"\n[green]✓ Manifest saved to {output_path}[/green]")
|
|
373
|
+
|
|
374
|
+
# Display summary (text mode only)
|
|
375
|
+
if log_format != "json" and not quiet:
|
|
376
|
+
console.print()
|
|
377
|
+
|
|
378
|
+
table = Table(title="Analysis Summary")
|
|
379
|
+
table.add_column("Metric", style="cyan")
|
|
380
|
+
table.add_column("Value", style="green")
|
|
381
|
+
|
|
382
|
+
table.add_row("Project", result.manifest.project.name or "Unknown")
|
|
383
|
+
table.add_row("Files Analyzed", str(result.files_analyzed))
|
|
384
|
+
table.add_row("Files Failed", str(result.files_failed))
|
|
385
|
+
table.add_row("Routes Found", str(len(result.manifest.entry_points)))
|
|
386
|
+
table.add_row("Functions", str(len(result.manifest.functions)))
|
|
387
|
+
table.add_row("Classes", str(len(result.manifest.classes)))
|
|
388
|
+
table.add_row(
|
|
389
|
+
"Frameworks", ", ".join(result.manifest.project.frameworks_detected) or "None"
|
|
390
|
+
)
|
|
391
|
+
table.add_row("Analysis Time", f"{result.total_time_ms}ms")
|
|
392
|
+
|
|
393
|
+
console.print(table)
|
|
394
|
+
|
|
395
|
+
if result.manifest.entry_points:
|
|
396
|
+
console.print()
|
|
397
|
+
routes_table = Table(title="Routes Detected")
|
|
398
|
+
routes_table.add_column("Method", style="yellow")
|
|
399
|
+
routes_table.add_column("Path", style="cyan")
|
|
400
|
+
routes_table.add_column("Handler", style="green")
|
|
401
|
+
|
|
402
|
+
for route in result.manifest.entry_points[:20]:
|
|
403
|
+
routes_table.add_row(route.method, route.path, route.handler_function)
|
|
404
|
+
|
|
405
|
+
if len(result.manifest.entry_points) > 20:
|
|
406
|
+
routes_table.add_row(
|
|
407
|
+
"...", f"({len(result.manifest.entry_points) - 20} more)", "..."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
console.print(routes_table)
|
|
411
|
+
|
|
412
|
+
if result.parse_errors:
|
|
413
|
+
console.print()
|
|
414
|
+
console.print(
|
|
415
|
+
f"[yellow]⚠ {len(result.parse_errors)} files failed to parse[/yellow]"
|
|
416
|
+
)
|
|
417
|
+
if verbose:
|
|
418
|
+
for err in result.parse_errors[:10]:
|
|
419
|
+
console.print(f" [dim]{err}[/dim]")
|
|
420
|
+
|
|
421
|
+
# Two-phase authenticated upload (default when not --no-upload)
|
|
422
|
+
if not no_upload:
|
|
423
|
+
_run_authenticated_upload(
|
|
424
|
+
ctx,
|
|
425
|
+
path,
|
|
426
|
+
result,
|
|
427
|
+
api_key_override,
|
|
428
|
+
api_url_override,
|
|
429
|
+
reasoning_url,
|
|
430
|
+
quiet,
|
|
431
|
+
)
|
|
432
|
+
elif cloud_url:
|
|
433
|
+
# Legacy: direct connection to reasoning engine (for local dev)
|
|
434
|
+
_run_verification_loop(path, result, cloud_url, quiet)
|
|
435
|
+
|
|
436
|
+
except Exception as e:
|
|
437
|
+
if ctx.obj.get("log_format") == "json":
|
|
438
|
+
log_error("scan_failed", error=str(e))
|
|
439
|
+
else:
|
|
440
|
+
error_console.print(f"[red]Analysis failed:[/red] {e}")
|
|
441
|
+
if ctx.obj.get("debug"):
|
|
442
|
+
import traceback
|
|
443
|
+
|
|
444
|
+
error_console.print(traceback.format_exc())
|
|
445
|
+
try:
|
|
446
|
+
from ..core.telemetry import emit_error_event
|
|
447
|
+
|
|
448
|
+
emit_error_event(
|
|
449
|
+
probe_version=__version__,
|
|
450
|
+
error_type=type(e).__name__,
|
|
451
|
+
)
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
sys.exit(ExitCode.MANIFEST_ERROR)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _emit_json_summary(
|
|
458
|
+
project_root: Path,
|
|
459
|
+
result: Any,
|
|
460
|
+
output_path: Path | None = None,
|
|
461
|
+
) -> None:
|
|
462
|
+
"""Emit the structured scan_complete record to stderr."""
|
|
463
|
+
summary = analyzer_summary(project_root, result, __version__)
|
|
464
|
+
if output_path is not None:
|
|
465
|
+
summary["manifest_path"] = str(output_path)
|
|
466
|
+
# Emit individual parse_error warning records with file:line context
|
|
467
|
+
details = getattr(result, "parse_error_details", None)
|
|
468
|
+
if details:
|
|
469
|
+
for detail in details:
|
|
470
|
+
log_warning("parse_error", **detail)
|
|
471
|
+
else:
|
|
472
|
+
for err in result.parse_errors:
|
|
473
|
+
if ": " in err:
|
|
474
|
+
file_part, _, msg = err.partition(": ")
|
|
475
|
+
log_warning("parse_error", file=file_part, error=msg)
|
|
476
|
+
else:
|
|
477
|
+
log_warning("parse_error", file="", error=err)
|
|
478
|
+
import json
|
|
479
|
+
import sys
|
|
480
|
+
|
|
481
|
+
sys.stderr.write(json.dumps(summary, default=str) + "\n")
|
|
482
|
+
sys.stderr.flush()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _run_authenticated_upload(
|
|
486
|
+
ctx: click.Context,
|
|
487
|
+
project_path: Path,
|
|
488
|
+
result: Any,
|
|
489
|
+
api_key_override: str | None,
|
|
490
|
+
api_url_override: str | None,
|
|
491
|
+
reasoning_url_override: str | None,
|
|
492
|
+
quiet: bool,
|
|
493
|
+
) -> None:
|
|
494
|
+
"""Register surface with applicationsservice (PAT), then upload manifest to the reasoning engine (same PAT + app_id)."""
|
|
495
|
+
import json
|
|
496
|
+
import os
|
|
497
|
+
|
|
498
|
+
from ..cloud.apisec_client import ApisecClient, RepoInfo
|
|
499
|
+
from ..core.credentials import load_credentials
|
|
500
|
+
from ..core.repo import detect_repo_info
|
|
501
|
+
from ..core.state import RepoState, read_state, write_state
|
|
502
|
+
|
|
503
|
+
pat = api_key_override or os.environ.get("APISEC_PAT")
|
|
504
|
+
api_url = api_url_override
|
|
505
|
+
if pat is None or api_url is None:
|
|
506
|
+
creds = load_credentials()
|
|
507
|
+
if creds:
|
|
508
|
+
pat = pat or creds.api_key
|
|
509
|
+
api_url = api_url or creds.api_url
|
|
510
|
+
|
|
511
|
+
if not pat or not api_url:
|
|
512
|
+
error_console.print(
|
|
513
|
+
"[red]No PAT or applicationsservice URL. Set APISEC_PAT (or pass --api-key), "
|
|
514
|
+
"API_APPLICATIONSSERVICE_URL (or --api-url), or run: "
|
|
515
|
+
"apisec-code-bolt auth <pat> --api-url https://...[/red]",
|
|
516
|
+
)
|
|
517
|
+
sys.exit(1)
|
|
518
|
+
|
|
519
|
+
repo_info_dict = detect_repo_info(project_path)
|
|
520
|
+
repo_info = RepoInfo(
|
|
521
|
+
repo_name=repo_info_dict["repo_name"],
|
|
522
|
+
repo_url=repo_info_dict["repo_url"],
|
|
523
|
+
branch=repo_info_dict["branch"],
|
|
524
|
+
commit_sha=repo_info_dict["commit_sha"],
|
|
525
|
+
scm_provider=repo_info_dict["scm_provider"],
|
|
526
|
+
canonical_repo_id=repo_info_dict["canonical_repo_id"],
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
client = ApisecClient(api_url=api_url, pat=pat)
|
|
530
|
+
|
|
531
|
+
if not quiet:
|
|
532
|
+
console.print()
|
|
533
|
+
console.print(
|
|
534
|
+
Panel(
|
|
535
|
+
f"[bold]Authenticated upload[/bold]\n"
|
|
536
|
+
f"Applicationsservice: {api_url}\n"
|
|
537
|
+
f"Repo: {repo_info.repo_name} ({repo_info.branch})\n"
|
|
538
|
+
f"Canonical id: {repo_info.canonical_repo_id}",
|
|
539
|
+
title="Cloud connection",
|
|
540
|
+
border_style="green",
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
prior = read_state(project_path)
|
|
546
|
+
existing_app_id = prior.application_id if prior else None
|
|
547
|
+
registration = client.register(
|
|
548
|
+
repo_info,
|
|
549
|
+
existing_app_id=existing_app_id,
|
|
550
|
+
force_new_application=existing_app_id is None,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
write_state(project_path, RepoState(application_id=registration.app_id))
|
|
554
|
+
|
|
555
|
+
manifest_data = json.loads(result.manifest.to_json())
|
|
556
|
+
upload_result = client.upload_and_verify(
|
|
557
|
+
registration,
|
|
558
|
+
manifest_data,
|
|
559
|
+
project_path,
|
|
560
|
+
reasoning_engine_url=reasoning_url_override,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if not quiet:
|
|
564
|
+
console.print()
|
|
565
|
+
vtable = Table(title="Verification Result")
|
|
566
|
+
vtable.add_column("Metric", style="cyan")
|
|
567
|
+
vtable.add_column("Value", style="green")
|
|
568
|
+
vtable.add_row("Analysis ID", upload_result.get("analysis_id") or "N/A")
|
|
569
|
+
vtable.add_row("Status", upload_result.get("status", "unknown"))
|
|
570
|
+
vtable.add_row("Rounds", str(upload_result.get("rounds", 0)))
|
|
571
|
+
vtable.add_row("Questions Answered", str(upload_result.get("questions_answered", 0)))
|
|
572
|
+
vtable.add_row("Duration", f"{upload_result.get('elapsed_seconds', 0):.1f}s")
|
|
573
|
+
if upload_result.get("error"):
|
|
574
|
+
vtable.add_row("Error", f"[red]{upload_result['error']}[/red]")
|
|
575
|
+
console.print(vtable)
|
|
576
|
+
|
|
577
|
+
console.print()
|
|
578
|
+
console.print(
|
|
579
|
+
Panel(
|
|
580
|
+
f"http://localhost:5174/application/{registration.app_id}",
|
|
581
|
+
title="View Results in APIsec",
|
|
582
|
+
border_style="cyan",
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
except Exception as e:
|
|
586
|
+
error_console.print(f"[red]Upload failed:[/red] {e}")
|
|
587
|
+
if ctx and ctx.obj.get("debug"):
|
|
588
|
+
import traceback
|
|
589
|
+
|
|
590
|
+
error_console.print(traceback.format_exc())
|
|
591
|
+
sys.exit(1)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _run_verification_loop(
|
|
595
|
+
project_path: Path,
|
|
596
|
+
result: Any,
|
|
597
|
+
cloud_url: str,
|
|
598
|
+
quiet: bool,
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Upload manifest and run the verification question/answer loop (PAT + app_id required)."""
|
|
601
|
+
import json
|
|
602
|
+
import os
|
|
603
|
+
|
|
604
|
+
from ..cloud.client import CloudClient
|
|
605
|
+
from ..core.credentials import load_credentials
|
|
606
|
+
from ..core.state import read_state
|
|
607
|
+
from ..query.executor import QueryExecutor
|
|
608
|
+
|
|
609
|
+
pat = os.environ.get("APISEC_PAT")
|
|
610
|
+
creds = load_credentials()
|
|
611
|
+
if not pat and creds:
|
|
612
|
+
pat = creds.api_key
|
|
613
|
+
prior = read_state(project_path)
|
|
614
|
+
app_id = os.environ.get("APISEC_APP_ID") or (prior.application_id if prior else None)
|
|
615
|
+
if not pat or not app_id:
|
|
616
|
+
error_console.print(
|
|
617
|
+
"[red]--cloud-url requires APISEC_PAT (or stored credentials) and an application id. "
|
|
618
|
+
"Set APISEC_APP_ID or run a full upload once so apisec-code-bolt/state.yaml "
|
|
619
|
+
"contains applicationId.[/red]",
|
|
620
|
+
)
|
|
621
|
+
sys.exit(1)
|
|
622
|
+
|
|
623
|
+
if not quiet:
|
|
624
|
+
console.print()
|
|
625
|
+
console.print(
|
|
626
|
+
Panel(
|
|
627
|
+
f"[bold]Verification loop[/bold]\nEngine: {cloud_url}",
|
|
628
|
+
title="Cloud connection",
|
|
629
|
+
border_style="green",
|
|
630
|
+
)
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
manifest_data = json.loads(result.manifest.to_json())
|
|
634
|
+
|
|
635
|
+
is_local = any(h in cloud_url for h in ("localhost", "127.0.0.1", "[::1]"))
|
|
636
|
+
if not is_local:
|
|
637
|
+
error_console.print(
|
|
638
|
+
"[yellow]Warning: --cloud-url points to a remote host. "
|
|
639
|
+
"TLS verification is only disabled for localhost. "
|
|
640
|
+
"Pass --insecure to override.[/yellow]"
|
|
641
|
+
)
|
|
642
|
+
with CloudClient(
|
|
643
|
+
api_url=cloud_url,
|
|
644
|
+
api_key=pat,
|
|
645
|
+
app_id=app_id,
|
|
646
|
+
verify_ssl=not is_local,
|
|
647
|
+
timeout_seconds=600,
|
|
648
|
+
) as client:
|
|
649
|
+
executor = QueryExecutor(project_root=project_path, max_batches=100, max_wait=600)
|
|
650
|
+
|
|
651
|
+
if not quiet:
|
|
652
|
+
with Progress(
|
|
653
|
+
SpinnerColumn(),
|
|
654
|
+
TextColumn("[progress.description]{task.description}"),
|
|
655
|
+
console=console,
|
|
656
|
+
) as progress:
|
|
657
|
+
progress.add_task("Running verification...", total=None)
|
|
658
|
+
exec_result = executor.run_connected(client, manifest_data)
|
|
659
|
+
else:
|
|
660
|
+
exec_result = executor.run_connected(client, manifest_data)
|
|
661
|
+
|
|
662
|
+
if not quiet:
|
|
663
|
+
console.print()
|
|
664
|
+
vtable = Table(title="Verification Result")
|
|
665
|
+
vtable.add_column("Metric", style="cyan")
|
|
666
|
+
vtable.add_column("Value", style="green")
|
|
667
|
+
vtable.add_row("Analysis ID", exec_result.analysis_id or "N/A")
|
|
668
|
+
vtable.add_row("Status", exec_result.final_status)
|
|
669
|
+
vtable.add_row("Rounds", str(exec_result.stats.rounds_completed))
|
|
670
|
+
vtable.add_row("Questions Answered", str(exec_result.stats.questions_answered))
|
|
671
|
+
vtable.add_row("Duration", f"{exec_result.stats.elapsed_seconds:.1f}s")
|
|
672
|
+
if exec_result.error:
|
|
673
|
+
vtable.add_row("Error", f"[red]{exec_result.error}[/red]")
|
|
674
|
+
console.print(vtable)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# =============================================================================
|
|
678
|
+
# Auth Command
|
|
679
|
+
# =============================================================================
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@cli.command()
|
|
683
|
+
@click.argument("api_key", required=False)
|
|
684
|
+
@click.option(
|
|
685
|
+
"--api-url",
|
|
686
|
+
type=str,
|
|
687
|
+
default=DEFAULT_API_URL,
|
|
688
|
+
help="APIsec API URL.",
|
|
689
|
+
)
|
|
690
|
+
@click.option(
|
|
691
|
+
"--check",
|
|
692
|
+
is_flag=True,
|
|
693
|
+
help="Check if already authenticated.",
|
|
694
|
+
)
|
|
695
|
+
@click.option(
|
|
696
|
+
"--logout",
|
|
697
|
+
is_flag=True,
|
|
698
|
+
help="Remove stored credentials.",
|
|
699
|
+
)
|
|
700
|
+
@click.pass_context
|
|
701
|
+
def auth(
|
|
702
|
+
ctx: click.Context,
|
|
703
|
+
api_key: str | None,
|
|
704
|
+
api_url: str,
|
|
705
|
+
check: bool,
|
|
706
|
+
logout: bool,
|
|
707
|
+
) -> None:
|
|
708
|
+
"""
|
|
709
|
+
Authenticate with the APIsec cloud.
|
|
710
|
+
|
|
711
|
+
\b
|
|
712
|
+
Examples:
|
|
713
|
+
# Interactive authentication
|
|
714
|
+
apisec-code-bolt auth
|
|
715
|
+
|
|
716
|
+
# Direct authentication
|
|
717
|
+
apisec-code-bolt auth sk_live_abc123...
|
|
718
|
+
|
|
719
|
+
# Check authentication status
|
|
720
|
+
apisec-code-bolt auth --check
|
|
721
|
+
"""
|
|
722
|
+
from ..core.credentials import (
|
|
723
|
+
clear_credentials,
|
|
724
|
+
load_credentials,
|
|
725
|
+
store_credentials,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
quiet = ctx.obj.get("quiet", False)
|
|
729
|
+
|
|
730
|
+
if logout:
|
|
731
|
+
clear_credentials()
|
|
732
|
+
if not quiet:
|
|
733
|
+
console.print("[green]Credentials removed.[/green]")
|
|
734
|
+
return
|
|
735
|
+
|
|
736
|
+
if check:
|
|
737
|
+
creds = load_credentials()
|
|
738
|
+
if creds and creds.api_key:
|
|
739
|
+
key = creds.api_key
|
|
740
|
+
masked = f"{key[:12]}...{key[-4:]}" if len(key) > 16 else f"{key[:4]}..."
|
|
741
|
+
if not quiet:
|
|
742
|
+
console.print(f"[green]Authenticated[/green] — API key: {masked}")
|
|
743
|
+
console.print(f" API URL: {creds.api_url}")
|
|
744
|
+
else:
|
|
745
|
+
if not quiet:
|
|
746
|
+
console.print(
|
|
747
|
+
"[yellow]Not authenticated. Run: apisec-code-bolt auth <api-key>[/yellow]"
|
|
748
|
+
)
|
|
749
|
+
return
|
|
750
|
+
|
|
751
|
+
if not api_key:
|
|
752
|
+
api_key = click.prompt("Enter your API key", hide_input=True)
|
|
753
|
+
|
|
754
|
+
path = store_credentials(api_key, api_url)
|
|
755
|
+
if not quiet:
|
|
756
|
+
console.print(f"[green]API key stored at {path}[/green]")
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# =============================================================================
|
|
760
|
+
# Answer Command (Air-gapped mode)
|
|
761
|
+
# =============================================================================
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@cli.command()
|
|
765
|
+
@click.option(
|
|
766
|
+
"-q",
|
|
767
|
+
"--questions",
|
|
768
|
+
"questions_file",
|
|
769
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
770
|
+
required=True,
|
|
771
|
+
help="Input questions file (JSON).",
|
|
772
|
+
)
|
|
773
|
+
@click.option(
|
|
774
|
+
"-o",
|
|
775
|
+
"--output",
|
|
776
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
777
|
+
default="answers.json",
|
|
778
|
+
help="Output answers file.",
|
|
779
|
+
)
|
|
780
|
+
@click.option(
|
|
781
|
+
"-r",
|
|
782
|
+
"--repo",
|
|
783
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
784
|
+
default=".",
|
|
785
|
+
help="Repository path.",
|
|
786
|
+
)
|
|
787
|
+
@click.option(
|
|
788
|
+
"--timeout",
|
|
789
|
+
type=int,
|
|
790
|
+
default=300,
|
|
791
|
+
help="Query timeout in seconds.",
|
|
792
|
+
)
|
|
793
|
+
@click.pass_context
|
|
794
|
+
def answer(
|
|
795
|
+
ctx: click.Context,
|
|
796
|
+
questions_file: Path,
|
|
797
|
+
output: Path,
|
|
798
|
+
repo: Path,
|
|
799
|
+
timeout: int,
|
|
800
|
+
) -> None:
|
|
801
|
+
"""
|
|
802
|
+
Answer verification queries from a questions file.
|
|
803
|
+
|
|
804
|
+
This is for air-gapped environments where the manifest was uploaded
|
|
805
|
+
separately and the cloud generated questions that need answers.
|
|
806
|
+
|
|
807
|
+
\b
|
|
808
|
+
Examples:
|
|
809
|
+
# Answer questions about current repo
|
|
810
|
+
apisec-code-bolt answer --questions questions.json
|
|
811
|
+
|
|
812
|
+
# Answer questions about specific repo
|
|
813
|
+
apisec-code-bolt answer -q questions.json -r /path/to/repo -o answers.json
|
|
814
|
+
"""
|
|
815
|
+
quiet = ctx.obj.get("quiet", False)
|
|
816
|
+
|
|
817
|
+
if not quiet:
|
|
818
|
+
console.print(
|
|
819
|
+
Panel(
|
|
820
|
+
f"[bold]Answering queries[/bold]\n"
|
|
821
|
+
f"Questions: {questions_file}\n"
|
|
822
|
+
f"Repository: {repo}\n"
|
|
823
|
+
f"Output: {output}",
|
|
824
|
+
title="apisec-code-bolt",
|
|
825
|
+
border_style="blue",
|
|
826
|
+
)
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
from ..query.executor import QueryExecutor
|
|
830
|
+
|
|
831
|
+
executor = QueryExecutor(project_root=repo)
|
|
832
|
+
|
|
833
|
+
if not quiet:
|
|
834
|
+
with Progress(
|
|
835
|
+
SpinnerColumn(),
|
|
836
|
+
TextColumn("[progress.description]{task.description}"),
|
|
837
|
+
console=console,
|
|
838
|
+
) as progress:
|
|
839
|
+
progress.add_task("Answering queries...", total=None)
|
|
840
|
+
exec_result = executor.run_airgapped(questions_file, output)
|
|
841
|
+
else:
|
|
842
|
+
exec_result = executor.run_airgapped(questions_file, output)
|
|
843
|
+
|
|
844
|
+
if not quiet:
|
|
845
|
+
console.print()
|
|
846
|
+
if exec_result.final_status == "complete":
|
|
847
|
+
console.print(
|
|
848
|
+
f"[green]✓ Answered {exec_result.stats.questions_answered} questions[/green]"
|
|
849
|
+
)
|
|
850
|
+
console.print(f" Output: {output}")
|
|
851
|
+
else:
|
|
852
|
+
console.print(f"[red]✗ Failed: {exec_result.error or exec_result.final_status}[/red]")
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
# =============================================================================
|
|
856
|
+
# Validate Command
|
|
857
|
+
# =============================================================================
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
@cli.command()
|
|
861
|
+
@click.argument(
|
|
862
|
+
"manifest_file",
|
|
863
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
864
|
+
)
|
|
865
|
+
@click.pass_context
|
|
866
|
+
def validate(ctx: click.Context, manifest_file: Path) -> None:
|
|
867
|
+
"""
|
|
868
|
+
Validate a manifest file against the schema.
|
|
869
|
+
|
|
870
|
+
Useful for testing and debugging manifest generation.
|
|
871
|
+
"""
|
|
872
|
+
quiet = ctx.obj.get("quiet", False)
|
|
873
|
+
|
|
874
|
+
if not quiet:
|
|
875
|
+
console.print(f"[dim]Validating: {manifest_file}[/dim]")
|
|
876
|
+
|
|
877
|
+
try:
|
|
878
|
+
import json
|
|
879
|
+
|
|
880
|
+
from ..core.manifest import Manifest
|
|
881
|
+
|
|
882
|
+
content = manifest_file.read_text()
|
|
883
|
+
data = json.loads(content)
|
|
884
|
+
Manifest.model_validate(data)
|
|
885
|
+
|
|
886
|
+
if not quiet:
|
|
887
|
+
console.print("[green]✓ Manifest is valid[/green]")
|
|
888
|
+
# Show summary
|
|
889
|
+
manifest = Manifest.model_validate(data)
|
|
890
|
+
console.print(f" Project: {manifest.project.name or 'Unknown'}")
|
|
891
|
+
console.print(f" Routes: {len(manifest.entry_points)}")
|
|
892
|
+
console.print(f" Functions: {len(manifest.functions)}")
|
|
893
|
+
console.print(f" Data flows: {len(manifest.data_flows)}")
|
|
894
|
+
except json.JSONDecodeError as e:
|
|
895
|
+
error_console.print(f"[red]Invalid JSON:[/red] {e}")
|
|
896
|
+
sys.exit(1)
|
|
897
|
+
except Exception as e:
|
|
898
|
+
error_console.print(f"[red]Validation failed:[/red] {e}")
|
|
899
|
+
if ctx.obj.get("debug"):
|
|
900
|
+
import traceback
|
|
901
|
+
|
|
902
|
+
error_console.print(traceback.format_exc())
|
|
903
|
+
sys.exit(1)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
# =============================================================================
|
|
907
|
+
# Telemetry Command
|
|
908
|
+
# =============================================================================
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@cli.command()
|
|
912
|
+
@click.argument(
|
|
913
|
+
"action",
|
|
914
|
+
type=click.Choice(["on", "off", "status"]),
|
|
915
|
+
required=True,
|
|
916
|
+
)
|
|
917
|
+
@click.pass_context
|
|
918
|
+
def telemetry(ctx: click.Context, action: str) -> None:
|
|
919
|
+
"""
|
|
920
|
+
Manage anonymous usage telemetry.
|
|
921
|
+
|
|
922
|
+
Telemetry is opt-in and disabled by default. When enabled, only
|
|
923
|
+
anonymous aggregate statistics (route counts, framework names, timing)
|
|
924
|
+
are transmitted. No code, paths, or credentials are ever included.
|
|
925
|
+
|
|
926
|
+
\b
|
|
927
|
+
Examples:
|
|
928
|
+
surface telemetry on # Enable telemetry
|
|
929
|
+
surface telemetry off # Disable telemetry
|
|
930
|
+
surface telemetry status # Show current status
|
|
931
|
+
"""
|
|
932
|
+
from ..core.telemetry import get_telemetry_status, set_telemetry_consent
|
|
933
|
+
|
|
934
|
+
quiet = ctx.obj.get("quiet", False)
|
|
935
|
+
|
|
936
|
+
if action == "on":
|
|
937
|
+
set_telemetry_consent(True)
|
|
938
|
+
if not quiet:
|
|
939
|
+
console.print(
|
|
940
|
+
"[green]Telemetry enabled.[/green] Anonymous usage statistics will be sent."
|
|
941
|
+
)
|
|
942
|
+
elif action == "off":
|
|
943
|
+
set_telemetry_consent(False)
|
|
944
|
+
if not quiet:
|
|
945
|
+
console.print("[yellow]Telemetry disabled.[/yellow]")
|
|
946
|
+
elif action == "status":
|
|
947
|
+
status = get_telemetry_status()
|
|
948
|
+
if not quiet:
|
|
949
|
+
if status == "enabled":
|
|
950
|
+
console.print("[green]Telemetry: enabled[/green]")
|
|
951
|
+
elif status == "disabled":
|
|
952
|
+
console.print("[yellow]Telemetry: disabled[/yellow]")
|
|
953
|
+
else:
|
|
954
|
+
console.print("[dim]Telemetry: not configured (disabled by default)[/dim]")
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# =============================================================================
|
|
958
|
+
# Init Command
|
|
959
|
+
# =============================================================================
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
@cli.command("init")
|
|
963
|
+
@click.option(
|
|
964
|
+
"--output",
|
|
965
|
+
"-o",
|
|
966
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
967
|
+
default=".surface.yaml",
|
|
968
|
+
help="Output file path (default: .surface.yaml).",
|
|
969
|
+
)
|
|
970
|
+
@click.option(
|
|
971
|
+
"--force",
|
|
972
|
+
is_flag=True,
|
|
973
|
+
help="Overwrite existing file.",
|
|
974
|
+
)
|
|
975
|
+
@click.pass_context
|
|
976
|
+
def init_command(ctx: click.Context, output: Path, force: bool) -> None:
|
|
977
|
+
"""
|
|
978
|
+
Scaffold a .surface.yaml configuration file.
|
|
979
|
+
|
|
980
|
+
Creates a commented template with all available configuration options.
|
|
981
|
+
|
|
982
|
+
\b
|
|
983
|
+
Examples:
|
|
984
|
+
surface init
|
|
985
|
+
surface init --output my-surface.yaml
|
|
986
|
+
surface init --force # overwrite existing
|
|
987
|
+
"""
|
|
988
|
+
quiet = ctx.obj.get("quiet", False)
|
|
989
|
+
|
|
990
|
+
if output.exists() and not force:
|
|
991
|
+
error_console.print(f"[red]File already exists:[/red] {output}\nUse --force to overwrite.")
|
|
992
|
+
sys.exit(ExitCode.CONFIG_ERROR)
|
|
993
|
+
|
|
994
|
+
template = """\
|
|
995
|
+
# .surface.yaml — APIsec Surface configuration
|
|
996
|
+
# Generated by: surface init
|
|
997
|
+
#
|
|
998
|
+
# All fields are optional — surface works with sensible defaults.
|
|
999
|
+
# See: https://docs.apisec.ai/surface/config
|
|
1000
|
+
|
|
1001
|
+
# -- Project settings --------------------------------------------------------
|
|
1002
|
+
|
|
1003
|
+
# project_name: my-service # Override the auto-detected project name
|
|
1004
|
+
# project_root: . # Root directory to analyze (default: .)
|
|
1005
|
+
|
|
1006
|
+
# -- Analysis settings -------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
analysis:
|
|
1009
|
+
# Framework hints (surface auto-detects but you can assist)
|
|
1010
|
+
# framework_hints:
|
|
1011
|
+
# - fastapi
|
|
1012
|
+
# - sqlalchemy
|
|
1013
|
+
|
|
1014
|
+
file_discovery:
|
|
1015
|
+
# Max files to analyze (default: 10000)
|
|
1016
|
+
# max_files: 10000
|
|
1017
|
+
|
|
1018
|
+
# Additional exclusion patterns (gitignore-style)
|
|
1019
|
+
# exclude_patterns:
|
|
1020
|
+
# - "vendor/**"
|
|
1021
|
+
# - "generated/**"
|
|
1022
|
+
|
|
1023
|
+
# Include test files in analysis (default: false)
|
|
1024
|
+
# include_tests: false
|
|
1025
|
+
|
|
1026
|
+
data_flow:
|
|
1027
|
+
# Data flow analysis depth (default: 5)
|
|
1028
|
+
# max_depth: 5
|
|
1029
|
+
|
|
1030
|
+
# Analysis mode: 'full' or 'fast'
|
|
1031
|
+
# mode: full
|
|
1032
|
+
|
|
1033
|
+
# -- Output settings ---------------------------------------------------------
|
|
1034
|
+
|
|
1035
|
+
output:
|
|
1036
|
+
# Default output format
|
|
1037
|
+
# format: json # or: yaml
|
|
1038
|
+
|
|
1039
|
+
# Default output file (relative to project_root)
|
|
1040
|
+
# output_file: apisec/manifest.json
|
|
1041
|
+
|
|
1042
|
+
# -- Cloud settings ----------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
cloud:
|
|
1045
|
+
# Set to false to skip cloud upload
|
|
1046
|
+
# enabled: true
|
|
1047
|
+
|
|
1048
|
+
# API URL (can also use APISEC_PAT env var)
|
|
1049
|
+
# api_url: https://api.apisec.ai
|
|
1050
|
+
"""
|
|
1051
|
+
|
|
1052
|
+
output.write_text(template, encoding="utf-8")
|
|
1053
|
+
if not quiet:
|
|
1054
|
+
console.print(f"[green]Created:[/green] {output}")
|
|
1055
|
+
console.print("[dim]Edit the file to customize your surface configuration.[/dim]")
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
# =============================================================================
|
|
1059
|
+
# Entry Point
|
|
1060
|
+
# =============================================================================
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def main() -> None:
|
|
1064
|
+
"""Main entry point."""
|
|
1065
|
+
cli(obj={})
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
if __name__ == "__main__":
|
|
1069
|
+
main()
|