aisbom-cli 0.7.8__tar.gz → 0.9.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.9.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)
@@ -103,6 +105,14 @@ You will see a combined Security & Legal risk assessment in your terminal:
103
105
 
104
106
  A compliant `sbom.json` (CycloneDX v1.6) including SHA256 hashes and license data will be generated in your current directory.
105
107
 
108
+ ### 4. Share Your SBOM (New!)
109
+ You can instantly generate a secure, hosted, and shareable link for your SBOM by appending the `--share` flag to your scan.
110
+
111
+ ```bash
112
+ aisbom scan ./my-project-folder --share
113
+ ```
114
+ *Note: Your SBOM will be uploaded to aisbom.io and available publicly to anyone with the link for 30 days. You will be prompted for confirmation unless you also pass `--share-yes`.*
115
+
106
116
  ---
107
117
 
108
118
  ## Advanced Usage
@@ -253,6 +263,52 @@ AI models are not just text files; they are executable programs and IP assets.
253
263
 
254
264
  ---
255
265
 
266
+ ## Telemetry & Privacy
267
+
268
+ 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.
269
+
270
+ ### What's collected
271
+
272
+ 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.
273
+
274
+ If you explicitly use `--share`: the generated `sbom.json` document is uploaded to our servers and retained for 30 days to generate the shareable viewer link. A `cli_share_created` event is fired tracking whether `has_share_yes=true|false`.
275
+
276
+ Per `aisbom diff`: a `cli_diff` event with `has_drift=true|false`.
277
+
278
+ On unhandled exceptions: a `cli_error` event records the exception class name only (e.g. `JSONDecodeError`) — never the message, traceback, or any file content.
279
+
280
+ 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.
281
+
282
+ ### What's never collected
283
+
284
+ 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.
285
+
286
+ ### Opt out
287
+
288
+ Set `AISBOM_NO_TELEMETRY=1`. This wins over every other setting — telemetry will not fire and `~/.aisbom/config.json` will not be written.
289
+
290
+ ```bash
291
+ # Permanent
292
+ export AISBOM_NO_TELEMETRY=1
293
+
294
+ # Single invocation
295
+ AISBOM_NO_TELEMETRY=1 aisbom scan ./my-project
296
+ ```
297
+
298
+ ### Where the data goes
299
+
300
+ 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.
301
+
302
+ ### CI environments
303
+
304
+ 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`.
305
+
306
+ ### Preview status
307
+
308
+ 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.
309
+
310
+ ---
311
+
256
312
  ## How to Verify (The "Trust Factor")
257
313
 
258
314
  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)
@@ -78,6 +80,14 @@ You will see a combined Security & Legal risk assessment in your terminal:
78
80
 
79
81
  A compliant `sbom.json` (CycloneDX v1.6) including SHA256 hashes and license data will be generated in your current directory.
80
82
 
83
+ ### 4. Share Your SBOM (New!)
84
+ You can instantly generate a secure, hosted, and shareable link for your SBOM by appending the `--share` flag to your scan.
85
+
86
+ ```bash
87
+ aisbom scan ./my-project-folder --share
88
+ ```
89
+ *Note: Your SBOM will be uploaded to aisbom.io and available publicly to anyone with the link for 30 days. You will be prompted for confirmation unless you also pass `--share-yes`.*
90
+
81
91
  ---
82
92
 
83
93
  ## Advanced Usage
@@ -228,6 +238,52 @@ AI models are not just text files; they are executable programs and IP assets.
228
238
 
229
239
  ---
230
240
 
241
+ ## Telemetry & Privacy
242
+
243
+ 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.
244
+
245
+ ### What's collected
246
+
247
+ 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.
248
+
249
+ If you explicitly use `--share`: the generated `sbom.json` document is uploaded to our servers and retained for 30 days to generate the shareable viewer link. A `cli_share_created` event is fired tracking whether `has_share_yes=true|false`.
250
+
251
+ Per `aisbom diff`: a `cli_diff` event with `has_drift=true|false`.
252
+
253
+ On unhandled exceptions: a `cli_error` event records the exception class name only (e.g. `JSONDecodeError`) — never the message, traceback, or any file content.
254
+
255
+ 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.
256
+
257
+ ### What's never collected
258
+
259
+ 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.
260
+
261
+ ### Opt out
262
+
263
+ Set `AISBOM_NO_TELEMETRY=1`. This wins over every other setting — telemetry will not fire and `~/.aisbom/config.json` will not be written.
264
+
265
+ ```bash
266
+ # Permanent
267
+ export AISBOM_NO_TELEMETRY=1
268
+
269
+ # Single invocation
270
+ AISBOM_NO_TELEMETRY=1 aisbom scan ./my-project
271
+ ```
272
+
273
+ ### Where the data goes
274
+
275
+ 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.
276
+
277
+ ### CI environments
278
+
279
+ 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`.
280
+
281
+ ### Preview status
282
+
283
+ 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.
284
+
285
+ ---
286
+
231
287
  ## How to Verify (The "Trust Factor")
