certone-sdk 0.2.0__py3-none-any.whl
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.
- certone_sdk/__init__.py +27 -0
- certone_sdk/agent.py +274 -0
- certone_sdk/client.py +262 -0
- certone_sdk/py.typed +0 -0
- certone_sdk/submit.py +363 -0
- certone_sdk-0.2.0.dist-info/METADATA +113 -0
- certone_sdk-0.2.0.dist-info/RECORD +9 -0
- certone_sdk-0.2.0.dist-info/WHEEL +4 -0
- certone_sdk-0.2.0.dist-info/entry_points.txt +3 -0
certone_sdk/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""CertOne SDK — a typed API client + the run-at-source agent.
|
|
2
|
+
|
|
3
|
+
from certone_sdk import CertOneClient
|
|
4
|
+
c = CertOneClient("https://api.yourco.com", token)
|
|
5
|
+
c.ingest_report(report, asset="Fraud Scorer", environment="ci", country_code="EU")
|
|
6
|
+
|
|
7
|
+
``certone-submit`` sends a project for a confidential scan and gates on the verdict (the path every
|
|
8
|
+
CI/MLOps/editor integration uses). The ``certone-agent`` CLI runs the engine where your code lives
|
|
9
|
+
and submits only the results, for systems that may not leave your environment.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from certone_sdk.client import CertOneClient, CertOneError, sha256_digest
|
|
15
|
+
from certone_sdk.submit import ProgressPrinter, Submission, pack_project, submit_and_wait
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CertOneClient",
|
|
19
|
+
"CertOneError",
|
|
20
|
+
"ProgressPrinter",
|
|
21
|
+
"Submission",
|
|
22
|
+
"__version__",
|
|
23
|
+
"pack_project",
|
|
24
|
+
"sha256_digest",
|
|
25
|
+
"submit_and_wait",
|
|
26
|
+
]
|
|
27
|
+
__version__ = "0.2.0"
|
certone_sdk/agent.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""certone-agent — certify an AI project from where it lives (a gateway, a build box, a device).
|
|
2
|
+
|
|
3
|
+
Two modes, for CI/cron (`run`, one-shot) or a long-running watcher (`watch`):
|
|
4
|
+
|
|
5
|
+
* ``--mode local`` (default, run-at-source): the model/code never leaves your environment. The
|
|
6
|
+
agent builds the AI BOM + runs the verdict loop locally, then POSTs the **report** (plus a content
|
|
7
|
+
digest that binds the ⑥ attestation) to the control plane via the SDK.
|
|
8
|
+
* ``--mode confidential``: the project is sent to the platform and processed inside an attested
|
|
9
|
+
confidential worker (static + dynamic tests on the target + metrics + the in-TEE correction),
|
|
10
|
+
exactly as ``certone-submit`` does. ``watch`` re-submits only when the project's content changes,
|
|
11
|
+
so a gateway re-certifies each new model version as it lands.
|
|
12
|
+
|
|
13
|
+
The API key comes from ``--token`` or ``$CERTONE_API_KEY`` (preferred: it then never appears in the
|
|
14
|
+
process list).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from certone_sdk.client import CertOneClient, CertOneError
|
|
29
|
+
from certone_sdk.submit import SKIP_DIRS, pack_project, submit_and_wait
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_once(
|
|
33
|
+
project: str,
|
|
34
|
+
pack_paths: list[str],
|
|
35
|
+
*,
|
|
36
|
+
client: CertOneClient,
|
|
37
|
+
asset: str | None = None,
|
|
38
|
+
environment: str = "ci",
|
|
39
|
+
country_code: str = "ZZ",
|
|
40
|
+
fail_on: str = "high",
|
|
41
|
+
snapshot_ref: str | None = None,
|
|
42
|
+
repo: str | None = None,
|
|
43
|
+
commit_hash: str | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Run the engine over ``project`` against ``pack_paths``; submit the report (run-at-source).
|
|
46
|
+
|
|
47
|
+
Pass ``repo`` + ``commit_hash`` (or run inside GitHub Actions, where they're auto-detected) to
|
|
48
|
+
close the two-way loop: the verdict is posted back as a native Check Run on that commit.
|
|
49
|
+
"""
|
|
50
|
+
from certone_engine.archive import make_targz
|
|
51
|
+
from certone_engine.evaluation.runner import run_check
|
|
52
|
+
|
|
53
|
+
result = run_check(project, list(pack_paths), fail_on=fail_on, snapshot_ref=snapshot_ref)
|
|
54
|
+
report = result.report.to_dict()
|
|
55
|
+
# Bind the attestation to the snapshot content without uploading it.
|
|
56
|
+
digest = "sha256:" + hashlib.sha256(make_targz(Path(project))).hexdigest()
|
|
57
|
+
return client.ingest_report(
|
|
58
|
+
report,
|
|
59
|
+
asset=asset,
|
|
60
|
+
environment=environment,
|
|
61
|
+
country_code=country_code,
|
|
62
|
+
snapshot_ref=snapshot_ref,
|
|
63
|
+
input_digest=digest,
|
|
64
|
+
repo=repo,
|
|
65
|
+
commit_hash=commit_hash,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def content_digest(project: str | Path) -> str:
|
|
70
|
+
"""``sha256:`` over the project's files (paths + bytes), skipping VCS metadata and caches --
|
|
71
|
+
what ``watch`` compares to decide whether a new version landed."""
|
|
72
|
+
root = Path(project).resolve()
|
|
73
|
+
h = hashlib.sha256()
|
|
74
|
+
for path in sorted(root.rglob("*")):
|
|
75
|
+
rel = path.relative_to(root)
|
|
76
|
+
if path.is_file() and not path.is_symlink() and not SKIP_DIRS.intersection(rel.parts):
|
|
77
|
+
h.update(rel.as_posix().encode() + b"\0")
|
|
78
|
+
with path.open("rb") as fh:
|
|
79
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
80
|
+
h.update(chunk)
|
|
81
|
+
return f"sha256:{h.hexdigest()}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_confidential(
|
|
85
|
+
project: str | Path,
|
|
86
|
+
*,
|
|
87
|
+
client: CertOneClient,
|
|
88
|
+
asset: str | None = None,
|
|
89
|
+
environment: str = "edge",
|
|
90
|
+
country_code: str = "ZZ",
|
|
91
|
+
fail_on: str = "high",
|
|
92
|
+
tee: str = "amd_sev_snp",
|
|
93
|
+
snapshot_ref: str | None = None,
|
|
94
|
+
commit_hash: str | None = None,
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Send ``project`` for a confidential scan and print its verdict; returns the verdict."""
|
|
97
|
+
root = Path(project).resolve()
|
|
98
|
+
name = asset or root.name
|
|
99
|
+
sub = submit_and_wait(
|
|
100
|
+
client,
|
|
101
|
+
pack_project(root),
|
|
102
|
+
asset=name,
|
|
103
|
+
environment=environment,
|
|
104
|
+
country_code=country_code,
|
|
105
|
+
fail_on=fail_on,
|
|
106
|
+
tee=tee,
|
|
107
|
+
snapshot_ref=snapshot_ref,
|
|
108
|
+
commit_hash=commit_hash,
|
|
109
|
+
on_submitted=lambda scan_id, digest: print(
|
|
110
|
+
f"certone-agent: {name} -> confidential scan {scan_id} ({digest})", flush=True
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
by = sub.report.get("by_severity") or {}
|
|
114
|
+
runtime = (sub.run.get("execution") or {}).get("runtime") or "the confidential worker"
|
|
115
|
+
att = sub.attestation.get("attestation_id") if sub.attestation else None
|
|
116
|
+
attestation = f"; attestation {att}" if att else ""
|
|
117
|
+
print(
|
|
118
|
+
f"certone-agent: verdict {sub.verdict.upper()} ({by.get('high', 0)} high, "
|
|
119
|
+
f"{by.get('medium', 0)} medium, {by.get('low', 0)} low) in {runtime}{attestation}",
|
|
120
|
+
flush=True,
|
|
121
|
+
)
|
|
122
|
+
return sub.verdict
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def watch_confidential(scan: Any, project: str | Path, interval: int, *, polls: int = 0) -> None:
|
|
126
|
+
"""Call ``scan()`` for the first version of ``project`` and again whenever its content changes.
|
|
127
|
+
|
|
128
|
+
A failed scan is retried on the next poll. ``polls`` > 0 bounds the loop (tests); 0 = forever.
|
|
129
|
+
"""
|
|
130
|
+
seen: str | None = None
|
|
131
|
+
done = 0
|
|
132
|
+
while polls <= 0 or done < polls:
|
|
133
|
+
digest = content_digest(project)
|
|
134
|
+
if digest != seen:
|
|
135
|
+
if seen is not None:
|
|
136
|
+
print(f"certone-agent: new version detected ({digest})", flush=True)
|
|
137
|
+
try:
|
|
138
|
+
scan()
|
|
139
|
+
seen = digest
|
|
140
|
+
except CertOneError as exc:
|
|
141
|
+
print(f"certone-agent: {exc}", file=sys.stderr, flush=True)
|
|
142
|
+
done += 1
|
|
143
|
+
if polls <= 0 or done < polls:
|
|
144
|
+
time.sleep(max(1, interval))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _ci_source(repo: str | None, commit_hash: str | None) -> tuple[str | None, str | None]:
|
|
148
|
+
"""Fill repo/commit from the GitHub Actions environment when not given explicitly."""
|
|
149
|
+
return (
|
|
150
|
+
repo or os.environ.get("GITHUB_REPOSITORY") or None,
|
|
151
|
+
commit_hash or os.environ.get("GITHUB_SHA") or None,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
156
|
+
p.add_argument("--project", default=".", help="path to the AI project (default: .)")
|
|
157
|
+
p.add_argument(
|
|
158
|
+
"--mode",
|
|
159
|
+
choices=("local", "confidential"),
|
|
160
|
+
default="local",
|
|
161
|
+
help="local: run at source and submit the report; confidential: scan in the platform TEE",
|
|
162
|
+
)
|
|
163
|
+
p.add_argument(
|
|
164
|
+
"--pack", action="append", default=[], dest="packs", help="PaC pack dir (repeatable)"
|
|
165
|
+
)
|
|
166
|
+
p.add_argument(
|
|
167
|
+
"--api", required=True, help="control-plane base URL, e.g. https://api.yourco.com"
|
|
168
|
+
)
|
|
169
|
+
p.add_argument("--token", default=None, help="API token, member role ($CERTONE_API_KEY)")
|
|
170
|
+
p.add_argument("--asset", help="AI system name (auto from snapshot-ref if omitted)")
|
|
171
|
+
p.add_argument("--environment", default=None, help="ci|jupyter|vscode|edge|api|cloud|cli")
|
|
172
|
+
p.add_argument("--country-code", default="ZZ", help="origin region, e.g. EU")
|
|
173
|
+
p.add_argument("--fail-on", default="high", help="low|medium|high|none")
|
|
174
|
+
p.add_argument("--tee", default="amd_sev_snp", help="amd_sev_snp | kata_cc (confidential)")
|
|
175
|
+
p.add_argument("--snapshot-ref", help="stable source ref, e.g. git:repo@sha")
|
|
176
|
+
p.add_argument("--repo", help="GitHub repo 'owner/name' for native PR checks (auto in Actions)")
|
|
177
|
+
p.add_argument("--commit", help="head SHA to post the Check Run on (auto in Actions)")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def deliver(client: CertOneClient, envelope_path: str | Path) -> dict[str, Any]:
|
|
181
|
+
"""Deliver a result signed inside an air-gapped network (``certone result sign``)."""
|
|
182
|
+
envelope = json.loads(Path(envelope_path).read_text(encoding="utf-8"))
|
|
183
|
+
return client.ingest_signed_result(envelope)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv: list[str] | None = None) -> int:
|
|
187
|
+
parser = argparse.ArgumentParser(prog="certone-agent", description=__doc__)
|
|
188
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
189
|
+
run_p = sub.add_parser("run", help="run once and submit (CI/cron)")
|
|
190
|
+
_add_common(run_p)
|
|
191
|
+
watch_p = sub.add_parser("watch", help="keep certifying (long-running)")
|
|
192
|
+
_add_common(watch_p)
|
|
193
|
+
watch_p.add_argument("--interval", type=int, default=300, help="seconds between checks")
|
|
194
|
+
deliver_p = sub.add_parser(
|
|
195
|
+
"deliver", help="deliver a signed result carried out of an air-gapped network"
|
|
196
|
+
)
|
|
197
|
+
deliver_p.add_argument(
|
|
198
|
+
"envelope", help="the result.signed.json written by `certone result sign`"
|
|
199
|
+
)
|
|
200
|
+
deliver_p.add_argument("--api", required=True, help="control-plane base URL")
|
|
201
|
+
deliver_p.add_argument("--token", default=None, help="API token ($CERTONE_API_KEY)")
|
|
202
|
+
args = parser.parse_args(argv)
|
|
203
|
+
|
|
204
|
+
token = args.token or os.environ.get("CERTONE_API_KEY")
|
|
205
|
+
if not token:
|
|
206
|
+
parser.error("no API key: pass --token or set CERTONE_API_KEY")
|
|
207
|
+
client = CertOneClient(args.api, token)
|
|
208
|
+
|
|
209
|
+
if args.cmd == "deliver":
|
|
210
|
+
try:
|
|
211
|
+
out = deliver(client, args.envelope)
|
|
212
|
+
except CertOneError as exc:
|
|
213
|
+
print(f"certone-agent: delivery refused: {exc}", file=sys.stderr)
|
|
214
|
+
return 2
|
|
215
|
+
report = out.get("report") or {}
|
|
216
|
+
print(
|
|
217
|
+
f"certone-agent: signed result accepted (signature verified): scan "
|
|
218
|
+
f"{out['scan_run']['id']}, verdict {str(report.get('verdict_status', '?')).upper()}"
|
|
219
|
+
)
|
|
220
|
+
return 0
|
|
221
|
+
repo, commit_hash = _ci_source(args.repo, args.commit)
|
|
222
|
+
|
|
223
|
+
if args.mode == "confidential":
|
|
224
|
+
|
|
225
|
+
def scan() -> str:
|
|
226
|
+
return run_confidential(
|
|
227
|
+
args.project,
|
|
228
|
+
client=client,
|
|
229
|
+
asset=args.asset,
|
|
230
|
+
environment=args.environment or "edge",
|
|
231
|
+
country_code=args.country_code,
|
|
232
|
+
fail_on=args.fail_on,
|
|
233
|
+
tee=args.tee,
|
|
234
|
+
snapshot_ref=args.snapshot_ref,
|
|
235
|
+
commit_hash=commit_hash,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if args.cmd == "watch":
|
|
239
|
+
print(f"certone-agent: watching {Path(args.project).resolve()}", flush=True)
|
|
240
|
+
watch_confidential(scan, args.project, args.interval)
|
|
241
|
+
return 0
|
|
242
|
+
try:
|
|
243
|
+
return 0 if scan() == "pass" else 1
|
|
244
|
+
except CertOneError as exc:
|
|
245
|
+
print(f"certone-agent: {exc}", file=sys.stderr)
|
|
246
|
+
return 2
|
|
247
|
+
|
|
248
|
+
def _go() -> str:
|
|
249
|
+
out = run_once(
|
|
250
|
+
args.project,
|
|
251
|
+
args.packs,
|
|
252
|
+
client=client,
|
|
253
|
+
asset=args.asset,
|
|
254
|
+
environment=args.environment or "ci",
|
|
255
|
+
country_code=args.country_code,
|
|
256
|
+
fail_on=args.fail_on,
|
|
257
|
+
snapshot_ref=args.snapshot_ref,
|
|
258
|
+
repo=repo,
|
|
259
|
+
commit_hash=commit_hash,
|
|
260
|
+
)
|
|
261
|
+
return str(out.get("report", {}).get("verdict_status", "?"))
|
|
262
|
+
|
|
263
|
+
if args.cmd == "watch":
|
|
264
|
+
while True:
|
|
265
|
+
verdict = _go()
|
|
266
|
+
print(f"certone-agent: submitted (verdict={verdict})")
|
|
267
|
+
time.sleep(max(5, args.interval))
|
|
268
|
+
verdict = _go()
|
|
269
|
+
print(f"certone-agent: submitted (verdict={verdict})")
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__": # pragma: no cover
|
|
274
|
+
raise SystemExit(main())
|
certone_sdk/client.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Typed HTTP client for the CertOne control-plane API (the SDK enterprises script against).
|
|
2
|
+
|
|
3
|
+
Implements the operations in ``sdk/SPEC.md``, the contract every CertOne SDK shares.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import IO, Any
|
|
13
|
+
|
|
14
|
+
# (connect, read) timeout for every API call so a hung control plane can't hang the caller's CI job.
|
|
15
|
+
_HTTP_TIMEOUT = (5, 30)
|
|
16
|
+
# Snapshot uploads can be large; the read side waits for the control plane to accept the archive.
|
|
17
|
+
_UPLOAD_TIMEOUT = (5, 300)
|
|
18
|
+
|
|
19
|
+
Snapshot = str | Path | bytes | IO[bytes]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CertOneError(RuntimeError):
|
|
23
|
+
"""A control-plane API call failed. ``status`` is the HTTP status (0 when none was received)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, status: int = 0, detail: str = "") -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
self.detail = detail
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sha256_digest(data: Snapshot) -> str:
|
|
32
|
+
"""``sha256:<hex>`` of a snapshot, the ``input_digest`` that binds an attestation to it."""
|
|
33
|
+
h = hashlib.sha256()
|
|
34
|
+
if isinstance(data, bytes):
|
|
35
|
+
h.update(data)
|
|
36
|
+
elif isinstance(data, str | Path):
|
|
37
|
+
with Path(data).open("rb") as fh:
|
|
38
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
39
|
+
h.update(chunk)
|
|
40
|
+
else:
|
|
41
|
+
for chunk in iter(lambda: data.read(1 << 20), b""):
|
|
42
|
+
h.update(chunk)
|
|
43
|
+
return f"sha256:{h.hexdigest()}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CertOneClient:
|
|
47
|
+
"""Minimal client. ``requests`` is imported lazily; inject ``session`` in tests."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, base_url: str, token: str | None = None, *, session: Any = None) -> None:
|
|
50
|
+
self.base_url = base_url.rstrip("/")
|
|
51
|
+
self.token = token
|
|
52
|
+
self._session = session
|
|
53
|
+
|
|
54
|
+
def _sess(self) -> Any:
|
|
55
|
+
if self._session is None:
|
|
56
|
+
import requests # lazy: only when a real call is made
|
|
57
|
+
|
|
58
|
+
self._session = requests.Session()
|
|
59
|
+
return self._session
|
|
60
|
+
|
|
61
|
+
def _headers(self, json_body: bool = False) -> dict[str, str]:
|
|
62
|
+
headers: dict[str, str] = {}
|
|
63
|
+
if self.token:
|
|
64
|
+
headers["authorization"] = f"Bearer {self.token}"
|
|
65
|
+
if json_body:
|
|
66
|
+
headers["content-type"] = "application/json"
|
|
67
|
+
return headers
|
|
68
|
+
|
|
69
|
+
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
resp = self._sess().post(
|
|
71
|
+
f"{self.base_url}{path}",
|
|
72
|
+
json=body,
|
|
73
|
+
headers=self._headers(json_body=True),
|
|
74
|
+
timeout=_HTTP_TIMEOUT,
|
|
75
|
+
)
|
|
76
|
+
result: dict[str, Any] = _result(resp)
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
def _get(self, path: str) -> Any:
|
|
80
|
+
resp = self._sess().get(
|
|
81
|
+
f"{self.base_url}{path}", headers=self._headers(), timeout=_HTTP_TIMEOUT
|
|
82
|
+
)
|
|
83
|
+
return _result(resp)
|
|
84
|
+
|
|
85
|
+
def _submit(self, path: str, snapshot: Snapshot, form: dict[str, str]) -> dict[str, Any]:
|
|
86
|
+
if isinstance(snapshot, str | Path):
|
|
87
|
+
with Path(snapshot).open("rb") as fh:
|
|
88
|
+
return self._submit(path, fh, form)
|
|
89
|
+
resp = self._sess().post(
|
|
90
|
+
f"{self.base_url}{path}",
|
|
91
|
+
data={k: v for k, v in form.items() if v is not None},
|
|
92
|
+
files={"snapshot": ("snapshot.tar.gz", snapshot, "application/gzip")},
|
|
93
|
+
headers=self._headers(),
|
|
94
|
+
timeout=_UPLOAD_TIMEOUT,
|
|
95
|
+
)
|
|
96
|
+
result: dict[str, Any] = _result(resp)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
# --- submit a project snapshot (.tar.gz) ---
|
|
100
|
+
def submit_check(
|
|
101
|
+
self,
|
|
102
|
+
snapshot: Snapshot,
|
|
103
|
+
*,
|
|
104
|
+
asset: str | None = None,
|
|
105
|
+
environment: str = "api",
|
|
106
|
+
country_code: str = "ZZ",
|
|
107
|
+
fail_on: str = "high",
|
|
108
|
+
packs: str = "",
|
|
109
|
+
snapshot_ref: str | None = None,
|
|
110
|
+
commit_hash: str | None = None,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""Static verdict (AI BOM + policy-as-code) over a snapshot."""
|
|
113
|
+
return self._submit(
|
|
114
|
+
"/v1/checks",
|
|
115
|
+
snapshot,
|
|
116
|
+
_form(asset, environment, country_code, fail_on, packs, snapshot_ref, commit_hash),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def submit_process(
|
|
120
|
+
self,
|
|
121
|
+
snapshot: Snapshot,
|
|
122
|
+
*,
|
|
123
|
+
asset: str | None = None,
|
|
124
|
+
environment: str = "api",
|
|
125
|
+
country_code: str = "ZZ",
|
|
126
|
+
fail_on: str = "high",
|
|
127
|
+
packs: str = "",
|
|
128
|
+
snapshot_ref: str | None = None,
|
|
129
|
+
commit_hash: str | None = None,
|
|
130
|
+
tee: str = "amd_sev_snp",
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Full processing in the attested confidential worker (static + dynamic + metrics).
|
|
133
|
+
|
|
134
|
+
On the confidential worker pool the result is ``queued`` with no report yet: follow it with
|
|
135
|
+
:meth:`wait_for_report`.
|
|
136
|
+
"""
|
|
137
|
+
form = _form(asset, environment, country_code, fail_on, packs, snapshot_ref, commit_hash)
|
|
138
|
+
form["tee"] = tee
|
|
139
|
+
return self._submit("/v1/process", snapshot, form)
|
|
140
|
+
|
|
141
|
+
# --- run-at-source: submit a pre-computed report (no artifact upload) ---
|
|
142
|
+
def ingest_report(
|
|
143
|
+
self,
|
|
144
|
+
report: dict[str, Any],
|
|
145
|
+
*,
|
|
146
|
+
asset: str | None = None,
|
|
147
|
+
environment: str = "api",
|
|
148
|
+
country_code: str = "ZZ",
|
|
149
|
+
snapshot_ref: str | None = None,
|
|
150
|
+
input_digest: str | None = None,
|
|
151
|
+
repo: str | None = None,
|
|
152
|
+
commit_hash: str | None = None,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
return self._post(
|
|
155
|
+
"/v1/checks/report",
|
|
156
|
+
{
|
|
157
|
+
"report": report,
|
|
158
|
+
"asset": asset,
|
|
159
|
+
"environment": environment,
|
|
160
|
+
"country_code": country_code,
|
|
161
|
+
"snapshot_ref": snapshot_ref,
|
|
162
|
+
"input_digest": input_digest,
|
|
163
|
+
"repo": repo,
|
|
164
|
+
"commit_hash": commit_hash,
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# --- air-gapped run-at-source: deliver a result signed inside the closed network ---
|
|
169
|
+
def ingest_signed_result(self, envelope: dict[str, Any]) -> dict[str, Any]:
|
|
170
|
+
"""Submit a ``certone result sign`` envelope; the platform verifies its source-key
|
|
171
|
+
signature before accepting it (re-delivering the same file returns the same scan)."""
|
|
172
|
+
return self._post("/v1/checks/report/signed", envelope)
|
|
173
|
+
|
|
174
|
+
# --- scans, reports, attestations ---
|
|
175
|
+
def get_scan(self, scan_id: str) -> dict[str, Any]:
|
|
176
|
+
scan: dict[str, Any] = self._get(f"/v1/scans/{scan_id}")
|
|
177
|
+
return scan
|
|
178
|
+
|
|
179
|
+
def wait_for_report(
|
|
180
|
+
self,
|
|
181
|
+
scan_id: str,
|
|
182
|
+
*,
|
|
183
|
+
timeout: float = 900.0,
|
|
184
|
+
interval: float = 2.0,
|
|
185
|
+
on_status: Callable[[str, float], None] | None = None,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
"""Poll a scan until it finishes, then return its report summary. ``on_status(status,
|
|
188
|
+
elapsed_s)`` is called after every poll (``queued``, ``running``, ``succeeded``...).
|
|
189
|
+
|
|
190
|
+
Raises :class:`CertOneError` when the scan failed or did not finish within ``timeout``.
|
|
191
|
+
"""
|
|
192
|
+
start = time.monotonic()
|
|
193
|
+
deadline = start + timeout
|
|
194
|
+
while True:
|
|
195
|
+
scan = self.get_scan(scan_id)
|
|
196
|
+
if on_status is not None:
|
|
197
|
+
on_status(str(scan.get("status", "")), time.monotonic() - start)
|
|
198
|
+
if scan.get("status") == "succeeded":
|
|
199
|
+
report: dict[str, Any] = self._get(f"/v1/scans/{scan_id}/report")
|
|
200
|
+
return report
|
|
201
|
+
if scan.get("status") == "failed":
|
|
202
|
+
raise CertOneError(f"scan {scan_id} failed: {scan.get('error') or 'no detail'}")
|
|
203
|
+
if time.monotonic() >= deadline:
|
|
204
|
+
raise CertOneError(f"scan {scan_id} did not finish within {timeout:.0f}s")
|
|
205
|
+
time.sleep(interval)
|
|
206
|
+
|
|
207
|
+
def get_run(self, scan_id: str) -> dict[str, Any]:
|
|
208
|
+
"""How a scan ran: ``execution.runtime`` (where its code executed) and ``tee_evidence``
|
|
209
|
+
(the confidential VM's hardware attestation, ``attested``)."""
|
|
210
|
+
run: dict[str, Any] = self._get(f"/v1/scans/{scan_id}/run")
|
|
211
|
+
return run
|
|
212
|
+
|
|
213
|
+
def get_report_document(self, report_id: str) -> dict[str, Any]:
|
|
214
|
+
"""The full engine report: verdict, summary, issues (``report_id``: the record ``id``)."""
|
|
215
|
+
doc: dict[str, Any] = self._get(f"/v1/reports/{report_id}/document")
|
|
216
|
+
return doc
|
|
217
|
+
|
|
218
|
+
def get_attestation(self, report_id: str) -> dict[str, Any]:
|
|
219
|
+
"""The signed attestation of a passing report (404 -> CertOneError when it did not pass)."""
|
|
220
|
+
attestation: dict[str, Any] = self._get(f"/v1/reports/{report_id}/attestation")
|
|
221
|
+
return attestation
|
|
222
|
+
|
|
223
|
+
# --- reads ---
|
|
224
|
+
def observability_summary(self) -> dict[str, Any]:
|
|
225
|
+
summary: dict[str, Any] = self._get("/v1/observability/summary")
|
|
226
|
+
return summary
|
|
227
|
+
|
|
228
|
+
def list_systems(self) -> list[dict[str, Any]]:
|
|
229
|
+
systems: list[dict[str, Any]] = self._get("/v1/systems")
|
|
230
|
+
return systems
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _form(
|
|
234
|
+
asset: str | None,
|
|
235
|
+
environment: str,
|
|
236
|
+
country_code: str,
|
|
237
|
+
fail_on: str,
|
|
238
|
+
packs: str,
|
|
239
|
+
snapshot_ref: str | None,
|
|
240
|
+
commit_hash: str | None,
|
|
241
|
+
) -> dict[str, str]:
|
|
242
|
+
form = {"environment": environment, "country_code": country_code, "fail_on": fail_on}
|
|
243
|
+
optional = {
|
|
244
|
+
"asset": asset,
|
|
245
|
+
"packs": packs,
|
|
246
|
+
"snapshot_ref": snapshot_ref,
|
|
247
|
+
"commit_hash": commit_hash,
|
|
248
|
+
}
|
|
249
|
+
form.update({k: v for k, v in optional.items() if v})
|
|
250
|
+
return form
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _result(resp: Any) -> Any:
|
|
254
|
+
status = int(getattr(resp, "status_code", 0))
|
|
255
|
+
if status >= 400:
|
|
256
|
+
detail = ""
|
|
257
|
+
try:
|
|
258
|
+
detail = str(resp.json().get("detail", ""))
|
|
259
|
+
except Exception: # best-effort error detail extraction
|
|
260
|
+
detail = getattr(resp, "text", "")
|
|
261
|
+
raise CertOneError(f"HTTP {status}: {detail}", status=status, detail=detail)
|
|
262
|
+
return resp.json()
|
certone_sdk/py.typed
ADDED
|
File without changes
|
certone_sdk/submit.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""certone-submit — send a project to CertOne for a confidential scan and gate on the verdict.
|
|
2
|
+
|
|
3
|
+
The one submission path every integration shares (GitHub Actions, GitLab CI, Azure ML, VS Code,
|
|
4
|
+
Jupyter, the edge agent): the project is packed, uploaded to the platform, and processed inside an
|
|
5
|
+
attested confidential worker (AMD SEV-SNP CVM or Kata confidential containers) -- static analysis,
|
|
6
|
+
dynamic tests on the target, metrics and the in-TEE correction. The command waits for the verdict,
|
|
7
|
+
prints the findings, writes the evidence, and exits non-zero unless the verdict passes, so a
|
|
8
|
+
pipeline step can gate on it.
|
|
9
|
+
|
|
10
|
+
certone-submit --project . --asset "Card Fraud Scorer" # key from $CERTONE_API_KEY
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 pass, 1 the verdict did not pass, 2 the scan could not be run (error, timeout).
|
|
13
|
+
The API key is read from ``--api-key`` or ``$CERTONE_API_KEY`` and is never printed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import tarfile
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import IO, Any, ClassVar
|
|
28
|
+
|
|
29
|
+
from certone_sdk.client import CertOneClient, CertOneError, sha256_digest
|
|
30
|
+
|
|
31
|
+
#: Never shipped in a snapshot: VCS metadata, caches, virtualenvs, dependency trees.
|
|
32
|
+
SKIP_DIRS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
".git",
|
|
35
|
+
".hg",
|
|
36
|
+
".svn",
|
|
37
|
+
"__pycache__",
|
|
38
|
+
".venv",
|
|
39
|
+
"venv",
|
|
40
|
+
"node_modules",
|
|
41
|
+
".mypy_cache",
|
|
42
|
+
".pytest_cache",
|
|
43
|
+
".ruff_cache",
|
|
44
|
+
".ipynb_checkpoints",
|
|
45
|
+
".certone",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
DEFAULT_API = "https://platform.certkor.ai"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pack_project(project: Path, top: str | None = None) -> bytes:
|
|
52
|
+
"""``project`` as a gzip tarball under one top-level folder (``top``, default: its name)."""
|
|
53
|
+
root = project.resolve()
|
|
54
|
+
name = top or root.name or "project"
|
|
55
|
+
buf = io.BytesIO()
|
|
56
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
57
|
+
for path in sorted(root.rglob("*")):
|
|
58
|
+
rel = path.relative_to(root)
|
|
59
|
+
if path.is_file() and not path.is_symlink() and not SKIP_DIRS.intersection(rel.parts):
|
|
60
|
+
tar.add(path, arcname=f"{name}/{rel.as_posix()}")
|
|
61
|
+
return buf.getvalue()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Submission:
|
|
66
|
+
"""A finished confidential scan: its id, report summary, full document and attestation."""
|
|
67
|
+
|
|
68
|
+
scan_id: str
|
|
69
|
+
report: dict[str, Any]
|
|
70
|
+
document: dict[str, Any]
|
|
71
|
+
attestation: dict[str, Any] | None
|
|
72
|
+
#: How it ran (``execution.runtime``, ``tee_evidence.attested``); empty when unavailable.
|
|
73
|
+
run: dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def verdict(self) -> str:
|
|
77
|
+
return str(self.report.get("verdict_status", "unknown"))
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def passed(self) -> bool:
|
|
81
|
+
return self.verdict == "pass"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def submit_and_wait(
|
|
85
|
+
client: CertOneClient,
|
|
86
|
+
project: Path | bytes,
|
|
87
|
+
*,
|
|
88
|
+
asset: str,
|
|
89
|
+
environment: str = "ci",
|
|
90
|
+
country_code: str = "ZZ",
|
|
91
|
+
fail_on: str = "high",
|
|
92
|
+
tee: str = "amd_sev_snp",
|
|
93
|
+
snapshot_ref: str | None = None,
|
|
94
|
+
commit_hash: str | None = None,
|
|
95
|
+
timeout: float = 1800.0,
|
|
96
|
+
on_submitted: Any = None,
|
|
97
|
+
on_progress: Callable[[str, float], None] | None = None,
|
|
98
|
+
) -> Submission:
|
|
99
|
+
"""Submit ``project`` (a folder, or an already-packed snapshot) for confidential processing and
|
|
100
|
+
wait for its verdict. ``on_submitted(scan_id, digest)`` is called once the platform accepted
|
|
101
|
+
it, ``on_progress(status, elapsed_s)`` on every status poll while it runs (see
|
|
102
|
+
:class:`ProgressPrinter`). Raises :class:`CertOneError` when the scan cannot be run or does not
|
|
103
|
+
finish in time."""
|
|
104
|
+
snapshot = project if isinstance(project, bytes) else pack_project(project)
|
|
105
|
+
submitted = client.submit_process(
|
|
106
|
+
snapshot,
|
|
107
|
+
asset=asset,
|
|
108
|
+
environment=environment,
|
|
109
|
+
country_code=country_code,
|
|
110
|
+
fail_on=fail_on,
|
|
111
|
+
snapshot_ref=snapshot_ref,
|
|
112
|
+
commit_hash=commit_hash,
|
|
113
|
+
tee=tee,
|
|
114
|
+
)
|
|
115
|
+
scan_id = str(submitted["scan_run"]["id"])
|
|
116
|
+
if on_submitted is not None:
|
|
117
|
+
on_submitted(scan_id, sha256_digest(snapshot))
|
|
118
|
+
report = client.wait_for_report(scan_id, timeout=timeout, on_status=on_progress)
|
|
119
|
+
document = client.get_report_document(report["id"])
|
|
120
|
+
attestation = (
|
|
121
|
+
client.get_attestation(report["id"]) if report.get("verdict_status") == "pass" else None
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
run = client.get_run(scan_id)
|
|
125
|
+
except CertOneError:
|
|
126
|
+
run = {} # informational only: the verdict and evidence above are what gate the pipeline
|
|
127
|
+
return Submission(scan_id, report, document, attestation, run)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ProgressPrinter:
|
|
131
|
+
"""Shows a running scan's progress: one line per status change, plus -- on a terminal -- a
|
|
132
|
+
spinner with the elapsed time, or -- in CI logs -- a heartbeat line every ``heartbeat_s``.
|
|
133
|
+
|
|
134
|
+
submit_and_wait(client, project, asset="...", on_progress=ProgressPrinter())
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
LABELS: ClassVar[dict[str, str]] = {
|
|
138
|
+
"queued": "queued for a confidential worker",
|
|
139
|
+
"running": "processing inside the TEE (static analysis, dynamic tests, metrics)",
|
|
140
|
+
"succeeded": "verdict ready",
|
|
141
|
+
"failed": "scan failed",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def __init__(self, stream: IO[str] | None = None, heartbeat_s: float = 30.0) -> None:
|
|
145
|
+
self.stream = stream or sys.stdout
|
|
146
|
+
self.tty = bool(getattr(self.stream, "isatty", lambda: False)())
|
|
147
|
+
utf8 = "utf" in (getattr(self.stream, "encoding", None) or "").lower()
|
|
148
|
+
self.spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" if utf8 else "|/-\\"
|
|
149
|
+
self.heartbeat_s = heartbeat_s
|
|
150
|
+
self._status: str | None = None
|
|
151
|
+
self._beat = 0.0
|
|
152
|
+
self._tick = 0
|
|
153
|
+
|
|
154
|
+
def __call__(self, status: str, elapsed: float) -> None:
|
|
155
|
+
clock = f"{int(elapsed) // 60}:{int(elapsed) % 60:02d}"
|
|
156
|
+
label = self.LABELS.get(status, status)
|
|
157
|
+
if status != self._status:
|
|
158
|
+
self._status, self._beat = status, elapsed
|
|
159
|
+
self._write(f" [{clock}] {label}\n")
|
|
160
|
+
elif self.tty:
|
|
161
|
+
self._tick += 1
|
|
162
|
+
frame = self.spinner[self._tick % len(self.spinner)]
|
|
163
|
+
self._write(f" {frame} {label} ... {clock}")
|
|
164
|
+
elif elapsed - self._beat >= self.heartbeat_s:
|
|
165
|
+
self._beat = elapsed
|
|
166
|
+
self._write(f" [{clock}] still {status}\n")
|
|
167
|
+
|
|
168
|
+
def _write(self, text: str) -> None:
|
|
169
|
+
if self.tty:
|
|
170
|
+
text = "\r\x1b[K" + text # replace the spinner line
|
|
171
|
+
self.stream.write(text)
|
|
172
|
+
self.stream.flush()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def key_from_vault(secret_url: str) -> str:
|
|
176
|
+
"""The API key held in an Azure Key Vault secret (``https://<vault>.vault.azure.net/secrets/
|
|
177
|
+
<name>[/<version>]``), read with the default Azure credential -- on Azure ML compute that is
|
|
178
|
+
the cluster's managed identity. Needs ``pip install "certone-sdk[azure]"``."""
|
|
179
|
+
from urllib.parse import urlsplit
|
|
180
|
+
|
|
181
|
+
from azure.identity import DefaultAzureCredential
|
|
182
|
+
from azure.keyvault.secrets import SecretClient
|
|
183
|
+
|
|
184
|
+
parts = urlsplit(secret_url)
|
|
185
|
+
segments = [s for s in parts.path.split("/") if s]
|
|
186
|
+
if parts.scheme != "https" or len(segments) < 2 or segments[0] != "secrets":
|
|
187
|
+
raise ValueError("expected https://<vault>.vault.azure.net/secrets/<name>")
|
|
188
|
+
client = SecretClient(f"https://{parts.netloc}", DefaultAzureCredential())
|
|
189
|
+
value = client.get_secret(segments[1], segments[2] if len(segments) > 2 else None).value
|
|
190
|
+
if not value:
|
|
191
|
+
raise ValueError(f"secret {segments[1]!r} is empty")
|
|
192
|
+
return str(value)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _git_commit(project: Path) -> str | None:
|
|
196
|
+
"""The CI's commit (GitHub, GitLab, Azure Pipelines), else None -- never shells out."""
|
|
197
|
+
for var in ("GITHUB_SHA", "CI_COMMIT_SHA", "BUILD_SOURCEVERSION"):
|
|
198
|
+
if os.environ.get(var):
|
|
199
|
+
return os.environ[var]
|
|
200
|
+
head = project / ".git" / "HEAD"
|
|
201
|
+
try:
|
|
202
|
+
ref = head.read_text(encoding="utf-8").strip()
|
|
203
|
+
if ref.startswith("ref: "):
|
|
204
|
+
return (project / ".git" / ref[5:]).read_text(encoding="utf-8").strip() or None
|
|
205
|
+
return ref or None
|
|
206
|
+
except OSError:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _findings(sub: Submission) -> list[tuple[str, str, str]]:
|
|
211
|
+
"""``(severity, description, where)`` for every finding, most severe first."""
|
|
212
|
+
order = {"high": 0, "medium": 1, "low": 2}
|
|
213
|
+
out: list[tuple[str, str, str]] = []
|
|
214
|
+
for issue in sorted(
|
|
215
|
+
sub.document.get("issues", []), key=lambda i: order.get(i.get("severity"), 3)
|
|
216
|
+
):
|
|
217
|
+
loc = issue.get("location") or {}
|
|
218
|
+
artifact, line = loc.get("artifact"), loc.get("line_start")
|
|
219
|
+
where = f"{artifact}{f':{line}' if line else ''}" if artifact not in (None, "", ".") else ""
|
|
220
|
+
out.append((str(issue.get("severity", "")), str(issue.get("description", "")), where))
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _runtime(sub: Submission) -> str:
|
|
225
|
+
runtime = (sub.run.get("execution") or {}).get("runtime")
|
|
226
|
+
if not runtime:
|
|
227
|
+
return ""
|
|
228
|
+
attested = (sub.run.get("tee_evidence") or {}).get("attested")
|
|
229
|
+
return str(runtime) + (" · hardware-attested" if attested else "")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _counts(sub: Submission) -> str:
|
|
233
|
+
by_sev = sub.report.get("by_severity") or {}
|
|
234
|
+
return ", ".join(f"{by_sev.get(s, 0)} {s}" for s in ("high", "medium", "low"))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _print_result(sub: Submission, out: Any) -> None:
|
|
238
|
+
total = sub.report.get("total_issues", 0)
|
|
239
|
+
print(f"verdict: {sub.verdict.upper()} ({total} finding(s): {_counts(sub)})", file=out)
|
|
240
|
+
if runtime := _runtime(sub):
|
|
241
|
+
print(f"executed in: {runtime}", file=out)
|
|
242
|
+
for severity, description, where in _findings(sub):
|
|
243
|
+
print(
|
|
244
|
+
f" [{severity.upper():6}] {description}" + (f" ({where})" if where else ""), file=out
|
|
245
|
+
)
|
|
246
|
+
if sub.attestation:
|
|
247
|
+
print(f"attestation: {sub.attestation.get('attestation_id')}", file=out)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _markdown(sub: Submission, asset: str) -> str:
|
|
251
|
+
"""A job summary (GitHub ``$GITHUB_STEP_SUMMARY``, GitLab/Azure Pipelines artifacts)."""
|
|
252
|
+
icon = "✅" if sub.passed else "❌"
|
|
253
|
+
lines = [
|
|
254
|
+
f"## {icon} CertOne: {asset} — {sub.verdict.upper()}",
|
|
255
|
+
"",
|
|
256
|
+
f"Confidential scan `{sub.scan_id}` · findings: {_counts(sub)}",
|
|
257
|
+
]
|
|
258
|
+
if runtime := _runtime(sub):
|
|
259
|
+
lines.append(f"Executed in: **{runtime}**")
|
|
260
|
+
if sub.attestation:
|
|
261
|
+
lines.append(f"Attestation: `{sub.attestation.get('attestation_id')}`")
|
|
262
|
+
rows = _findings(sub)
|
|
263
|
+
if rows:
|
|
264
|
+
lines += ["", "| Severity | Finding | Where |", "|---|---|---|"]
|
|
265
|
+
for severity, description, where in rows:
|
|
266
|
+
cell = description.replace("|", "\\|")
|
|
267
|
+
lines.append(f"| {severity} | {cell} | {f'`{where}`' if where else ''} |")
|
|
268
|
+
return "\n".join(lines) + "\n"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def main(argv: list[str] | None = None) -> int:
|
|
272
|
+
ap = argparse.ArgumentParser(prog="certone-submit", description=__doc__.split("\n\n")[0])
|
|
273
|
+
ap.add_argument("--project", type=Path, default=Path(), help="project folder (default: .)")
|
|
274
|
+
ap.add_argument("--asset", required=True, help="AI system name in CertOne")
|
|
275
|
+
ap.add_argument(
|
|
276
|
+
"--api",
|
|
277
|
+
default=os.environ.get("CERTONE_API_URL", DEFAULT_API),
|
|
278
|
+
help="platform URL (default: $CERTONE_API_URL or %(default)s)",
|
|
279
|
+
)
|
|
280
|
+
ap.add_argument("--api-key", default=None, help="API key (default: $CERTONE_API_KEY)")
|
|
281
|
+
ap.add_argument(
|
|
282
|
+
"--api-key-vault",
|
|
283
|
+
default=None,
|
|
284
|
+
help="read the key from an Azure Key Vault secret URL "
|
|
285
|
+
"(https://<vault>.vault.azure.net/secrets/<name>) with the compute's managed identity",
|
|
286
|
+
)
|
|
287
|
+
ap.add_argument("--environment", default="ci", help="ci|jupyter|vscode|edge|api|cloud|cli")
|
|
288
|
+
ap.add_argument("--country-code", default="ZZ", help="data-residency region, e.g. EU, US")
|
|
289
|
+
ap.add_argument("--fail-on", default="high", help="low|medium|high|none")
|
|
290
|
+
ap.add_argument("--tee", default="amd_sev_snp", help="amd_sev_snp | kata_cc")
|
|
291
|
+
ap.add_argument("--snapshot-ref", default=None, help="stable source ref, e.g. git:repo@sha")
|
|
292
|
+
ap.add_argument("--commit", default=None, help="commit hash (default: from CI / .git)")
|
|
293
|
+
ap.add_argument("--timeout", type=float, default=1800.0, help="seconds to wait for the verdict")
|
|
294
|
+
ap.add_argument("--report-out", type=Path, default=None, help="write the full report JSON here")
|
|
295
|
+
ap.add_argument(
|
|
296
|
+
"--attestation-out",
|
|
297
|
+
type=Path,
|
|
298
|
+
default=None,
|
|
299
|
+
help="write the attestation JSON here (passing verdicts only)",
|
|
300
|
+
)
|
|
301
|
+
ap.add_argument(
|
|
302
|
+
"--markdown-out",
|
|
303
|
+
type=Path,
|
|
304
|
+
default=None,
|
|
305
|
+
help="append a markdown summary here (e.g. $GITHUB_STEP_SUMMARY)",
|
|
306
|
+
)
|
|
307
|
+
a = ap.parse_args(argv)
|
|
308
|
+
|
|
309
|
+
key = a.api_key or os.environ.get("CERTONE_API_KEY")
|
|
310
|
+
if not key and a.api_key_vault:
|
|
311
|
+
try:
|
|
312
|
+
key = key_from_vault(a.api_key_vault)
|
|
313
|
+
except Exception as exc: # any vault/identity failure: report it, never the key
|
|
314
|
+
print(f"certone-submit: cannot read the key from Key Vault: {exc}", file=sys.stderr)
|
|
315
|
+
return 2
|
|
316
|
+
if not key:
|
|
317
|
+
print(
|
|
318
|
+
"certone-submit: no API key (pass --api-key, --api-key-vault or set CERTONE_API_KEY)",
|
|
319
|
+
file=sys.stderr,
|
|
320
|
+
)
|
|
321
|
+
return 2
|
|
322
|
+
if not a.project.is_dir():
|
|
323
|
+
print(f"certone-submit: {a.project} is not a folder", file=sys.stderr)
|
|
324
|
+
return 2
|
|
325
|
+
|
|
326
|
+
def submitted(scan_id: str, digest: str) -> None:
|
|
327
|
+
print(
|
|
328
|
+
f"submitted {a.asset!r} for confidential processing: scan {scan_id} ({digest})",
|
|
329
|
+
flush=True,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
print(f"CertOne: packing {a.project.resolve().name} -> {a.api}", flush=True)
|
|
333
|
+
try:
|
|
334
|
+
sub = submit_and_wait(
|
|
335
|
+
CertOneClient(a.api, key),
|
|
336
|
+
a.project,
|
|
337
|
+
asset=a.asset,
|
|
338
|
+
environment=a.environment,
|
|
339
|
+
country_code=a.country_code,
|
|
340
|
+
fail_on=a.fail_on,
|
|
341
|
+
tee=a.tee,
|
|
342
|
+
snapshot_ref=a.snapshot_ref,
|
|
343
|
+
commit_hash=a.commit or _git_commit(a.project),
|
|
344
|
+
timeout=a.timeout,
|
|
345
|
+
on_submitted=submitted,
|
|
346
|
+
on_progress=ProgressPrinter(),
|
|
347
|
+
)
|
|
348
|
+
except CertOneError as exc:
|
|
349
|
+
print(f"certone-submit: {exc}", file=sys.stderr)
|
|
350
|
+
return 2
|
|
351
|
+
_print_result(sub, sys.stdout)
|
|
352
|
+
if a.report_out:
|
|
353
|
+
a.report_out.write_text(json.dumps(sub.document, indent=2), encoding="utf-8")
|
|
354
|
+
if a.attestation_out and sub.attestation:
|
|
355
|
+
a.attestation_out.write_text(json.dumps(sub.attestation, indent=2), encoding="utf-8")
|
|
356
|
+
if a.markdown_out:
|
|
357
|
+
with a.markdown_out.open("a", encoding="utf-8") as fh:
|
|
358
|
+
fh.write(_markdown(sub, a.asset))
|
|
359
|
+
return 0 if sub.passed else 1
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
if __name__ == "__main__": # pragma: no cover
|
|
363
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: certone-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CertOne SDK — typed API client, certone-submit (confidential scan + verdict gate) and the run-at-source agent.
|
|
5
|
+
Project-URL: Homepage, https://certkor.ai
|
|
6
|
+
Author: Certkor AI
|
|
7
|
+
License: Proprietary
|
|
8
|
+
Keywords: ai-compliance,certification,ci,confidential-computing,eu-ai-act
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: Other/Proprietary License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: requests>=2.31
|
|
21
|
+
Provides-Extra: agent
|
|
22
|
+
Requires-Dist: certone-engine>=0.1.0; extra == 'agent'
|
|
23
|
+
Provides-Extra: azure
|
|
24
|
+
Requires-Dist: azure-identity>=1.15; extra == 'azure'
|
|
25
|
+
Requires-Dist: azure-keyvault-secrets>=4.7; extra == 'azure'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# certone-sdk
|
|
33
|
+
|
|
34
|
+
The Python SDK and command-line tools for [CertOne](https://certkor.ai), Certkor's AI compliance
|
|
35
|
+
certification platform. Send an AI project for a confidential scan from any pipeline, notebook,
|
|
36
|
+
editor or device, gate on the verdict, and fetch the evidence and the attestation.
|
|
37
|
+
|
|
38
|
+
CertOne evaluates the project inside a hardware-attested trusted execution environment (AMD SEV-SNP
|
|
39
|
+
confidential VMs, or Kata confidential containers): it builds the AI bill of materials, checks it
|
|
40
|
+
against the policy packs for the regions where the system runs (EU AI Act, NIST AI RMF, ISO/IEC
|
|
41
|
+
42001, sector rules...), runs dynamic tests and computes the evaluation metrics.
|
|
42
|
+
|
|
43
|
+
You need a CertOne tenant and an API key (Developer Hub -> API keys, `member` role).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install certone-sdk
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3.10+. Extras: `certone-sdk[azure]` reads the API key from Azure Key Vault with the compute's
|
|
52
|
+
managed identity.
|
|
53
|
+
|
|
54
|
+
## Command line: `certone-submit`
|
|
55
|
+
|
|
56
|
+
One command for any CI/CD system. It packs the project, submits it for a confidential scan, shows
|
|
57
|
+
progress until the verdict, and exits `0` on pass, `1` on fail, `2` on error -- so the job stops on a
|
|
58
|
+
failing certification.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
export CERTONE_API_KEY=ck_... # a tenant member key
|
|
62
|
+
certone-submit --project . --asset "Fraud Scorer" --country-code EU --environment ci
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| option | |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `--asset` | AI system name in CertOne (required) |
|
|
68
|
+
| `--country-code` | region the system operates in, e.g. `EU`, `US`, `CA` |
|
|
69
|
+
| `--environment` | `ci`, `jupyter`, `vscode`, `edge`, `api`, `cloud`, `cli` |
|
|
70
|
+
| `--tee` | `amd_sev_snp` (default) or `kata_cc` |
|
|
71
|
+
| `--fail-on` | lowest severity that fails the gate: `low`, `medium`, `high` (default), `none` |
|
|
72
|
+
| `--api` | platform URL (default `$CERTONE_API_URL`, else `https://platform.certkor.ai`) |
|
|
73
|
+
| `--api-key-vault` | read the key from `https://<vault>.vault.azure.net/secrets/<name>` |
|
|
74
|
+
| `--report-out`, `--attestation-out`, `--markdown-out` | write the report, the attestation (passing verdicts) or a markdown summary (e.g. `$GITHUB_STEP_SUMMARY`) |
|
|
75
|
+
|
|
76
|
+
## Python
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import os
|
|
80
|
+
from certone_sdk import CertOneClient, ProgressPrinter, submit_and_wait
|
|
81
|
+
|
|
82
|
+
client = CertOneClient("https://platform.certkor.ai", os.environ["CERTONE_API_KEY"])
|
|
83
|
+
sub = submit_and_wait(client, "rag-service", asset="RAG Answering Service", tee="kata_cc",
|
|
84
|
+
country_code="EU", on_progress=ProgressPrinter())
|
|
85
|
+
print(sub.verdict, sub.report["by_severity"])
|
|
86
|
+
for issue in sub.document["issues"]:
|
|
87
|
+
print(issue["severity"], issue["description"])
|
|
88
|
+
if sub.passed:
|
|
89
|
+
print(sub.attestation["attestation_id"])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The client also covers the rest of the API: `list_systems()`, `observability_summary()`,
|
|
93
|
+
`get_run()` (where the scan executed and the TEE's hardware attestation), `get_report_document()`,
|
|
94
|
+
`get_attestation()`.
|
|
95
|
+
|
|
96
|
+
## Agent: `certone-agent`
|
|
97
|
+
|
|
98
|
+
For devices and long-running services: keep certifying a project as it changes.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# re-scan confidentially whenever the project's content changes
|
|
102
|
+
certone-agent watch --mode confidential --project /opt/model --asset "Line Robot Picker" \
|
|
103
|
+
--api https://platform.certkor.ai --environment edge --country-code EU --interval 600
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`--mode local` runs the evaluation where the code lives and submits only the results (needs the
|
|
107
|
+
CertOne engine, `certone-sdk[agent]`, available to CertOne customers). `certone-agent deliver`
|
|
108
|
+
uploads a result signed inside an air-gapped network (`certone result sign`); the platform verifies
|
|
109
|
+
it against the source keys registered for the tenant.
|
|
110
|
+
|
|
111
|
+
## Licence
|
|
112
|
+
|
|
113
|
+
Proprietary. Use requires a CertOne subscription; see https://certkor.ai.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
certone_sdk/__init__.py,sha256=7sVcWywbVzVTTjiH_xXPYn8NGktxEMAipZ3cMho8LGY,936
|
|
2
|
+
certone_sdk/agent.py,sha256=p6551qG0KR7-qdUqHcKVgJ_G_JeCAoW1CuJUq7CNHso,10829
|
|
3
|
+
certone_sdk/client.py,sha256=kfOYjp47LziXruFZtbgBjm4WOG6ysuvQinaZRinNyps,9672
|
|
4
|
+
certone_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
certone_sdk/submit.py,sha256=cZKd9yAWUkErjCgJSbF_5bYfEgH1NH0IA1xZc0M7oUo,14654
|
|
6
|
+
certone_sdk-0.2.0.dist-info/METADATA,sha256=SmWMcPIoo-c62kzSwuxUz5XFvZqHl6ktL-yEct4hapA,4891
|
|
7
|
+
certone_sdk-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
certone_sdk-0.2.0.dist-info/entry_points.txt,sha256=uj_QGQNe5pBWMLWfbY9tHsMwGf6GsGr-MPYdDBIPH_c,98
|
|
9
|
+
certone_sdk-0.2.0.dist-info/RECORD,,
|