clawprobe 1.3.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.
clawprobe/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ClawProbe — Zero-Dependency Mobile APK 300s Live Dynamic Privacy Inspection & CI/CD Gate CLI."""
2
+
3
+ __version__ = "1.3.0"
clawprobe/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Enable `python -m clawprobe` and `pipx run clawprobe` execution."""
4
+
5
+ from clawprobe.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
clawprobe/cli.py ADDED
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ `clawprobe` — Mobile Application 300s Live Dynamic Technical Inspection & CI/CD Privacy Gate CLI:
5
+ - `clawprobe doctor`
6
+ - `clawprobe auth login --api-key cnx_live_xxx --secret sec_xxx [--endpoint https://www.atooli.com]`
7
+ - `clawprobe auth verify`
8
+ - `clawprobe analyze ./app.apk --attest-authorized --mode privacy --fail-on-preconsent --reclaim --out ./report.pdf`
9
+ - `clawprobe job <job_id> [--out ./report.pdf] [--json-out ./report.json] [--bundle ./bundle.zip] [--reclaim]`
10
+ - `clawprobe reclaim <job_id>`
11
+
12
+ Exit Codes:
13
+ - `0`: Completed cleanly (and 0 Stage-1 pre-consent flows when `--fail-on-preconsent` is set)
14
+ - `1`: Usage, argument, file-not-found, or authentication/attestation error
15
+ - `2`: CI/CD Privacy Gate Triggered (`--fail-on-preconsent` set and Stage-1 pre-consent flows detected)
16
+ - `3`: Quota or bound-package limit exceeded (`ERR_QUOTA_EXCEEDED` / `ERR_PACKAGE_QUOTA_EXCEEDED`)
17
+ - `4`: Network, service unavailability, or sandbox queue timeout
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import email.utils
24
+ import json
25
+ import os
26
+ import ssl
27
+ import stat
28
+ import sys
29
+ import time
30
+ from pathlib import Path
31
+
32
+ from clawprobe import __version__
33
+ from clawprobe.client import (
34
+ CRED_PATH,
35
+ DEFAULT_PUBLIC_ENDPOINT,
36
+ ClawNexusClient,
37
+ load_credentials,
38
+ save_credentials,
39
+ )
40
+
41
+
42
+ def _build_client(args: argparse.Namespace) -> ClawNexusClient:
43
+ creds = load_credentials(
44
+ endpoint=getattr(args, "endpoint", None),
45
+ api_key=getattr(args, "api_key", None),
46
+ sign_secret=getattr(args, "secret", None),
47
+ spki_pin=getattr(args, "spki_pin", None),
48
+ )
49
+ return ClawNexusClient(
50
+ endpoint=creds["endpoint"],
51
+ api_key=creds["api_key"],
52
+ sign_secret=creds["sign_secret"],
53
+ ca_cert=getattr(args, "ca_cert", None),
54
+ spki_pins=creds["spki_pins"],
55
+ pin_mode=getattr(args, "pin_mode", None),
56
+ timeout=60.0,
57
+ )
58
+
59
+
60
+ def _run_doctor(client: ClawNexusClient) -> int:
61
+ checks: dict = {
62
+ "cli_version": __version__,
63
+ "python_version": sys.version.split()[0],
64
+ "openssl_version": ssl.OPENSSL_VERSION,
65
+ "tls13_supported": bool(getattr(ssl, "HAS_TLSv1_3", False)),
66
+ "endpoint": client.endpoint,
67
+ "pin_mode": client.pin_mode,
68
+ }
69
+ ok = sys.version_info >= (3, 9)
70
+
71
+ if CRED_PATH.exists():
72
+ mode_oct = stat.S_IMODE(os.stat(CRED_PATH).st_mode)
73
+ checks["credentials_file"] = str(CRED_PATH)
74
+ checks["credentials_permissions"] = f"0{mode_oct:o}"
75
+ if os.name != "nt" and (mode_oct & 0o077) != 0:
76
+ checks["credentials_warning"] = "Permissions broader than 0600; run chmod 600 ~/.clawnexus/credentials.json"
77
+ else:
78
+ checks["credentials_file"] = "not_configured (using env vars or anonymous doctor)"
79
+
80
+ try:
81
+ t0 = time.time()
82
+ st, hdrs, _ = client.request_raw("GET", "/api/v1/health", require_auth=False, retries=1)
83
+ rtt_ms = round((time.time() - t0) * 1000.0, 1)
84
+ checks["health_http_status"] = st
85
+ checks["rtt_ms"] = rtt_ms
86
+ checks["peer_spki_sha256"] = client.last_peer_spki_sha256
87
+ checks["spki_pin_matched"] = client.last_spki_pin_matched
88
+ if st != 200:
89
+ ok = False
90
+ date_hdr = hdrs.get("date")
91
+ if date_hdr:
92
+ dt = email.utils.parsedate_to_datetime(date_hdr)
93
+ skew_sec = round(abs(time.time() - dt.timestamp()), 2)
94
+ checks["clock_skew_sec"] = skew_sec
95
+ checks["clock_skew_ok"] = skew_sec <= 60.0
96
+ if skew_sec > 60.0:
97
+ ok = False
98
+ checks["clock_warning"] = f"Clock skew {skew_sec}s exceeds ±60s HMAC v2 anti-replay window"
99
+ except Exception as exc:
100
+ ok = False
101
+ checks["connection_error"] = str(exc)
102
+
103
+ checks["status"] = "ok" if ok else "failed"
104
+ print(json.dumps(checks, indent=2, ensure_ascii=False))
105
+ return 0 if ok else 4
106
+
107
+
108
+ def _download_artifacts(
109
+ client: ClawNexusClient,
110
+ job_id: str,
111
+ pdf_out: str | None,
112
+ json_out: str | None,
113
+ bundle_out: str | None,
114
+ ) -> dict:
115
+ downloaded = {}
116
+ st_j, _, raw_j = client.request_raw("GET", f"/api/v1/inspect/jobs/{job_id}/report.json")
117
+ if st_j != 200:
118
+ raise RuntimeError(f"Failed to fetch report.json (HTTP {st_j}): {raw_j.decode('utf-8', errors='replace')}")
119
+ report_obj = json.loads(raw_j.decode("utf-8"))
120
+ if json_out:
121
+ jp = Path(json_out)
122
+ jp.parent.mkdir(parents=True, exist_ok=True)
123
+ jp.write_bytes(raw_j)
124
+ downloaded["report_json"] = str(jp)
125
+ if pdf_out:
126
+ st_p, _, raw_p = client.request_raw("GET", f"/api/v1/inspect/jobs/{job_id}/report.pdf")
127
+ if st_p != 200:
128
+ raise RuntimeError(f"Failed to fetch report.pdf (HTTP {st_p})")
129
+ pp = Path(pdf_out)
130
+ pp.parent.mkdir(parents=True, exist_ok=True)
131
+ pp.write_bytes(raw_p)
132
+ downloaded["report_pdf"] = str(pp)
133
+ if bundle_out:
134
+ st_b, _, raw_b = client.request_raw("GET", f"/api/v1/inspect/jobs/{job_id}/bundle.zip")
135
+ if st_b != 200:
136
+ raise RuntimeError(f"Failed to fetch bundle.zip (HTTP {st_b})")
137
+ bp = Path(bundle_out)
138
+ bp.parent.mkdir(parents=True, exist_ok=True)
139
+ bp.write_bytes(raw_b)
140
+ downloaded["bundle_zip"] = str(bp)
141
+ return {"report": report_obj, "downloaded": downloaded}
142
+
143
+
144
+ def _reclaim_job(client: ClawNexusClient, job_id: str) -> dict:
145
+ payload = json.dumps({"confirm_reclaim": True}).encode("utf-8")
146
+ st, _, raw = client.request_raw(
147
+ "POST",
148
+ f"/api/v1/inspect/jobs/{job_id}/reclaim",
149
+ body=payload,
150
+ content_type="application/json",
151
+ )
152
+ body = json.loads(raw.decode("utf-8", errors="replace"))
153
+ if st != 200:
154
+ raise RuntimeError(f"Failed to reclaim job {job_id} (HTTP {st}): {body}")
155
+ return body.get("reclaim_receipt", body)
156
+
157
+
158
+ def main() -> None:
159
+ parser = argparse.ArgumentParser(
160
+ prog="clawprobe",
161
+ description="ClawProbe APK 300s Live Dynamic Technical Inspection & CI/CD Privacy Gate CLI (v1.3)",
162
+ )
163
+ parser.add_argument("--version", action="version", version=f"clawprobe {__version__}")
164
+ parser.add_argument("--endpoint", default=None, help=f"Gateway URL (default: {DEFAULT_PUBLIC_ENDPOINT})")
165
+ parser.add_argument("--api-key", default=None, help="Commercial API Key (cnx_live_* or env CLAWNEXUS_API_KEY)")
166
+ parser.add_argument("--secret", default=None, help="HMAC-SHA256 Sign Secret (sec_* or env CLAWNEXUS_SIGN_SECRET)")
167
+ parser.add_argument("--ca-cert", default=None, help="Optional custom TLS CA PEM path")
168
+ parser.add_argument("--spki-pin", default=None, help="Optional comma-separated SPKI SHA-256 Base64 pins")
169
+ parser.add_argument("--pin-mode", choices=["ca", "strict", "off"], default=None, help="TLS pin policy (default: ca)")
170
+
171
+ sub = parser.add_subparsers(dest="cmd", required=True)
172
+
173
+ sub.add_parser("doctor", help="Check Python/OpenSSL TLS 1.3 support, certificate chain, and ±60s clock skew")
174
+
175
+ p_auth = sub.add_parser("auth", help="Manage CLI authentication credentials")
176
+ auth_sub = p_auth.add_subparsers(dest="auth_cmd", required=True)
177
+ p_login = auth_sub.add_parser("login", help="Store APIKEY, HMAC secret, and endpoint in ~/.clawnexus/credentials.json")
178
+ p_login.add_argument("--endpoint", default=DEFAULT_PUBLIC_ENDPOINT, help=f"Service endpoint (default: {DEFAULT_PUBLIC_ENDPOINT})")
179
+ p_login.add_argument("--api-key", required=True)
180
+ p_login.add_argument("--secret", required=True)
181
+ p_login.add_argument("--spki-pin", default=None)
182
+ auth_sub.add_parser("verify", help="Verify TLS 1.3 handshake, HMAC v2 authentication, and inspection credits")
183
+
184
+ p_an = sub.add_parser("analyze", help="Submit APK for 300s 3-stage live sandbox technical inspection")
185
+ p_an.add_argument("apk", help="Path to target .apk file")
186
+ p_an.add_argument(
187
+ "--attest-authorized",
188
+ action="store_true",
189
+ default=False,
190
+ help="Mandatory: Attest that you hold lawful ownership or written authorization to submit this APK for technical testing",
191
+ )
192
+ p_an.add_argument("--mode", choices=["privacy", "forensic", "sdk-hunt"], default="privacy")
193
+ p_an.add_argument("--confidential", action="store_true", default=False, help="Enable confidential vault + TTL shred")
194
+ p_an.add_argument("--ttl", type=int, default=72, help="Confidential retention TTL in hours (default 72)")
195
+ p_an.add_argument("--fail-on-preconsent", action="store_true", default=False, help="Exit code 2 if Stage-1 pre-consent flows exist")
196
+ p_an.add_argument("--queue-timeout", type=int, default=900, help="Max seconds to wait in queue if all sandboxes are busy (default 900)")
197
+ p_an.add_argument("--no-wait", action="store_true", default=False, help="Return job_id immediately without polling")
198
+ p_an.add_argument("--out", default=None, help="Output path for PDF Technical Inspection Report")
199
+ p_an.add_argument("--json-out", default=None, help="Output path for JSON Technical Inspection Report")
200
+ p_an.add_argument("--bundle", default=None, help="Output path for full ZIP bundle (PDF+JSON+PCAP+SHA256)")
201
+ p_an.add_argument(
202
+ "--reclaim",
203
+ action="store_true",
204
+ default=False,
205
+ help="Immediately 3-pass overwrite & zero-shred server-side job artifacts after downloading local deliverables",
206
+ )
207
+
208
+ p_job = sub.add_parser("job", help="Query inspection job status and download technical report deliverables")
209
+ p_job.add_argument("job_id", help="Job ID (cn_apk_*)")
210
+ p_job.add_argument("--out", default=None, help="Output path for PDF report")
211
+ p_job.add_argument("--json-out", default=None, help="Output path for JSON report")
212
+ p_job.add_argument("--bundle", default=None, help="Output path for ZIP bundle")
213
+ p_job.add_argument("--fail-on-preconsent", action="store_true", default=False)
214
+ p_job.add_argument(
215
+ "--reclaim",
216
+ action="store_true",
217
+ default=False,
218
+ help="Immediately 3-pass overwrite & zero-shred server-side job artifacts after downloading",
219
+ )
220
+
221
+ p_rec = sub.add_parser("reclaim", help="Immediately 3-pass overwrite & zero-shred server-side artifacts for a completed job")
222
+ p_rec.add_argument("job_id", help="Job ID (cn_apk_*)")
223
+
224
+ args = parser.parse_args()
225
+
226
+ if args.cmd == "auth" and args.auth_cmd == "login":
227
+ saved = save_credentials(args.endpoint, args.api_key, args.secret, spki_pin=args.spki_pin)
228
+ print(
229
+ json.dumps(
230
+ {
231
+ "status": "saved",
232
+ "credentials_file": str(saved),
233
+ "endpoint": (args.endpoint or DEFAULT_PUBLIC_ENDPOINT).rstrip("/"),
234
+ "spki_pinning": "strict" if args.spki_pin else "ca_chain_verified",
235
+ },
236
+ indent=2,
237
+ )
238
+ )
239
+ return
240
+
241
+ client = _build_client(args)
242
+
243
+ if args.cmd == "doctor":
244
+ sys.exit(_run_doctor(client))
245
+
246
+ if args.cmd == "auth" and args.auth_cmd == "verify":
247
+ st, body = client.request_json("GET", "/api/v1/auth/verify")
248
+ print(json.dumps(body, indent=2, ensure_ascii=False))
249
+ sys.exit(0 if st == 200 else 1)
250
+
251
+ if args.cmd == "analyze":
252
+ if not args.attest_authorized:
253
+ print(
254
+ json.dumps(
255
+ {
256
+ "status": "error",
257
+ "error_code": "ERR_AUTHORIZATION_ATTESTATION_REQUIRED",
258
+ "message": "Mandatory flag `--attest-authorized` missing: you must attest lawful ownership or written authorization to submit this APK under ClawNexus Terms of Service.",
259
+ },
260
+ ensure_ascii=False,
261
+ indent=2,
262
+ ),
263
+ file=sys.stderr,
264
+ )
265
+ sys.exit(1)
266
+
267
+ apk_p = Path(args.apk)
268
+ if not apk_p.exists():
269
+ print(json.dumps({"status": "error", "message": f"APK not found: {apk_p}"}), file=sys.stderr)
270
+ sys.exit(1)
271
+
272
+ q_deadline = time.time() + max(0, int(args.queue_timeout))
273
+ q_attempt = 0
274
+ while True:
275
+ try:
276
+ st, sub_resp = client.upload_multipart(
277
+ "/api/v1/inspect/submit",
278
+ file_path=apk_p,
279
+ fields={
280
+ "mode": args.mode,
281
+ "confidential": "true" if args.confidential else "false",
282
+ "ttl_hours": str(args.ttl),
283
+ "authorization_attested": "true",
284
+ "wait": "false",
285
+ },
286
+ )
287
+ except Exception as net_exc:
288
+ print(json.dumps({"status": "error", "error_code": "ERR_NETWORK_UNAVAILABLE", "message": str(net_exc)}), file=sys.stderr)
289
+ sys.exit(4)
290
+
291
+ err_code = str(sub_resp.get("error_code") or "")
292
+ if st == 429 and err_code == "ERR_P0_HOT_SPARE_BUSY" and time.time() < q_deadline:
293
+ q_attempt += 1
294
+ wait_s = min(60, 15 * (2 ** min(q_attempt - 1, 2)))
295
+ rem_q = int(max(0.0, q_deadline - time.time()))
296
+ print(
297
+ f"[clawprobe] All clean sandboxes busy (attempt #{q_attempt}); retrying in {wait_s}s (queue timeout remaining: {rem_q}s)...",
298
+ file=sys.stderr,
299
+ )
300
+ time.sleep(wait_s)
301
+ continue
302
+ break
303
+
304
+ if st != 200:
305
+ print(json.dumps(sub_resp, indent=2, ensure_ascii=False), file=sys.stderr)
306
+ err_code = str(sub_resp.get("error_code") or "")
307
+ if err_code in ("ERR_QUOTA_EXCEEDED", "ERR_PACKAGE_QUOTA_EXCEEDED", "ERR_PACKAGE_NOT_IN_WHITELIST"):
308
+ sys.exit(3)
309
+ if st in (429, 502, 503, 504):
310
+ sys.exit(4)
311
+ sys.exit(1)
312
+
313
+ job_id = sub_resp["job_id"]
314
+ if args.no_wait:
315
+ print(json.dumps(sub_resp, indent=2, ensure_ascii=False))
316
+ return
317
+
318
+ deadline = time.time() + 420.0
319
+ st_body: dict = {}
320
+ while time.time() < deadline:
321
+ time.sleep(2.5)
322
+ _, st_body = client.request_json("GET", f"/api/v1/inspect/jobs/{job_id}")
323
+ jst = st_body.get("job_status", "")
324
+ if jst == "COMPLETED":
325
+ break
326
+ if jst == "FAILED":
327
+ print(json.dumps(st_body, indent=2, ensure_ascii=False), file=sys.stderr)
328
+ sys.exit(1)
329
+ else:
330
+ print(json.dumps({"status": "error", "error_code": "ERR_INSPECT_TIMEOUT", "message": "Timed out waiting for inspection completion", "job_id": job_id}), file=sys.stderr)
331
+ sys.exit(4)
332
+
333
+ dl = _download_artifacts(client, job_id, args.out, args.json_out, args.bundle)
334
+ reclaim_receipt = _reclaim_job(client, job_id) if args.reclaim else None
335
+ rep = dl["report"]
336
+ summary = rep.get("layer1_deterministic_network_ledger", {}).get("summary", {})
337
+ pre_detected = bool(summary.get("pre_consent_network_detected"))
338
+ out_payload = {
339
+ "status": "ok",
340
+ "report_title": rep.get("report_title"),
341
+ "job_id": job_id,
342
+ "package_name": rep.get("layer1_deterministic_network_ledger", {}).get("package_name"),
343
+ "wall_time_sec": st_body.get("wall_time_sec"),
344
+ "authorization_attestation": rep.get("report_metadata", {}).get("customer_authorization_attestation"),
345
+ "summary": summary,
346
+ "anchored_findings": rep.get("anchored_technical_synthesis", {}).get("verified_anchored_findings", []),
347
+ "downloaded_files": dl["downloaded"],
348
+ }
349
+ if reclaim_receipt is not None:
350
+ out_payload["reclaim_receipt"] = reclaim_receipt
351
+ print(json.dumps(out_payload, indent=2, ensure_ascii=False))
352
+ if args.fail_on_preconsent and pre_detected:
353
+ sys.exit(2)
354
+ return
355
+
356
+ if args.cmd == "reclaim":
357
+ receipt = _reclaim_job(client, args.job_id)
358
+ print(json.dumps({"status": "ok", "reclaim_receipt": receipt}, indent=2, ensure_ascii=False))
359
+ return
360
+
361
+ if args.cmd == "job":
362
+ st, st_body = client.request_json("GET", f"/api/v1/inspect/jobs/{args.job_id}")
363
+ if st != 200:
364
+ print(json.dumps(st_body, indent=2, ensure_ascii=False), file=sys.stderr)
365
+ sys.exit(1)
366
+ if st_body.get("job_status") != "COMPLETED" or not (args.out or args.json_out or args.bundle or args.reclaim):
367
+ print(json.dumps(st_body, indent=2, ensure_ascii=False))
368
+ return
369
+ dl = _download_artifacts(client, args.job_id, args.out, args.json_out, args.bundle) if (args.out or args.json_out or args.bundle) else {"report": {}, "downloaded": {}}
370
+ reclaim_receipt = _reclaim_job(client, args.job_id) if args.reclaim else None
371
+ rep = dl["report"]
372
+ summary = rep.get("layer1_deterministic_network_ledger", {}).get("summary", {})
373
+ res_out = {**st_body, "summary": summary, "downloaded_files": dl["downloaded"]}
374
+ if reclaim_receipt is not None:
375
+ res_out["reclaim_receipt"] = reclaim_receipt
376
+ res_out["job_status"] = "RECLAIMED_SHREDDED"
377
+ print(json.dumps(res_out, indent=2, ensure_ascii=False))
378
+ if args.fail_on_preconsent and summary.get("pre_consent_network_detected"):
379
+ sys.exit(2)
380
+
381
+
382
+ if __name__ == "__main__":
383
+ main()
clawprobe/client.py ADDED
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ ClawProbe — Zero-dependency stdlib TLS 1.3 + Certificate Chain / SPKI Verification +
5
+ Canonical HMAC-SHA256 v2 (Sorted Query + Full Raw HTTP Body) Anti-Replay HTTP Client.
6
+
7
+ Licensed under the Apache License, Version 2.0.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import hmac
15
+ import http.client
16
+ import json
17
+ import os
18
+ import secrets
19
+ import socket
20
+ import ssl
21
+ import time
22
+ import urllib.parse
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ CRED_PATH = Path(os.path.expanduser("~/.clawnexus/credentials.json"))
27
+ DEFAULT_PUBLIC_ENDPOINT = "https://www.atooli.com"
28
+
29
+ # Default SPKI SHA-256 Pins for https://www.atooli.com:
30
+ # 1. DigiCert Intermediate CA (Encryption Everywhere DV TLS CA - G2) — survives leaf cert rotation
31
+ # 2. DigiCert Global Root G2 — root trust anchor SPKI
32
+ # 3. www.atooli.com current leaf SPKI
33
+ DEFAULT_PINNED_SPKI_SHA256: tuple[str, ...] = (
34
+ "gxeKFFaZ2HFJIsTdTjEl6nVo3ckTCX+qzRMqb9Xoa1w=",
35
+ "i7WTqTvh0OioIruIfFR4kMPnBqrS2rdiVPl/s2uC/CY=",
36
+ "h3mQK/w8dMmMne2+T0gN9L5DAL5BrhfskqFXjK5oOSo=",
37
+ )
38
+
39
+ EMBEDDED_PUBLIC_CA_CHAIN_PEM = """-----BEGIN CERTIFICATE-----
40
+ MIIEqjCCA5KgAwIBAgIQDeD/te5iy2EQn2CMnO1e0zANBgkqhkiG9w0BAQsFADBh
41
+ MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
42
+ d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
43
+ MjAeFw0xNzExMjcxMjQ2NDBaFw0yNzExMjcxMjQ2NDBaMG4xCzAJBgNVBAYTAlVT
44
+ MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
45
+ b20xLTArBgNVBAMTJEVuY3J5cHRpb24gRXZlcnl3aGVyZSBEViBUTFMgQ0EgLSBH
46
+ MjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO8Uf46i/nr7pkgTDqnE
47
+ eSIfCFqvPnUq3aF1tMJ5hh9MnO6Lmt5UdHfBGwC9Si+XjK12cjZgxObsL6Rg1njv
48
+ NhAMJ4JunN0JGGRJGSevbJsA3sc68nbPQzuKp5Jc8vpryp2mts38pSCXorPR+sch
49
+ QisKA7OSQ1MjcFN0d7tbrceWFNbzgL2csJVQeogOBGSe/KZEIZw6gXLKeFe7mupn
50
+ NYJROi2iC11+HuF79iAttMc32Cv6UOxixY/3ZV+LzpLnklFq98XORgwkIJL1HuvP
51
+ ha8yvb+W6JislZJL+HLFtidoxmI7Qm3ZyIV66W533DsGFimFJkz3y0GeHWuSVMbI
52
+ lfsCAwEAAaOCAU8wggFLMB0GA1UdDgQWBBR435GQX+7erPbFdevVTFVT7yRKtjAf
53
+ BgNVHSMEGDAWgBROIlQgGJXm427mD/r6uRLtBhePOTAOBgNVHQ8BAf8EBAMCAYYw
54
+ HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMBIGA1UdEwEB/wQIMAYBAf8C
55
+ AQAwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
56
+ Y2VydC5jb20wQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQu
57
+ Y29tL0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDBMBgNVHSAERTBDMDcGCWCGSAGG
58
+ /WwBAjAqMCgGCCsGAQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BT
59
+ MAgGBmeBDAECATANBgkqhkiG9w0BAQsFAAOCAQEAoBs1eCLKakLtVRPFRjBIJ9LJ
60
+ L0s8ZWum8U8/1TMVkQMBn+CPb5xnCD0GSA6L/V0ZFrMNqBirrr5B241OesECvxIi
61
+ 98bZ90h9+q/X5eMyOD35f8YTaEMpdnQCnawIwiHx06/0BfiTj+b/XQih+mqt3ZXe
62
+ xNCJqKexdiB2IWGSKcgahPacWkk/BAQFisKIFYEqHzV974S3FAz/8LIfD58xnsEN
63
+ GfzyIDkH3JrwYZ8caPTf6ZX9M1GrISN8HnWTtdNCH2xEajRa/h9ZBXjUyFKQrGk2
64
+ n2hcLrfZSbynEC/pSw/ET7H5nWwckjmAJ1l9fcnbqkU/pf6uMQmnfl0JQjJNSg==
65
+ -----END CERTIFICATE-----
66
+ -----BEGIN CERTIFICATE-----
67
+ MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh
68
+ MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
69
+ d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
70
+ MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT
71
+ MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
72
+ b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG
73
+ 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI
74
+ 2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx
75
+ 1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ
76
+ q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz
77
+ tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ
78
+ vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP
79
+ BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV
80
+ 5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY
81
+ 1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4
82
+ NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG
83
+ Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91
84
+ 8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe
85
+ pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl
86
+ MrY=
87
+ -----END CERTIFICATE-----
88
+ """
89
+
90
+
91
+ class SPKIPinMismatchError(ssl.SSLCertVerificationError):
92
+ """Raised when the peer TLS 1.3 certificate SPKI SHA-256 fingerprint does not match pinned SPKI hashes."""
93
+
94
+
95
+ def _read_asn1_tlv(data: bytes, offset: int) -> tuple[int, int, int, int]:
96
+ """Parse one ASN.1 DER TLV -> (tag, header_start, value_start, next_offset)."""
97
+ if offset >= len(data):
98
+ raise ValueError("Truncated ASN.1 DER at tag")
99
+ tag = data[offset]
100
+ if offset + 1 >= len(data):
101
+ raise ValueError("Truncated ASN.1 DER at length")
102
+ first_len = data[offset + 1]
103
+ if (first_len & 0x80) == 0:
104
+ length = first_len
105
+ val_start = offset + 2
106
+ else:
107
+ num_len_bytes = first_len & 0x7F
108
+ if num_len_bytes == 0 or num_len_bytes > 4 or offset + 2 + num_len_bytes > len(data):
109
+ raise ValueError("Invalid ASN.1 DER length octets")
110
+ length = int.from_bytes(data[offset + 2 : offset + 2 + num_len_bytes], "big")
111
+ val_start = offset + 2 + num_len_bytes
112
+ next_off = val_start + length
113
+ if next_off > len(data):
114
+ raise ValueError("Truncated ASN.1 DER value")
115
+ return tag, offset, val_start, next_off
116
+
117
+
118
+ def extract_spki_der_from_x509_der(cert_der: bytes) -> bytes:
119
+ """Extract raw DER `SubjectPublicKeyInfo` (`RFC 5280 §4.1`) from an X.509 DER certificate using pure stdlib."""
120
+ c_tag, _, c_val, _ = _read_asn1_tlv(cert_der, 0)
121
+ if c_tag != 0x30:
122
+ raise ValueError("X.509 certificate outer element is not SEQUENCE")
123
+ tbs_tag, _, tbs_val, tbs_end = _read_asn1_tlv(cert_der, c_val)
124
+ if tbs_tag != 0x30:
125
+ raise ValueError("X.509 tbsCertificate is not SEQUENCE")
126
+
127
+ pos = tbs_val
128
+ t0, _, _, next0 = _read_asn1_tlv(cert_der, pos)
129
+ if t0 == 0xA0: # [0] EXPLICIT Version
130
+ pos = next0
131
+ # Skip serialNumber, signature, issuer, validity, subject
132
+ for _ in range(5):
133
+ _, _, _, pos = _read_asn1_tlv(cert_der, pos)
134
+
135
+ spki_tag, spki_hdr, _, spki_end = _read_asn1_tlv(cert_der, pos)
136
+ if spki_tag != 0x30 or spki_end > tbs_end:
137
+ raise ValueError("Malformed X.509 SubjectPublicKeyInfo")
138
+ return cert_der[spki_hdr:spki_end]
139
+
140
+
141
+ def extract_spki_sha256_b64(cert_der: bytes) -> str:
142
+ """Compute Base64-encoded SHA-256 fingerprint of the X.509 certificate's `SubjectPublicKeyInfo` DER."""
143
+ spki_der = extract_spki_der_from_x509_der(cert_der)
144
+ return base64.b64encode(hashlib.sha256(spki_der).digest()).decode("ascii")
145
+
146
+
147
+ def canonicalize_path_and_query(path: str, raw_query: str = "") -> str:
148
+ """
149
+ Canonical Path + Sorted Query String:
150
+ `path?k1=v1&k2=v2` (sorted lexicographically by `(key, value)`, RFC 3986 percent-encoded)
151
+ or `path` when query is empty.
152
+ """
153
+ if "?" in path:
154
+ base_path, q_in_path = path.split("?", 1)
155
+ eff_query = raw_query if raw_query else q_in_path
156
+ else:
157
+ base_path = path
158
+ eff_query = raw_query
159
+ if not eff_query:
160
+ return base_path
161
+ pairs = urllib.parse.parse_qsl(eff_query, keep_blank_values=True)
162
+ if not pairs:
163
+ return base_path
164
+ sorted_pairs = sorted(pairs, key=lambda kv: (kv[0], kv[1]))
165
+ sorted_qs = urllib.parse.urlencode(sorted_pairs, doseq=True, quote_via=urllib.parse.quote)
166
+ return f"{base_path}?{sorted_qs}"
167
+
168
+
169
+ def compute_hmac_signature_v2(
170
+ sign_secret: str,
171
+ method: str,
172
+ path_with_query: str,
173
+ timestamp: str,
174
+ nonce: str,
175
+ full_raw_body_sha256: str,
176
+ ) -> str:
177
+ """
178
+ Canonical HMAC-SHA256 v2:
179
+ `METHOD\\npath?sorted_query\\nts\\nnonce\\nsha256(full_raw_http_body)`
180
+ """
181
+ canon_target = canonicalize_path_and_query(path_with_query)
182
+ canonical = f"{method.upper()}\n{canon_target}\n{timestamp}\n{nonce}\n{full_raw_body_sha256.lower()}".encode("utf-8")
183
+ return hmac.new(sign_secret.encode("utf-8"), canonical, hashlib.sha256).hexdigest()
184
+
185
+
186
+ def save_credentials(
187
+ endpoint: str | None,
188
+ api_key: str,
189
+ sign_secret: str,
190
+ spki_pin: str | None = None,
191
+ ) -> Path:
192
+ CRED_PATH.parent.mkdir(parents=True, exist_ok=True)
193
+ ep = (endpoint or DEFAULT_PUBLIC_ENDPOINT).strip().rstrip("/")
194
+ explicit_pins = [p.strip() for p in (spki_pin or "").split(",") if p.strip()]
195
+ payload = {
196
+ "endpoint": ep,
197
+ "api_key": api_key.strip(),
198
+ "sign_secret": sign_secret.strip(),
199
+ "spki_pins": explicit_pins,
200
+ "updated_at": int(time.time()),
201
+ }
202
+ CRED_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
203
+ os.chmod(CRED_PATH, 0o600)
204
+ return CRED_PATH
205
+
206
+
207
+ def load_credentials(
208
+ endpoint: str | None = None,
209
+ api_key: str | None = None,
210
+ sign_secret: str | None = None,
211
+ spki_pin: str | None = None,
212
+ ) -> dict[str, Any]:
213
+ stored: dict[str, Any] = {}
214
+ if CRED_PATH.exists():
215
+ try:
216
+ stored = json.loads(CRED_PATH.read_text(encoding="utf-8"))
217
+ except Exception:
218
+ stored = {}
219
+ stored_ep = str(stored.get("endpoint") or "").rstrip("/")
220
+ ep = (
221
+ endpoint
222
+ or os.environ.get("CLAW_ENDPOINT")
223
+ or os.environ.get("CLAWNEXUS_ENDPOINT")
224
+ or (stored_ep if stored_ep.startswith("https://www.atooli.com") else "")
225
+ or DEFAULT_PUBLIC_ENDPOINT
226
+ ).rstrip("/")
227
+ ak = (
228
+ api_key
229
+ or os.environ.get("CLAW_API_KEY")
230
+ or os.environ.get("CLAWNEXUS_API_KEY")
231
+ or stored.get("api_key")
232
+ or ""
233
+ ).strip()
234
+ sk = (
235
+ sign_secret
236
+ or os.environ.get("CLAW_API_SECRET")
237
+ or os.environ.get("CLAW_SIGN_SECRET")
238
+ or os.environ.get("CLAWNEXUS_SIGN_SECRET")
239
+ or stored.get("sign_secret")
240
+ or ""
241
+ ).strip()
242
+ pin_arg = spki_pin or os.environ.get("CLAW_SPKI_PIN") or os.environ.get("CLAWNEXUS_SPKI_PIN") or ""
243
+ if pin_arg.strip():
244
+ pins: tuple[str, ...] | None = tuple(p.strip() for p in pin_arg.split(",") if p.strip())
245
+ elif stored_ep == ep and isinstance(stored.get("spki_pins"), list) and stored["spki_pins"]:
246
+ stored_tuple = tuple(str(p).strip() for p in stored["spki_pins"] if str(p).strip())
247
+ pins = None if (not stored_tuple or set(stored_tuple).issubset(set(DEFAULT_PINNED_SPKI_SHA256))) else stored_tuple
248
+ else:
249
+ pins = None
250
+ return {"endpoint": ep, "api_key": ak, "sign_secret": sk, "spki_pins": pins}
251
+
252
+
253
+ class ClawNexusClient:
254
+ def __init__(
255
+ self,
256
+ endpoint: str = DEFAULT_PUBLIC_ENDPOINT,
257
+ api_key: str = "",
258
+ sign_secret: str = "",
259
+ ca_cert: str | None = None,
260
+ spki_pins: tuple[str, ...] | list[str] | None = None,
261
+ pin_mode: str | None = None,
262
+ timeout: float = 45.0,
263
+ ) -> None:
264
+ self.endpoint = (endpoint or DEFAULT_PUBLIC_ENDPOINT).rstrip("/")
265
+ self.api_key = api_key
266
+ self.sign_secret = sign_secret
267
+ self.ca_cert = ca_cert
268
+ self._explicit_spki_pins = spki_pins is not None
269
+ self.spki_pins: tuple[str, ...] = (
270
+ tuple(spki_pins) if spki_pins is not None else DEFAULT_PINNED_SPKI_SHA256
271
+ )
272
+ self.timeout = timeout
273
+ parsed = urllib.parse.urlparse(self.endpoint)
274
+ self.scheme = parsed.scheme.lower()
275
+ self.host = parsed.hostname or "127.0.0.1"
276
+ self.port = parsed.port or (443 if self.scheme == "https" else 80)
277
+ raw_mode = (pin_mode or os.environ.get("CLAWNEXUS_PIN_MODE") or "").strip().lower()
278
+ if raw_mode in ("strict", "ca", "off"):
279
+ self.pin_mode = raw_mode
280
+ else:
281
+ self.pin_mode = "strict" if self._explicit_spki_pins else "ca"
282
+ self.last_peer_spki_sha256: str = ""
283
+ self.last_spki_pin_matched: bool = False
284
+
285
+ def _ssl_context(self) -> ssl.SSLContext:
286
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
287
+ if getattr(ssl, "HAS_TLSv1_3", False):
288
+ try:
289
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_3
290
+ except ValueError:
291
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
292
+ else:
293
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
294
+ ctx.verify_mode = ssl.CERT_REQUIRED
295
+ ctx.check_hostname = True
296
+ try:
297
+ ctx.load_default_certs(ssl.Purpose.SERVER_AUTH)
298
+ except Exception:
299
+ pass
300
+ ctx.load_verify_locations(cadata=EMBEDDED_PUBLIC_CA_CHAIN_PEM)
301
+ if self.ca_cert:
302
+ ctx.load_verify_locations(cafile=self.ca_cert)
303
+ return ctx
304
+
305
+ def _verify_peer_spki_pin(self, tls_sock: ssl.SSLSocket) -> None:
306
+ peer_der = tls_sock.getpeercert(binary_form=True)
307
+ if not peer_der:
308
+ raise SPKIPinMismatchError("Peer did not present an X.509 certificate during TLS 1.3 handshake")
309
+ actual_pin = extract_spki_sha256_b64(peer_der)
310
+ self.last_peer_spki_sha256 = actual_pin
311
+
312
+ candidate_pins = [actual_pin]
313
+ if hasattr(tls_sock, "get_verified_chain"):
314
+ try:
315
+ for c_der in tls_sock.get_verified_chain() or []:
316
+ if isinstance(c_der, (bytes, bytearray)):
317
+ candidate_pins.append(extract_spki_sha256_b64(bytes(c_der)))
318
+ except Exception:
319
+ pass
320
+
321
+ matched = any(
322
+ hmac.compare_digest(cp, exp)
323
+ for cp in candidate_pins
324
+ for exp in self.spki_pins
325
+ )
326
+ self.last_spki_pin_matched = matched
327
+ if self.pin_mode == "off" or not self.spki_pins:
328
+ return
329
+ if self.pin_mode == "strict" and not matched:
330
+ raise SPKIPinMismatchError(
331
+ f"TLS 1.3 SPKI pin verification failed: peer SPKI sha256={actual_pin} not in pinned set {self.spki_pins}"
332
+ )
333
+
334
+ def request_raw(
335
+ self,
336
+ method: str,
337
+ path_with_query: str,
338
+ body: bytes = b"",
339
+ content_type: str = "",
340
+ require_auth: bool = True,
341
+ retries: int = 2,
342
+ ) -> tuple[int, dict[str, str], bytes]:
343
+ full_body_sha = hashlib.sha256(body).hexdigest().lower()
344
+
345
+ last_err: Exception | None = None
346
+ default_port = (self.scheme == "https" and self.port == 443) or (self.scheme == "http" and self.port == 80)
347
+ host_hdr = self.host if default_port else f"{self.host}:{self.port}"
348
+ for attempt in range(retries + 1):
349
+ headers: dict[str, str] = {"Host": host_hdr}
350
+ if content_type:
351
+ headers["Content-Type"] = content_type
352
+ if method.upper() == "POST":
353
+ headers["Content-Length"] = str(len(body))
354
+ if require_auth:
355
+ if not self.api_key or not self.sign_secret:
356
+ raise ValueError("Missing API key or sign_secret; run `clawprobe auth login` or set CLAWNEXUS_API_KEY & CLAWNEXUS_SIGN_SECRET")
357
+ ts = str(int(time.time()))
358
+ nonce = secrets.token_hex(12)
359
+ sig = compute_hmac_signature_v2(
360
+ self.sign_secret,
361
+ method,
362
+ path_with_query,
363
+ ts,
364
+ nonce,
365
+ full_body_sha,
366
+ )
367
+ headers.update(
368
+ {
369
+ "X-API-Key": self.api_key,
370
+ "X-Claw-Sig-Version": "v2",
371
+ "X-Claw-Timestamp": ts,
372
+ "X-Claw-Nonce": nonce,
373
+ "X-Claw-Signature": sig,
374
+ "X-Claw-Body-SHA256": full_body_sha,
375
+ }
376
+ )
377
+
378
+ sock: socket.socket | ssl.SSLSocket | None = None
379
+ try:
380
+ raw_sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
381
+ if self.scheme == "https":
382
+ tls_sock = self._ssl_context().wrap_socket(raw_sock, server_hostname=self.host)
383
+ self._verify_peer_spki_pin(tls_sock)
384
+ sock = tls_sock
385
+ else:
386
+ sock = raw_sock
387
+ req_head = (
388
+ f"{method.upper()} {path_with_query} HTTP/1.1\r\n"
389
+ + "\r\n".join(f"{k}: {v}" for k, v in headers.items())
390
+ + "\r\nConnection: close\r\n\r\n"
391
+ ).encode("latin-1")
392
+ sock.sendall(req_head)
393
+ if body:
394
+ sock.sendall(body)
395
+ resp = http.client.HTTPResponse(sock)
396
+ resp.begin()
397
+ resp_bytes = resp.read()
398
+ resp_hdrs = {k.lower(): v for k, v in resp.getheaders()}
399
+ status = resp.status
400
+ sock.close()
401
+ if status in (502, 504) and attempt < retries:
402
+ time.sleep(0.4 * (attempt + 1))
403
+ continue
404
+ return status, resp_hdrs, resp_bytes
405
+ except ssl.SSLError:
406
+ if sock is not None:
407
+ try:
408
+ sock.close()
409
+ except Exception:
410
+ pass
411
+ raise
412
+ except (socket.timeout, ConnectionError, OSError) as exc:
413
+ last_err = exc
414
+ if sock is not None:
415
+ try:
416
+ sock.close()
417
+ except Exception:
418
+ pass
419
+ if attempt < retries:
420
+ time.sleep(0.4 * (attempt + 1))
421
+ continue
422
+ raise
423
+ raise RuntimeError(f"Request failed after retries: {last_err}")
424
+
425
+ def request_json(
426
+ self,
427
+ method: str,
428
+ path_with_query: str,
429
+ payload: dict[str, Any] | None = None,
430
+ require_auth: bool = True,
431
+ ) -> tuple[int, dict[str, Any]]:
432
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b""
433
+ ctype = "application/json; charset=utf-8" if payload is not None else ""
434
+ st, _, raw = self.request_raw(method, path_with_query, body=body, content_type=ctype, require_auth=require_auth)
435
+ try:
436
+ return st, json.loads(raw.decode("utf-8"))
437
+ except Exception:
438
+ return st, {"raw_text": raw.decode("utf-8", errors="replace")}
439
+
440
+ def upload_multipart(
441
+ self,
442
+ path_with_query: str,
443
+ file_path: Path,
444
+ fields: dict[str, str],
445
+ ) -> tuple[int, dict[str, Any]]:
446
+ file_bytes = file_path.read_bytes()
447
+ boundary = f"----ClawNexusCLIBoundary{secrets.token_hex(12)}"
448
+ parts: list[bytes] = []
449
+ for k, v in fields.items():
450
+ parts.append(f"--{boundary}\r\n".encode("utf-8"))
451
+ parts.append(f'Content-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode("utf-8"))
452
+ parts.append(f"--{boundary}\r\n".encode("utf-8"))
453
+ parts.append(
454
+ f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"\r\n'
455
+ f"Content-Type: application/octet-stream\r\n\r\n".encode("utf-8")
456
+ )
457
+ parts.append(file_bytes)
458
+ parts.append(f"\r\n--{boundary}--\r\n".encode("utf-8"))
459
+ multipart_body = b"".join(parts)
460
+
461
+ st, _, raw = self.request_raw(
462
+ "POST",
463
+ path_with_query,
464
+ body=multipart_body,
465
+ content_type=f"multipart/form-data; boundary={boundary}",
466
+ require_auth=True,
467
+ )
468
+ try:
469
+ return st, json.loads(raw.decode("utf-8"))
470
+ except Exception:
471
+ return st, {"raw_text": raw.decode("utf-8", errors="replace")}
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: clawprobe
3
+ Version: 1.3.0
4
+ Summary: ClawProbe (洞察·灵枢): Zero-Dependency Mobile APK 300s Live Dynamic Privacy Inspection & CI/CD Gate CLI
5
+ Author-email: ClawProbe Security Team <876387@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://www.atooli.com
8
+ Project-URL: Documentation, https://www.atooli.com/#docs-cli
9
+ Project-URL: Repository, https://github.com/876387/clawprobe
10
+ Project-URL: Issues, https://github.com/876387/clawprobe/issues
11
+ Keywords: android,apk,privacy,compliance,miit,devsecops,ci-cd,security-testing,network-analysis
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Information Technology
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Security
24
+ Classifier: Topic :: Software Development :: Quality Assurance
25
+ Classifier: Topic :: Software Development :: Testing
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Dynamic: license-file
30
+
31
+ # ClawProbe CLI (`clawprobe`)
32
+
33
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
34
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
35
+ [![Zero Dependencies](https://img.shields.io/badge/dependencies-0%20(stdlib%20only)-brightgreen.svg)](https://pypi.org/project/clawprobe/)
36
+
37
+ **ClawProbe CLI** 是 **ClawProbe 移动应用 300 秒真机动态技术体检与 CI/CD 隐私门禁平台**([https://www.atooli.com](https://www.atooli.com))的官方命令行客户端。
38
+
39
+ 基于 **100% Python 标准库(Zero Third-Party Dependencies)** 构建,内置 **HMAC-SHA256 v2 全报文防重放签名** 与 **证书链公钥指纹校验(SPKI Pinning / Embedded CA)**,开箱即可嵌入 GitHub Actions、GitLab CI、Jenkins 等企业级 DevSecOps 流水线,在每次发版前自动拦截 **「用户点击隐私弹窗同意前抢跑联网」** 等隐私合规隐患。
40
+
41
+ ---
42
+
43
+ ## 核心特性 (Key Features)
44
+
45
+ 1. **零第三方依赖 (Zero External Dependencies)**:仅依赖 Python 3.9+ 标准库(`ssl`、`http.client`、`hashlib`、`hmac`、`json`),无需编译 C 扩展,无第三方供应链依赖风险。
46
+ 2. **300 秒三阶段真机沙箱时序体检 (300s 3-Stage Live Sandbox Inspection)**:
47
+ - **Stage 1 (`pre_consent` · 0~45s)**:冷启动静默期(未点击隐私弹窗同意前)网络通信五元组、DNS/SNI 与敏感留痕检测;
48
+ - **Stage 2 (`post_consent` · 45~255s)**:点击同意隐私弹窗后 UI 自动化遍历与第三方 SDK 加密框架清单盘点;
49
+ - **Stage 3 (`background` · 255~300s)**:退后台静默期心跳、唤醒、静默上传与高损耗 UDP/PCDN 传输行为观测。
50
+ 3. **CI/CD 确定性退出码门禁 (Deterministic Exit Codes)**:通过 `--fail-on-preconsent` 自动阻断存在未同意前抢跑通信的构建任务(返回 `Exit Code 2`)。
51
+ 4. **队列拥塞智能指数退避 (Smart Queue Backoff)**:高并发场景下遇 `HTTP 429 (ERR_P0_HOT_SPARE_BUSY)` 自动在 `--queue-timeout` 窗口内指数退避重试,保障夜间流水线稳定收敛。
52
+ 5. **一键 3 遍覆写物理销毁 (`--reclaim`)**:下载完本地 PDF/JSON/ZIP 证据包后,支持在流水线末尾自动触发云端工件 3 遍覆写物理清零并返回 SHA-256 销毁回执。
53
+
54
+ ---
55
+
56
+ ## 快速安装 (Installation)
57
+
58
+ 推荐使用 `pipx` 或 `pip` 直接从官方 PyPI 安装:
59
+
60
+ ```bash
61
+ # 推荐:使用 pipx 隔离安装全局命令(含官方 PEP 503 高速镜像源,国内 20ms 直连)
62
+ pipx install --pip-args="--extra-index-url https://www.atooli.com/simple" clawprobe
63
+
64
+ # 或使用 pip 安装
65
+ pip install -U --extra-index-url https://www.atooli.com/simple clawprobe
66
+
67
+ # 也可直接从 GitHub Release 安装
68
+ pip install -U https://github.com/876387/clawprobe/releases/download/v1.3.0/clawprobe-1.3.0-py3-none-any.whl
69
+ ```
70
+
71
+ 安装完成后,可执行一键环境与链路自检(无需 API Key):
72
+
73
+ ```bash
74
+ clawprobe doctor
75
+ ```
76
+
77
+ ---
78
+
79
+ ## 快速上手 (Quick Start)
80
+
81
+ ### 1. 配置访问凭据
82
+
83
+ 前往 [ClawProbe 官网工作台 (https://www.atooli.com)](https://www.atooli.com) 申请试用或生产凭据后,配置环境变量或执行 `auth login`:
84
+
85
+ ```bash
86
+ # 方式 A:环境变量(适合 CI/CD 流水线)
87
+ export CLAW_API_KEY="cnx_live_your_key_id"
88
+ export CLAW_API_SECRET="sec_your_hmac_secret"
89
+
90
+ # 方式 B:本地持久化登录(权限自动锁定为 0600)
91
+ clawprobe auth login --api-key cnx_live_your_key_id --secret sec_your_hmac_secret
92
+
93
+ # 验证鉴权与当前剩余额度
94
+ clawprobe auth verify
95
+ ```
96
+
97
+ ### 2. 提交 APK 并启用发版前隐私门禁
98
+
99
+ ```bash
100
+ clawprobe analyze ./app-release.apk \
101
+ --attest-authorized \
102
+ --mode privacy \
103
+ --fail-on-preconsent \
104
+ --queue-timeout 900 \
105
+ --out ./clawprobe-report.pdf \
106
+ --json-out ./clawprobe-report.json \
107
+ --reclaim
108
+ ```
109
+
110
+ - 若检测完成且 **Stage 1(未同意隐私政策前)** 未发现抢跑外联,命令返回 `Exit Code 0`,并将结构化 JSON 与中英双语 PDF 技术体检报告写入本地。
111
+ - 若发现未同意前抢跑外联,命令在终端打印 `[FL-xxxx]` 证据锚点摘要并返回 `Exit Code 2`,直接阻断 CI 发版流水线。
112
+
113
+ ### 3. 查询历史任务与手动物理销毁云端工件
114
+
115
+ ```bash
116
+ # 查询任务并下载完整 ZIP 证据包(含 PDF + JSON + PCAP + SHA256 清单)
117
+ clawprobe job <job_id> \
118
+ --out ./report.pdf \
119
+ --json-out ./report.json \
120
+ --bundle ./evidence-bundle.zip
121
+
122
+ # 立即触发云端任务工件 3 遍覆写物理销毁
123
+ clawprobe reclaim <job_id>
124
+ ```
125
+
126
+ ---
127
+
128
+ ## CI/CD 退出码规范 (Exit Codes)
129
+
130
+ | Exit Code | 含义 (Meaning) | 触发场景 (Scenario) |
131
+ | :--- | :--- | :--- |
132
+ | `0` | **PASS (合规通过)** | 任务完成,且未触发 `--fail-on-preconsent` 门禁条件 |
133
+ | `1` | **USAGE_OR_AUTH_ERROR (参数/鉴权异常)** | 参数缺失、未勾选 `--attest-authorized`、API Key 或 HMAC 签名错误 |
134
+ | `2` | **PRIVACY_GATE_TRIGGERED (门禁拦截)** | 启用 `--fail-on-preconsent` 时,检测到同意隐私政策前发生抢跑联网行为 |
135
+ | `3` | **QUOTA_EXCEEDED (配额/包名超限)** | 检测额度耗尽 (`ERR_QUOTA_EXCEEDED`) 或超出账号绑定的包名上限 |
136
+ | `4` | **NETWORK_OR_QUEUE_TIMEOUT (网络/排队超时)** | 网络不可达、SPKI 证书指纹校验失败或超过 `--queue-timeout` 等待窗口 |
137
+
138
+ ---
139
+
140
+ ## CI/CD 流水线集成模板 (CI/CD Templates)
141
+
142
+ 仓库内置开箱即用的流水线模板,可直接复制到您的工程中:
143
+
144
+ - **GitHub Actions**:[`ci-templates/github/privacy-gate.yml`](./ci-templates/github/privacy-gate.yml)
145
+ - **GitLab CI**:[`ci-templates/gitlab/.gitlab-ci.yml`](./ci-templates/gitlab/.gitlab-ci.yml)
146
+ - **结构化报告样卷**:[`samples/sample_report.json`](./samples/sample_report.json)
147
+
148
+ ---
149
+
150
+ ## 合规与安全声明 (Compliance & Security Notice)
151
+
152
+ 1. **合法授权使用**:用户仅可上传并检测其拥有合法著作权或已获明确书面授权的自有 Android 应用包(APK),调用时须显式传入 `--attest-authorized`。
153
+ 2. **客观技术留痕**:本工具及云端沙箱输出的检测报告仅客观记录特定运行环境与特定时间窗口内的网络通信五元组、TLS 握手特征及第三方 SDK 特征留痕(`[FL-xxxx]` 锚点),供研发与安全团队开展内部合规自查,不构成任何法律意见或行政/司法鉴定结论。
154
+ 3. **开源许可**:本命令行客户端(`clawprobe`)采用 [Apache-2.0 License](./LICENSE) 开源发布。
155
+
156
+ ---
157
+
158
+ - **官方网站与在线免登录快检**:[https://www.atooli.com](https://www.atooli.com)
159
+ - **问题反馈 (Issues)**:[https://github.com/876387/clawprobe/issues](https://github.com/876387/clawprobe/issues)
@@ -0,0 +1,10 @@
1
+ clawprobe/__init__.py,sha256=Bt7vQ2s5JWuEGBmy2GLtZ0D6MPuqPsO4L-gMFsP21iM,125
2
+ clawprobe/__main__.py,sha256=-TDZ0Gsk0zLD_gFj_IxKi55aV3jFuqqScQBW0V7U6uY,189
3
+ clawprobe/cli.py,sha256=kwmA78WvLMIUFeaOiQ5rIIvrVxqGUw2_sLxaZM4KdJE,17498
4
+ clawprobe/client.py,sha256=IjQGz5G66F_js_fc6G170l7ICgymtOLdFFWvrrGMfhY,19492
5
+ clawprobe-1.3.0.dist-info/licenses/LICENSE,sha256=fCkLdQSHhTRBGfIwOBmxR87jvEI7O_oBbx4ruYccTqw,5517
6
+ clawprobe-1.3.0.dist-info/METADATA,sha256=8R1TFuI_jGQxheIPlYvUaYJ5-Lvka091OgkHdPkUMxQ,8126
7
+ clawprobe-1.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ clawprobe-1.3.0.dist-info/entry_points.txt,sha256=vjwIzVhCWa4YrhJ3TERZGObqbTyKUJ_pl68rnwGtCQw,49
9
+ clawprobe-1.3.0.dist-info/top_level.txt,sha256=3VnC7IqdMHy9PaM4BwBdX54TplNE50tHkBHr0l-B6ag,10
10
+ clawprobe-1.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clawprobe = clawprobe.cli:main
@@ -0,0 +1,108 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity.
18
+
19
+ "You" (or "Your") shall mean an individual or Legal Entity
20
+ exercising permissions granted by this License.
21
+
22
+ "Source" form shall mean the preferred form for making modifications,
23
+ including but not limited to software source code, documentation
24
+ source, and configuration files.
25
+
26
+ "Object" form shall mean any form resulting from mechanical
27
+ transformation or translation of a Source form, including but
28
+ not limited to compiled object code, generated documentation,
29
+ and conversions to other media types.
30
+
31
+ "Work" shall mean the work of authorship, whether in Source or
32
+ Object form, made available under the License, as indicated by a
33
+ copyright notice that is included in or attached to the work.
34
+
35
+ "Derivative Works" shall mean any work, whether in Source or Object
36
+ form, that is based on (or derived from) the Work and for which the
37
+ editorial revisions, annotations, elaborations, or other modifications
38
+ represent, as a whole, an original work of authorship.
39
+
40
+ "Contribution" shall mean any work of authorship, including
41
+ the original version of the Work and any modifications or additions
42
+ to that Work or Derivative Works thereof, that is intentionally
43
+ submitted to Licensor for inclusion in the Work by the copyright owner
44
+ or by an individual or Legal Entity authorized to submit on behalf of
45
+ the copyright owner.
46
+
47
+ 2. Grant of Copyright License. Subject to the terms and conditions of
48
+ this License, each Contributor hereby grants to You a perpetual,
49
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
50
+ copyright license to reproduce, prepare Derivative Works of,
51
+ publicly display, publicly perform, sublicense, and distribute the
52
+ Work and such Derivative Works in Source or Object form.
53
+
54
+ 3. Grant of Patent License. Subject to the terms and conditions of
55
+ this License, each Contributor hereby grants to You a perpetual,
56
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
57
+ (except as stated in this section) patent license to make, have made,
58
+ use, offer to sell, sell, import, and otherwise transfer the Work.
59
+
60
+ 4. Redistribution. You may reproduce and distribute copies of the
61
+ Work or Derivative Works thereof in any medium, with or without
62
+ modifications, and in Source or Object form, provided that You
63
+ meet the following conditions:
64
+
65
+ (a) You must give any other recipients of the Work or
66
+ Derivative Works a copy of this License; and
67
+
68
+ (b) You must cause any modified files to carry prominent notices
69
+ stating that You changed the files; and
70
+
71
+ (c) You must retain, in the Source form of any Derivative Works
72
+ that You distribute, all copyright, patent, trademark, and
73
+ attribution notices from the Source form of the Work; and
74
+
75
+ (d) If the Work includes a "NOTICE" text file as part of its
76
+ distribution, then any Derivative Works that You distribute must
77
+ include a readable copy of the attribution notices contained
78
+ within such NOTICE file.
79
+
80
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
81
+ any Contribution intentionally submitted for inclusion in the Work
82
+ by You to the Licensor shall be under the terms and conditions of
83
+ this License, without any additional terms or conditions.
84
+
85
+ 6. Trademarks. This License does not grant permission to use the trade
86
+ names, trademarks, service marks, or product names of the Licensor,
87
+ except as required for reasonable and customary use in describing the
88
+ origin of the Work and reproducing the content of the NOTICE file.
89
+
90
+ 7. Disclaimer of Warranty. Unless required by applicable law or
91
+ agreed to in writing, Licensor provides the Work (and each
92
+ Contributor provides its Contributions) on an "AS IS" BASIS,
93
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
94
+ implied, including, without limitation, any warranties or conditions
95
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
96
+ PARTICULAR PURPOSE.
97
+
98
+ 8. Limitation of Liability. In no event and under no legal theory,
99
+ whether in tort (including negligence), contract, or otherwise,
100
+ unless required by applicable law (such as deliberate and grossly
101
+ negligent acts) or agreed to in writing, shall any Contributor be
102
+ liable to You for damages, including any direct, indirect, special,
103
+ incidental, or consequential damages of any character arising as a
104
+ result of this License or out of the use or inability to use the
105
+ Work.
106
+
107
+ Copyright 2026 ClawProbe (https://www.atooli.com)
108
+ Licensed under the Apache License, Version 2.0 (the "License").
@@ -0,0 +1 @@
1
+ clawprobe