dante-watch 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -23
- package/package.json +1 -1
- package/src/bib/answers.mjs +2 -2
- package/src/bib/bib.json +1 -1
- package/src/bib/ear.mjs +19 -18
- package/src/bib/net.json +1 -1
- package/src/bib/net.mjs +7 -9
- package/src/bib/probe.mjs +7 -8
- package/src/bib/pruef.json +1 -0
- package/src/bib/router.mjs +16 -19
- package/src/bib/system.mjs +14 -15
- package/src/bib/wash.mjs +5 -13
- package/src/checks/battery.mjs +15 -10
- package/src/checks/common.mjs +5 -5
- package/src/checks/demo.mjs +2 -2
- package/src/checks/formats.mjs +221 -0
- package/src/checks/hf.mjs +252 -0
- package/src/checks/loc.mjs +2 -4
- package/src/checks/php2shell.mjs +8 -9
- package/src/checks/rechte.mjs +2 -3
- package/src/checks/secrets.mjs +2 -4
- package/src/checks/sshd.mjs +3 -5
- package/src/checks/webshell.mjs +3 -5
- package/src/cli.mjs +15 -10
- package/src/core/check.mjs +4 -8
- package/src/core/consent.mjs +7 -7
- package/src/core/render.mjs +2 -2
- package/src/core/sh.mjs +12 -7
- package/src/i18n-checks.mjs +2 -3
- package/src/i18n.mjs +54 -14
- package/src/mascot.mjs +3 -5
- package/src/me/deep.mjs +37 -0
- package/src/me/diary.mjs +51 -0
- package/src/me/home.mjs +10 -0
- package/src/me/lock.mjs +41 -0
- package/src/me/nudge.mjs +52 -0
- package/src/me/onboard.mjs +102 -0
- package/src/me/overview.mjs +10 -0
- package/src/me/patrol.mjs +43 -0
- package/src/me/pick.mjs +64 -0
- package/src/me/pool.mjs +15 -0
- package/src/me/recon.mjs +94 -0
- package/src/me/refresh.mjs +70 -0
- package/src/me/schedule-deep.mjs +86 -0
- package/src/me/schedule.mjs +120 -0
- package/src/me/texts.mjs +291 -0
- package/src/me/user.mjs +67 -0
- package/src/pack/builtin.mjs +67 -4
- package/src/pack/ere.mjs +87 -0
- package/src/pack/install.mjs +6 -8
- package/src/pack/key.mjs +2 -3
- package/src/pack/know.mjs +4 -5
- package/src/pack/pack.mjs +14 -10
- package/src/pack/rules.mjs +109 -1
- package/src/pack/texts.mjs +8 -7
- package/src/session.mjs +199 -37
- package/src/ticker.mjs +10 -9
- package/src/ui/footer.mjs +3 -5
- package/src/ui/job-worker.mjs +3 -3
- package/src/ui/job.mjs +7 -6
- package/src/ui/quips.mjs +1 -2
- package/src/ui/wait.mjs +3 -4
- package/src/version.mjs +11 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Checks for AI models and agent configs on the local machine: model_card (origin, license),
|
|
2
|
+
// model_files (formats that execute code on load, extra Python files) and agent_config (MCP servers and
|
|
3
|
+
// their reach). Rules come from the Hugging Face pack. Only names, sizes, card fields and JSON keys are
|
|
4
|
+
// read; weights are never opened and secret values are never printed.
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { readdirSync, readFileSync, statSync, existsSync, realpathSync } from "node:fs";
|
|
7
|
+
import { join, basename, extname } from "node:path";
|
|
8
|
+
import { targetLabel } from "../core/sh.mjs";
|
|
9
|
+
import { result, evidence, SAFE, WARN, RISK, UNKNOWN } from "../core/check.mjs";
|
|
10
|
+
import { toJs } from "../pack/ere.mjs";
|
|
11
|
+
import { lang } from "../i18n.mjs";
|
|
12
|
+
import { read as readUser } from "../me/user.mjs";
|
|
13
|
+
import { loc } from "./loc.mjs";
|
|
14
|
+
|
|
15
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "__pycache__", ".venv", "venv", "Pods", "target"]);
|
|
16
|
+
const BUDGET_MS = 60000, MAX_EVIDENCE = 14;
|
|
17
|
+
const expand = (p) => String(p || "").replace(/^~(?=\/)/, homedir());
|
|
18
|
+
const name3 = (o, fb) => (o && o.i18n && o.i18n[lang()]) || fb;
|
|
19
|
+
// Many checks can describe the same finding; a person needs the worst few, once each.
|
|
20
|
+
const RANK = { risk: 0, warn: 1, info: 2 };
|
|
21
|
+
const topHits = (hits, n = 3) => { const sorted = [...hits].sort((a, b) => RANK[(a.c || a).level] - RANK[(b.c || b).level]); const seen = new Set(), out = []; for (const h of sorted) { const t = name3(h.c || h, (h.c || h).name).toLowerCase().replace(/[^a-zäöüß ]/g, "").slice(0, 28); if (seen.has(t)) continue; seen.add(t); out.push(h); if (out.length === n) break; } return { shown: out, more: hits.length - out.length }; };
|
|
22
|
+
|
|
23
|
+
// ---- where models live on this machine
|
|
24
|
+
export function modelRoots(env = process.env) {
|
|
25
|
+
const h = homedir(), out = [];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const add = (p) => { if (!p || !existsSync(p)) return; let real = p; try { real = realpathSync(p).toLowerCase(); } catch { /* keep */ } if (seen.has(real)) return; seen.add(real); out.push(p); };
|
|
28
|
+
add(env.HUGGINGFACE_HUB_CACHE); if (env.HF_HOME) add(join(env.HF_HOME, "hub")); add(env.TRANSFORMERS_CACHE);
|
|
29
|
+
add(join(h, ".cache", "huggingface", "hub")); add(join(h, ".cache", "torch", "hub")); add(join(h, "models")); add(join(h, "Models"));
|
|
30
|
+
const u = readUser();
|
|
31
|
+
for (const p of (u && u.projects) || []) add(expand(p));
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Walk the roots (files behind a symlink count - a Hugging Face cache is made of them - folders behind one do not).
|
|
36
|
+
// onDir(dir, entries[{name, size}]) is called for every folder. Stops at the time budget and says so.
|
|
37
|
+
export function walk(roots, onDir, { maxDepth = 9, budgetMs = BUDGET_MS } = {}) {
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
let incomplete = false, dirs = 0;
|
|
40
|
+
const stack = roots.map((r) => [r, 0]);
|
|
41
|
+
while (stack.length) {
|
|
42
|
+
if (Date.now() - start > budgetMs) { incomplete = true; break; }
|
|
43
|
+
const [dir, depth] = stack.pop();
|
|
44
|
+
let ents; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
45
|
+
const files = [];
|
|
46
|
+
for (const e of ents) {
|
|
47
|
+
const p = join(dir, e.name);
|
|
48
|
+
if (e.isDirectory()) { if (depth < maxDepth && !SKIP_DIRS.has(e.name)) stack.push([p, depth + 1]); continue; }
|
|
49
|
+
let st; try { st = statSync(p); } catch { continue; }
|
|
50
|
+
if (st.isFile()) files.push({ name: e.name, size: st.size });
|
|
51
|
+
}
|
|
52
|
+
dirs++;
|
|
53
|
+
onDir(dir, files);
|
|
54
|
+
}
|
|
55
|
+
return { incomplete, dirs };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// "models--org--name/snapshots/<rev>/..." -> "org/name"; any other folder -> its name
|
|
59
|
+
export function modelName(dir) {
|
|
60
|
+
const m = dir.match(/models--([^/]+?)--([^/]+)\/snapshots\//);
|
|
61
|
+
return m ? `${m[1]}/${m[2]}` : basename(dir);
|
|
62
|
+
}
|
|
63
|
+
const rev = (dir) => { const m = dir.match(/snapshots\/([0-9a-f]{7})/); return m ? m[1] : ""; };
|
|
64
|
+
|
|
65
|
+
// ---- the model card: README front matter (a small YAML subset) and config.json
|
|
66
|
+
export function parseFrontMatter(text) {
|
|
67
|
+
const m = String(text).match(/^---\s*\n([\s\S]*?)\n---\s*(\n|$)/);
|
|
68
|
+
if (!m) return null;
|
|
69
|
+
const out = {}; let key = null;
|
|
70
|
+
for (const raw of m[1].split("\n")) {
|
|
71
|
+
const line = raw.replace(/\s+$/, "");
|
|
72
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
73
|
+
const item = line.match(/^\s+-\s+(.*)$/);
|
|
74
|
+
if (item && key) { (out[key] = Array.isArray(out[key]) ? out[key] : []).push(item[1].replace(/^["']|["']$/g, "")); continue; }
|
|
75
|
+
const kv = line.match(/^([A-Za-z0-9_.-]+):\s*(.*)$/);
|
|
76
|
+
if (!kv) continue;
|
|
77
|
+
key = kv[1]; const v = kv[2].trim();
|
|
78
|
+
if (v === "") { out[key] = out[key] ?? ""; continue; }
|
|
79
|
+
if (v.startsWith("[") && v.endsWith("]")) out[key] = v.slice(1, -1).split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
80
|
+
else out[key] = v.replace(/^["']|["']$/g, "");
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
export function cardFacts(dir, files) {
|
|
85
|
+
const names = new Set(files.map((f) => f.name));
|
|
86
|
+
let card = null, cfg = null;
|
|
87
|
+
if (names.has("README.md")) { try { card = parseFrontMatter(readFileSync(join(dir, "README.md"), "utf8").slice(0, 60000)); } catch { /* unreadable */ } }
|
|
88
|
+
if (names.has("config.json")) { try { cfg = JSON.parse(readFileSync(join(dir, "config.json"), "utf8").slice(0, 400000)); } catch { /* unreadable */ } }
|
|
89
|
+
const list = (v) => (Array.isArray(v) ? v : v === undefined || v === "" ? [] : [String(v)]);
|
|
90
|
+
const facts = {
|
|
91
|
+
license: card && card.license !== undefined ? String(card.license).toLowerCase() : "",
|
|
92
|
+
base_model: card ? list(card.base_model) : [],
|
|
93
|
+
pipeline_tag: card && card.pipeline_tag ? String(card.pipeline_tag) : "",
|
|
94
|
+
library_name: card && card.library_name ? String(card.library_name) : "",
|
|
95
|
+
tags: card ? list(card.tags) : [], language: card ? list(card.language) : [], datasets: card ? list(card.datasets) : [],
|
|
96
|
+
gated: !!(card && (card.extra_gated_prompt !== undefined || card.extra_gated_fields !== undefined || String(card.gated) === "true" || card.gated === "manual" || card.gated === "auto")),
|
|
97
|
+
auto_map: !!(cfg && cfg.auto_map && typeof cfg.auto_map === "object"),
|
|
98
|
+
architectures: cfg && Array.isArray(cfg.architectures) ? cfg.architectures.map(String) : [],
|
|
99
|
+
model_type: cfg && cfg.model_type ? String(cfg.model_type) : "",
|
|
100
|
+
quantization: !!(cfg && cfg.quantization_config),
|
|
101
|
+
};
|
|
102
|
+
return { facts, hasCard: !!card, hasCfg: !!cfg, cardFile: names.has("README.md") ? join(dir, "README.md") : "", cfgFile: names.has("config.json") ? join(dir, "config.json") : "" };
|
|
103
|
+
}
|
|
104
|
+
// one declarative test on one field
|
|
105
|
+
export function testCheck(c, facts) {
|
|
106
|
+
const v = facts[c.field];
|
|
107
|
+
const empty = Array.isArray(v) ? v.length === 0 : v === "" || v === false || v === undefined;
|
|
108
|
+
const vals = Array.isArray(v) ? v.map(String) : [String(v)];
|
|
109
|
+
switch (c.op) {
|
|
110
|
+
case "missing": return empty;
|
|
111
|
+
case "present": return !empty;
|
|
112
|
+
case "equals": return !empty && vals.some((x) => x.toLowerCase() === String(c.value).toLowerCase());
|
|
113
|
+
case "in": return !empty && vals.some((x) => c.values.map((y) => y.toLowerCase()).includes(x.toLowerCase()));
|
|
114
|
+
case "matches": { const re = toJs(c.value); return !!re && !empty && vals.some((x) => re.test(x)); }
|
|
115
|
+
default: return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const localOnly = (rule, id, title) => targetLabel() !== "localhost"
|
|
120
|
+
? result({ id, title, verdict: UNKNOWN, was: "Diese Prüfung liest Modelle und Agent-Dateien auf dem eigenen Rechner.", wie: "Auf einem Server über SSH schaut Dante hier nicht nach.", wo: "Nur dieser Rechner.", evidence: [evidence("Ziel ist ein Server", { cmd: "targetLabel()", line: targetLabel() })] })
|
|
121
|
+
: null;
|
|
122
|
+
|
|
123
|
+
const find = (id, rule) => ({ id, know: rule.know || rule.id, title: rule.title, aliases: rule.aliases || [], cve: [], deep: !!rule.deep });
|
|
124
|
+
|
|
125
|
+
// ---- CHARACTER: where does the model come from?
|
|
126
|
+
export function modelCardCheck(rule) {
|
|
127
|
+
return { ...find(rule.id, rule), run() {
|
|
128
|
+
const m = loc(rule), id = rule.id, title = m.title;
|
|
129
|
+
const off = localOnly(rule, id, title); if (off) return off;
|
|
130
|
+
const roots = modelRoots();
|
|
131
|
+
if (!roots.length) return result({ id, title, verdict: SAFE, was: "Keine Modell-Ordner gefunden.", wie: "Kein Hugging-Face-Cache und keine Modell-Ordner auf diesem Rechner.", wo: "Dieser Rechner.", evidence: [evidence("Modell-Ordner gesucht", { cmd: "~/.cache/huggingface/hub, ~/models, Projektordner", line: "(keiner dieser Ordner existiert)" })] });
|
|
132
|
+
const lic = new Map((rule.licenses || []).map((l) => [l.id, l.class]));
|
|
133
|
+
const models = [];
|
|
134
|
+
const w = walk(roots, (dir, files) => {
|
|
135
|
+
const names = new Set(files.map((f) => f.name));
|
|
136
|
+
if (!names.has("config.json") && !names.has("README.md")) return;
|
|
137
|
+
if (!files.some((f) => /\.(safetensors|bin|pt|pth|ckpt|gguf|onnx|h5|pkl|msgpack)$/i.test(f.name)) && !names.has("config.json")) return;
|
|
138
|
+
const c = cardFacts(dir, files);
|
|
139
|
+
if (c.hasCfg || c.hasCard) models.push({ dir, ...c });
|
|
140
|
+
});
|
|
141
|
+
if (w.incomplete) return result({ id, title, verdict: UNKNOWN, was: "Konnte nicht alle Modell-Ordner lesen.", wie: "Das Zeitlimit war erreicht — ohne vollständige Suche sage ich nichts.", wo: "Dieser Rechner.", evidence: [evidence("Zeitlimit", { cmd: "walk", line: `${models.length} Modelle bis dahin` })] });
|
|
142
|
+
const ev = [];
|
|
143
|
+
let warn = 0;
|
|
144
|
+
for (const md of models.slice(0, MAX_EVIDENCE)) {
|
|
145
|
+
const f = md.facts, cls = lic.get(f.license) || (f.license ? "unknown" : "");
|
|
146
|
+
const hits = (rule.checks || []).filter((c) => testCheck(c, f));
|
|
147
|
+
const warns = hits.filter((c) => c.level === "warn");
|
|
148
|
+
if (warns.length) warn++;
|
|
149
|
+
const bits = [`Lizenz ${f.license || "—"}${cls ? " (" + cls + ")" : ""}`, f.base_model.length ? "Basis " + f.base_model.slice(0, 2).join(", ") : "", f.pipeline_tag, f.library_name, f.auto_map ? "eigener Code" : "", f.gated ? "gated" : ""].filter(Boolean).join(" · ");
|
|
150
|
+
ev.push(evidence(`${modelName(md.dir)}${rev(md.dir) ? "@" + rev(md.dir) : ""}: ${bits}${hits.length ? " — " + (() => { const t = topHits(hits); return t.shown.map((c) => (c.level === "info" ? "" : "⚠ ") + name3(c, c.name)).join("; ") + (t.more > 0 ? ` (+${t.more})` : ""); })() : ""}`, { cmd: "README.md front matter + config.json", line: md.cardFile || md.cfgFile }));
|
|
151
|
+
}
|
|
152
|
+
if (models.length > MAX_EVIDENCE) ev.push(evidence(`… und ${models.length - MAX_EVIDENCE} weitere`, { cmd: "walk", line: `${models.length} Modelle insgesamt` }));
|
|
153
|
+
if (!models.length) return result({ id, title, verdict: SAFE, was: "Keine Modelle mit Karte oder Konfiguration gefunden.", wie: `${roots.length} Ordner durchsucht.`, wo: "Dieser Rechner.", evidence: [evidence("Ordner durchsucht", { cmd: "walk", line: `(keine Modelle in: ${roots.join(", ")})` })] });
|
|
154
|
+
const bad = models.filter((md) => (rule.checks || []).some((c) => c.level === "warn" && testCheck(c, md.facts))).length;
|
|
155
|
+
return result({ id, title, verdict: bad ? WARN : SAFE, was: m.bad && bad ? m.bad : (m.ok || "Herkunft der Modelle ist angegeben."), wie: bad ? (m.bad_how || "Die Zeilen nennen Modell, Lizenz, Basis und was auffällt.") : (m.ok_how || "Karte und Konfiguration sagen, woher die Modelle kommen."), wo: "Dieser Rechner.", evidence: ev });
|
|
156
|
+
} };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- BEHAVIOUR: what does loading it do?
|
|
160
|
+
export function modelFilesCheck(rule) {
|
|
161
|
+
const kinds = new Map((rule.formats || []).map((f) => [f.ext.toLowerCase(), f]));
|
|
162
|
+
const codeRes = (rule.code || []).map((c) => ({ c, re: toJs(c.re) })).filter((x) => x.re);
|
|
163
|
+
return { ...find(rule.id, rule), run() {
|
|
164
|
+
const m = loc(rule), id = rule.id, title = m.title;
|
|
165
|
+
const off = localOnly(rule, id, title); if (off) return off;
|
|
166
|
+
const roots = modelRoots();
|
|
167
|
+
if (!roots.length) return result({ id, title, verdict: SAFE, was: "Keine Modell-Ordner gefunden.", wie: "Kein Hugging-Face-Cache und keine Modell-Ordner auf diesem Rechner.", wo: "Dieser Rechner.", evidence: [evidence("Modell-Ordner gesucht", { cmd: "~/.cache/huggingface/hub, ~/models, Projektordner", line: "(keiner dieser Ordner existiert)" })] });
|
|
168
|
+
const dirs = [];
|
|
169
|
+
const w = walk(roots, (dir, files) => {
|
|
170
|
+
const weights = files.filter((f) => kinds.has(extname(f.name).slice(1).toLowerCase()));
|
|
171
|
+
const names = new Set(files.map((f) => f.name));
|
|
172
|
+
if (!weights.length || (!names.has("config.json") && !weights.some((f) => kinds.get(extname(f.name).slice(1).toLowerCase()).kind !== "unknown"))) return;
|
|
173
|
+
const cfg = (() => { try { return JSON.parse(readFileSync(join(dir, "config.json"), "utf8").slice(0, 400000)); } catch { return null; } })();
|
|
174
|
+
const hasCustomCode = !!(cfg && cfg.auto_map) || files.some((f) => /^(modeling|configuration|tokenization)_.*\.py$/.test(f.name));
|
|
175
|
+
dirs.push({ dir, weights, files, hasCustomCode });
|
|
176
|
+
});
|
|
177
|
+
if (w.incomplete) return result({ id, title, verdict: UNKNOWN, was: "Konnte nicht alle Modell-Ordner lesen.", wie: "Das Zeitlimit war erreicht — ohne vollständige Suche sage ich nichts.", wo: "Dieser Rechner.", evidence: [evidence("Zeitlimit", { cmd: "walk", line: `${dirs.length} Modell-Ordner bis dahin` })] });
|
|
178
|
+
const ev = [], steps = [];
|
|
179
|
+
let warn = 0, safeOnly = 0;
|
|
180
|
+
for (const d of dirs) {
|
|
181
|
+
const byKind = { code: [], safe: [], unknown: [] };
|
|
182
|
+
for (const f of d.weights) byKind[(kinds.get(extname(f.name).slice(1).toLowerCase()) || { kind: "unknown" }).kind].push(f);
|
|
183
|
+
const hits = [];
|
|
184
|
+
if (d.hasCustomCode) for (const f of d.files.filter((x) => /\.py$/.test(x.name) && x.size < 400 * 1024)) {
|
|
185
|
+
let text; try { text = readFileSync(join(d.dir, f.name), "latin1"); } catch { continue; }
|
|
186
|
+
const lines = text.split("\n");
|
|
187
|
+
for (const { c, re } of codeRes) for (let i = 0; i < lines.length; i++) if (lines[i].length < 2000 && re.test(lines[i])) { hits.push({ c, file: join(d.dir, f.name), n: i + 1 }); break; }
|
|
188
|
+
}
|
|
189
|
+
const label = `${modelName(d.dir)}${rev(d.dir) ? "@" + rev(d.dir) : ""}`;
|
|
190
|
+
const redundant = byKind.code.length && byKind.safe.length; // a pickle next to a safetensors copy is loaded by the safe one
|
|
191
|
+
if (byKind.code.length && !redundant) {
|
|
192
|
+
warn++;
|
|
193
|
+
const exts = [...new Set(byKind.code.map((f) => extname(f.name).slice(1).toLowerCase()))];
|
|
194
|
+
if (ev.length < MAX_EVIDENCE) ev.push(evidence(`${label}: lädt per ${exts.join("/")} — dabei kann Code laufen (${name3(kinds.get(exts[0]), kinds.get(exts[0]).name)})`, { cmd: "Dateiendungen im Ordner", line: `${join(d.dir, byKind.code[0].name)} · ${byKind.code[0].size} Bytes` }));
|
|
195
|
+
steps.push({ desc: `Ordner ansehen (nur lesen): ${label}`, script: `ls -l '${d.dir.replace(/'/g, "'\\''")}'` });
|
|
196
|
+
} else if (byKind.safe.length && !byKind.code.length) {
|
|
197
|
+
safeOnly++;
|
|
198
|
+
if (ev.length < MAX_EVIDENCE) ev.push(evidence(`${label}: nur ${[...new Set(byKind.safe.map((f) => extname(f.name).slice(1)))].join("/")} — enthält keinen ausführbaren Code`, { cmd: "Dateiendungen im Ordner", line: join(d.dir, byKind.safe[0].name) }));
|
|
199
|
+
} else if (redundant && ev.length < MAX_EVIDENCE) ev.push(evidence(`${label}: Pickle liegt neben einer safetensors-Kopie`, { cmd: "Dateiendungen im Ordner", line: join(d.dir, byKind.code[0].name) }));
|
|
200
|
+
for (const h of hits.slice(0, 4)) { warn++; if (ev.length < MAX_EVIDENCE + 6) ev.push(evidence(`${label}: eigener Code — Muster „${name3(h.c, h.c.name)}“`, { cmd: "Muster in den .py-Dateien des Modells", line: `${h.file}:${h.n}` })); }
|
|
201
|
+
}
|
|
202
|
+
if (!dirs.length) return result({ id, title, verdict: SAFE, was: "Keine Modell-Dateien gefunden.", wie: `${roots.length} Ordner durchsucht.`, wo: "Dieser Rechner.", evidence: [evidence("Ordner durchsucht", { cmd: "walk", line: `(keine Modell-Dateien in: ${roots.join(", ")})` })] });
|
|
203
|
+
if (!warn) return result({ id, title, verdict: SAFE, was: m.ok || "Beim Laden läuft kein fremder Code.", wie: m.ok_how || "Die Modelle liegen in Formaten ohne ausführbaren Code, und ihr Zusatzcode zeigt keine Auffälligkeit.", wo: "Dieser Rechner.", evidence: ev });
|
|
204
|
+
return result({ id, title, verdict: WARN, was: m.bad || "Beim Laden mancher Modelle kann fremder Code laufen.", wie: m.bad_how || "Pickle-Formate führen beim Laden Code aus; die Zeilen nennen Modell, Datei und Muster. Von Hand ansehen, nicht blind löschen.", wo: "Dieser Rechner.", evidence: ev,
|
|
205
|
+
fix: steps.length ? { why: "Besser die safetensors-Variante laden (kein Code darin). Bis dahin: nur Modelle aus Quellen laden, denen du traust.", steps: steps.slice(0, 5) } : null });
|
|
206
|
+
} };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- CAPABILITIES: what may an agent do?
|
|
210
|
+
function serversOf(json) {
|
|
211
|
+
const out = [];
|
|
212
|
+
const take = (obj) => { if (obj && typeof obj === "object" && !Array.isArray(obj)) for (const [name, s] of Object.entries(obj)) if (s && typeof s === "object" && (s.command || s.url || s.args)) out.push({ name, s }); };
|
|
213
|
+
if (!json || typeof json !== "object") return out;
|
|
214
|
+
take(json.mcpServers); take(json.servers); take(json.context_servers); take(json.mcp && json.mcp.servers);
|
|
215
|
+
if (json.projects && typeof json.projects === "object") for (const p of Object.values(json.projects)) if (p && p.mcpServers) take(p.mcpServers);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
const where = (name, s) => ({
|
|
219
|
+
command: String(s.command || ""), args: Array.isArray(s.args) ? s.args.map(String).join(" ") : "", url: String(s.url || ""), name,
|
|
220
|
+
env: s.env && typeof s.env === "object" ? Object.entries(s.env).map(([k, v]) => `${k}=${typeof v === "string" ? v : ""}`).join("\n") : "",
|
|
221
|
+
});
|
|
222
|
+
export function agentConfigCheck(rule) {
|
|
223
|
+
const checks = (rule.checks || []).map((c) => ({ c, re: toJs(c.re) })).filter((x) => x.re);
|
|
224
|
+
const caps = (rule.caps || []).map((c) => ({ c, re: toJs(c.re) })).filter((x) => x.re);
|
|
225
|
+
return { ...find(rule.id, rule), run() {
|
|
226
|
+
const m = loc(rule), id = rule.id, title = m.title;
|
|
227
|
+
const off = localOnly(rule, id, title); if (off) return off;
|
|
228
|
+
const tried = [], found = [];
|
|
229
|
+
const proj = []; const u = readUser(); for (const p of (u && u.projects) || []) proj.push(expand(p));
|
|
230
|
+
for (const f of rule.files) {
|
|
231
|
+
if (f.startsWith("~/") || f.includes("/")) { const p = expand(f); tried.push(p); if (existsSync(p)) found.push(p); }
|
|
232
|
+
else for (const root of proj) for (const p of [join(root, f), ...(() => { try { return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")).slice(0, 60).map((e) => join(root, e.name, f)); } catch { return []; } })()]) { if (existsSync(p) && !found.includes(p)) found.push(p); }
|
|
233
|
+
}
|
|
234
|
+
if (!found.length) return result({ id, title, verdict: SAFE, was: "Keine Agent- oder MCP-Konfiguration gefunden.", wie: `${tried.length} bekannte Orte geprüft.`, wo: "Dieser Rechner.", evidence: [evidence("Bekannte Konfig-Dateien gesucht", { cmd: "existsSync auf bekannte Pfade", line: `(keine von ${tried.length} gefunden, z. B. ${tried[0] || "?"})` })] });
|
|
235
|
+
const ev = [], steps = [];
|
|
236
|
+
let worst = SAFE, servers = 0, unreadable = 0;
|
|
237
|
+
for (const file of found) {
|
|
238
|
+
let json; try { json = JSON.parse(readFileSync(file, "utf8").slice(0, 2 * 1024 * 1024)); } catch { unreadable++; ev.push(evidence(`${basename(file)} ist kein lesbares JSON`, { cmd: "JSON.parse", line: file })); if (worst === SAFE) worst = WARN; continue; }
|
|
239
|
+
for (const { name, s } of serversOf(json)) {
|
|
240
|
+
servers++;
|
|
241
|
+
const w = where(name, s);
|
|
242
|
+
const have = [...new Set(caps.filter((x) => x.re.test(`${w.command} ${w.args} ${name}`)).map((x) => x.c.cap))];
|
|
243
|
+
const hits = checks.filter((x) => x.re.test(w[x.c.where] || ""));
|
|
244
|
+
for (const lvl of ["risk", "warn"]) if (hits.some((x) => x.c.level === lvl) && (worst === SAFE || (lvl === "risk" && worst !== RISK))) worst = lvl === "risk" ? RISK : (worst === RISK ? RISK : WARN);
|
|
245
|
+
if (ev.length < MAX_EVIDENCE + 8) ev.push(evidence(`${name}${have.length ? " kann: " + have.join(", ") : ""}${hits.length ? " — " + (() => { const t = topHits(hits); return t.shown.map((x) => (x.c.level === "info" ? "" : "⚠ ") + name3(x.c, x.c.name)).join("; ") + (t.more > 0 ? ` (+${t.more})` : ""); })() : ""}`, { cmd: "JSON-Eintrag des Servers (Werte werden nie gezeigt)", line: `${file} · Server „${name}“` }));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!servers && !unreadable) return result({ id, title, verdict: SAFE, was: "Die Konfiguration nennt keine Server.", wie: `${found.length} Datei(en) gelesen, kein Server eingetragen.`, wo: "Dieser Rechner.", evidence: [evidence("Konfig-Dateien gelesen", { cmd: "JSON.parse", line: found[0] })] });
|
|
249
|
+
return result({ id, title, verdict: worst, was: worst === SAFE ? (m.ok || "Keine auffälligen Rechte in den Agent-Konfigurationen.") : (m.bad || "Ein Agent hat mehr Rechte, als er braucht."), wie: worst === SAFE ? (m.ok_how || `${servers} Server geprüft; die Zeilen sagen, was jeder kann.`) : (m.bad_how || "Die Zeilen nennen Server, Fähigkeit und was auffällt — Werte zeigt Dante nie."), wo: "Dieser Rechner.", evidence: ev,
|
|
250
|
+
fix: worst === SAFE ? null : { why: "Nur die Rechte lassen, die der Agent wirklich braucht; Schlüssel nicht in die Datei schreiben, sondern als Umgebungsvariable setzen.", steps: [{ desc: "Konfig-Datei ansehen (nur lesen)", script: `ls -l '${found[0].replace(/'/g, "'\\''")}'` }] } });
|
|
251
|
+
} };
|
|
252
|
+
}
|
package/src/checks/loc.mjs
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// i18n.en / i18n.es
|
|
3
|
-
// never put a German sentence into an English session. An older pack without them falls back to the
|
|
4
|
-
// German base (the phrasebook in i18n-checks.mjs still translates what it knows).
|
|
1
|
+
// Picks the wording of a rule for the current language. Rules carry German as base text plus
|
|
2
|
+
// i18n.en / i18n.es; the pack factory rejects rules without both. Older packs fall back to German.
|
|
5
3
|
import { lang } from "../i18n.mjs";
|
|
6
4
|
|
|
7
5
|
export function loc(rule) {
|
package/src/checks/php2shell.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// or "risk". The changelog is the authority. If Dante cannot read the changelog, it says UNKNOWN.
|
|
1
|
+
// Version-bound CVE check (PHP2Shell, CVE-2026-109618/-619 first), built from a version_cve rule.
|
|
2
|
+
// Distro backports keep the old version number, so a version alone never proves safe or vulnerable;
|
|
3
|
+
// the package changelog decides, and without it the verdict is unknown.
|
|
5
4
|
import { run, firstLine } from "../core/sh.mjs";
|
|
6
5
|
import { result, evidence, SAFE, WARN, RISK, UNKNOWN } from "../core/check.mjs";
|
|
7
6
|
import { FAMILY_OF, fixSteps } from "../bib/system.mjs";
|
|
@@ -10,7 +9,7 @@ import { BUILTIN } from "../pack/builtin.mjs";
|
|
|
10
9
|
import { loc } from "./loc.mjs";
|
|
11
10
|
|
|
12
11
|
// How to ask a program for its version, which service belongs to it, where its changelog lives.
|
|
13
|
-
// This table is
|
|
12
|
+
// This table is code: a pack only picks a name from it (rules.mjs PROBES), it cannot add a command.
|
|
14
13
|
const PROBES = {
|
|
15
14
|
php: { ver: "php -v", doc: "/usr/share/doc/php*-common/changelog.*", svc: "php[0-9.]*-fpm" },
|
|
16
15
|
nginx: { ver: "nginx -v 2>&1", doc: "/usr/share/doc/nginx*/changelog.*", svc: "nginx" },
|
|
@@ -44,7 +43,7 @@ export function versionCheck(rule) {
|
|
|
44
43
|
|
|
45
44
|
// 1) Is the program present at all?
|
|
46
45
|
const cli = run(P.ver);
|
|
47
|
-
const fpm = P.svc ? run("systemctl list-units --type=service --all --no-legend 2>/dev/null | grep -iE " + sq(P.svc) + " | awk '{print $1, $4}'") : { ran: false, ok: false, out: "" }; // no service to ask
|
|
46
|
+
const fpm = P.svc ? run("systemctl list-units --type=service --all --no-legend 2>/dev/null | grep -iE " + sq(P.svc) + " | awk '{print $1, $4}'") : { ran: false, ok: false, out: "" }; // no service to ask -> it must not count as "I looked"
|
|
48
47
|
const anyFpm = fpm.ok && fpm.out.trim().length > 0;
|
|
49
48
|
const anyCli = cli.ok && parseVer(cli.out) != null;
|
|
50
49
|
if (!anyCli && !anyFpm) {
|
|
@@ -84,7 +83,7 @@ export function versionCheck(rule) {
|
|
|
84
83
|
wo, evidence: ev });
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
// Not upstream-fixed and no CVE line found. Could we even
|
|
86
|
+
// Not upstream-fixed and no CVE line found. Could we even read the changelog?
|
|
88
87
|
if (!canReadChangelog) {
|
|
89
88
|
ev.push(evidence("Changelog nicht lesbar — Patch-Stand nicht belegbar", { cmd: "ls " + P.doc, line: firstLine(docs.err || docs.out) || "(keine Datei)" }));
|
|
90
89
|
return result({ id, title, verdict: UNKNOWN,
|
|
@@ -94,7 +93,7 @@ export function versionCheck(rule) {
|
|
|
94
93
|
fix: "Patch-Stand von Hand prüfen (Changelog/Advisory) oder " + L + " aktualisieren und " + (rule.probe === "php" ? "PHP-FPM" : "den Dienst") + " neu starten." });
|
|
95
94
|
}
|
|
96
95
|
|
|
97
|
-
// Changelog readable, CVE absent, version below fix line
|
|
96
|
+
// Changelog readable, CVE absent, version below fix line -> genuinely unpatched. Exposure decides urgency.
|
|
98
97
|
ev.push(evidence("Changelog gelesen, kein CVE-Patch darin", { cmd: "zcat changelog | grep CVE", line: "(keine Treffer, Datei lesbar)" }));
|
|
99
98
|
let exposed = null; // only PHP has an exposure test (a web server that hands .php to FastCGI)
|
|
100
99
|
if (rule.probe === "php") {
|
|
@@ -111,7 +110,7 @@ export function versionCheck(rule) {
|
|
|
111
110
|
const major = (parseVer(firstLine(cli.out)) || [8, 3]).slice(0, 2).join(".");
|
|
112
111
|
const fpmSvc = anyFpm ? firstLine(fpm.out).split(" ")[0] : ("php" + major + "-fpm");
|
|
113
112
|
// The fix must speak the system's own package manager. Read the family off the target itself
|
|
114
|
-
// (os-release), not from anything the user claimed
|
|
113
|
+
// (os-release), not from anything the user claimed - unreadable -> debian (the most common).
|
|
115
114
|
const osId = firstLine(run(". /etc/os-release 2>/dev/null; echo \"$ID\"").out).toLowerCase();
|
|
116
115
|
const family = FAMILY_OF[osId] || "debian";
|
|
117
116
|
ev.push(evidence("System-Familie (für den Fix-Weg): " + family, { cmd: ". /etc/os-release; echo $ID", line: osId || "(nicht lesbar → debian angenommen)" }));
|
package/src/checks/rechte.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// it only lists paths with the dangerous mode bit. Can't search → UNKNOWN, never "safe".
|
|
1
|
+
// World-writable files under /etc and the web roots. Anyone can turn such a file into a foothold
|
|
2
|
+
// (cron, config, PHP page). Lists paths only. If the search cannot run the verdict is unknown.
|
|
4
3
|
import { run } from "../core/sh.mjs";
|
|
5
4
|
import { result, evidence, SAFE, WARN, UNKNOWN } from "../core/check.mjs";
|
|
6
5
|
import { webRoots, lines } from "./common.mjs";
|
package/src/checks/secrets.mjs
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// public" holes. Read-only. Honest rule: no standard web root found → UNKNOWN (we don't know where
|
|
4
|
-
// the site is), never "safe".
|
|
1
|
+
// Readable secrets inside a web root (.env, *.sql, backups, .git/config), from a path_exposed rule.
|
|
2
|
+
// Without a standard web root the verdict is unknown, because the site could be anywhere.
|
|
5
3
|
import { run } from "../core/sh.mjs";
|
|
6
4
|
import { result, evidence, SAFE, RISK, UNKNOWN } from "../core/check.mjs";
|
|
7
5
|
import { webRoots, lines, sq } from "./common.mjs";
|
package/src/checks/sshd.mjs
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (`sshd -T`) so an Include or a drop-in cannot hide it; falls back to the config file if sshd -T
|
|
5
|
-
// is not available. Read-only. Can't read either → UNKNOWN, never "safe".
|
|
1
|
+
// SSH exposure from an sshd_setting rule: the verdict is a warning only if every listed setting is bad
|
|
2
|
+
// (by default root login and password login together). Reads the effective config via `sshd -T` so an
|
|
3
|
+
// Include cannot hide a value, and falls back to the config file.
|
|
6
4
|
import { run } from "../core/sh.mjs";
|
|
7
5
|
import { result, evidence, SAFE, WARN, UNKNOWN } from "../core/check.mjs";
|
|
8
6
|
import { BUILTIN } from "../pack/builtin.mjs";
|
package/src/checks/webshell.mjs
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// file's contents (that could be attacker code). A match is a heuristic → verdict WARN ("look here
|
|
5
|
-
// by hand"), never a bare RISK. No web root → UNKNOWN, never "safe".
|
|
1
|
+
// Looks for webshell markers in a web root (input passed to a call, eval of decoded data, system()
|
|
2
|
+
// with request data), from a file_pattern rule. Reports the path and the marker, not the file content.
|
|
3
|
+
// A match is a warning, since the patterns are heuristics.
|
|
6
4
|
import { run } from "../core/sh.mjs";
|
|
7
5
|
import { result, evidence, SAFE, WARN, UNKNOWN } from "../core/check.mjs";
|
|
8
6
|
import { webRoots, lines, sq } from "./common.mjs";
|
package/src/cli.mjs
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
// It looks and reports with real lines. On a real finding it comes to the foreground, shows its
|
|
4
|
-
// plan (plain words + the exact commands) and asks ja/nein. On yes it acts, then re-proves.
|
|
2
|
+
// Command line entry: flags, one-shot checks and the interactive session.
|
|
5
3
|
import { configureTarget, targetLabel, probe, run, firstLine } from "./core/sh.mjs";
|
|
6
4
|
import { renderResult, renderSummary } from "./core/render.mjs";
|
|
7
5
|
import { RISK, WARN, SAFE, UNKNOWN } from "./core/check.mjs";
|
|
@@ -10,7 +8,7 @@ import { banner } from "./mascot.mjs";
|
|
|
10
8
|
import { activeBattery } from "./checks/battery.mjs";
|
|
11
9
|
import { demo } from "./checks/demo.mjs";
|
|
12
10
|
import { staleLine, VERSION, BIB_DATE } from "./ticker.mjs";
|
|
13
|
-
import { lang, t } from "./i18n.mjs";
|
|
11
|
+
import { lang, t, initLang } from "./i18n.mjs";
|
|
14
12
|
import { tx } from "./i18n-checks.mjs";
|
|
15
13
|
|
|
16
14
|
function parseArgs(argv) {
|
|
@@ -25,6 +23,8 @@ function parseArgs(argv) {
|
|
|
25
23
|
else if (t === "--demo") a.demo = true;
|
|
26
24
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
27
25
|
else if (t === "--version" || t === "-v") a.version = true;
|
|
26
|
+
else if (t === "--patrol") a.patrol = true;
|
|
27
|
+
else if (t === "--deep") a.deep = true;
|
|
28
28
|
}
|
|
29
29
|
return a;
|
|
30
30
|
}
|
|
@@ -35,6 +35,8 @@ const HELP = `Dante — wächter
|
|
|
35
35
|
npx dante-watch --ssh user@host [--key /pfad] prüft einen Server, der dir gehört
|
|
36
36
|
--fix bei einem Fund: Plan zeigen und nach deinem Ja schließen
|
|
37
37
|
--demo harmlose Übung: zeigt die ganze Schleife (human → plan → ja/nein → fix → Beweis)
|
|
38
|
+
--patrol eine Patrouille (das startet der Zeitplan): prüft, schreibt das Tagebuch, ändert nichts
|
|
39
|
+
--deep ein Tiefenlauf (wöchentlich, startet der Zeitplan): liest Projektordner nach Schlüsseln, bohrt bei Treffern nach
|
|
38
40
|
--version welche Version läuft
|
|
39
41
|
--json Maschinen-Ausgabe
|
|
40
42
|
-y, --yes nicht nachfragen (für Automode/Cron)
|
|
@@ -44,7 +46,7 @@ Dante ändert nichts ohne dein Ja. Jedes Urteil trägt die echte Zeile; fehlt si
|
|
|
44
46
|
const C = (code, s) => (process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
45
47
|
|
|
46
48
|
async function handleFinding(check, r, a) {
|
|
47
|
-
// "Human, hör zu"
|
|
49
|
+
// "Human, hör zu" - foreground, plan in words + exact commands, then ja/nein, then re-prove.
|
|
48
50
|
console.log("\n" + C("41;37", t("listen")) + " " + C("1", tx(r.title)));
|
|
49
51
|
console.log(" " + C("2", t("what")) + " " + tx(r.was));
|
|
50
52
|
console.log(" " + C("2", t("how")) + " " + tx(r.wie));
|
|
@@ -53,7 +55,7 @@ async function handleFinding(check, r, a) {
|
|
|
53
55
|
console.log(" " + (i + 1) + ") " + tx(s.desc));
|
|
54
56
|
console.log(" " + C("2", "$ " + s.script));
|
|
55
57
|
});
|
|
56
|
-
// On a real risk, Dante tells you to distrust it
|
|
58
|
+
// On a real risk, Dante tells you to distrust it : check against another AI.
|
|
57
59
|
console.log(" " + C("33", t("distrust")));
|
|
58
60
|
|
|
59
61
|
const answer = await ask(check.id, t("run"), { yes: a.yes, plan: r.fix });
|
|
@@ -78,12 +80,15 @@ async function handleFinding(check, r, a) {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
async function main() {
|
|
83
|
+
initLang();
|
|
81
84
|
const a = parseArgs(process.argv.slice(2));
|
|
82
85
|
if (a.help) { console.log(HELP); return; }
|
|
86
|
+
if (a.deep) { const { deep } = await import("./me/deep.mjs"); process.exit(await deep()); }
|
|
87
|
+
if (a.patrol) { const { patrol } = await import("./me/patrol.mjs"); process.exit(await patrol()); } // what the schedule starts: no screen, only the diary and the log
|
|
83
88
|
if (a.version) { console.log("dante-watch " + VERSION + " · bib " + BIB_DATE); return; }
|
|
84
89
|
|
|
85
|
-
// No flags + a real terminal
|
|
86
|
-
// Flags or a pipe
|
|
90
|
+
// No flags + a real terminal -> the face: summon, greet, arm, talk, stay open.
|
|
91
|
+
// Flags or a pipe -> one-shot (for cron / --json / automode).
|
|
87
92
|
const bare = process.argv.slice(2).length === 0;
|
|
88
93
|
if (bare && process.stdin.isTTY && process.stdout.isTTY) {
|
|
89
94
|
const { session } = await import("./session.mjs");
|
|
@@ -112,7 +117,7 @@ async function main() {
|
|
|
112
117
|
for (const r of results) console.log(renderResult(r));
|
|
113
118
|
console.log(renderSummary(results, target));
|
|
114
119
|
|
|
115
|
-
// Findings with a plan
|
|
120
|
+
// Findings with a plan -> the consent flow (only with --fix or --demo; otherwise just report).
|
|
116
121
|
if (a.fix || a.demo) {
|
|
117
122
|
for (const c of checks) {
|
|
118
123
|
const r = results.find((x) => x.id === c.id);
|
|
@@ -123,7 +128,7 @@ async function main() {
|
|
|
123
128
|
} else if (results.some((r) => (r.verdict === RISK || r.verdict === WARN) && r.fix)) {
|
|
124
129
|
console.log("\n" + C("2", "Dante kann das nach deinem Ja schließen: noch einmal mit ") + "--fix" + C("2", " starten."));
|
|
125
130
|
}
|
|
126
|
-
const old = staleLine(lang()); // the inner clock
|
|
131
|
+
const old = staleLine(lang()); // the inner clock - local arithmetic, no update check over the net
|
|
127
132
|
if (old) console.log("\n" + C("33", old));
|
|
128
133
|
process.exit(results.some((r) => r.verdict === RISK) ? 2 : 0);
|
|
129
134
|
}
|
package/src/core/check.mjs
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// A check returns a verdict and a list of EVIDENCE. Every evidence item ties a plain-language
|
|
4
|
-
// claim to the exact command and the raw line it came from. The renderer refuses to print a
|
|
5
|
-
// claim that has no raw line. A check that cannot gather real evidence must return "unknown" —
|
|
6
|
-
// never "safe". Safe is a thing you prove, not a thing you assume.
|
|
1
|
+
// Result contract for checks. Every piece of evidence ties a claim to the command that ran and the
|
|
2
|
+
// line it printed. A safe, risk or warn verdict without a grounded line is downgraded to unknown.
|
|
7
3
|
|
|
8
4
|
export const SAFE = "safe"; // proven not vulnerable, with evidence
|
|
9
5
|
export const WARN = "warn"; // partially mitigated / needs a step (e.g. restart)
|
|
10
6
|
export const RISK = "risk"; // proven exposed / vulnerable, with evidence
|
|
11
|
-
export const UNKNOWN = "unknown"; // could not gather real evidence
|
|
7
|
+
export const UNKNOWN = "unknown"; // could not gather real evidence - never claim safety
|
|
12
8
|
|
|
13
9
|
export function evidence(claim, source) {
|
|
14
|
-
// source = { cmd, line }
|
|
10
|
+
// source = { cmd, line } - cmd is the real command, line is the real output it produced.
|
|
15
11
|
const line = (source && source.line != null) ? String(source.line).trim() : "";
|
|
16
12
|
const cmd = (source && source.cmd) ? String(source.cmd) : "";
|
|
17
13
|
return { claim: String(claim), cmd, line, grounded: line.length > 0 };
|
package/src/core/consent.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Confirmation before any change: show the plan and the exact commands, then wait for a yes.
|
|
2
|
+
// A per-check trust counter offers automatic mode after five confirmations; it lives in
|
|
3
|
+
// ~/.dante/trust.json.
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
@@ -19,7 +19,7 @@ export function automodeFor(id) {
|
|
|
19
19
|
return d[id] && d[id].automode === true;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// Only a clean yes is a yes. "ja aber erst morgen", "jein", "ja?" are not
|
|
22
|
+
// Only a clean yes is a yes. "ja aber erst morgen", "jein", "ja?" are not - when in doubt, nothing
|
|
23
23
|
// changes. (Before: anything starting with "j" counted, so "ja aber nicht jetzt" ran the fix.)
|
|
24
24
|
const YES = /^(ja|j|jo|jap|jawohl|ja bitte|ja mach|yes|y|yep|yes please|yeah|sure|si|sí|claro|dale|ok|okay|mach|mach es|los|go)[.!]*$/;
|
|
25
25
|
const ALWAYS = /^(a|automode|immer|always|siempre)[.!]*$/;
|
|
@@ -42,16 +42,16 @@ export async function ask(id, question, { yes = false, plan = null, rl: shared =
|
|
|
42
42
|
const dim = (t) => console.log(" \x1b[2m" + t + "\x1b[0m");
|
|
43
43
|
let answer = "no";
|
|
44
44
|
try {
|
|
45
|
-
// The consent ear may explain, delay or refuse
|
|
45
|
+
// The consent ear may explain, delay or refuse - it can never say yes. Yes is typed, cleanly.
|
|
46
46
|
for (let round = 0; round < 4; round++) {
|
|
47
47
|
answer = hear(await rl.question(question));
|
|
48
48
|
if (answer !== "no" || NO.test(lastRaw)) break;
|
|
49
49
|
const { hearConsent } = await import("../bib/ear.mjs");
|
|
50
50
|
const meant = hearConsent(lastRaw).intent;
|
|
51
|
-
// A long yes ("ja ohne wenn und aber", "Sí. apt upgrade
|
|
51
|
+
// A long yes ("ja ohne wenn und aber", "Sí. apt upgrade ...") is not run - it is asked back.
|
|
52
52
|
if (YES_START.test(lastRaw) && meant !== "nein") { dim(t("sayClean")); continue; }
|
|
53
53
|
if (meant === "spaeter") { dim(t("later")); break; }
|
|
54
|
-
// "what does it do" and "is it dangerous" overlap (measured: most yes/no mix-ups)
|
|
54
|
+
// "what does it do" and "is it dangerous" overlap (measured: most yes/no mix-ups) - both get both.
|
|
55
55
|
if ((meant === "was_macht" || meant === "gefahr") && plan) {
|
|
56
56
|
plan.steps.forEach((s, i) => dim(`${i + 1}) ${tx(s.desc)} — ${t("stepIs")} ${s.script}`));
|
|
57
57
|
dim(plan.risk || t("danger")(plan.steps.length));
|
package/src/core/render.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Formats a check result as what / how / where plus evidence lines. A claim without a real
|
|
2
|
+
// line is printed as a gap, not as proof.
|
|
3
3
|
import { SAFE, WARN, RISK, UNKNOWN } from "./check.mjs";
|
|
4
4
|
import { t } from "../i18n.mjs";
|
|
5
5
|
import { tx } from "../i18n-checks.mjs";
|
package/src/core/sh.mjs
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Nothing here is a model's opinion — it is the machine's own output.
|
|
1
|
+
// Runs commands, locally or over ssh, and returns stdout, stderr and the exit status. All evidence in
|
|
2
|
+
// the program comes through here.
|
|
4
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
4
|
|
|
6
|
-
let PREFIX = null; // e.g. ["ssh","-i","/key","root@host"]
|
|
5
|
+
let PREFIX = null; // e.g. ["ssh","-i","/key","root@host"] - set by configureTarget()
|
|
6
|
+
|
|
7
|
+
// user@host, host, host:port-less names and addresses (letters, digits, . _ - @ : and [] for IPv6); never a leading -
|
|
8
|
+
export const validHost = (h) => typeof h === "string" && h.length > 0 && h.length < 256 && /^[A-Za-z0-9_][A-Za-z0-9._@:\[\]%-]*$/.test(h);
|
|
7
9
|
|
|
8
10
|
export function configureTarget(ssh) {
|
|
9
|
-
// ssh: null (local) | {host, key?}
|
|
11
|
+
// ssh: null (local) | {host, key?} - we build an argv prefix, never a shell string.
|
|
10
12
|
if (!ssh) { PREFIX = null; return; }
|
|
13
|
+
// A target is a name or address, never an option: a "host" that starts with "-" (-oProxyCommand=...) would be run by ssh
|
|
14
|
+
// itself as an option. Anything that is not a plain host is refused here and answers "no channel", never a command.
|
|
15
|
+
if (!validHost(ssh.host)) { PREFIX = ["sh", "-c", "echo 'ungültiges Ziel' >&2; exit 255", "dante"]; return; }
|
|
11
16
|
const p = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
|
|
12
17
|
if (ssh.key) p.push("-i", ssh.key);
|
|
13
|
-
p.push(ssh.host);
|
|
18
|
+
p.push("--", ssh.host);
|
|
14
19
|
PREFIX = p;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
export function targetLabel() {
|
|
18
|
-
return PREFIX ? PREFIX[PREFIX.length - 1] : "localhost";
|
|
23
|
+
return PREFIX ? (PREFIX[0] === "ssh" ? PREFIX[PREFIX.length - 1] : "ungültig") : "localhost";
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
/**
|
package/src/i18n-checks.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Unknown text — above all every raw line from a real command — passes through unchanged.
|
|
1
|
+
// Translations for the fixed sentences of the checks. The checks themselves are written in German;
|
|
2
|
+
// the renderer looks sentences up here and passes unknown text (such as command output) through.
|
|
4
3
|
import { lang } from "./i18n.mjs";
|
|
5
4
|
|
|
6
5
|
const BOOK = [
|