feat(diagnostic): Sicherheit im Modell-Katalog — Anbieter, HF-Scan, Skripte im Repo

Die Box laedt nur die GGUF-Datei (llama.cpp -hf / paralleler Download per
sha256); Skripte, Pickle u.ae. im selben Repo kommen nie auf die Box. Liegen
sie dort, ist das trotzdem ein Warnsignal fuer den Anbieter.

- hfRepoSafety: HF-Dateiliste mit securityFileStatus (ProtectAI, AV,
  Pickle-Import, VirusTotal) → Befunde (unsafe/suspicious/caution …),
  Anzahl geprueft-sicher, riskante Dateien (.py/.sh/.exe/.pkl/.pt …),
  bekannter Anbieter (Hersteller + bekannte GGUF-Umwandler).
- Katalog: "⚠ Befund" / "⚠ Skripte" / "✓" je Eintrag; beim Hinzufuegen
  sofort, Bestand beim naechsten Aktualisieren.
- ℹ: Abschnitt "Sicherheit".
- Laden/Nur downloaden: bei Befunden erst Rueckfrage.

Getestet gegen HuggingFace: unsloth/mradermacher bekannt und ohne Befund;
Virtue-AI-HUB/VulnLLM-R-7B (Original) mit Befund "caution" an
training_args.bin (Pickle mit Imports) — das GGUF von mradermacher ist sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 21:15:48 +01:00
co-authored by Claude Opus 5.5
parent 9529bb0b16
commit 237a216945
2 changed files with 58 additions and 4 deletions
+22 -1
View File
@@ -9830,6 +9830,9 @@
const searchText = [m.id, m.description, m.hfRepo, m.quant].filter(Boolean).join(' ');
return '<tr data-search="' + escapeHtml(searchText) + '" data-tools="' + (m.tools === false ? 'nein' : 'ja') + '" style="border-bottom:1px solid #1E1E2E;">' +
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b>'
+ (m.safety && m.safety.flagged && m.safety.flagged.length ? ' <span title="HuggingFace meldet Befunde bei Dateien in diesem Repo — siehe ℹ" style="font-size:10px;color:#FF453A;border:1px solid #FF453A;border-radius:8px;padding:0 5px;">⚠ Befund</span>'
: m.safety && m.safety.risky && m.safety.risky.length ? ' <span title="Im Repo liegen Skripte/ausführbare Dateien neben dem Modell — wir laden sie nicht, aber Vorsicht beim Anbieter (siehe ℹ)" style="font-size:10px;color:#FF9F0A;border:1px solid #FF9F0A;border-radius:8px;padding:0 5px;">⚠ Skripte</span>' : '')
+ (m.safety && m.safety.known ? ' <span title="Bekannter Anbieter (Hersteller oder bekannter GGUF-Umwandler)" style="font-size:10px;color:#8888AA;">✓</span>' : '')
+ (m.tools ? ' <span title="Kann Werkzeuge aufrufen (laut Chat-Vorlage) — z.B. Websuche, wenn ARIA es lokal nutzt" style="font-size:10px;color:#34C759;border:1px solid #34C759;border-radius:8px;padding:0 5px;">🔧</span>' : '')
+ (m.uncensored ? ' <span title="Unzensierte Fassung — lehnt kaum etwas ab" style="font-size:10px;color:#FFB020;border:1px solid #FFB020;border-radius:8px;padding:0 5px;">unzensiert</span>' : '')
+ (m.added_at && (Date.now() - new Date(m.added_at).getTime()) < 7 * 864e5 ? ' <span title="In den letzten 7 Tagen in den Katalog gekommen" style="font-size:10px;color:#4DB8FF;border:1px solid #4DB8FF;border-radius:8px;padding:0 5px;">neu</span>' : '')
@@ -10279,6 +10282,7 @@
<div id="model-info-summary" style="margin-bottom:10px;color:#8888AA;">Kurzbeschreibung wird vom lokalen Modell aus der Modellkarte erstellt …</div>
${fam}
<table style="font-size:12px;margin-top:8px;">
${row('Sicherheit', _modelSafetyHtml(j.safety))}
${row('Werkzeuge', j.tools === true ? '🔧 ja — die Chat-Vorlage kennt Werkzeug-Aufrufe' : j.tools === false ? 'nein — eher für Übersetzen, Zusammenfassen, Erklären' : 'unbekannt (keine Chat-Vorlage)')}
${row('Größe', m.sizeGB ? '~' + m.sizeGB + ' GB · ' + escapeHtml(m.quant || '') : '')}
${row('Grundmodell', j.base ? escapeHtml(j.base) : '')}
@@ -10290,6 +10294,15 @@
</table>
${j.intro ? `<details style="margin-top:10px;"><summary style="cursor:pointer;color:#8888AA;">Aus der Modellkarte (${escapeHtml(j.introFrom)}, englisch)</summary><div style="white-space:pre-wrap;color:#C8C8DA;font-size:12px;margin-top:6px;">${escapeHtml(j.intro)}</div></details>` : ''}`);
}
function _modelSafetyHtml(sf) {
if (!sf) return 'unbekannt';
const parts = [sf.known ? '✓ bekannter Anbieter' : '<span style="color:#FF9F0A;">unbekannter Anbieter — eher vorsichtig</span>'];
parts.push(sf.flagged.length
? `<span style="color:#FF453A;">⚠ HuggingFace meldet Befunde: ${sf.flagged.map(f => escapeHtml(f.path) + ' (' + escapeHtml(f.status) + ')').join(', ')}</span>`
: `HuggingFace-Scan: ${sf.scanned} Datei(en) geprüft, keine Befunde`);
if (sf.risky.length) parts.push(`<span style="color:#FF9F0A;">Im Repo liegen Skripte/ausführbare Dateien: ${sf.risky.map(escapeHtml).join(', ')}.</span> <span style="color:#8888AA;">Die Box lädt nur die GGUF-Datei — diese Dateien kommen nie auf die Box. Aber: Warnsignal für den Anbieter.</span>`);
return parts.join('<br>');
}
// Deutsche Kurzbeschreibung: einmal vom lokalen Modell erzeugt, danach aus dem Katalog
async function _loadModelSummary(repo) {
let j;
@@ -10551,9 +10564,10 @@
}
// Nur auf die Platte der Box (der aus der Ziel-Karten-Auswahl) — in keine Karte
function downloadModelOnly(i) {
async function downloadModelOnly(i) {
const m = _llmCatalog[i];
if (!m) return;
if (!await _safetyGate(m)) return;
const sel = document.getElementById('llm-cat-box-' + i);
const target = String((sel && sel.value) || '').split('|')[0];
if (!target) return;
@@ -10568,9 +10582,16 @@
openLlmDownloads();
}
async function _safetyGate(m) {
const sf = m && m.safety;
if (!sf || !sf.flagged || !sf.flagged.length) return true;
return await uiConfirm(`Vorsicht bei ${m.id}\n\nHuggingFace meldet bei Dateien in diesem Repo Befunde (${sf.flagged.map(f => f.path).join(', ')}). `
+ 'Die Box lädt zwar nur die GGUF-Datei, aber wer Verdächtiges hochlädt, dem ist auch das Modell nicht zu trauen. Trotzdem laden?');
}
async function provisionModel(i) {
const m = _llmCatalog[i];
if (!m) return;
if (!await _safetyGate(m)) return;
const sel = document.getElementById('llm-cat-box-' + i);
const [target, gpu] = String((sel && sel.value) || '').split('|');
if (!target) return;
+36 -3
View File
@@ -573,10 +573,13 @@ async function refreshLlmCatalogFromHF() {
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
}));
}
// Werkzeug-Faehigkeit nachtragen (alle Eintraege, die noch keine Angabe haben)
const noTools = [...byId.values()].filter(e => e.hfRepo && e.tools === undefined);
// Werkzeug-Faehigkeit + Sicherheits-Scan nachtragen (wo noch keine Angabe)
const noTools = [...byId.values()].filter(e => e.hfRepo && (e.tools === undefined || e.safety === undefined));
for (let i = 0; i < noTools.length; i += 6) {
await Promise.all(noTools.slice(i, i + 6).map(async e => { e.tools = await hfToolSupport(e.hfRepo); }));
await Promise.all(noTools.slice(i, i + 6).map(async e => {
if (e.tools === undefined) e.tools = await hfToolSupport(e.hfRepo);
if (e.safety === undefined) e.safety = await hfRepoSafety(e.hfRepo);
}));
}
let added = 0;
for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
@@ -827,6 +830,7 @@ async function llmModelInfo(repo) {
tags: tags.slice(0, 20), families,
uncensored: !!unc,
tools: await hfToolSupport(repo),
safety: await hfRepoSafety(repo),
uncensoredWhy: unc ? `erkannt an „${unc[0]}“ in Name/Schlagworten` : "",
intro: await hfReadmeIntro(base || repo) || await hfReadmeIntro(repo),
introFrom: base || repo,
@@ -847,6 +851,34 @@ async function hfToolSupport(repo) {
return tpl ? false : null; // keine Vorlage = unbekannt
}
// ── Sicherheit eines Repos ─────────────────────────────────────────
// Die Box laedt NUR die GGUF-Datei (llama.cpp -hf / paralleler Download per
// sha256) — Skripte, Pickle o.ae. daneben werden nie geholt oder ausgefuehrt.
// Liegen sie trotzdem im Repo, ist das ein Warnsignal fuer den Anbieter.
// HuggingFace scannt jede Datei (securityFileStatus) — das Ergebnis zeigen wir.
const EXTRA_KNOWN_GGUF_AUTHORS = new Set(["mradermacher", "thebloke", "maziyarpanahi", "nousresearch",
"dphn", "cognitivecomputations", "fdtn-ai", "quantfactory", "lmstudio-ai"]);
function knownGgufAuthor(a) { // Funktion: TRUSTED_GGUF_AUTHORS steht weiter unten in der Datei
return TRUSTED_GGUF_AUTHORS.has(a) || EXTRA_KNOWN_GGUF_AUTHORS.has(a);
}
const RISKY_FILE_RE = /\.(py|sh|bash|bat|cmd|ps1|exe|dll|so|dylib|pkl|pickle|pt|pth|ckpt|joblib|npy|zip|tar|gz|7z|jar|js|vbs)$|(^|\/)pytorch_model[^/]*\.bin$/i;
async function hfRepoSafety(repo) {
const author = String(repo).split("/")[0].toLowerCase();
const out = { known: knownGgufAuthor(author), flagged: [], risky: [], scanned: 0 };
const tree = await hfJson(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1&expand=true`);
for (const f of Array.isArray(tree) ? tree : []) {
if (f.type !== "file") continue;
const st = f.securityFileStatus || {};
const subs = Object.values(st).filter(v => v && typeof v === "object").map(v => String(v.status || ""));
const all = [String(st.status || ""), ...subs].map(x => x.toLowerCase());
if (all.some(x => /unsafe|suspicious|malicious|caution|infected/.test(x))) out.flagged.push({ path: f.path, status: all.filter(Boolean).join(", ") });
else if (all.includes("safe")) out.scanned++;
if (RISKY_FILE_RE.test(f.path)) out.risky.push(f.path);
}
out.risky = out.risky.slice(0, 20);
return out;
}
async function addHfRepoToCatalog(repo, description = "") {
repo = hfRepoFromInput(repo);
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo — Link oder org/name");
@@ -864,6 +896,7 @@ async function addHfRepoToCatalog(repo, description = "") {
cat.push({ id, hfRepo: repo, quant: q.quant, ctx: 8192, sizeGB: q.sizeGB, quants: q.quants,
description: description || "Aus den HuggingFace-Trends übernommen", source: "hf",
tools: await hfToolSupport(repo), uncensored: UNCENSORED_RE.test(repo),
safety: await hfRepoSafety(repo),
added_at: new Date().toISOString().slice(0, 10) });
saveLlmCatalog(cat);
return { models: cat, id, existed: false };