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.
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/PKG-INFO +60 -4
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/README.md +57 -1
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/aisbom/cli.py +181 -13
- aisbom_cli-0.9.0/aisbom/telemetry.py +214 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/aisbom/version_check.py +1 -1
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/pyproject.toml +3 -3
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/LICENSE +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/aisbom/__init__.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/aisbom/diff.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/aisbom/linter.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/aisbom/mock_generator.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/aisbom/remote.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/aisbom/safety.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.0}/aisbom/scanner.py +0 -0
- {aisbom_cli-0.7.8 → aisbom_cli-0.9.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.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,<
|
|
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)
|
|
@@ -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/
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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"
|
|
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.
|
|
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.
|
|
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
|