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,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()
|