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.
Files changed (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. 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()