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 +3 -0
- clawprobe/__main__.py +8 -0
- clawprobe/cli.py +383 -0
- clawprobe/client.py +471 -0
- clawprobe-1.3.0.dist-info/METADATA +159 -0
- clawprobe-1.3.0.dist-info/RECORD +10 -0
- clawprobe-1.3.0.dist-info/WHEEL +5 -0
- clawprobe-1.3.0.dist-info/entry_points.txt +2 -0
- clawprobe-1.3.0.dist-info/licenses/LICENSE +108 -0
- clawprobe-1.3.0.dist-info/top_level.txt +1 -0
clawprobe/__init__.py
ADDED
clawprobe/__main__.py
ADDED
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
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
34
|
+
[](https://www.python.org/downloads/)
|
|
35
|
+
[-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,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
|