232
288
 
233
289
  Security tools require trust. **We do not distribute malicious binaries.**
@@ -18,7 +18,11 @@ 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
25
+ import requests
22
26
 
23
27
  app = typer.Typer()
24
28
  console = Console()
@@ -51,6 +55,56 @@ def _check_update_status():
51
55
  expand=False
52
56
  ))
53
57
 
58
+
59
+ def _classify_target(target: str) -> str:
60
+ """Bucket the scan target into a category label for telemetry."""
61
+ if target.startswith("hf://"):
62
+ return "huggingface"
63
+ if target.startswith("https://"):
64
+ return "https"
65
+ if target.startswith("http://"):
66
+ return "http"
67
+ return "local"
68
+
69
+
70
+ def _summarize_model_format(artifacts: list[dict]) -> str:
71
+ """Reduce per-artifact `framework` values to a single label.
72
+
73
+ Returns 'none' if no artifacts, the framework name if all match,
74
+ or 'mixed' if multiple distinct frameworks were found."""
75
+ if not artifacts:
76
+ return "none"
77
+ formats = {(a.get("framework") or "unknown").lower() for a in artifacts}
78
+ if len(formats) == 1:
79
+ return formats.pop()
80
+ return "mixed"
81
+
82
+
83
+ def _maybe_emit_install_event() -> threading.Thread | None:
84
+ """Fire cli_install_first_seen on the very first invocation per machine.
85
+
86
+ Suppressed in CI (config dirs are ephemeral there) and when the home
87
+ config dir isn't writable. Self-gates via the existence of
88
+ ~/.aisbom/config.json — once that file is created, this is a no-op."""
89
+ if telemetry.is_ci():
90
+ return None
91
+ home = telemetry.get_config_dir()
92
+ if home is None:
93
+ return None
94
+ if (home / "config.json").exists():
95
+ return None
96
+ # post_event() will internally call get_or_init_config() which writes
97
+ # config.json, so the next invocation sees it and skips this branch.
98
+ return telemetry.post_event("cli_install_first_seen", {})
99
+
100
+
101
+ def _flush_telemetry_threads(threads: list[threading.Thread | None]) -> None:
102
+ """Wait briefly for in-flight telemetry POSTs to flush before exit."""
103
+ for t in threads:
104
+ if t is not None:
105
+ t.join(timeout=2.0)
106
+
107
+
54
108
  class OutputFormat(str, Enum):
55
109
  JSON = "json"
56
110
  MARKDOWN = "markdown"
@@ -98,7 +152,9 @@ def scan(
98
152
  fail_on_risk: bool = typer.Option(True, help="Return exit code 2 if Critical risks are found"),
99
153
  strict: bool = typer.Option(False, help="Enable strict allowlisting mode (flags any unknown imports)"),
100
154
  lint: bool = typer.Option(False, help="Enable Migration Linter (checks for weights_only=True compatibility)"),
101
- format: OutputFormat = typer.Option(OutputFormat.JSON, help="Output format (JSON for SBOM, MARKDOWN for Human Report, SPDX for Compliance)")
155
+ format: OutputFormat = typer.Option(OutputFormat.JSON, help="Output format (JSON for SBOM, MARKDOWN for Human Report, SPDX for Compliance)"),
156
+ share: bool = typer.Option(False, help="Upload the SBOM to aisbom.io and generate a shareable viewer link"),
157
+ share_yes: bool = typer.Option(False, "--share-yes", help="Skip confirmation prompt when using --share")
102
158
  ):
