aisbom-cli 0.7.8__tar.gz → 0.8.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aisbom-cli
3
- Version: 0.7.8
3
+ Version: 0.8.0
4
4
  Summary: An AI Supply Chain security tool that that detects Pickle bombs and generates CycloneDX SBOMs for Machine Learning models.
5
5
  License-File: LICENSE
6
6
  Author: Ajoy L
@@ -13,12 +13,12 @@ Classifier: Programming Language :: Python :: 3.13
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Requires-Dist: click (<8.4.0)
15
15
  Requires-Dist: cyclonedx-python-lib (>=8.5,<12.0)
16
- Requires-Dist: packaging (>=24,<26)
16
+ Requires-Dist: packaging (>=24,<27)
17
17
  Requires-Dist: pip-requirements-parser (>=32.0.1,<33.0.0)
18
18
  Requires-Dist: requests (>=2.32.3,<3.0.0)
19
19
  Requires-Dist: rich (>=13.7.1,<15.0.0)
20
20
  Requires-Dist: spdx-tools (>=0.8.3,<0.9.0)
21
- Requires-Dist: typer[all] (>=0.12.5,<0.22.0)
21
+ Requires-Dist: typer[all] (>=0.12.5,<0.25.0)
22
22
  Project-URL: Homepage, https://www.aisbom.io/
23
23
  Project-URL: Repository, https://github.com/Lab700xOrg/aisbom
24
24
  Description-Content-Type: text/markdown
@@ -53,7 +53,9 @@ pip install aisbom-cli
53
53
  *Note: The package name is `aisbom-cli`, but the command you run is `aisbom`.*
54
54
 
55
55
  ### 1a. Standalone Binary (Air-Gapped)
