p1 secret bootstrap: privacy-gated, placeholder-first ordering
Browse files- probes/p1_secret_bootstrap.py +145 -0
probes/p1_secret_bootstrap.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 1 bootstrap worker: create a PRIVATE Kaggle dataset to hold the HF token, safely.
|
| 2 |
+
#
|
| 3 |
+
# Why this exists (closes D-002). §2 permits the token to come "from the environment or a secret
|
| 4 |
+
# store" and forbids it reaching a public artifact. p1_credential_path proved the Kaggle CLI is
|
| 5 |
+
# preinstalled and already authenticated inside an internet-enabled session, so a job can create its
|
| 6 |
+
# own private dataset and later jobs can mount it from /kaggle/input instead of carrying the token in
|
| 7 |
+
# their source.
|
| 8 |
+
#
|
| 9 |
+
# THE ORDERING HERE IS A SECURITY CONTROL, NOT STYLE. `kaggle datasets create` decides visibility
|
| 10 |
+
# from a metadata field whose name has varied between CLI versions (isPrivate vs defineIsPrivate), and
|
| 11 |
+
# an unrecognised key is silently ignored -- which would create a PUBLIC dataset. A public dataset
|
| 12 |
+
# containing the token would be an unrecoverable leak, so:
|
| 13 |
+
#
|
| 14 |
+
# 1. create with an inert placeholder file only,
|
| 15 |
+
# 2. PROVE it is private by failing to download that file anonymously,
|
| 16 |
+
# 3. only then push the real credential as a new version,
|
| 17 |
+
# 4. re-prove, and delete nothing that was not created here.
|
| 18 |
+
#
|
| 19 |
+
# If step 2 ever shows anonymous read succeeding, the secret is never uploaded and the job says so.
|
| 20 |
+
# No token value is ever printed; kernel stdout is retained. Zero GPU quota (CPU session).
|
| 21 |
+
|
| 22 |
+
import hashlib
|
| 23 |
+
import json
|
| 24 |
+
import os
|
| 25 |
+
import subprocess
|
| 26 |
+
import time
|
| 27 |
+
import urllib.error
|
| 28 |
+
import urllib.request
|
| 29 |
+
|
| 30 |
+
R = {}
|
| 31 |
+
DS_SLUG = "ounce100m-secret-store"
|
| 32 |
+
DS_ID = f"dodosoomro/{DS_SLUG}"
|
| 33 |
+
TOKEN_ENV = "OUNCE100M_HF_TOKEN"
|
| 34 |
+
DS_DIR = "/kaggle/working/ds"
|
| 35 |
+
PLACEHOLDER = "placeholder.txt"
|
| 36 |
+
SECRET_FILE = "credentials.json"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def scrub(s):
|
| 40 |
+
import re
|
| 41 |
+
|
| 42 |
+
s = s if isinstance(s, str) else str(s)
|
| 43 |
+
s = re.sub(r"hf_[A-Za-z0-9]{16,}", "<REDACTED_TOKEN>", s)
|
| 44 |
+
return re.sub(r"[A-Za-z0-9_\-]{36,}", "<REDACTED_LONG>", s)[:1200]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def run(cmd, timeout=300):
|
| 48 |
+
p = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
| 49 |
+
return {"rc": p.returncode, "out": scrub(p.stdout), "err": scrub(p.stderr)}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def anon_can_read(path):
|
| 53 |
+
"""True only if an unauthenticated request can fetch the file. That means PUBLIC -- bad."""
|
| 54 |
+
url = f"https://www.kaggle.com/api/v1/datasets/download/{DS_ID}"
|
| 55 |
+
try:
|
| 56 |
+
with urllib.request.urlopen(url, timeout=60) as r:
|
| 57 |
+
return {"readable": True, "status": r.status, "bytes": len(r.read(2048))}
|
| 58 |
+
except urllib.error.HTTPError as e:
|
| 59 |
+
return {"readable": False, "status": e.code, "reason": str(e.reason)[:80]}
|
| 60 |
+
except Exception as e:
|
| 61 |
+
return {"readable": None, "error": f"{type(e).__name__}: {e}"[:160]}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def write_metadata(private=True):
|
| 65 |
+
"""Both spellings of the visibility key, because the CLI version in the image is 2.0.2 and the
|
| 66 |
+
field name has moved across releases; an unrecognised key is ignored without error."""
|
| 67 |
+
md = {
|
| 68 |
+
"title": "ounce100m runtime assets",
|
| 69 |
+
"id": DS_ID,
|
| 70 |
+
"resources": [{"path": PLACEHOLDER, "description": "bootstrap placeholder",
|
| 71 |
+
"resourceName": "placeholder", "resourceType": "dataset"}],
|
| 72 |
+
}
|
| 73 |
+
if private:
|
| 74 |
+
md["isPrivate"] = True
|
| 75 |
+
md["defineIsPrivate"] = True
|
| 76 |
+
with open(os.path.join(DS_DIR, "dataset-metadata.json"), "w") as f:
|
| 77 |
+
json.dump(md, f)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def write_files(with_secret):
|
| 81 |
+
os.makedirs(DS_DIR, exist_ok=True)
|
| 82 |
+
with open(os.path.join(DS_DIR, PLACEHOLDER), "w") as f:
|
| 83 |
+
f.write("ounce100m bootstrap placeholder\n")
|
| 84 |
+
if with_secret:
|
| 85 |
+
tok = os.environ[TOKEN_ENV]
|
| 86 |
+
with open(os.path.join(DS_DIR, SECRET_FILE), "w") as f:
|
| 87 |
+
json.dump({"HF_TOKEN": tok}, f)
|
| 88 |
+
os.chmod(os.path.join(DS_DIR, SECRET_FILE), 0o600)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
tok = os.environ.get(TOKEN_ENV, "")
|
| 92 |
+
R["pre"] = {"token_present": bool(tok), "token_length": len(tok),
|
| 93 |
+
"token_sha256_prefix": hashlib.sha256(tok.encode()).hexdigest()[:12] if tok else None}
|
| 94 |
+
|
| 95 |
+
if not tok:
|
| 96 |
+
R["fatal"] = f"{TOKEN_ENV} unset; nothing created"
|
| 97 |
+
else:
|
| 98 |
+
write_files(with_secret=False)
|
| 99 |
+
write_metadata(private=True)
|
| 100 |
+
|
| 101 |
+
existing = run(f"kaggle datasets status {DS_ID} 2>&1", 120)
|
| 102 |
+
R["pre_existing_status"] = existing
|
| 103 |
+
already = existing["rc"] == 0
|
| 104 |
+
|
| 105 |
+
if not already:
|
| 106 |
+
t = time.monotonic()
|
| 107 |
+
R["create"] = run(f"kaggle datasets create -p {DS_DIR} -q 2>&1", timeout=600)
|
| 108 |
+
R["create"]["seconds"] = round(time.monotonic() - t, 1)
|
| 109 |
+
time.sleep(12) # visibility is applied asynchronously; an immediate probe can lie
|
| 110 |
+
|
| 111 |
+
# ---- the gate: prove privacy BEFORE any secret exists on the dataset
|
| 112 |
+
privacy = anon_can_read(PLACEHOLDER)
|
| 113 |
+
R["privacy_check_placeholder_only"] = privacy
|
| 114 |
+
R["private_confirmed"] = privacy.get("readable") is False
|
| 115 |
+
|
| 116 |
+
if R["private_confirmed"]:
|
| 117 |
+
write_files(with_secret=True)
|
| 118 |
+
t = time.monotonic()
|
| 119 |
+
R["version_with_secret"] = run(
|
| 120 |
+
f'kaggle datasets version -p {DS_DIR} -m "provision runtime credential" -q 2>&1',
|
| 121 |
+
timeout=600)
|
| 122 |
+
R["version_with_secret"]["seconds"] = round(time.monotonic() - t, 1)
|
| 123 |
+
time.sleep(12)
|
| 124 |
+
after = anon_can_read(SECRET_FILE)
|
| 125 |
+
R["privacy_check_after_secret"] = after
|
| 126 |
+
R["secret_still_private"] = after.get("readable") is False
|
| 127 |
+
if not R["secret_still_private"]:
|
| 128 |
+
R["EMERGENCY"] = ("Anonymous read succeeded AFTER the credential was uploaded -- the "
|
| 129 |
+
"dataset may be public. Delete dodosoomro/ounce100m-secret-store from "
|
| 130 |
+
"the Kaggle web UI immediately and rotate the HF token.")
|
| 131 |
+
else:
|
| 132 |
+
R["refused_to_upload"] = ("Dataset was anonymously readable while holding only a placeholder; "
|
| 133 |
+
"the secret was NOT uploaded. Fix visibility handling before retry.")
|
| 134 |
+
|
| 135 |
+
R["files"] = run(f"kaggle datasets files {DS_ID} 2>&1", 120)
|
| 136 |
+
if R.get("secret_still_private"):
|
| 137 |
+
R["mount_hint_for_future_jobs"] = {
|
| 138 |
+
"datasetDataSources": [DS_ID],
|
| 139 |
+
"read_from": f"/kaggle/input/{DS_SLUG}/{SECRET_FILE}",
|
| 140 |
+
"then": "json.load(...)['HF_TOKEN'] -> os.environ['HF_TOKEN']; never print the value",
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
print("PROBE_JSON_BEGIN")
|
| 144 |
+
print(json.dumps(R, indent=1, default=str))
|
| 145 |
+
print("PROBE_JSON_END")
|