103
159
  """
104
160
  Deep Introspection Scan: Analyzes binary headers and dependency manifests.
@@ -109,14 +165,30 @@ def scan(
109
165
 
110
166
  console.print(Panel.fit(f"🚀 [bold cyan]AIsbom[/bold cyan] Scanning: [underline]{target}[/underline]"))
111
167
 
112
-
168
+ # Telemetry: per-invocation scan_id groups all events from this scan into
169
+ # one GA4 session. Started here so an early failure still has an id.
170
+ scan_id = uuid.uuid4().hex
171
+ t_start = time.monotonic()
172
+ telemetry_threads: list[threading.Thread | None] = [
173
+ _maybe_emit_install_event(),
174
+ ]
175
+
113
176
  # 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]"):
177
+ try:
178
+ scanner = DeepScanner(target, strict_mode=strict, lint=lint)
179
+ if isinstance(target, str) and (target.startswith("http://") or target.startswith("https://") or target.startswith("hf://")):
180
+ with console.status("[cyan]Resolving remote repository...[/cyan]"):
181
+ results = scanner.scan()
182
+ else:
117
183
  results = scanner.scan()
118
- else:
119
- results = scanner.scan()
184
+ except Exception as e:
185
+ err_thread = telemetry.post_event(
186
+ "cli_error",
187
+ {"command": "scan", "error_type": type(e).__name__},
188
+ scan_id=scan_id,
189
+ )
190
+ _flush_telemetry_threads(telemetry_threads + [err_thread])
191
+ raise
120
192
  # Track highest risk for exit code purposes (CI friendly)
121
193
  def _risk_score(label: str) -> int:
122
194
  text = (label or "").upper()
@@ -134,7 +206,40 @@ def scan(
134
206
  exit_code = max(exit_code, 1)
135
207
  if fail_on_risk and highest_risk >= 3:
136
208
  exit_code = 2
137
-
209
+
210
+ # Telemetry: fire cli_scan plus any conditional follow-ups now that the
211
+ # scan completed and risk is known. Non-blocking; flushed before exit.
212
+ _risk_label = {3: "critical", 2: "medium", 1: "low", 0: "none"}.get(highest_risk, "unknown")
213
+ scan_duration_ms = int((time.monotonic() - t_start) * 1000)
214
+ scan_params = {
215
+ "target_type": _classify_target(target),
216
+ "model_format": _summarize_model_format(results.get("artifacts", [])),
217
+ "risk_level_max": _risk_label,
218
+ "scan_duration_ms": str(scan_duration_ms),
219
+ "file_count": str(len(results.get("artifacts", []))),
220
+ "parse_error_count": str(len(results.get("errors", []))),
221
+ "strict_mode": "true" if strict else "false",
222
+ }
223
+ telemetry_threads.append(
224
+ telemetry.post_event("cli_scan", scan_params, scan_id=scan_id)
225
+ )
226
+ if highest_risk >= 3:
227
+ critical_count = sum(
228
+ 1 for a in results.get("artifacts", [])
229
+ if "CRITICAL" in (a.get("risk_level") or "").upper()
230
+ )
231
+ telemetry_threads.append(
232
+ telemetry.post_event(
233
+ "cli_scan_critical_found",
234
+ {"critical_count": str(critical_count)},
235
+ scan_id=scan_id,
236
+ )
237
+ )
238
+ if strict:
239
+ telemetry_threads.append(
240
+ telemetry.post_event("cli_strict_mode", {}, scan_id=scan_id)
241
+ )
242
+
138
243
  # 2. Render Results (UI)
139
244
  if results['artifacts']:
140
245
  table = Table(title="🧠 AI Model Artifacts Found")
@@ -243,6 +348,45 @@ def scan(
243
348
 
244
349
  console.print(f"\n[bold green]✔ Compliance Artifact Generated:[/bold green] {output} (CycloneDX v{schema_version})")
245
350
 
351
+ has_content = bool(results.get('artifacts') or results.get('dependencies'))
352
+ if share and has_content:
353
+ do_share = True
354
+ if not share_yes:
355
+ do_share = typer.confirm(
356
+ "Upload this SBOM to aisbom.io to generate a shareable link?\n"
357
+ "Data will be public to anyone with the link and expires in 30 days.",
358
+ default=False
359
+ )
360
+ if not do_share:
361
+ console.print("[dim]Share cancelled.[/dim]")
362
+
363
+ if do_share:
364
+ with console.status("[cyan]Uploading SBOM to aisbom.io...[/cyan]"):
365
+ try:
366
+ json_str = outputter.output_as_string()
367
+ res = requests.post(
368
+ "https://aisbom.io/api/sbom-share",
369
+ data=json_str,
370
+ headers={
371
+ "Content-Type": "application/json",
372
+ "User-Agent": telemetry._build_user_agent()
373
+ },
374
+ timeout=15.0
375
+ )
376
+ res.raise_for_status()
377
+ share_url = res.json().get("url")
378
+
379
+ console.print(f"\n[bold green]✔ Share Link Created:[/bold green] [underline cyan]{share_url}[/underline cyan]")
380
+ console.print("[dim]Anyone with this link can view this SBOM. Expires in 30 days.[/dim]")
381
+
382
+ telemetry_threads.append(telemetry.post_event(
383
+ "cli_share_created",
384
+ {"has_share_yes": "true" if share_yes else "false"},
385
+ scan_id=scan_id
386
+ ))
387
+ except Exception as e:
388
+ console.print(f"\n[bold red]✖ Failed to create share link:[/bold red] {e}")
389
+
246
390
  elif format == OutputFormat.SPDX:
247
391
  from .spdx_gen import generate_spdx_sbom
248
392
  spdx_json = generate_spdx_sbom(results)
@@ -262,12 +406,10 @@ def scan(
262
406
  except importlib.metadata.PackageNotFoundError:
263
407
  ver = "unknown"
264
408
 
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
409
  console.print(Panel(
268
410
  f"[bold white]📊 Visualize this report:[/bold white]\n"
269
411
  f"Drag and drop [cyan]{output}[/cyan] into our secure offline viewer:\n"
270
- f"👉 [link={viewer_url}]Open Secure Viewer (aisbom.io)[/link]",
412
+ f"> View detailed artifact visualizer: https://aisbom.io/viewer",
271
413
  border_style="blue",
272
414
  expand=False
273
415
  ))
@@ -281,6 +423,9 @@ def scan(
281
423
  # Check update status before exiting
282
424
  _check_update_status()
283
425
 
426
+ # Flush in-flight telemetry POSTs so events aren't lost on exit.
427
+ _flush_telemetry_threads(telemetry_threads)
428
+
284
429
  # Non-zero exit codes for CI/CD when high risk or errors are present
285
430
  raise typer.Exit(code=exit_code)
286
431
 
@@ -362,11 +507,18 @@ def diff(
362
507
  t = threading.Thread(target=run_version_check_wrapper, daemon=True)
363
508
  t.start()
364
509
 
510
+ # Telemetry: per-invocation scan_id groups events into one GA4 session.
511
+ scan_id = uuid.uuid4().hex
512
+ telemetry_threads: list[threading.Thread | None] = [
513
+ _maybe_emit_install_event(),
514
+ ]
515
+
365
516
  path_old = Path(old_file)
366
517
  path_new = Path(new_file)
367
518
 
368
519
  if not path_old.exists() or not path_new.exists():
369
520
  console.print("[bold red]Error:[/bold red] One or both files do not exist.")
521
+ _flush_telemetry_threads(telemetry_threads)
370
522
  raise typer.Exit(code=1)
371
523
 
372
524
  try:
@@ -374,8 +526,22 @@ def diff(
374
526
  result = differ.compare()
375
527
  except Exception as e:
376
528
  console.print(f"[bold red]Error parsing SBOMs:[/bold red] {e}")
529
+ telemetry_threads.append(telemetry.post_event(
530
+ "cli_error",
531
+ {"command": "diff", "error_type": type(e).__name__},
532
+ scan_id=scan_id,
533
+ ))
534
+ _flush_telemetry_threads(telemetry_threads)
377
535
  raise typer.Exit(code=1)
378
536
 
537
+ # Telemetry: comparison succeeded; fire cli_diff with drift signal.
538
+ has_drift = bool(result.added or result.removed or result.changed)
539
+ telemetry_threads.append(telemetry.post_event(
540
+ "cli_diff",
541
+ {"has_drift": "true" if has_drift else "false"},
542
+ scan_id=scan_id,
543
+ ))
544
+
379
545
  console.print(Panel.fit(f"[bold cyan]Comparing[/bold cyan] {path_old.name} -> {path_new.name}"))
380
546
 
381
547
  table = Table(title="Drift Analysis")
@@ -454,11 +620,13 @@ def diff(
454
620
 
455
621
  if fail_on_risk_increase and (result.risk_increased or result.hash_drifted):
456
622
  console.print("\n[bold red]FAILURE: Critical risk increase or hash drift detected![/bold red]")
623
+ _flush_telemetry_threads(telemetry_threads)
457
624
  raise typer.Exit(code=1)
458
-
625
+
459
626
  console.print("\n[bold green]Success: No critical regression detected.[/bold green]")
460
-
627
+
461
628
  _check_update_status()
629
+ _flush_telemetry_threads(telemetry_threads)
462
630
 
463
631
  if __name__ == "__main__":
464
632
  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.9.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