opencomplai-cli 0.1.2__tar.gz → 0.3.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.
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/.gitignore +15 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/PKG-INFO +6 -2
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/pyproject.toml +12 -1
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/__init__.py +1 -1
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/commands/checker.py +29 -5
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/commands/dashboard.py +65 -5
- opencomplai_cli-0.3.0/src/opencomplai_cli/commands/push.py +153 -0
- opencomplai_cli-0.3.0/src/opencomplai_cli/commands/serve.py +37 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/connectors/github_actions.py +126 -25
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/connectors/gitlab_ci.py +124 -24
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/data/checker-local.html +49 -5
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/main.py +723 -8
- opencomplai_cli-0.3.0/src/opencomplai_cli/oidc_client.py +77 -0
- opencomplai_cli-0.3.0/src/opencomplai_cli/publish.py +310 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_checker_cmd.py +40 -0
- opencomplai_cli-0.3.0/tests/test_connectors.py +686 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_dashboard_cmd.py +125 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_eu_ai_scan_golden.py +6 -1
- opencomplai_cli-0.3.0/tests/test_oidc_client.py +91 -0
- opencomplai_cli-0.3.0/tests/test_publish.py +434 -0
- opencomplai_cli-0.3.0/tests/test_push_cmd.py +413 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_scan_cmd.py +150 -1
- opencomplai_cli-0.3.0/tests/test_scan_quick.py +96 -0
- opencomplai_cli-0.1.2/tests/test_connectors.py +0 -198
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/README.md +0 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/commands/__init__.py +0 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/commands/interactive_init.py +0 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/connectors/__init__.py +0 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/__init__.py +0 -0
- {opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/tests/test_cli.py +0 -0
|
@@ -57,6 +57,7 @@ docs/checker-widget/node_modules/
|
|
|
57
57
|
packages/cli/src/opencomplai_cli/data/checker-local.html
|
|
58
58
|
|
|
59
59
|
# --- Public-repo hardening (keep AI-tooling and internal artifacts out) ---
|
|
60
|
+
.oss-sync-state
|
|
60
61
|
.claude/
|
|
61
62
|
.cursor/
|
|
62
63
|
.autopilot.json
|
|
@@ -67,3 +68,17 @@ x.key
|
|
|
67
68
|
.tmp-*.db
|
|
68
69
|
ci-logs/
|
|
69
70
|
tmp/
|
|
71
|
+
|
|
72
|
+
.vercel
|
|
73
|
+
.env*.local
|
|
74
|
+
|
|
75
|
+
# `vercel env pull <environment>` writes to a file named after the environment
|
|
76
|
+
# when no `-o` path is given, so a mistyped pull drops a real token into the
|
|
77
|
+
# repo root under a name none of the patterns above match. Ignore the three
|
|
78
|
+
# environment names outright rather than relying on catching it in review.
|
|
79
|
+
/development
|
|
80
|
+
/preview
|
|
81
|
+
/production
|
|
82
|
+
|
|
83
|
+
# Scratch worktrees created by local agent tooling.
|
|
84
|
+
.harness-worktrees/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: opencomplai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Opencomplai command-line interface for EU AI Act compliance assessment
|
|
5
5
|
Project-URL: Homepage, https://opencomplai.com
|
|
6
6
|
Project-URL: Documentation, https://docs.opencomplai.com
|
|
@@ -12,8 +12,12 @@ Requires-Dist: questionary>=2.0.0
|
|
|
12
12
|
Requires-Dist: rich>=13.0.0
|
|
13
13
|
Requires-Dist: typer>=0.9.0
|
|
14
14
|
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: jsonschema>=4.21; extra == 'dev'
|
|
15
16
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
16
17
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
18
|
+
Provides-Extra: serve
|
|
19
|
+
Requires-Dist: fastapi>=0.110; extra == 'serve'
|
|
20
|
+
Requires-Dist: uvicorn>=0.27; extra == 'serve'
|
|
17
21
|
Description-Content-Type: text/markdown
|
|
18
22
|
|
|
19
23
|
# opencomplai-cli
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "opencomplai-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Opencomplai command-line interface for EU AI Act compliance assessment"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "AGPL-3.0-only" }
|
|
@@ -24,6 +24,17 @@ opencomplai = "opencomplai_cli.main:app"
|
|
|
24
24
|
dev = [
|
|
25
25
|
"pytest>=7.0",
|
|
26
26
|
"ruff>=0.1.0",
|
|
27
|
+
# Only used by test_publish.py's structural schema-validation test (an
|
|
28
|
+
# end-to-end proof that the real repo-root compliance-artifact.json,
|
|
29
|
+
# once mapped, validates against dashboard-saas's vendored
|
|
30
|
+
# first_scan_status.schema.json) -- never imported by runtime code.
|
|
31
|
+
# Same package/version floor dashboard-saas/services/ingest-api's
|
|
32
|
+
# pyproject.toml already declares.
|
|
33
|
+
"jsonschema>=4.21",
|
|
34
|
+
]
|
|
35
|
+
serve = [
|
|
36
|
+
"fastapi>=0.110",
|
|
37
|
+
"uvicorn>=0.27",
|
|
27
38
|
]
|
|
28
39
|
|
|
29
40
|
[tool.hatch.build.targets.wheel]
|
|
@@ -155,25 +155,48 @@ def run_interactive_wizard(*, skip_allowed: bool = True) -> CheckerSession | Non
|
|
|
155
155
|
"Is the system a product with AI as safety component under Annex I harmonisation law?",
|
|
156
156
|
default=False,
|
|
157
157
|
)
|
|
158
|
+
if answers["hr1_annex_i"]:
|
|
159
|
+
answers["hr8_conformity_assessment"] = _confirm(
|
|
160
|
+
"Is that Annex I product required to undergo a third-party "
|
|
161
|
+
"conformity assessment by a notified body (Art 6(1)(b))?",
|
|
162
|
+
default=False,
|
|
163
|
+
)
|
|
158
164
|
answers["hr2_annex_iii"] = _confirm(
|
|
159
165
|
"Does the system fall within an Annex III high-risk use case?",
|
|
160
166
|
default=False,
|
|
161
167
|
)
|
|
162
|
-
if answers["
|
|
168
|
+
if answers["hr2_annex_iii"]:
|
|
169
|
+
answers["hr7_profiling"] = _confirm(
|
|
170
|
+
"Does the system perform profiling of natural persons (automated "
|
|
171
|
+
"processing of personal data to evaluate, analyse, or predict "
|
|
172
|
+
"aspects such as behaviour, preferences, location, or performance)?",
|
|
173
|
+
default=False,
|
|
174
|
+
)
|
|
175
|
+
# The Art. 6(3)(a)-(d) exceptions are an Annex III derogation only; an
|
|
176
|
+
# Annex I trigger (safety component + conformity assessment) has none.
|
|
177
|
+
hr1_effective = answers["hr1_annex_i"] and answers.get(
|
|
178
|
+
"hr8_conformity_assessment", False
|
|
179
|
+
)
|
|
180
|
+
if answers["hr2_annex_iii"] and not hr1_effective:
|
|
163
181
|
answers["hr3_art_6_3"] = _confirm(
|
|
164
|
-
"
|
|
182
|
+
"Is the AI system intended only to improve the result of a "
|
|
183
|
+
"previously completed human activity (Art 6(3)(b))?",
|
|
165
184
|
default=False,
|
|
166
185
|
)
|
|
167
186
|
answers["hr4_narrow_task"] = _confirm(
|
|
168
|
-
"Is the AI intended only for a narrow procedural task (Art 6(3)
|
|
187
|
+
"Is the AI intended only for a narrow procedural task (Art 6(3)(a))?",
|
|
169
188
|
default=False,
|
|
170
189
|
)
|
|
171
190
|
answers["hr5_no_significant_risk"] = _confirm(
|
|
172
|
-
"
|
|
191
|
+
"Is the AI system intended only to detect decision-making patterns "
|
|
192
|
+
"or deviations from prior decisions, without replacing or "
|
|
193
|
+
"influencing the prior human assessment and without proper human "
|
|
194
|
+
"review (Art 6(3)(c))?",
|
|
173
195
|
default=False,
|
|
174
196
|
)
|
|
175
197
|
answers["hr6_accessory"] = _confirm(
|
|
176
|
-
"Is the system
|
|
198
|
+
"Is the AI system intended only to perform a preparatory task for "
|
|
199
|
+
"an assessment relevant to the Annex III use case (Art 6(3)(d))?",
|
|
177
200
|
default=False,
|
|
178
201
|
)
|
|
179
202
|
|
|
@@ -199,6 +222,7 @@ def run_interactive_wizard(*, skip_allowed: bool = True) -> CheckerSession | Non
|
|
|
199
222
|
default=False,
|
|
200
223
|
)
|
|
201
224
|
if answers["s1_gpai"]:
|
|
225
|
+
_show_help("gpai_systemic_risk_threshold")
|
|
202
226
|
answers["s1_gpai_systemic_risk"] = _confirm(
|
|
203
227
|
"Does the GPAI model have systemic risk (high impact capabilities)?",
|
|
204
228
|
default=False,
|
|
@@ -62,6 +62,30 @@ _CONFIG_FILE = _OPENCOMPLAI_DIR / "config.yaml"
|
|
|
62
62
|
_SIGNING_PUB = _OPENCOMPLAI_DIR / "signing.pub"
|
|
63
63
|
_EGRESS_ALLOWLIST = _OPENCOMPLAI_DIR / "egress_allowlist.json"
|
|
64
64
|
_LEDGER_FILE = _OPENCOMPLAI_DIR / "ledger.jsonl"
|
|
65
|
+
_CLIENT_SECRET_FILE = _OPENCOMPLAI_DIR / "client.secret"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _write_client_secret(raw_secret: str) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Persist the plaintext client_secret received once from /enroll. Stored
|
|
71
|
+
as a dedicated file (mirroring signing.key/signing.pub's separateness
|
|
72
|
+
from config.yaml's non-secret flat scalars), chmod 0600 where the
|
|
73
|
+
platform supports it — a no-op on Windows, where this permission model
|
|
74
|
+
doesn't apply the same way.
|
|
75
|
+
"""
|
|
76
|
+
_OPENCOMPLAI_DIR.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
_CLIENT_SECRET_FILE.write_text(raw_secret)
|
|
78
|
+
try:
|
|
79
|
+
os.chmod(_CLIENT_SECRET_FILE, 0o600)
|
|
80
|
+
except (OSError, NotImplementedError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_client_secret() -> str | None:
|
|
85
|
+
if not _CLIENT_SECRET_FILE.exists():
|
|
86
|
+
return None
|
|
87
|
+
secret = _CLIENT_SECRET_FILE.read_text().strip()
|
|
88
|
+
return secret or None
|
|
65
89
|
|
|
66
90
|
|
|
67
91
|
# ---------------------------------------------------------------------------
|
|
@@ -242,6 +266,17 @@ def enroll(
|
|
|
242
266
|
audit_event_hash = data.get("audit_event_hash", "")
|
|
243
267
|
dashboard_url_from_api = data.get("dashboard_url", base_url)
|
|
244
268
|
|
|
269
|
+
# --- Persist OIDC client-credentials pair (AUTH-SAAS) ---
|
|
270
|
+
# /enroll is the only place these are ever returned; client_id is not
|
|
271
|
+
# secret (like install_id) and lives in config.yaml, client_secret gets
|
|
272
|
+
# its own file since it must never be handed back a second time.
|
|
273
|
+
client_id = data.get("client_id", "")
|
|
274
|
+
client_secret = data.get("client_secret", "")
|
|
275
|
+
if client_id and client_secret:
|
|
276
|
+
cfg["client_id"] = client_id
|
|
277
|
+
_write_config(cfg)
|
|
278
|
+
_write_client_secret(client_secret)
|
|
279
|
+
|
|
245
280
|
# --- Write EgressConsent ledger event (OSS-side immutable ledger) ---
|
|
246
281
|
from datetime import UTC, datetime
|
|
247
282
|
|
|
@@ -269,6 +304,9 @@ def enroll(
|
|
|
269
304
|
_console.print(f" tenant_id: {tenant}")
|
|
270
305
|
_console.print(f" install_id: {install_id}")
|
|
271
306
|
_console.print(f" audit_event_hash: {audit_event_hash}")
|
|
307
|
+
if client_id and client_secret:
|
|
308
|
+
_console.print(f" client_id: {client_id}")
|
|
309
|
+
_console.print(f" client_secret: saved to {_CLIENT_SECRET_FILE}")
|
|
272
310
|
_console.print(f"\nDashboard: {dashboard_url_from_api}")
|
|
273
311
|
_console.print(
|
|
274
312
|
"\nNext step: run [bold]opencomplai check --sign[/bold] to emit your first signed artifact."
|
|
@@ -307,6 +345,18 @@ def withdraw(
|
|
|
307
345
|
).rstrip("/")
|
|
308
346
|
|
|
309
347
|
# --- Notify dashboard if URL is available ---
|
|
348
|
+
# Best-effort and non-fatal by design (an offline install can still clear
|
|
349
|
+
# its own local state) — but a >=400 response is not the same as being
|
|
350
|
+
# unreachable: the dashboard *processed* the request and refused it, so
|
|
351
|
+
# egress consent almost certainly stays active server-side. That case
|
|
352
|
+
# gets its own louder message below rather than folding into the local
|
|
353
|
+
# "Withdrawal complete" line, which would otherwise claim a success that
|
|
354
|
+
# did not happen remotely (admin-api's /v1/admin/withdraw requires an
|
|
355
|
+
# authenticated, tenant-scoped service JWT as of SEC-2 — this CLI does
|
|
356
|
+
# not yet mint one, so every call currently gets 401; see HANDOFF.md).
|
|
357
|
+
# Vacuously true when no dashboard URL is configured at all — a purely
|
|
358
|
+
# local install has no remote consent state to fall out of sync with.
|
|
359
|
+
dashboard_notified = not base_url
|
|
310
360
|
if base_url:
|
|
311
361
|
try:
|
|
312
362
|
status, data = _post(
|
|
@@ -315,9 +365,12 @@ def withdraw(
|
|
|
315
365
|
)
|
|
316
366
|
if status >= 400:
|
|
317
367
|
_err_console.print(
|
|
318
|
-
f"[
|
|
319
|
-
f"{data.get('message', data)}"
|
|
368
|
+
f"[red]Error:[/red] dashboard rejected the withdraw request "
|
|
369
|
+
f"({status}: {data.get('message', data)}) — egress consent was "
|
|
370
|
+
"NOT revoked server-side. Clearing local state only."
|
|
320
371
|
)
|
|
372
|
+
else:
|
|
373
|
+
dashboard_notified = True
|
|
321
374
|
except ConnectionError as exc:
|
|
322
375
|
_err_console.print(
|
|
323
376
|
f"[yellow]Warning:[/yellow] could not reach dashboard: {exc}"
|
|
@@ -342,9 +395,16 @@ def withdraw(
|
|
|
342
395
|
_write_egress_allowlist(allowlist)
|
|
343
396
|
|
|
344
397
|
if removed:
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
398
|
+
if dashboard_notified:
|
|
399
|
+
_console.print(
|
|
400
|
+
f"[green]Withdrawal complete.[/green] Egress allowlist entry removed for tenant {tenant}."
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
_console.print(
|
|
404
|
+
f"[yellow]Local withdrawal complete[/yellow] — egress allowlist entry removed "
|
|
405
|
+
f"for tenant {tenant}, but the dashboard was not notified (see error above). "
|
|
406
|
+
"Server-side egress consent likely remains active."
|
|
407
|
+
)
|
|
348
408
|
else:
|
|
349
409
|
_console.print(
|
|
350
410
|
f"[dim]No egress allowlist entry found for tenant {tenant} — nothing to remove.[/dim]"
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI: ``opencomplai push`` — publish a signed ``compliance-artifact.json`` to
|
|
3
|
+
the Opencomplai Premium Dashboard (GO-LIVE CORE-4).
|
|
4
|
+
|
|
5
|
+
Deliberately a standalone command, not a ``--push`` flag on ``check``/
|
|
6
|
+
``scan``: those commands' exit-code semantics are a CI-gating contract
|
|
7
|
+
(0/1/2/3/4, see ``main.py``'s module docstring) that must never depend on
|
|
8
|
+
network reachability. ``push`` is a separate, opt-in step a pipeline runs
|
|
9
|
+
*after* ``check`` has already gated the build locally.
|
|
10
|
+
|
|
11
|
+
Auth model: API-key, not the CI connectors' JWT/OIDC path. ``push`` sends
|
|
12
|
+
no ``install_id`` in its envelope at all -- ``dashboard_ingest.routes``
|
|
13
|
+
derives it server-side from the authenticated API key's own project (see
|
|
14
|
+
ingest-api's ``test_key_auth.py::test_envelope_install_id_unknown_is
|
|
15
|
+
_overridden_by_principal``), and a key-authed request MAY omit
|
|
16
|
+
``signature`` entirely since the key itself attests authenticity
|
|
17
|
+
(``dashboard_ingest.contract.validate_envelope_shape``'s
|
|
18
|
+
``require_install_id=False`` default for this path).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
from rich.markup import escape
|
|
31
|
+
|
|
32
|
+
from opencomplai_cli.publish import (
|
|
33
|
+
envelope_signature,
|
|
34
|
+
prepare_scan_status_artifact,
|
|
35
|
+
publish_scan_status,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
console = Console()
|
|
39
|
+
err_console = Console(stderr=True)
|
|
40
|
+
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_PUBLISH_FAILED = 3
|
|
43
|
+
|
|
44
|
+
_LOCAL_HOSTNAMES = {"localhost", "127.0.0.1"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_push(artifact_file: Path, *, env: dict[str, str] | None = None) -> int:
|
|
48
|
+
"""
|
|
49
|
+
Read ``artifact_file``, shape it, and POST it to the dashboard.
|
|
50
|
+
|
|
51
|
+
Returns the process exit code (0 on HTTP 200/201, 3 otherwise) instead
|
|
52
|
+
of calling ``sys.exit`` directly -- same convention as
|
|
53
|
+
``connectors.github_actions.run_connector`` -- so tests can assert on
|
|
54
|
+
the return value without catching ``SystemExit``. The ``env`` mapping
|
|
55
|
+
defaults to ``os.environ`` and exists for the same reason: tests pass a
|
|
56
|
+
synthetic environment instead of monkeypatching process-global state.
|
|
57
|
+
"""
|
|
58
|
+
_env = env if env is not None else os.environ
|
|
59
|
+
|
|
60
|
+
if not artifact_file.exists():
|
|
61
|
+
err_console.print(f"[red]Error:[/red] artifact file not found: {artifact_file}")
|
|
62
|
+
err_console.print(
|
|
63
|
+
"Run [bold]opencomplai check --sign[/bold] first to produce one."
|
|
64
|
+
)
|
|
65
|
+
return EXIT_PUBLISH_FAILED
|
|
66
|
+
|
|
67
|
+
# A directory path, an unreadable file, or non-UTF-8 bytes must all
|
|
68
|
+
# produce a clean one-line error rather than an uncaught traceback --
|
|
69
|
+
# this is a CLI a pipeline script parses stderr from, not a Python API.
|
|
70
|
+
try:
|
|
71
|
+
raw_text = artifact_file.read_text(encoding="utf-8")
|
|
72
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
73
|
+
err_console.print(f"[red]Error:[/red] could not read {artifact_file}: {exc}")
|
|
74
|
+
return EXIT_PUBLISH_FAILED
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
artifact: Any = json.loads(raw_text)
|
|
78
|
+
except json.JSONDecodeError as exc:
|
|
79
|
+
err_console.print(f"[red]Error:[/red] {artifact_file} is not valid JSON: {exc}")
|
|
80
|
+
return EXIT_PUBLISH_FAILED
|
|
81
|
+
|
|
82
|
+
if not isinstance(artifact, dict):
|
|
83
|
+
err_console.print(
|
|
84
|
+
f"[red]Error:[/red] {artifact_file} must contain a JSON object "
|
|
85
|
+
"(a ScanStatusArtifact), not a "
|
|
86
|
+
f"{type(artifact).__name__}"
|
|
87
|
+
)
|
|
88
|
+
return EXIT_PUBLISH_FAILED
|
|
89
|
+
|
|
90
|
+
api_key = _env.get("OPENCOMPLAI_API_KEY", "")
|
|
91
|
+
dashboard_url = _env.get("OPENCOMPLAI_DASHBOARD_URL", "").rstrip("/")
|
|
92
|
+
if not api_key or not dashboard_url:
|
|
93
|
+
err_console.print(
|
|
94
|
+
"[red]Error:[/red] OPENCOMPLAI_API_KEY and OPENCOMPLAI_DASHBOARD_URL "
|
|
95
|
+
"must both be set."
|
|
96
|
+
)
|
|
97
|
+
return EXIT_PUBLISH_FAILED
|
|
98
|
+
|
|
99
|
+
parsed_url = urlparse(dashboard_url)
|
|
100
|
+
if parsed_url.scheme == "http" and parsed_url.hostname not in _LOCAL_HOSTNAMES:
|
|
101
|
+
err_console.print(
|
|
102
|
+
f"[yellow]Warning:[/yellow] OPENCOMPLAI_DASHBOARD_URL ({dashboard_url}) "
|
|
103
|
+
"uses plain http -- the API key will be sent unencrypted over the network. "
|
|
104
|
+
"Use https unless this is local development."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
prepared = prepare_scan_status_artifact(
|
|
108
|
+
artifact, commit_env=_env, repo_dir=artifact_file.resolve().parent
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# No install_id (see module docstring) -- the server derives it from
|
|
112
|
+
# the API key's own project. signature is only forwarded when the
|
|
113
|
+
# mapper changed nothing at all (G-5, see publish.envelope_signature);
|
|
114
|
+
# otherwise it falls back to "" (never null) so the envelope always
|
|
115
|
+
# matches the JSON Schema's string-or-absent shape.
|
|
116
|
+
signature = envelope_signature(artifact, prepared)
|
|
117
|
+
original_signature = artifact.get("signature")
|
|
118
|
+
if (
|
|
119
|
+
isinstance(original_signature, str)
|
|
120
|
+
and original_signature != ""
|
|
121
|
+
and not signature
|
|
122
|
+
):
|
|
123
|
+
err_console.print(
|
|
124
|
+
"[dim]note: artifact signature not forwarded (mapped payload differs "
|
|
125
|
+
"from signed bytes; the API key attests this push)[/dim]"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
envelope = {
|
|
129
|
+
"system_id": prepared.get("system_id", "unknown"),
|
|
130
|
+
"artifact": prepared,
|
|
131
|
+
"signature": signature,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
status, data = publish_scan_status(dashboard_url, api_key, envelope)
|
|
135
|
+
|
|
136
|
+
if status in (200, 201):
|
|
137
|
+
outcome = escape(
|
|
138
|
+
str(data.get("outcome", "accepted" if status == 201 else "replayed"))
|
|
139
|
+
)
|
|
140
|
+
content_hash = escape(str(data.get("content_hash", "")))
|
|
141
|
+
console.print(f"[green]Push {outcome}.[/green]")
|
|
142
|
+
console.print(f" system_id: {envelope['system_id']}")
|
|
143
|
+
console.print(f" content_hash: {content_hash}")
|
|
144
|
+
console.print(f"\nDashboard: {dashboard_url}")
|
|
145
|
+
return EXIT_OK
|
|
146
|
+
|
|
147
|
+
# `data` is server-controlled (a JSON error body echoed back to the
|
|
148
|
+
# terminal) -- escaped so it can never inject rich markup.
|
|
149
|
+
err_console.print(f"[red]Push failed ({status}):[/red] {escape(str(data))}")
|
|
150
|
+
return EXIT_PUBLISH_FAILED
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = ["EXIT_OK", "EXIT_PUBLISH_FAILED", "run_push"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""CLI: opencomplai serve — local loopback dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
err_console = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_serve(project_root: Path, host: str, port: int) -> None:
|
|
15
|
+
if host not in {"127.0.0.1", "localhost"}:
|
|
16
|
+
err_console.print(
|
|
17
|
+
"[red]Error:[/red] serve binds to 127.0.0.1/localhost only "
|
|
18
|
+
"(this is not the Pro SaaS dashboard)."
|
|
19
|
+
)
|
|
20
|
+
raise typer.Exit(2)
|
|
21
|
+
try:
|
|
22
|
+
import uvicorn
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
err_console.print(
|
|
25
|
+
"[red]Error:[/red] Install the serve extra: "
|
|
26
|
+
"pip install 'opencomplai-cli[serve]'"
|
|
27
|
+
)
|
|
28
|
+
raise typer.Exit(2) from exc
|
|
29
|
+
|
|
30
|
+
from opencomplai_core.local_dashboard import create_app
|
|
31
|
+
|
|
32
|
+
app = create_app(project_root.resolve())
|
|
33
|
+
err_console.print(
|
|
34
|
+
f"[green]OpenComplAI local dashboard[/green] on http://{host}:{port}/ "
|
|
35
|
+
"(loopback only — not Pro/SaaS)"
|
|
36
|
+
)
|
|
37
|
+
uvicorn.run(app, host=host, port=port, log_level="info")
|
{opencomplai_cli-0.1.2 → opencomplai_cli-0.3.0}/src/opencomplai_cli/connectors/github_actions.py
RENAMED
|
@@ -19,7 +19,19 @@ Environment variables consumed
|
|
|
19
19
|
``GITHUB_STEP_SUMMARY`` — path to the job summary markdown file.
|
|
20
20
|
``OPENCOMPLAI_DASHBOARD_URL`` — dashboard ingest base URL.
|
|
21
21
|
``OPENCOMPLAI_TENANT_ID`` — tenant ID for the current org.
|
|
22
|
-
``
|
|
22
|
+
``OPENCOMPLAI_API_KEY`` — an ``ock_...`` API key (GO-LIVE CORE-3/-4).
|
|
23
|
+
Takes precedence over everything below: when
|
|
24
|
+
set, the connector sends the key-authed
|
|
25
|
+
envelope (no ``install_id``) and never
|
|
26
|
+
touches ``OPENCOMPLAI_AUTH_TOKEN`` or OIDC.
|
|
27
|
+
``OPENCOMPLAI_AUTH_TOKEN`` — bearer token for dashboard ingest (legacy
|
|
28
|
+
JWT path, only consulted when
|
|
29
|
+
OPENCOMPLAI_API_KEY is unset). Takes
|
|
30
|
+
precedence over the three vars below when set.
|
|
31
|
+
``OPENCOMPLAI_CLIENT_ID`` — OIDC client_id from /enroll, used only when
|
|
32
|
+
OPENCOMPLAI_AUTH_TOKEN is unset.
|
|
33
|
+
``OPENCOMPLAI_CLIENT_SECRET`` — OIDC client_secret from /enroll (same gate).
|
|
34
|
+
``OPENCOMPLAI_TOKEN_ENDPOINT`` — the IdP's OAuth2 token endpoint (same gate).
|
|
23
35
|
|
|
24
36
|
Exit codes
|
|
25
37
|
----------
|
|
@@ -49,8 +61,14 @@ import json
|
|
|
49
61
|
import os
|
|
50
62
|
import subprocess
|
|
51
63
|
import sys
|
|
64
|
+
from pathlib import Path
|
|
52
65
|
from typing import Any
|
|
53
66
|
|
|
67
|
+
# `check --sign` (main.py's check_cmd) always writes its artifact here,
|
|
68
|
+
# relative to whatever cwd the subprocess inherited -- this connector never
|
|
69
|
+
# passes cwd= to subprocess.run, so that is this process's own cwd too.
|
|
70
|
+
_ARTIFACT_FILENAME = "compliance-artifact.json"
|
|
71
|
+
|
|
54
72
|
# ---------------------------------------------------------------------------
|
|
55
73
|
# Platform detection helpers
|
|
56
74
|
# ---------------------------------------------------------------------------
|
|
@@ -119,8 +137,18 @@ def run_connector(
|
|
|
119
137
|
stdout = result.stdout
|
|
120
138
|
stderr = result.stderr
|
|
121
139
|
|
|
122
|
-
#
|
|
123
|
-
|
|
140
|
+
# Prefer the artifact `check --sign` wrote to disk. The `cmd` list
|
|
141
|
+
# above never passes `-o json`, so real stdout is Rich's indent=2
|
|
142
|
+
# human/JSON output (`console.print_json`) -- never the single-line
|
|
143
|
+
# JSON-object-per-line shape `_parse_artifact_result` scans for, so
|
|
144
|
+
# that parse essentially never matches a real CI run and this
|
|
145
|
+
# connector silently never published anything. The stdout parse is
|
|
146
|
+
# kept as a fallback for a caller who passes `-o json` explicitly via
|
|
147
|
+
# `check_args`, or if the artifact file is missing for some other
|
|
148
|
+
# reason (e.g. `check` crashed before writing it).
|
|
149
|
+
artifact_result = _read_disk_artifact(
|
|
150
|
+
Path(_ARTIFACT_FILENAME)
|
|
151
|
+
) or _parse_artifact_result(stdout)
|
|
124
152
|
|
|
125
153
|
# Set step output.
|
|
126
154
|
if artifact_result:
|
|
@@ -153,6 +181,26 @@ def run_connector(
|
|
|
153
181
|
return 0
|
|
154
182
|
|
|
155
183
|
|
|
184
|
+
def _read_disk_artifact(path: Path) -> dict[str, Any] | None:
|
|
185
|
+
"""Read a ``ScanStatusArtifact`` dict written to disk by ``check --sign``.
|
|
186
|
+
|
|
187
|
+
Returns ``None`` (never raises) on anything short of a real artifact --
|
|
188
|
+
missing file, unreadable/non-UTF-8, invalid JSON, JSON that isn't an
|
|
189
|
+
object, or an object missing the ``result`` field -- so callers can
|
|
190
|
+
fall back to the stdout parse in every one of those cases exactly as
|
|
191
|
+
if the disk read had never been attempted.
|
|
192
|
+
"""
|
|
193
|
+
if not path.exists():
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
197
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
198
|
+
return None
|
|
199
|
+
if not isinstance(data, dict) or "result" not in data:
|
|
200
|
+
return None
|
|
201
|
+
return data
|
|
202
|
+
|
|
203
|
+
|
|
156
204
|
def _parse_artifact_result(stdout: str) -> dict[str, Any] | None:
|
|
157
205
|
for line in stdout.splitlines():
|
|
158
206
|
line = line.strip()
|
|
@@ -204,35 +252,88 @@ def _publish_to_dashboard(artifact: dict | None, env: dict[str, str]) -> None:
|
|
|
204
252
|
if not artifact:
|
|
205
253
|
return
|
|
206
254
|
base_url = env.get("OPENCOMPLAI_DASHBOARD_URL", "").rstrip("/")
|
|
255
|
+
if not base_url:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
from opencomplai_cli.publish import (
|
|
259
|
+
envelope_signature,
|
|
260
|
+
prepare_scan_status_artifact,
|
|
261
|
+
publish_scan_status,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# G-4: the raw artifact never validated against ingest's schema
|
|
265
|
+
# (additionalProperties: false rejected evidence_hashes/rationale_hash/
|
|
266
|
+
# scan_summary/gap_report/eval_summary/install_id outright, and
|
|
267
|
+
# commit_ref="HEAD" failed minLength: 7) — every payload this connector
|
|
268
|
+
# ever sent was rejected with a 422. prepare_scan_status_artifact fills
|
|
269
|
+
# in only what the OSS artifact genuinely lacks (policy_bundle_version,
|
|
270
|
+
# timestamp, a real commit_ref); everything else passes through
|
|
271
|
+
# untouched onto the now-widened schema.
|
|
272
|
+
|
|
273
|
+
api_key = env.get("OPENCOMPLAI_API_KEY", "")
|
|
274
|
+
if api_key:
|
|
275
|
+
# ock_ key-authed path (GO-LIVE CORE-3/CORE-4) — the same auth
|
|
276
|
+
# `opencomplai push` uses. Takes priority over OPENCOMPLAI_AUTH_TOKEN
|
|
277
|
+
# and the OIDC client-credentials grant below: a caller who sets
|
|
278
|
+
# OPENCOMPLAI_API_KEY has opted into the key-authed contract, which
|
|
279
|
+
# is a different envelope shape (no install_id at all — the server
|
|
280
|
+
# derives it from the key's own project), not just a different
|
|
281
|
+
# bearer value on the same envelope.
|
|
282
|
+
prepared = prepare_scan_status_artifact(artifact, commit_env=env)
|
|
283
|
+
envelope = {
|
|
284
|
+
"system_id": prepared.get("system_id", "unknown"),
|
|
285
|
+
"artifact": prepared,
|
|
286
|
+
"signature": envelope_signature(artifact, prepared),
|
|
287
|
+
}
|
|
288
|
+
status, data = publish_scan_status(base_url, api_key, envelope)
|
|
289
|
+
if status not in (200, 201):
|
|
290
|
+
_annotate(
|
|
291
|
+
"warning",
|
|
292
|
+
f"Opencomplai dashboard publish failed: HTTP {status} {data}",
|
|
293
|
+
)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# Legacy JWT/OIDC path (pre-CORE-3 envelope contract). An operator-set
|
|
297
|
+
# OPENCOMPLAI_AUTH_TOKEN always wins. If unset, acquire one via the
|
|
298
|
+
# OIDC client-credentials grant using the pair /enroll issued
|
|
299
|
+
# (AUTH-SAAS) — same trio of env vars, no manual token pasting.
|
|
207
300
|
token = env.get("OPENCOMPLAI_AUTH_TOKEN", "")
|
|
208
|
-
if not
|
|
301
|
+
if not token:
|
|
302
|
+
client_id = env.get("OPENCOMPLAI_CLIENT_ID", "")
|
|
303
|
+
client_secret = env.get("OPENCOMPLAI_CLIENT_SECRET", "")
|
|
304
|
+
token_endpoint = env.get("OPENCOMPLAI_TOKEN_ENDPOINT", "")
|
|
305
|
+
if client_id and client_secret and token_endpoint:
|
|
306
|
+
from opencomplai_cli.oidc_client import OidcTokenError, acquire_token
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
token, _ = acquire_token(token_endpoint, client_id, client_secret)
|
|
310
|
+
except OidcTokenError as exc:
|
|
311
|
+
_annotate(
|
|
312
|
+
"warning", f"Opencomplai OIDC token acquisition failed: {exc}"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if not token:
|
|
209
316
|
return
|
|
210
317
|
|
|
211
|
-
|
|
212
|
-
import urllib.request
|
|
318
|
+
prepared = prepare_scan_status_artifact(artifact, commit_env=env)
|
|
213
319
|
|
|
214
320
|
install_id = env.get("OPENCOMPLAI_INSTALL_ID", "unknown")
|
|
215
|
-
|
|
321
|
+
envelope = {
|
|
216
322
|
"install_id": install_id,
|
|
217
|
-
"system_id":
|
|
218
|
-
"artifact":
|
|
219
|
-
|
|
323
|
+
"system_id": prepared.get("system_id", "unknown"),
|
|
324
|
+
"artifact": prepared,
|
|
325
|
+
# envelope_signature (not prepared.get("signature", "")) — otherwise
|
|
326
|
+
# an unsigned OSS artifact dict carrying an explicit
|
|
327
|
+
# `"signature": None` key would send JSON `null`: .get()'s default
|
|
328
|
+
# only applies when the key is absent, not when it's None.
|
|
329
|
+
"signature": envelope_signature(artifact, prepared),
|
|
220
330
|
}
|
|
221
|
-
data =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
"Authorization": f"Bearer {token}",
|
|
228
|
-
},
|
|
229
|
-
method="POST",
|
|
230
|
-
)
|
|
231
|
-
try:
|
|
232
|
-
with urllib.request.urlopen(req, timeout=30):
|
|
233
|
-
pass
|
|
234
|
-
except Exception as exc:
|
|
235
|
-
_annotate("warning", f"Opencomplai dashboard publish failed: {exc}")
|
|
331
|
+
status, data = publish_scan_status(base_url, token, envelope)
|
|
332
|
+
if status not in (200, 201):
|
|
333
|
+
_annotate(
|
|
334
|
+
"warning",
|
|
335
|
+
f"Opencomplai dashboard publish failed: HTTP {status} {data}",
|
|
336
|
+
)
|
|
236
337
|
|
|
237
338
|
|
|
238
339
|
# ---------------------------------------------------------------------------
|