56
- For environments where installing Python is not possible, download the single-file executable from our [Releases page](https://github.com/Lab700xOrg/aisbom/releases/tag/v0.7.0).
56
+ For environments where installing Python is not possible, download the single-file executable from our [Releases page](https://github.com/Lab700xOrg/aisbom/releases/latest).
57
+
58
+ > **📚 Guide:** [How to Audit Air-Gapped / Offline Systems](docs/air-gapped-guide.md)
57
59
 
58
60
  **Available Binaries:**
59
61
  * `aisbom-linux-amd64` (Linux x86_64)
@@ -253,6 +255,50 @@ AI models are not just text files; they are executable programs and IP assets.
253
255
 
254
256
  ---
255
257
 
258
+ ## Telemetry & Privacy
259
+
260
+ AIsbom collects a small amount of anonymous usage telemetry — what model formats people scan, how often critical findings appear, whether scans run in CI — to help us prioritize what to build. We treat this with the same care we expect from any security tool. Read what we collect, then opt out if you'd rather not participate.
261
+
262
+ ### What's collected
263
+
264
+ Per `aisbom scan`: `target_type` (the **bucket**: `local` / `huggingface` / `http` / `https` — never the actual path or URL), `model_format` (the file-type bucket), `risk_level_max`, `scan_duration_ms`, `file_count`, `parse_error_count`, `strict_mode`. A `cli_scan_critical_found` event with a count is added when at least one CRITICAL is found.
265
+
266
+ Per `aisbom diff`: a `cli_diff` event with `has_drift=true|false`.
267
+
268
+ On unhandled exceptions: a `cli_error` event records the exception class name only (e.g. `JSONDecodeError`) — never the message, traceback, or any file content.
269
+
270
+ Each event carries an anonymous `user_id` — a SHA-256 of your machine's MAC address plus an app salt, truncated to 16 hex chars. Stored in `~/.aisbom/config.json`. Lets us see returning users without identifying anyone.
271
+
272
+ ### What's never collected
273
+
274
+ File paths, directory contents, model names, target URLs, file hashes from your SBOMs, exception messages, tracebacks, or anything that could identify you, your project, or your organization.
275
+
276
+ ### Opt out
277
+
278
+ Set `AISBOM_NO_TELEMETRY=1`. This wins over every other setting — telemetry will not fire and `~/.aisbom/config.json` will not be written.
279
+
280
+ ```bash
281
+ # Permanent
282
+ export AISBOM_NO_TELEMETRY=1
283
+
284
+ # Single invocation
285
+ AISBOM_NO_TELEMETRY=1 aisbom scan ./my-project
286
+ ```
287
+
288
+ ### Where the data goes
289
+
290
+ Events POST to `https://api.aisbom.io/v1/telemetry` (a Cloudflare Worker we operate), which sanitizes the payload and forwards to Google Analytics 4 on the dedicated `cli.aisbom.io` data stream. We don't share, sell, or use this data for ad targeting.
291
+
292
+ ### CI environments
293
+
294
+ When `CI=true` or `GITHUB_ACTIONS=true`, the `cli_install_first_seen` event is suppressed (containers are ephemeral and would otherwise spam the metric). Other events still fire, tagged `is_ci=true`.
295
+
296
+ ### Preview status
297
+
298
+ In the current release, telemetry is **off by default** and only fires when `AISBOM_TELEMETRY_V2=1` is set. The next release will flip to default-on; `AISBOM_NO_TELEMETRY=1` is the opt-out and works in both states.
299
+
300
+ ---
301
+
256
302
  ## How to Verify (The "Trust Factor")
257
303
 
258
304
  Security tools require trust. **We do not distribute malicious binaries.**
@@ -28,7 +28,9 @@ pip install aisbom-cli
28
28
  *Note: The package name is `aisbom-cli`, but the command you run is `aisbom`.*
29
29
 
30
30
  ### 1a. Standalone Binary (Air-Gapped)
31
- For environments where installing Python is not possible, download the single-file executable from our [Releases page](https://github.com/Lab700xOrg/aisbom/releases/tag/v0.7.0).
31
+ For environments where installing Python is not possible, download the single-file executable from our [Releases page](https://github.com/Lab700xOrg/aisbom/releases/latest).
32
+
33
+ > **📚 Guide:** [How to Audit Air-Gapped / Offline Systems](docs/air-gapped-guide.md)
32
34
 
33
35
  **Available Binaries:**
34
36
  * `aisbom-linux-amd64` (Linux x86_64)
@@ -228,6 +230,50 @@ AI models are not just text files; they are executable programs and IP assets.
228
230
 
229
231
  ---
230
232
 
233
+ ## Telemetry & Privacy
234
+
235
+ AIsbom collects a small amount of anonymous usage telemetry — what model formats people scan, how often critical findings appear, whether scans run in CI — to help us prioritize what to build. We treat this with the same care we expect from any security tool. Read what we collect, then opt out if you'd rather not participate.
236
+
237
+ ### What's collected
238
+
239
+ Per `aisbom scan`: `target_type` (the **bucket**: `local` / `huggingface` / `http` / `https` — never the actual path or URL), `model_format` (the file-type bucket), `risk_level_max`, `scan_duration_ms`, `file_count`, `parse_error_count`, `strict_mode`. A `cli_scan_critical_found` event with a count is added when at least one CRITICAL is found.
240
+
241
+ Per `aisbom diff`: a `cli_diff` event with `has_drift=true|false`.
242
+
243
+ On unhandled exceptions: a `cli_error` event records the exception class name only (e.g. `JSONDecodeError`) — never the message, traceback, or any file content.
244
+
245
+ Each event carries an anonymous `user_id` — a SHA-256 of your machine's MAC address plus an app salt, truncated to 16 hex chars. Stored in `~/.aisbom/config.json`. Lets us see returning users without identifying anyone.
246
+
247
+ ### What's never collected
248
+
249
+ File paths, directory contents, model names, target URLs, file hashes from your SBOMs, exception messages, tracebacks, or anything that could identify you, your project, or your organization.
250
+
251
+ ### Opt out
252
+
253
+ Set `AISBOM_NO_TELEMETRY=1`. This wins over every other setting — telemetry will not fire and `~/.aisbom/config.json` will not be written.
254
+
255
+ ```bash
256
+ # Permanent
257
+ export AISBOM_NO_TELEMETRY=1
258
+
259
+ # Single invocation
260
+ AISBOM_NO_TELEMETRY=1 aisbom scan ./my-project
261
+ ```
262
+
263
+ ### Where the data goes
264
+
265
+ Events POST to `https://api.aisbom.io/v1/telemetry` (a Cloudflare Worker we operate), which sanitizes the payload and forwards to Google Analytics 4 on the dedicated `cli.aisbom.io` data stream. We don't share, sell, or use this data for ad targeting.
266
+
267
+ ### CI environments
268
+
269
+ When `CI=true` or `GITHUB_ACTIONS=true`, the `cli_install_first_seen` event is suppressed (containers are ephemeral and would otherwise spam the metric). Other events still fire, tagged `is_ci=true`.
270
+
271
+ ### Preview status
272
+
273
+ In the current release, telemetry is **off by default** and only fires when `AISBOM_TELEMETRY_V2=1` is set. The next release will flip to default-on; `AISBOM_NO_TELEMETRY=1` is the opt-out and works in both states.
274
+
275
+ ---
276
+
231
277
  ## How to Verify (The "Trust Factor")
232
278
 
233
279
  Security tools require trust. **We do not distribute malicious binaries.**
@@ -18,7 +18,10 @@ from .scanner import DeepScanner
18
18
  from .diff import SBOMDiff
19
19
 
20
20
  import threading
21
+ import time
22
+ import uuid
21
23
  from .version_check import check_latest_version
24
+ from . import telemetry
22
25
 
23
26
  app = typer.Typer()
24
27
  console = Console()
@@ -51,6 +54,56 @@ def _check_update_status():
51
54
  expand=False
52
55
  ))
53
56
 
57
+
58
+ def _classify_target(target: str) -> str:
59
+ """Bucket the scan target into a category label for telemetry."""
60
+ if target.startswith("hf://"):
61
+ return "huggingface"
62
+ if target.startswith("https://"):
63
+ return "https"
64
+ if target.startswith("http://"):
65
+ return "http"
66
+ return "local"
67
+
68
+
69
+ def _summarize_model_format(artifacts: list[dict]) -> str:
70
+ """Reduce per-artifact `framework` values to a single label.
71
+
72
+ Returns 'none' if no artifacts, the framework name if all match,
73
+ or 'mixed' if multiple distinct frameworks were found."""
74
+ if not artifacts:
75
+ return "none"
76
+ formats = {(a.get("framework") or "unknown").lower() for a in artifacts}
77
+ if len(formats) == 1:
78
+ return formats.pop()
79
+ return "mixed"
80
+
81
+
82
+ def _maybe_emit_install_event() -> threading.Thread | None:
83
+ """Fire cli_install_first_seen on the very first invocation per machine.
84
+
85
+ Suppressed in CI (config dirs are ephemeral there) and when the home
86
+ config dir isn't writable. Self-gates via the existence of
87
+ ~/.aisbom/config.json — once that file is created, this is a no-op."""
88
+ if telemetry.is_ci():
89
+ return None
90
+ home = telemetry.get_config_dir()
91
+ if home is None:
92
+ return None
93
+ if (home / "config.json").exists():
94
+ return None
95
+ # post_event() will internally call get_or_init_config() which writes
96
+ # config.json, so the next invocation sees it and skips this branch.
97
+ return telemetry.post_event("cli_install_first_seen", {})
98
+
99
+
100
+ def _flush_telemetry_threads(threads: list[threading.Thread | None]) -> None:
101
+ """Wait briefly for in-flight telemetry POSTs to flush before exit."""
102
+ for t in threads:
103
+ if t is not None:
104
+ t.join(timeout=2.0)
105
+
106
+
54
107
  class OutputFormat(str, Enum):
55
108
  JSON = "json"
56
109
  MARKDOWN = "markdown"
@@ -109,14 +162,30 @@ def scan(
109
162
 
110
163
  console.print(Panel.fit(f"🚀 [bold cyan]AIsbom[/bold cyan] Scanning: [underline]{target}[/underline]"))
111
164
 
112
-
165
+ # Telemetry: per-invocation scan_id groups all events from this scan into
166
+ # one GA4 session. Started here so an early failure still has an id.
167
+ scan_id = uuid.uuid4().hex
168
+ t_start = time.monotonic()
169
+ telemetry_threads: list[threading.Thread | None] = [
170
+ _maybe_emit_install_event(),
171
+ ]
172
+
113
173
  # 1. Run the Logic
114
- scanner = DeepScanner(target, strict_mode=strict, lint=lint)
115
- if isinstance(target, str) and (target.startswith("http://") or target.startswith("https://") or target.startswith("hf://")):
116
- with console.status("[cyan]Resolving remote repository...[/cyan]"):
174
+ try:
175
+ scanner = DeepScanner(target, strict_mode=strict, lint=lint)
176
+ if isinstance(target, str) and (target.startswith("http://") or target.startswith("https://") or target.startswith("hf://")):
177
+ with console.status("[cyan]Resolving remote repository...[/cyan]"):
178
+ results = scanner.scan()
179
+ else:
117
180
  results = scanner.scan()
118
- else:
119
- results = scanner.scan()
181
+ except Exception as e:
182
+ err_thread = telemetry.post_event(
183
+ "cli_error",
184
+ {"command": "scan", "error_type": type(e).__name__},
185
+ scan_id=scan_id,
186
+ )
187
+ _flush_telemetry_threads(telemetry_threads + [err_thread])
188
+ raise
120
189
  # Track highest risk for exit code purposes (CI friendly)
121
190
  def _risk_score(label: str) -> int:
122
191
  text = (label or "").upper()
@@ -134,7 +203,40 @@ def scan(
134
203
  exit_code = max(exit_code, 1)
135
204
  if fail_on_risk and highest_risk >= 3:
136
205
  exit_code = 2
137
-
206
+
207
+ # Telemetry: fire cli_scan plus any conditional follow-ups now that the
208
+ # scan completed and risk is known. Non-blocking; flushed before exit.
209
+ _risk_label = {3: "critical", 2: "medium", 1: "low", 0: "none"}.get(highest_risk, "unknown")
210
+ scan_duration_ms = int((time.monotonic() - t_start) * 1000)
211
+ scan_params = {
212
+ "target_type": _classify_target(target),
213
+ "model_format": _summarize_model_format(results.get("artifacts", [])),
214
+ "risk_level_max": _risk_label,
215
+ "scan_duration_ms": str(scan_duration_ms),
216
+ "file_count": str(len(results.get("artifacts", []))),
217
+ "parse_error_count": str(len(results.get("errors", []))),
218
+ "strict_mode": "true" if strict else "false",
219
+ }
220
+ telemetry_threads.append(
221
+ telemetry.post_event("cli_scan", scan_params, scan_id=scan_id)
222
+ )
223
+ if highest_risk >= 3:
224
+ critical_count = sum(
225
+ 1 for a in results.get("artifacts", [])
226
+ if "CRITICAL" in (a.get("risk_level") or "").upper()
227
+ )
228
+ telemetry_threads.append(
229
+ telemetry.post_event(
230
+ "cli_scan_critical_found",
231
+ {"critical_count": str(critical_count)},
232
+ scan_id=scan_id,
233
+ )
234
+ )
235
+ if strict:
236
+ telemetry_threads.append(
237
+ telemetry.post_event("cli_strict_mode", {}, scan_id=scan_id)
238
+ )
239
+
138
240
  # 2. Render Results (UI)
139
241
  if results['artifacts']:
140
242
  table = Table(title="🧠 AI Model Artifacts Found")
@@ -262,12 +364,10 @@ def scan(
262
364
  except importlib.metadata.PackageNotFoundError:
263
365
  ver = "unknown"
264
366
 
265
- viewer_url = f"https://www.aisbom.io/viewer.html?utm_source=cli&utm_medium=terminal&utm_campaign=scan_output&utm_version={ver}"
266
-
267
367
  console.print(Panel(
268
368
  f"[bold white]📊 Visualize this report:[/bold white]\n"
269
369
  f"Drag and drop [cyan]{output}[/cyan] into our secure offline viewer:\n"
270
- f"👉 [link={viewer_url}]Open Secure Viewer (aisbom.io)[/link]",
370
+ f"> View detailed artifact visualizer: https://aisbom.io/viewer",
271
371
  border_style="blue",
272
372
  expand=False
273
373
  ))
@@ -281,6 +381,9 @@ def scan(
281
381
  # Check update status before exiting
282
382
  _check_update_status()
283
383
 
384
+ # Flush in-flight telemetry POSTs so events aren't lost on exit.
385
+ _flush_telemetry_threads(telemetry_threads)
386
+
284
387
  # Non-zero exit codes for CI/CD when high risk or errors are present
285
388
  raise typer.Exit(code=exit_code)
286
389
 
@@ -362,11 +465,18 @@ def diff(
362
465
  t = threading.Thread(target=run_version_check_wrapper, daemon=True)
363
466
  t.start()
364
467
 
468
+ # Telemetry: per-invocation scan_id groups events into one GA4 session.
469
+ scan_id = uuid.uuid4().hex
470
+ telemetry_threads: list[threading.Thread | None] = [
471
+ _maybe_emit_install_event(),
472
+ ]
473
+
365
474
  path_old = Path(old_file)
366
475
  path_new = Path(new_file)
367
476
 
368
477
  if not path_old.exists() or not path_new.exists():
369
478
  console.print("[bold red]Error:[/bold red] One or both files do not exist.")
479
+ _flush_telemetry_threads(telemetry_threads)
370
480
  raise typer.Exit(code=1)
371
481
 
372
482
  try:
@@ -374,8 +484,22 @@ def diff(
374
484
  result = differ.compare()
375
485
  except Exception as e:
376
486
  console.print(f"[bold red]Error parsing SBOMs:[/bold red] {e}")
487
+ telemetry_threads.append(telemetry.post_event(
488
+ "cli_error",
489
+ {"command": "diff", "error_type": type(e).__name__},
490
+ scan_id=scan_id,
491
+ ))
492
+ _flush_telemetry_threads(telemetry_threads)
377
493
  raise typer.Exit(code=1)
378
494
 
495
+ # Telemetry: comparison succeeded; fire cli_diff with drift signal.
496
+ has_drift = bool(result.added or result.removed or result.changed)
497
+ telemetry_threads.append(telemetry.post_event(
498
+ "cli_diff",
499
+ {"has_drift": "true" if has_drift else "false"},
500
+ scan_id=scan_id,
501
+ ))
502
+
379
503
  console.print(Panel.fit(f"[bold cyan]Comparing[/bold cyan] {path_old.name} -> {path_new.name}"))
380
504
 
381
505
  table = Table(title="Drift Analysis")
@@ -454,11 +578,13 @@ def diff(
454
578
 
455
579
  if fail_on_risk_increase and (result.risk_increased or result.hash_drifted):
456
580
  console.print("\n[bold red]FAILURE: Critical risk increase or hash drift detected![/bold red]")
581
+ _flush_telemetry_threads(telemetry_threads)
457
582
  raise typer.Exit(code=1)
458
-
583
+
459
584
  console.print("\n[bold green]Success: No critical regression detected.[/bold green]")
460
-
585
+
461
586
  _check_update_status()
587
+ _flush_telemetry_threads(telemetry_threads)
462
588
 
463
589
  if __name__ == "__main__":
464
590
  app()
@@ -0,0 +1,214 @@
1
+ """
2
+ Privacy-respecting CLI telemetry for aisbom-cli.
3
+
4
+ Events are POSTed to api.aisbom.io/v1/telemetry, which forwards to GA4
5
+ Measurement Protocol on the AIsbom CLI stream. See
6
+ cloudcowork/PHASE_1_2_DESIGN.md for the full design.
7
+
8
+ Gates (all must be passed before any network call):
9
+ AISBOM_NO_TELEMETRY — opt-out, always wins. If set, no events fire and no
10
+ config files are written.
11
+ AISBOM_TELEMETRY_V2 — opt-in for the v2 rollout. Until set to "1",
12
+ post_event() is a no-op even with no opt-out.
13
+ Default-flipped to on in a future release.
14
+
15
+ PyInstaller constraint: this module imports only stdlib + `requests`. Do not
16
+ add new third-party dependencies without updating scripts/build_binaries.sh.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import importlib.metadata
23
+ import json
24
+ import os
25
+ import platform
26
+ import threading
27
+ import uuid
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ import requests
32
+
33
+ TELEMETRY_ENDPOINT = "https://api.aisbom.io/v1/telemetry"
34
+ POST_TIMEOUT_SEC = 3.0
35
+ CONFIG_SCHEMA_VERSION = 1
36
+
37
+ # Hardcoded app salt. Purpose: make user_id non-reversible to a MAC address
38
+ # even if a user_id is ever exposed (e.g., GA4 export). The threat model is
39
+ # "outside attacker reverses user_id → MAC", not "attacker compromises this
40
+ # source code". Constant value is fine; rotate by bumping the suffix if the
41
+ # hash space ever needs to be reset.
42
+ _USER_ID_SALT = "aisbom-cli-1.0"
43
+
44
+
45
+ def is_ci() -> bool:
46
+ """True when running inside a CI environment (GITHUB_ACTIONS or CI set)."""
47
+ return bool(os.getenv("GITHUB_ACTIONS") or os.getenv("CI"))
48
+
49
+
50
+ def _telemetry_disabled() -> bool:
51
+ """All-paths short-circuit. Both env vars consulted; opt-out always wins."""
52
+ if os.getenv("AISBOM_NO_TELEMETRY"):
53
+ return True
54
+ if os.getenv("AISBOM_TELEMETRY_V2") != "1":
55
+ return True
56
+ return False
57
+
58
+
59
+ def get_config_dir() -> Path | None:
60
+ """
61
+ Return the writable ~/.aisbom directory, creating it if needed.
62
+
63
+ Returns None if the dir cannot be created or written to (sandboxed
64
+ environments, read-only FS, restrictive Docker, etc.). Callers should
65
+ treat None as "skip all stateful telemetry" — never raise from telemetry.
66
+ """
67
+ try:
68
+ home = Path.home() / ".aisbom"
69
+ home.mkdir(exist_ok=True, parents=True)
70
+ probe = home / ".write_probe"
71
+ probe.write_text("")
72
+ probe.unlink()
73
+ return home
74
+ except (OSError, PermissionError):
75
+ return None
76
+
77
+
78
+ def save_config(cfg: dict) -> None:
79
+ """
80
+ Atomically persist config.json to ~/.aisbom/. Silent no-op if the dir is
81
+ unwritable. Uses write-temp-then-rename so concurrent CLI invocations
82
+ cannot corrupt the file.
83
+ """
84
+ home = get_config_dir()
85
+ if home is None:
86
+ return
87
+ tmp = home / "config.json.tmp"
88
+ try:
89
+ tmp.write_text(json.dumps(cfg, separators=(",", ":")))
90
+ tmp.replace(home / "config.json")
91
+ except (OSError, PermissionError):
92
+ # Never raise from telemetry. Best-effort cleanup of orphaned tmp.
93
+ try:
94
+ tmp.unlink(missing_ok=True)
95
+ except OSError:
96
+ pass
97
+
98
+
99
+ def _generate_user_id() -> str:
100
+ """
101
+ Anonymous, stable per-machine user_id. Hash of MAC address + app salt,
102
+ truncated to 16 hex chars. uuid.getnode() returns a random number with
103
+ the multicast bit set if it can't read the MAC, which means a small
104
+ fraction of users (<1%) will get a fresh user_id per process. Documented
105
+ risk; affects returning-user metrics for those users only.
106
+ """
107
+ raw = f"{uuid.getnode()}:{_USER_ID_SALT}"
108
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
109
+
110
+
111
+ def get_or_init_config() -> dict:
112
+ """
113
+ Load or initialize config.json.
114
+
115
+ Returns:
116
+ Config dict with keys schema_version, user_id, installed_at.
117
+ Returns empty dict if telemetry is disabled or config dir unwritable.
118
+
119
+ On first call (and when config is missing/corrupt), generates a stable
120
+ anonymous user_id and records install timestamp.
121
+ """
122
+ if _telemetry_disabled():
123
+ return {}
124
+ home = get_config_dir()
125
+ if home is None:
126
+ return {}
127
+ config_path = home / "config.json"
128
+ if config_path.exists():
129
+ try:
130
+ return json.loads(config_path.read_text())
131
+ except (json.JSONDecodeError, OSError):
132
+ # corrupt or unreadable — fall through to reinit
133
+ pass
134
+ cfg = {
135
+ "schema_version": CONFIG_SCHEMA_VERSION,
136
+ "user_id": _generate_user_id(),
137
+ "installed_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
138
+ }
139
+ save_config(cfg)
140
+ return cfg
141
+
142
+
143
+ def _build_user_agent() -> str:
144
+ """
145
+ Match the format from version_check.py:26 verbatim so the Worker's UA
146
+ parser at aisbom-edge/src/index.js works for both endpoints:
147
+
148
+ aisbom-cli/{version} ({system}; python {py_ver}; ci={true|false})
149
+ """
150
+ try:
151
+ version = importlib.metadata.version("aisbom-cli")
152
+ except importlib.metadata.PackageNotFoundError:
153
+ version = "unknown"
154
+ system = platform.system()
155
+ py_ver = platform.python_version()
156
+ is_ci_str = "true" if is_ci() else "false"
157
+ return f"aisbom-cli/{version} ({system}; python {py_ver}; ci={is_ci_str})"
158
+
159
+
160
+ def _do_post(event: str, params: dict, scan_id: str | None) -> None:
161
+ """
162
+ Send one event to the Worker. Silent on any failure — the contract is
163
+ that telemetry never raises and never blocks a user-facing flow.
164
+ """
165
+ try:
166
+ body: dict = {"event": event, "params": params}
167
+ if scan_id:
168
+ body["scan_id"] = scan_id
169
+ requests.post(
170
+ TELEMETRY_ENDPOINT,
171
+ json=body,
172
+ headers={"User-Agent": _build_user_agent()},
173
+ timeout=POST_TIMEOUT_SEC,
174
+ )
175
+ except Exception:
176
+ # Catch-all is intentional. Telemetry must not surface errors.
177
+ pass
178
+
179
+
180
+ def post_event(
181
+ event: str,
182
+ params: dict | None = None,
183
+ scan_id: str | None = None,
184
+ ) -> threading.Thread | None:
185
+ """
186
+ Fire one telemetry event in a non-daemon background thread.
187
+
188
+ Args:
189
+ event: The event name (validated against allowlist by the Worker).
190
+ params: Custom event params. user_id is auto-injected from config.
191
+ scan_id: Optional UUID grouping multiple events from one CLI invocation
192
+ into a single GA4 session.
193
+
194
+ Returns:
195
+ threading.Thread when the POST has been dispatched; the caller may
196
+ join() with a timeout before exiting to give it a chance to flush.
197
+ None if telemetry is disabled (opt-out or v2 not enabled), in which
198
+ case nothing was queued and nothing will be sent.
199
+
200
+ Never raises. Never blocks on the network.
201
+ """
202
+ if _telemetry_disabled():
203
+ return None
204
+ cfg = get_or_init_config()
205
+ full_params = dict(params or {})
206
+ if cfg.get("user_id"):
207
+ full_params["user_id"] = cfg["user_id"]
208
+ thread = threading.Thread(
209
+ target=_do_post,
210
+ args=(event, full_params, scan_id),
211
+ daemon=False, # non-daemon so process waits for in-flight POSTs at exit
212
+ )
213
+ thread.start()
214
+ return thread
@@ -4,7 +4,7 @@ import importlib.metadata
4
4
  import requests
5
5
  from packaging.version import parse as parse_version
6
6
 
7
- API_URL = "https://api.aisbom.io/v1/version"
7
+ API_URL = "https://api.aisbom.io/v1/version?utm_source=cli&utm_medium=terminal"
8
8
 
9
9
  def check_latest_version() -> str | None:
10
10
  """
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "aisbom-cli"
3
- version = "0.7.8"
3
+ version = "0.8.0"
4
4
  description = "An AI Supply Chain security tool that that detects Pickle bombs and generates CycloneDX SBOMs for Machine Learning models."
5
5
  authors = ["Ajoy L <lab700xdev@gmail.com>"]
6
6
  readme = "README.md"
@@ -9,13 +9,13 @@ repository = "https://github.com/Lab700xOrg/aisbom"
9
9
  urls = { "Homepage" = "https://www.aisbom.io/" }
10
10
  [tool.poetry.dependencies]
11
11
  python = "^3.11"
12
- typer = {extras = ["all"], version = ">=0.12.5,<0.22.0"}
12
+ typer = {extras = ["all"], version = ">=0.12.5,<0.25.0"}
13
13
  rich = ">=13.7.1,<15.0.0"
14
14
  cyclonedx-python-lib = ">=8.5,<12.0"
15
15
  pip-requirements-parser = "^32.0.1"
16
16
  click = "<8.4.0"
17
17
  spdx-tools = "^0.8.3"
18
- packaging = ">=24,<26"
18
+ packaging = ">=24,<27"
19
19
 
20
20
  [tool.poetry.dependencies.requests]
21
21
  version = "^2.32.3"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes