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.
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/PKG-INFO +50 -4
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/README.md +47 -1
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/cli.py +138 -12
- aisbom_cli-0.8.0/aisbom/telemetry.py +214 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/version_check.py +1 -1
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/pyproject.toml +3 -3
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/LICENSE +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/__init__.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/diff.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/linter.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/mock_generator.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/remote.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/safety.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/scanner.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.8.0}/aisbom/spdx_gen.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aisbom-cli
|
|
3
|
-
Version: 0.
|
|
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,<
|
|
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.
|
|
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/
|
|
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/
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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"
|
|
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.
|
|
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.
|
|
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,<
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|