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,451 @@
1
+ """
2
+ Opt-in anonymous telemetry for the surface CLI.
3
+
4
+ DESIGN:
5
+ - Fully opt-in: disabled by default, enabled with `surface telemetry on`
6
+ - Consent stored in ~/.config/apisec/telemetry.txt
7
+ - Payload scrubbed of PII before transmission
8
+ - Silently swallows all errors (telemetry must never break the CLI)
9
+ - Fire-and-forget: does not block on network I/O
10
+
11
+ PII GUARD:
12
+ - Only allowlisted keys are included in the payload
13
+ - String values are checked for forbidden patterns (paths, @, credentials, etc.)
14
+ - Any violation raises TelemetryPIIError and the whole send is aborted
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import threading
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # =============================================================================
27
+ # Constants
28
+ # =============================================================================
29
+
30
+ _CONSENT_FILE = Path.home() / ".config" / "apisec" / "telemetry.txt"
31
+ _INSTALLATION_ID_FILE = Path.home() / ".config" / "apisec" / "installation_id"
32
+ _REGISTRATION_CODE_FILE = Path.home() / ".config" / "apisec" / "registration_code"
33
+
34
+ _ALLOWED_KEYS: frozenset[str] = frozenset(
35
+ {
36
+ "event",
37
+ "probe_version",
38
+ "python_version",
39
+ "platform",
40
+ "files_analyzed",
41
+ "files_failed",
42
+ "files_skipped",
43
+ "frameworks",
44
+ "languages",
45
+ "routes_found",
46
+ "routes_by_framework",
47
+ "analysis_time_ms",
48
+ "parse_time_ms",
49
+ "extraction_time_ms",
50
+ "stage_times_ms",
51
+ "extractor_times_ms",
52
+ "framework_count",
53
+ "has_errors",
54
+ "error_type",
55
+ "installation_id",
56
+ "registration_code",
57
+ }
58
+ )
59
+
60
+ # Patterns that indicate PII in a string value
61
+ _FORBIDDEN_VALUE_PATTERNS: tuple[str, ...] = (
62
+ "/", # File paths
63
+ "@", # Email addresses
64
+ "://", # URLs
65
+ "password",
66
+ "secret",
67
+ "token",
68
+ "key",
69
+ "private",
70
+ "credential",
71
+ )
72
+
73
+
74
+ # =============================================================================
75
+ # PII Guard
76
+ # =============================================================================
77
+
78
+
79
+ class TelemetryPIIError(ValueError):
80
+ """Raised when a payload value fails the PII check."""
81
+
82
+ pass
83
+
84
+
85
+ def _scrub_payload(payload: dict[str, Any]) -> dict[str, Any]:
86
+ """
87
+ Strip non-allowlisted keys and check string values for forbidden patterns.
88
+
89
+ Raises TelemetryPIIError if a string value contains a forbidden pattern.
90
+ Returns the scrubbed payload dict.
91
+ """
92
+ scrubbed: dict[str, Any] = {}
93
+
94
+ for key, value in payload.items():
95
+ # Only include allowlisted keys
96
+ if key not in _ALLOWED_KEYS:
97
+ continue
98
+
99
+ if isinstance(value, str):
100
+ value_lower = value.lower()
101
+ for pattern in _FORBIDDEN_VALUE_PATTERNS:
102
+ if pattern in value_lower:
103
+ raise TelemetryPIIError(
104
+ f"Payload key {key!r} contains forbidden pattern {pattern!r}"
105
+ )
106
+ scrubbed[key] = value
107
+
108
+ elif isinstance(value, (int, float, bool)):
109
+ scrubbed[key] = value
110
+
111
+ elif isinstance(value, list):
112
+ # Filter list items that would fail the string check
113
+ clean_list = []
114
+ for item in value:
115
+ if isinstance(item, str):
116
+ item_lower = item.lower()
117
+ if any(p in item_lower for p in _FORBIDDEN_VALUE_PATTERNS):
118
+ continue # Drop the item, don't fail the whole payload
119
+ clean_list.append(item)
120
+ elif isinstance(item, (int, float, bool)):
121
+ clean_list.append(item)
122
+ scrubbed[key] = clean_list
123
+
124
+ elif isinstance(value, dict):
125
+ # Dicts of {str: int} are safe (timing maps, route counts).
126
+ # Only allow int values — drop any string values that could be PII.
127
+ clean_dict = {
128
+ k: v
129
+ for k, v in value.items()
130
+ if isinstance(k, str) and isinstance(v, (int, float, bool))
131
+ }
132
+ scrubbed[key] = clean_dict
133
+
134
+ # None and other types are silently dropped
135
+
136
+ return scrubbed
137
+
138
+
139
+ def _safe_payload(**kwargs: Any) -> dict[str, Any] | None:
140
+ """Build a clean payload dict, returning None if scrubbing raises."""
141
+ try:
142
+ return _scrub_payload(kwargs)
143
+ except TelemetryPIIError as exc:
144
+ logger.debug("Telemetry PII check failed: %s", exc)
145
+ return None
146
+
147
+
148
+ # =============================================================================
149
+ # Consent Management
150
+ # =============================================================================
151
+
152
+
153
+ def is_telemetry_enabled() -> bool:
154
+ """Return True unless the user has explicitly opted out."""
155
+ try:
156
+ if not _CONSENT_FILE.exists():
157
+ return True # opt-out model: enabled until the user says otherwise
158
+ content = _CONSENT_FILE.read_text(encoding="utf-8").strip().lower()
159
+ return content != "disabled"
160
+ except Exception:
161
+ return True
162
+
163
+
164
+ def set_telemetry_consent(enabled: bool) -> None:
165
+ """Write consent status to the consent file."""
166
+ try:
167
+ _CONSENT_FILE.parent.mkdir(parents=True, exist_ok=True)
168
+ _CONSENT_FILE.write_text(
169
+ "enabled" if enabled else "disabled",
170
+ encoding="utf-8",
171
+ )
172
+ except Exception as exc:
173
+ logger.debug("Could not write telemetry consent: %s", exc)
174
+
175
+
176
+ _NOTICE = (
177
+ "ℹ apisec-code-bolt collects anonymous usage data (framework names, "
178
+ "file counts, timing) to improve the scanner. No source code or paths "
179
+ "are ever sent. Run `surface telemetry off` to opt out."
180
+ )
181
+
182
+
183
+ def check_first_run_notice(quiet: bool = False, json_mode: bool = False) -> bool:
184
+ """
185
+ On the very first run (no consent file), show the opt-out notice and
186
+ write the consent file so the notice is only shown once.
187
+
188
+ Returns True if this was the first run.
189
+ """
190
+ if _CONSENT_FILE.exists():
191
+ return False
192
+ try:
193
+ set_telemetry_consent(True)
194
+ if not quiet and not json_mode:
195
+ print(_NOTICE, flush=True)
196
+ elif json_mode:
197
+ # Emit as a structured record so pipelines can surface it
198
+ import json as _json
199
+ import sys
200
+
201
+ sys.stderr.write(
202
+ _json.dumps({"level": "info", "event": "telemetry_notice", "message": _NOTICE})
203
+ + "\n"
204
+ )
205
+ sys.stderr.flush()
206
+ except Exception:
207
+ pass
208
+ return True
209
+
210
+
211
+ def get_telemetry_status() -> str:
212
+ """Return 'enabled', 'disabled', or 'not_set'."""
213
+ try:
214
+ if not _CONSENT_FILE.exists():
215
+ return "not_set"
216
+ content = _CONSENT_FILE.read_text(encoding="utf-8").strip().lower()
217
+ if content == "enabled":
218
+ return "enabled"
219
+ return "disabled"
220
+ except Exception:
221
+ return "not_set"
222
+
223
+
224
+ # =============================================================================
225
+ # Installation Identity
226
+ # =============================================================================
227
+
228
+
229
+ def get_installation_id() -> str:
230
+ """
231
+ Return a stable, one-way hash that identifies this installation.
232
+
233
+ On first call a random UUID is generated and stored at
234
+ ~/.config/apisec/installation_id. Every subsequent call returns the
235
+ SHA-256 hex digest of that UUID — the raw UUID never leaves the machine.
236
+
237
+ The hash lets Mohsin filter CloudWatch events by customer after mapping
238
+ installation IDs out-of-band during onboarding. Returns "" on any error
239
+ so telemetry is never blocked by a filesystem issue.
240
+
241
+ SHA-256 hex is safe through the PII scrubber: it only contains [0-9a-f],
242
+ so none of the forbidden patterns (which all contain non-hex chars) match.
243
+ """
244
+ import hashlib
245
+ import uuid
246
+
247
+ try:
248
+ if _INSTALLATION_ID_FILE.exists():
249
+ raw = _INSTALLATION_ID_FILE.read_text(encoding="utf-8").strip()
250
+ else:
251
+ raw = str(uuid.uuid4())
252
+ _INSTALLATION_ID_FILE.parent.mkdir(parents=True, exist_ok=True)
253
+ _INSTALLATION_ID_FILE.write_text(raw, encoding="utf-8")
254
+ return hashlib.sha256(raw.encode()).hexdigest()
255
+ except Exception:
256
+ return ""
257
+
258
+
259
+ # =============================================================================
260
+ # Registration Code
261
+ # =============================================================================
262
+
263
+
264
+ def get_registration_code() -> str:
265
+ """Return the stored registration code, or '' if not yet set.
266
+
267
+ Checks APISEC_REGISTRATION_CODE env var first so CI/automated environments
268
+ can bypass the interactive first-run prompt without writing a config file.
269
+ """
270
+ import os
271
+
272
+ env_code = os.environ.get("APISEC_REGISTRATION_CODE", "").strip()
273
+ if env_code:
274
+ return env_code
275
+ try:
276
+ if _REGISTRATION_CODE_FILE.exists():
277
+ return _REGISTRATION_CODE_FILE.read_text(encoding="utf-8").strip()
278
+ except Exception:
279
+ pass
280
+ return ""
281
+
282
+
283
+ def set_registration_code(code: str) -> None:
284
+ """Persist the registration code."""
285
+ try:
286
+ _REGISTRATION_CODE_FILE.parent.mkdir(parents=True, exist_ok=True)
287
+ _REGISTRATION_CODE_FILE.write_text(code.strip(), encoding="utf-8")
288
+ except Exception as exc:
289
+ logger.debug("Could not write registration code: %s", exc)
290
+
291
+
292
+ def needs_registration() -> bool:
293
+ """Return True if no registration code has been stored yet."""
294
+ return not bool(get_registration_code())
295
+
296
+
297
+ # =============================================================================
298
+ # Event Emission
299
+ # =============================================================================
300
+
301
+
302
+ def emit_install_event(*, probe_version: str = "") -> None:
303
+ """
304
+ Fire-and-forget event on first-ever run of the CLI.
305
+
306
+ Captures timestamp (via the server's receipt time), probe version,
307
+ platform, installation ID, and registration code so Mohsin can see
308
+ exactly when each customer installation went live.
309
+ """
310
+ if not is_telemetry_enabled():
311
+ return
312
+
313
+ import platform as _platform
314
+ import sys
315
+
316
+ payload = _safe_payload(
317
+ event="install",
318
+ probe_version=probe_version,
319
+ python_version=f"{sys.version_info.major}.{sys.version_info.minor}",
320
+ platform=_platform.system().lower(),
321
+ installation_id=get_installation_id(),
322
+ registration_code=get_registration_code(),
323
+ )
324
+ if payload is None:
325
+ return
326
+
327
+ def _send() -> None:
328
+ try:
329
+ import httpx
330
+
331
+ httpx.post(_TELEMETRY_ENDPOINT, json=payload, timeout=5.0)
332
+ except Exception:
333
+ pass
334
+
335
+ threading.Thread(target=_send, daemon=True).start()
336
+
337
+
338
+ _TELEMETRY_ENDPOINT = "https://api.apisec.ai/v1/surface/telemetry/events"
339
+
340
+
341
+ def emit_analyze_event(
342
+ *,
343
+ probe_version: str = "",
344
+ files_analyzed: int = 0,
345
+ files_failed: int = 0,
346
+ files_skipped: int = 0,
347
+ frameworks: list[str] | None = None,
348
+ languages: list[str] | None = None,
349
+ routes_found: int = 0,
350
+ routes_by_framework: dict[str, int] | None = None,
351
+ analysis_time_ms: int = 0,
352
+ parse_time_ms: int = 0,
353
+ extraction_time_ms: int = 0,
354
+ stage_times_ms: dict[str, int] | None = None,
355
+ extractor_times_ms: dict[str, int] | None = None,
356
+ has_errors: bool = False,
357
+ ) -> None:
358
+ """
359
+ Fire-and-forget telemetry event for a completed analysis run.
360
+
361
+ Does nothing if telemetry is disabled or not yet consented.
362
+ Silently catches all errors.
363
+ """
364
+ if not is_telemetry_enabled():
365
+ return
366
+
367
+ import platform as _platform
368
+ import sys
369
+
370
+ payload = _safe_payload(
371
+ event="analyze",
372
+ probe_version=probe_version,
373
+ python_version=f"{sys.version_info.major}.{sys.version_info.minor}",
374
+ platform=_platform.system().lower(),
375
+ installation_id=get_installation_id(),
376
+ files_analyzed=files_analyzed,
377
+ files_failed=files_failed,
378
+ files_skipped=files_skipped,
379
+ frameworks=frameworks or [],
380
+ languages=languages or [],
381
+ routes_found=routes_found,
382
+ routes_by_framework=routes_by_framework or {},
383
+ framework_count=len(frameworks or []),
384
+ analysis_time_ms=analysis_time_ms,
385
+ parse_time_ms=parse_time_ms,
386
+ extraction_time_ms=extraction_time_ms,
387
+ stage_times_ms=stage_times_ms or {},
388
+ extractor_times_ms=extractor_times_ms or {},
389
+ has_errors=has_errors,
390
+ )
391
+ if payload is None:
392
+ return
393
+
394
+ def _send() -> None:
395
+ try:
396
+ import httpx
397
+
398
+ httpx.post(
399
+ _TELEMETRY_ENDPOINT,
400
+ json=payload,
401
+ timeout=5.0,
402
+ )
403
+ except Exception:
404
+ pass # Telemetry must never surface errors
405
+
406
+ t = threading.Thread(target=_send, daemon=True)
407
+ t.start()
408
+
409
+
410
+ def emit_error_event(
411
+ *,
412
+ probe_version: str = "",
413
+ error_type: str = "",
414
+ ) -> None:
415
+ """
416
+ Fire-and-forget telemetry event for an unhandled scan crash.
417
+
418
+ Only the exception class name is sent — never the message, which could
419
+ contain file paths or other PII. Does nothing if telemetry is disabled.
420
+ Silently catches all errors.
421
+ """
422
+ if not is_telemetry_enabled():
423
+ return
424
+
425
+ import platform as _platform
426
+ import sys
427
+
428
+ payload = _safe_payload(
429
+ event="analyze_error",
430
+ probe_version=probe_version,
431
+ python_version=f"{sys.version_info.major}.{sys.version_info.minor}",
432
+ platform=_platform.system().lower(),
433
+ installation_id=get_installation_id(),
434
+ error_type=error_type,
435
+ )
436
+ if payload is None:
437
+ return
438
+
439
+ def _send() -> None:
440
+ try:
441
+ import httpx
442
+
443
+ httpx.post(
444
+ _TELEMETRY_ENDPOINT,
445
+ json=payload,
446
+ timeout=5.0,
447
+ )
448
+ except Exception:
449
+ pass
450
+
451
+ threading.Thread(target=_send, daemon=True).start()