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.
Files changed (63) hide show
  1. package/README.md +56 -23
  2. package/package.json +1 -1
  3. package/src/bib/answers.mjs +2 -2
  4. package/src/bib/bib.json +1 -1
  5. package/src/bib/ear.mjs +19 -18
  6. package/src/bib/net.json +1 -1
  7. package/src/bib/net.mjs +7 -9
  8. package/src/bib/probe.mjs +7 -8
  9. package/src/bib/pruef.json +1 -0
  10. package/src/bib/router.mjs +16 -19
  11. package/src/bib/system.mjs +14 -15
  12. package/src/bib/wash.mjs +5 -13
  13. package/src/checks/battery.mjs +15 -10
  14. package/src/checks/common.mjs +5 -5
  15. package/src/checks/demo.mjs +2 -2
  16. package/src/checks/formats.mjs +221 -0
  17. package/src/checks/hf.mjs +252 -0
  18. package/src/checks/loc.mjs +2 -4
  19. package/src/checks/php2shell.mjs +8 -9
  20. package/src/checks/rechte.mjs +2 -3
  21. package/src/checks/secrets.mjs +2 -4
  22. package/src/checks/sshd.mjs +3 -5
  23. package/src/checks/webshell.mjs +3 -5
  24. package/src/cli.mjs +15 -10
  25. package/src/core/check.mjs +4 -8
  26. package/src/core/consent.mjs +7 -7
  27. package/src/core/render.mjs +2 -2
  28. package/src/core/sh.mjs +12 -7
  29. package/src/i18n-checks.mjs +2 -3
  30. package/src/i18n.mjs +54 -14
  31. package/src/mascot.mjs +3 -5
  32. package/src/me/deep.mjs +37 -0
  33. package/src/me/diary.mjs +51 -0
  34. package/src/me/home.mjs +10 -0
  35. package/src/me/lock.mjs +41 -0
  36. package/src/me/nudge.mjs +52 -0
  37. package/src/me/onboard.mjs +102 -0
  38. package/src/me/overview.mjs +10 -0
  39. package/src/me/patrol.mjs +43 -0
  40. package/src/me/pick.mjs +64 -0
  41. package/src/me/pool.mjs +15 -0
  42. package/src/me/recon.mjs +94 -0
  43. package/src/me/refresh.mjs +70 -0
  44. package/src/me/schedule-deep.mjs +86 -0
  45. package/src/me/schedule.mjs +120 -0
  46. package/src/me/texts.mjs +291 -0
  47. package/src/me/user.mjs +67 -0
  48. package/src/pack/builtin.mjs +67 -4
  49. package/src/pack/ere.mjs +87 -0
  50. package/src/pack/install.mjs +6 -8
  51. package/src/pack/key.mjs +2 -3
  52. package/src/pack/know.mjs +4 -5
  53. package/src/pack/pack.mjs +14 -10
  54. package/src/pack/rules.mjs +109 -1
  55. package/src/pack/texts.mjs +8 -7
  56. package/src/session.mjs +199 -37
  57. package/src/ticker.mjs +10 -9
  58. package/src/ui/footer.mjs +3 -5
  59. package/src/ui/job-worker.mjs +3 -3
  60. package/src/ui/job.mjs +7 -6
  61. package/src/ui/quips.mjs +1 -2
  62. package/src/ui/wait.mjs +3 -4
  63. package/src/version.mjs +11 -0
@@ -1,18 +1,15 @@
1
- // The ear: turns what a human types into an intent + a probability. No LLM, no network.
2
- // Weighted naive Bayes over four kinds of features, learned by counting a bib of example lines:
3
- // f: first word (the "starts with hi → greeting 78%" signal), w: words, b: word pairs,
4
- // c: letter triples (so "prüff", "servr", "chek" still land). Each kind carries its own weight.
5
- // A feature found in many intents ("me", "wie", "bitte") says little, so it is weighed down by
6
- // how many intents share it — the bib decides what is a filler word, not a hand-made list.
7
- // The answer is honest about doubt: below the threshold, or when most of the words are unknown,
8
- // it does not guess — it says "unklar" and hands back the two best candidates.
1
+ // Naive Bayes classifier for what the user typed: returns an intent and a probability. No network.
2
+ // Four feature kinds are counted from the example library: first word, words, word pairs, letter
3
+ // triples (so typos like "prüff" or "chek" still land). A feature shared by many intents counts for
4
+ // less, which makes filler words fall out without a stop-word list. Below the confidence threshold it
5
+ // answers "unklar" and returns the two best candidates instead of guessing.
9
6
 
10
7
  const WEIGHT = { f: 1.5, w: 1, b: 1, c: 0.25 };
11
8
  const ALPHA = 0.5; // smoothing: an unseen pairing is rare, not impossible
12
- const SHARP = 2; // measured on the held-out set: 1 → too shy, ≥2.5 → only bolder, not wiser
9
+ const SHARP = 2; // measured on the held-out set: 1 -> too shy, ≥2.5 -> only bolder, not wiser
13
10
  export const SURE = 0.6; // below this the router asks instead of acting
14
11
  // Risk-weighted: an intent that only ANSWERS (a question, small talk, a greeting) may speak from
15
- // SURE_ANSWER on — a wrong answer is wrong text, never a deed. Actions keep SURE.
12
+ // SURE_ANSWER on - a wrong answer is wrong text, never a deed. Actions keep SURE.
16
13
  // Measured (25.10.): 135 of 146 question misses had the right intent on top, only under 60 %.
17
14
  export const ANSWER_ONLY = new Set(["frage_luecke", "frage_urteil", "frage_gehackt", "frage_dante", "smalltalk", "gruss", "hilfe", "unklar"]);
18
15
  export let SURE_ANSWER = Number(process.env.DANTE_SURE_ANSWER || 0.4);
@@ -48,13 +45,13 @@ export function features(text) {
48
45
  return out;
49
46
  }
50
47
 
51
- // The model only keeps raw counts, so it can learn one line at a time (the kitchen loop adds a
52
- // line the moment it finds a gap and checks again right away). Everything derived — balance,
53
- // filler-word weight, trust — is worked out at the moment of hearing, from those counts.
48
+ // The model only keeps raw counts, so it can learn one line at a time (the build server loop adds a
49
+ // line the moment it finds a gap and checks again right away). Everything derived - balance,
50
+ // filler-word weight, trust - is worked out at the moment of hearing, from those counts.
54
51
  // balance: every intent weighs the same in total, so one huge intent ("unklar", 10× the others)
55
52
  // keeps its breadth of words but stops winning by sheer mass. Measured on the bib, not guessed.
56
53
  // trust: a feature seen once ("prüff" in one single line) gets a fifth of its weight, full weight
57
- // from TRUST sightings on — one example must not turn into "99.9 % sure".
54
+ // from TRUST sightings on - one example must not turn into "99.9 % sure".
58
55
  const TRUST = 5; // measured: 3 still let one "prüff"-line decide alone, 5 does not
59
56
 
60
57
  export function model({ balance = true, trust = TRUST } = {}) {
@@ -73,7 +70,7 @@ export function learn(m, { intent, text }) {
73
70
  return m;
74
71
  }
75
72
 
76
- // The exact inverse of learn — the kitchen takes a line back out when it proved too broad.
73
+ // The exact inverse of learn - the build server takes a line back out when it proved too broad.
77
74
  export function unlearn(m, { intent, text }) {
78
75
  const k = m.intents[intent];
79
76
  if (!k) return m;
@@ -112,8 +109,8 @@ export function classify(m, text, { sharp = SHARP, all = false } = {}) {
112
109
  };
113
110
  const words = tokens(text);
114
111
  const known = words.length ? words.filter(knownWord).length / words.length : 0;
115
- // Complement: judge each intent by how badly the line fits ALL THE OTHERS. Big intents
116
- // (400 lines) then stop swallowing small ones (160 lines) — "ich geh jetzt" stays "raus".
112
+ // Complement: judge each intent by how badly the line fits all the OTHERS. Big intents
113
+ // (400 lines) then stop swallowing small ones (160 lines) - "ich geh jetzt" stays "raus".
117
114
  const restTotal = names.reduce((a, c, i) => a + scale[i] * totals[i], 0);
118
115
  const rest = fs.map(([f]) => names.reduce((a, c, i) => a + scale[i] * (m.intents[c].f[f] || 0), 0));
119
116
  const raw = names.map((c, i) => {
@@ -132,11 +129,11 @@ export function classify(m, text, { sharp = SHARP, all = false } = {}) {
132
129
  return { intent: sure ? best.intent : "unklar", p: best.p, known, ranked: all ? ranked : ranked.slice(0, 3) };
133
130
  }
134
131
 
135
- // "this machine" in any of the three languages — a slot, not an intent: "check local" is a check
132
+ // "this machine" in any of the three languages - a slot, not an intent: "check local" is a check
136
133
  // whose target is here. A word list decides it, no probability in between.
137
134
  const LOCAL = /(^|\s)(lokal|local|localhost|hier|here|aqu[ií]|diese[nm]? (rechner|laptop|mac|pc)|meine?[nm]? (rechner|laptop|mac|pc)|this (machine|computer|box|laptop|mac|pc)|my (own )?(machine|computer|box|laptop|mac|pc)|mi (compu|computadora|pc|equipo|laptop|mac)|este equipo|esta (compu|computadora|pc))(?=$|[\s,.!?])/i;
138
135
 
139
- // The value a command needs (target / key path / local), read from the raw line — never invented.
136
+ // The value a command needs (target / key path / local), read from the raw line - never invented.
140
137
  export function slots(text) {
141
138
  const ziel = String(text).match(/\b[\w.-]+@[\w.-]+\b/) || String(text).match(/\b\d{1,3}(\.\d{1,3}){3}\b/);
142
139
  const pfad = String(text).match(/(?:^|\s)((?:~|\.{0,2})\/\S+)/);
@@ -1,16 +1,10 @@
1
- // The sixth bib: system knowledge — kept apart from the five language bibs on purpose.
2
- // The language router (the "intent catcher") hears the user; when the move is a system job it
3
- // OPENS this book and works. Two parts:
4
- // S — the moves Dante must RECOGNISE (grows via the same kitchen loop as the language bib)
5
- // RECIPES — the HANDS: per system family, the exact command and which real line means a hit.
6
- // There is no E1/E2/E3 depth here: nothing personal to anonymise. The only split is the family,
7
- // and the family is a SLOT (like ziel/pfad), picked by the fingerprint — not an intent.
8
- // Dante does not trust what the user says the system is. He reads os-release himself first; that
9
- // reading is itself a recipe in this book (intent "fingerabdruck").
1
+ // Seeds for the three domain classifiers (system moves, check requests, knowledge questions) and the
2
+ // recipes for system-specific commands. The OS family is read from the target itself
3
+ // (os-release), never taken from what the user says. Unknown families yield "unbekannt", not a verdict.
10
4
 
11
- // System families Dante can stand on. Everything else is "unbekannt" → Dante says so, never SAFE.
5
+ // System families Dante can stand on. Everything else is "unbekannt" -> Dante says so, never SAFE.
12
6
  export const FAMILIES = ["debian", "alpine", "fedora", "arch", "unbekannt"];
13
- // os-release ID / ID_LIKE → family (the fingerprint maps real output to one column).
7
+ // os-release ID / ID_LIKE -> family (the fingerprint maps real output to one column).
14
8
  export const FAMILY_OF = {
15
9
  debian: "debian", ubuntu: "debian", raspbian: "debian", mint: "debian", devuan: "debian",
16
10
  alpine: "alpine",
@@ -29,8 +23,12 @@ const S = {
29
23
  export const SYS_PROBE = Object.entries(S).flatMap(([intent, xs]) => xs.map((text) => ({ intent, text })));
30
24
 
31
25
  // The Muster-Bib seed: how a user asks the watchman to look for what is WRONG (beyond php2shell).
32
- // Recognition only — the hands are the deterministic battery in src/checks/. Grown by the loop.
26
+ // Recognition only - the hands are the deterministic battery in src/checks/. Grown by the loop.
33
27
  const M = {
28
+ umschauen: ["schau dich um", "lern mich kennen", "sieh dich mal um", "was weißt du über meinen rechner", "mach dich mit meinem system vertraut", "look around", "get to know my machine", "have a look at my setup", "mira a tu alrededor", "conoce mi equipo", "echa un vistazo a mi sistema"],
29
+ scharf: ["schalt dich scharf", "aktiviere die patrouille", "lauf mehrmals am tag", "überwache mich automatisch", "arm yourself", "turn on the patrol", "run on a schedule", "watch my machine automatically", "ponte en marcha", "activa la patrulla", "revisa automáticamente"],
30
+ hf: ["prüf meine modelle", "ist das modell sicher zu laden", "schau dir meine ki modelle an", "was darf mein agent", "prüf meine mcp server", "check meine huggingface modelle", "laden meine gewichte fremden code", "check my models", "are my huggingface models safe to load", "look at my mcp servers", "what is my agent allowed to do", "scan my downloaded models", "revisa mis modelos", "son seguros mis modelos de hugging face", "mira mis servidores mcp", "qué puede hacer mi agente"],
31
+ abschalten: ["schalt dich ab", "stopp die patrouille", "lauf nicht mehr automatisch", "nimm den zeitplan raus", "disarm", "turn off the patrol", "stop running automatically", "remove the schedule", "desarmar", "apaga la patrulla", "quita el horario"],
34
32
  patrouille: ["prüf alles", "mach die runde", "kompletter check", "check alles durch", "alles scannen", "komplett prüfen", "mach die ganze runde", "scan everything", "full check", "run all checks", "check everything", "revisa todo", "chequeo completo", "escanéalo todo"],
35
33
  webshell: ["hat mir wer was reingelegt", "gibt es fremde dateien", "such nach webshells", "ist ne hintertür drin", "schädliche php dateien", "backdoor finden", "fremder code im webroot", "check for webshells", "any backdoor files", "malicious php files", "look for a shell", "busca webshells", "archivos maliciosos", "hay una puerta trasera"],
36
34
  secrets: ["ist meine env offen", "leaken meine geheimnisse", "ist mein git public", "sind passwörter sichtbar", "liegt die .env offen", "db passwort sichtbar", "are my secrets exposed", "is my .env public", "exposed env file", "secrets leaking", "mis secretos están expuestos", "está mi env público", "contraseña de la base visible"],
@@ -39,6 +37,7 @@ const M = {
39
37
  };
40
38
  // The Fachwissen seed: how people ask Dante to explain (what he watches / one topic or finding).
41
39
  const W = {
40
+ bericht: ["was ist passiert", "was hast du gemacht", "gibt es was neues", "wie lief die nacht", "hast du was gefunden", "was war seit gestern", "what happened", "what did you do", "anything new", "did you find anything", "what was going on while i was away", "qué pasó", "qué hiciste", "hay novedades", "encontraste algo", "cómo fue la noche"],
42
41
  job: ["was überwachst du", "was ist dein job", "was prüfst du alles", "wofür bist du da", "was kannst du inhaltlich", "worauf achtest du", "what do you watch", "what is your job", "what do you check", "what are you for", "what do you look for", "que vigilas", "cual es tu trabajo", "que revisas", "para que sirves", "en que te fijas"],
43
42
  thema: ["was ist eine webshell", "woran erkenne ich offene geheimnisse", "warum ist root login gefährlich", "wie prüfst du ssh", "ist das normal", "was tue ich dagegen", "was bedeutet php2shell", "what is a webshell", "what stands out about open secrets", "why is root login dangerous", "how do you check ssh", "is that normal", "what do i do about it", "que es una webshell", "que llama la atencion en los permisos", "por que es peligroso el login de root", "como compruebas ssh", "es normal esto", "que hago al respecto"],
44
43
  };
@@ -46,11 +45,11 @@ export const WISSEN_PROBE = Object.entries(W).flatMap(([intent, xs]) => xs.map((
46
45
 
47
46
  export const MUSTER_PROBE = Object.entries(M).flatMap(([intent, xs]) => xs.map((text) => ({ intent, text })));
48
47
 
49
- // The HANDS. Per family: the exact command Dante runs on the real server, and the rule that reads
50
- // the real output. Small and hand-verified — this is where "every verdict carries a real line"
48
+ // The hands. Per family: the exact command Dante runs on the real server, and the rule that reads
49
+ // the real output. Small and hand-verified - this is where "every verdict carries a real line"
51
50
  // lives. The loop may PROPOSE additions, but a recipe is only the shape below; the hit-logic is
52
51
  // deterministic code in src, never DeepSeek prose. php2shell = CVE-2026-109618 (fictional here):
53
- // affected PHP < 8.1.27, 8.2.0–8.2.13, 8.3.0–8.3.1 — AND only when the vulnerable module is loaded.
52
+ // affected PHP < 8.1.27, 8.2.0–8.2.13, 8.3.0–8.3.1 - and only when the vulnerable module is loaded.
54
53
  export const RECIPES = {
55
54
  debian: {
56
55
  fingerabdruck: { cmd: ". /etc/os-release 2>/dev/null; echo \"$ID ${VERSION_ID}\"", beleg: "die ID-Zeile aus /etc/os-release" },
package/src/bib/wash.mjs CHANGED
@@ -1,16 +1,8 @@
1
- // Washer for the language library: replaces personal values in a user sentence with global
2
- // placeholders and leaves the rest 1:1. wash(text) -> { text, hits } (hits = count per placeholder).
3
- //
4
- // Order matters so nothing is half-matched: URL > key paths > mail > user@host > tokens > IP >
5
- // domain > path > card/IBAN > phone > long numbers > hex/base64 keys > secret labels > names.
6
- //
7
- // Limits (on purpose "better too little than too much"):
8
- // - {NAME} only fires for (a) a known first name + capitalized surname, (b) a title cue
9
- // (Herr/Frau/Dr./Mr./Sr./...) + capitalized name, (c) an intro cue ("mein Name ist", "me llamo",
10
- // "my name is") + name, (d) Spanish messaging cue ("escribirle a danny jose ...", lowercase ok). Lone first names, lowercase names and unknown pairs stay untouched.
11
- // - Windows paths with spaces are cut at the first space. Relative paths (src/x.mjs) stay.
12
- // - Domains are only recognised with a known TLD (so index.php / app.js survive).
13
- // - {NUM} also eats plain big numbers (1000000); versions (8.3.6) and CVE-2026-109618 stay.
1
+ // Replaces personal values in a sentence (paths, hosts, mails, tokens, numbers, names) with placeholders
2
+ // before it is used as training data. wash(text) returns { text, hits }.
3
+ // The order of the rules matters, longer patterns first. It prefers leaving something in over
4
+ // removing too much: names are only replaced with a clear cue (known first name + surname, title,
5
+ // "my name is"), domains need a known TLD, versions and CVE ids stay.
14
6
 
15
7
  const L = "\\p{L}\\p{N}_";
16
8
  const TLD = "com|net|org|io|de|es|fr|uk|us|ai|app|dev|co|info|biz|eu|online|space|xyz|site|cloud|me|tv|ch|at|nl|it|ru|cn|jp|do|edu|gov|store|shop|pro|tech";
@@ -1,19 +1,23 @@
1
- // The watchman's battery: the full patrol, built from RULES. The free state is the built-in rule
2
- // list; every installed pack adds rules or replaces ones with the same id (newest pack wins). One
3
- // ordered list so the session, the patrol and the tests all run the SAME set. Each check is
4
- // read-only and carries its own real lines. `rechte` (world-writable files) has no rule type yet and
5
- // stays plain code.
1
+ // Assembles the checks from rules. Built-in rules are the free tier; each installed pack adds rules or
2
+ // replaces one with the same id (newest wins). Deep rules (secret formats, models, agent config) are
3
+ // kept out of the light battery and run in the deep run or on request. `rechte` has no rule type
4
+ // yet and stays plain code.
6
5
  import { versionCheck } from "./php2shell.mjs";
7
6
  import { exposedCheck } from "./secrets.mjs";
8
7
  import { patternCheck } from "./webshell.mjs";
9
8
  import { sshdCheck } from "./sshd.mjs";
9
+ import { formatsCheck } from "./formats.mjs";
10
+ import { modelCardCheck, modelFilesCheck, agentConfigCheck } from "./hf.mjs";
10
11
  import { rechte } from "./rechte.mjs";
11
12
  import { BUILTIN } from "../pack/builtin.mjs";
12
13
  import { loadPacks } from "../pack/pack.mjs";
13
14
 
14
- const FACTORY = { version_cve: versionCheck, path_exposed: exposedCheck, file_pattern: patternCheck, sshd_setting: sshdCheck };
15
+ const FACTORY = { version_cve: versionCheck, path_exposed: exposedCheck, file_pattern: patternCheck, sshd_setting: sshdCheck, secret_format: formatsCheck, model_card: modelCardCheck, model_files: modelFilesCheck, agent_config: agentConfigCheck };
15
16
 
16
- export const buildBattery = (rules) => [...rules.map((r) => FACTORY[r.type](r)), rechte];
17
+ // Deep rules (the Prüf-Bib's secret formats) are not part of the light battery that the patrol and the session run;
18
+ // they run in the deep run only (buildDeep).
19
+ export const buildBattery = (rules) => [...rules.filter((r) => !r.deep).map((r) => FACTORY[r.type](r)), rechte];
20
+ export const buildDeep = (rules) => rules.filter((r) => r.deep).map((r) => FACTORY[r.type](r));
17
21
 
18
22
  // a rule of a later list replaces the rule with the same id, new ids are added
19
23
  export function mergeRules(base, ...more) {
@@ -30,15 +34,16 @@ export const BATTERY = buildBattery(BUILTIN.rules);
30
34
  // checks. notices lists the pack files he refused (code per file), for the person to be told once.
31
35
  export function activeBattery(opts) {
32
36
  const { packs, notices } = loadPacks(opts);
33
- return { battery: buildBattery(rulesWith(packs)), packs, notices };
37
+ const rules = rulesWith(packs);
38
+ return { battery: buildBattery(rules), deep: buildDeep(rules), packs, notices };
34
39
  }
35
40
 
36
- // muster intent → check id. The ear recognises the worry; this says which hand to use.
41
+ // muster intent -> check id. The ear recognises the worry; this says which hand to use.
37
42
  export const MUSTER = { pruef_php2shell: "php2shell", secrets: "secrets", webshell: "webshell", sshd: "sshd", rechte: "rechte" };
38
43
 
39
44
  export const checkById = (id, list = BATTERY) => list.find((c) => c.id === id) || null;
40
45
 
41
- // Run one check safely — a thrown check becomes an honest UNKNOWN, never a crash or a false verdict.
46
+ // Run one check safely - a thrown check becomes an honest UNKNOWN, never a crash or a false verdict.
42
47
  export function runCheck(check) {
43
48
  try { return check.run(); }
44
49
  catch (e) { return { id: check.id, title: check.title, verdict: "unknown", was: "Check brach ab.", wie: String(e.message || e), wo: "", evidence: [] }; }
@@ -1,8 +1,8 @@
1
- // Shared bits for the watchman battery. Read-only helpers only — a real line, or nothing.
1
+ // Helpers shared by the checks: quoting, web root lookup, line trimming. Read-only.
2
2
  import { run, firstLine } from "../core/sh.mjs";
3
3
 
4
4
  // Single-quote an argument for the remote shell. Double quotes would let the shell expand a `$` in
5
- // a regex (e.g. $_GET → the _ variable), which silently mangled the webshell pattern. Single quotes
5
+ // a regex (e.g. $_GET -> the _ variable), which silently mangled the webshell pattern. Single quotes
6
6
  // make every character literal; an embedded ' is closed, escaped and reopened.
7
7
  export const sq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
8
8
 
@@ -13,17 +13,17 @@ export const WEBROOTS = ["/var/www/html", "/var/www", "/usr/share/nginx/html", "
13
13
  // Which of the standard web roots actually exist on the target (its own output decides).
14
14
  // Exit code is unreliable here: the for-loop's last `[ -d ] && echo` fails for the last missing
15
15
  // path, so the loop exits non-zero even when it printed real roots. We trust the OUTPUT (the lines
16
- // the machine actually printed), not the exit code — ran is enough.
16
+ // the machine actually printed), not the exit code - ran is enough.
17
17
  export function webRoots() {
18
18
  const r = run("for d in " + WEBROOTS.join(" ") + "; do [ -d \"$d\" ] && echo \"$d\"; done");
19
19
  let roots = r.ran ? r.out.split("\n").map((s) => s.trim()).filter(Boolean) : [];
20
- // Drop a root that sits inside another found root (/var/www/html under /var/www) — otherwise a
20
+ // Drop a root that sits inside another found root (/var/www/html under /var/www) - otherwise a
21
21
  // deep search finds the same file through both and reports it twice.
22
22
  roots = roots.filter((d) => !roots.some((o) => o !== d && (d === o || d.startsWith(o + "/"))));
23
23
  return { ran: r.ran, roots, line: firstLine(r.err || r.out) };
24
24
  }
25
25
 
26
- // First N non-empty lines of some output, trimmed — for a compact, honest evidence list.
26
+ // First N non-empty lines of some output, trimmed - for a compact, honest evidence list.
27
27
  export function lines(s, n = 10) {
28
28
  return String(s || "").split("\n").map((x) => x.trim()).filter(Boolean).slice(0, n);
29
29
  }
@@ -1,5 +1,5 @@
1
- // Demo check (only with --demo): a harmless "open door" = a temp file. Lets you watch the whole
2
- // loop — human-listen → plan → ja/nein → fix → re-prove — without touching anything real.
1
+ // Harmless demo check for --demo: a temp file stands in for an open door, so the whole
2
+ // ask -> plan -> confirm -> fix -> verify loop can be tried without touching anything real.
3
3
  import { run, firstLine } from "../core/sh.mjs";
4
4
  import { result, evidence, SAFE, RISK } from "../core/check.mjs";
5
5
 
@@ -0,0 +1,221 @@
1
+ // Deep check for secrets in project files (keys, tokens, private keys, passwords), built from a
2
+ // secret_format rule. A router picks the pattern groups by marker files (package.json, composer.json, ...).
3
+ // A hit is followed up with file mode and git state (tracked, in history, ignored). The report names file,
4
+ // line and kind of secret but never the value. Local scans run in Node, remote ones with the server's grep.
5
+ import { run, targetLabel } from "../core/sh.mjs";
6
+ import { result, evidence, SAFE, WARN, RISK, UNKNOWN } from "../core/check.mjs";
7
+ import { webRoots, lines, sq } from "./common.mjs";
8
+ import { loc } from "./loc.mjs";
9
+ import { lang } from "../i18n.mjs";
10
+ import { read as readUser } from "../me/user.mjs";
11
+ import { homedir } from "node:os";
12
+ import { readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { toJs, literalOf, automaton } from "../pack/ere.mjs";
15
+
16
+ const SKIP = ["node_modules", ".git", "vendor", "dist", "build", ".venv", "venv", "__pycache__", "Pods", "target", ".next", ".cache"];
17
+ const MARKERS = [
18
+ ["node", "package.json"], ["php", "composer.json"], ["python", "requirements.txt"], ["python", "pyproject.toml"], ["go", "go.mod"],
19
+ ["ruby", "Gemfile"], ["java", "pom.xml"], ["java", "build.gradle"], ["rust", "Cargo.toml"], ["apple", "*.xcodeproj"], ["apple", "Package.swift"],
20
+ ];
21
+ // Example files and placeholder lines are not secrets: a template (.env.example) is meant to be committed, and a
22
+ // line that reads "xxxxxx", "YOUR_KEY_HERE" or "<token>" holds no key. The line is judged inside the shell; it is
23
+ // never printed.
24
+ const EXAMPLE_FILES = ["*.example", "*.sample", "*.dist", "*.template", "*.tpl"];
25
+ export const PLACEHOLDER = "x{6,}|X{6,}|\\*{4,}|example|sample|your[_ -]|changeme|change_me|placeholder|dummy|replace[_ -]?me|insert[_ -]|<[A-Za-z_ -]+>|todo|fake|redacted";
26
+ // Which files are looked at: no skipped folders, no example files, nothing over 400 KB (secrets live in small
27
+ // config and code files; big data files are slow and noisy) - the same list for every group of patterns.
28
+ const PRUNE = SKIP.map((d) => "-name " + sq(d)).join(" -o ");
29
+ const NOT_EXAMPLE = EXAMPLE_FILES.map((f) => "! -name " + sq(f)).join(" ");
30
+ const pickFiles = (roots) => `find ${roots.map(sq).join(" ")} \\( ${PRUNE} \\) -prune -o -type f -size -400k ${NOT_EXAMPLE} -print0 2>/dev/null`;
31
+ const MAX_FILES = 12; // how many hit files he drills into
32
+ const SCAN_SECONDS = 120;
33
+
34
+ const fname = (f) => (f.i18n && f.i18n[lang()]) || f.name;
35
+ const expand = (p) => String(p || "").replace(/^~(?=\/)/, homedir());
36
+
37
+ // Where to look: the web roots of the target and, on this machine, the project folders from user.md.
38
+ export function scanRoots() {
39
+ const wr = webRoots();
40
+ const roots = [...wr.roots];
41
+ if (targetLabel() === "localhost") {
42
+ const u = readUser();
43
+ for (const p of (u && u.projects) || []) { const e = expand(p); if (e && !roots.includes(e)) roots.push(e); }
44
+ }
45
+ return { ran: wr.ran, roots, line: wr.line };
46
+ }
47
+
48
+ // The router: which systems are present under these roots (real marker files found), plus the machine itself.
49
+ export function routeSystems(roots) {
50
+ const found = new Set(["generic", "web"]);
51
+ const find = MARKERS.map(([, n]) => "-name " + sq(n)).join(" -o ");
52
+ const r = run("find " + roots.map(sq).join(" ") + " -maxdepth 4 \\( " + find + " \\) -not -path '*/node_modules/*' 2>/dev/null | head -200", { timeout: 40000 });
53
+ const lineSet = lines(r.out, 200);
54
+ for (const [sys, n] of MARKERS) { const tail = n.replace("*", ""); if (lineSet.some((l) => l.endsWith(tail))) found.add(sys); }
55
+ const os = run("uname -s");
56
+ found.add(/darwin/i.test(os.out) ? "apple" : "linux");
57
+ return { systems: [...found], line: lineSet[0] || "(keine Projekt-Dateien gefunden)" };
58
+ }
59
+
60
+ // grep -E pattern list for the given formats
61
+ const eArgs = (fs) => fs.map((f) => "-e " + sq(f.re)).join(" ");
62
+ const CHUNK = 250; // patterns per grep call: keeps the command short (ssh, ARG_MAX) and the grep fast
63
+ const chunks = (fs) => Array.from({ length: Math.ceil(fs.length / CHUNK) }, (_, i) => fs.slice(i * CHUNK, (i + 1) * CHUNK));
64
+
65
+ // The grep on the target decides what it understands (GNU, BSD and ugrep differ). A pattern it refuses must not
66
+ // silently turn into "nothing found": test the list on empty input, split it until the refused ones are known, and
67
+ // say so. Returns { ok: usable formats, refused: count, ran }.
68
+ export function usable(fs) {
69
+ const test = (list) => run(`printf '' | grep -E ${eArgs(list)} >/dev/null 2>&1; echo $?`, { timeout: 20000 });
70
+ const rc = (r) => (r.ran ? Number((r.out || "").trim().split("\n").pop()) : NaN);
71
+ const ok = [];
72
+ let refused = 0, ran = true;
73
+ const go = (list) => {
74
+ if (!list.length) return;
75
+ const r = test(list), c = rc(r);
76
+ if (!r.ran || Number.isNaN(c)) { ran = false; return; }
77
+ if (c < 2) { ok.push(...list); return; }
78
+ if (list.length === 1) { refused++; return; }
79
+ const h = Math.ceil(list.length / 2);
80
+ go(list.slice(0, h)); go(list.slice(h));
81
+ };
82
+ for (const c of chunks(fs)) go(c);
83
+ return { ok, refused, ran };
84
+ }
85
+
86
+ export function drill(file) {
87
+ const dir = file.replace(/\/[^/]*$/, "") || "/";
88
+ const script = [
89
+ `f=${sq(file)}; d=${sq(dir)}`,
90
+ `m=$(ls -ld "$f" | cut -c1-10)`,
91
+ `if git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
92
+ ` if git -C "$d" ls-files --error-unmatch "$f" >/dev/null 2>&1; then g=tracked`,
93
+ ` elif [ -n "$(git -C "$d" log --oneline -1 -- "$f" 2>/dev/null)" ]; then g=history`,
94
+ ` elif git -C "$d" check-ignore -q "$f" 2>/dev/null; then g=ignored`,
95
+ ` else g=untracked; fi`,
96
+ `else g=nogit; fi`,
97
+ `echo "$m $g"`,
98
+ ].join("\n");
99
+ const r = run(script, { timeout: 15000 });
100
+ const [mode = "", git = "nogit"] = (r.out.split("\n").pop() || "").trim().split(/\s+/);
101
+ return { ran: r.ran && !!mode, mode, git, worldReadable: mode[7] === "r", line: r.out.split("\n").pop() || "" };
102
+ }
103
+
104
+ // ---- the search on this machine, in Node: prefilter with Aho-Corasick on the fixed text of every pattern, then
105
+ // only the patterns whose text occurs in the file are tried, line by line. Placeholder lines are skipped here.
106
+ const PH = new RegExp(PLACEHOLDER, "i");
107
+ const isExample = (n) => /\.(example|sample|dist|template|tpl)$/.test(n);
108
+ export function scanNode(roots, fs, { budgetMs = SCAN_SECONDS * 1000, maxFiles = 300000 } = {}) {
109
+ const start = Date.now();
110
+ const mine = [];
111
+ let skipped = 0;
112
+ for (const f of fs) { const js = toJs(f.re), lit = literalOf(f.re); if (js && lit.length >= 3) mine.push({ f, js, lit }); else skipped++; }
113
+ if (!mine.length) return { ok: false, why: "Keins der Muster lässt sich hier lesen" };
114
+ const lits = [...new Set(mine.map((x) => x.lit))], byLit = new Map(lits.map((l, i) => [i, mine.filter((x) => x.lit === l)]));
115
+ const find = automaton(lits);
116
+ const out = [];
117
+ let seen = 0, incomplete = "";
118
+ const stack = [...roots];
119
+ while (stack.length && !incomplete) {
120
+ const dir = stack.pop();
121
+ let ents; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { continue; } // an unreadable folder is skipped
122
+ for (const e of ents) {
123
+ if (Date.now() - start > budgetMs) { incomplete = "Zeitlimit erreicht"; break; }
124
+ if (seen > maxFiles) { incomplete = "zu viele Dateien"; break; }
125
+ const p = join(dir, e.name);
126
+ if (e.isDirectory()) { if (!SKIP.includes(e.name)) stack.push(p); continue; }
127
+ if (!e.isFile() || isExample(e.name)) continue;
128
+ let buf; try { if (statSync(p).size > 400 * 1024) continue; buf = readFileSync(p); } catch { continue; }
129
+ seen++;
130
+ if (buf.subarray(0, 4096).includes(0)) continue; // binary
131
+ const text = buf.toString("latin1"), got = find(text);
132
+ if (!got.size) continue;
133
+ const lines = text.split("\n"), hit = [];
134
+ for (const id of got) for (const x of byLit.get(id)) {
135
+ for (let i = 0; i < lines.length; i++) if (lines[i].length < 2000 && x.js.test(lines[i]) && !PH.test(lines[i])) { hit.push({ f: x.f, n: i + 1 }); break; }
136
+ }
137
+ if (hit.length) { hit.sort((a, b) => a.n - b.n); out.push({ file: p, hit }); if (out.length >= MAX_FILES * 4) { incomplete = ""; stack.length = 0; break; } }
138
+ }
139
+ }
140
+ if (incomplete) return { ok: false, why: incomplete + ` (${seen} Dateien gelesen)` };
141
+ return { ok: true, files: out, seen, used: mine.length, cmd: `Node: ${lits.length} feste Texte (Aho-Corasick) → ${mine.length} Formate`, note: skipped ? { claim: `${skipped} Muster lassen sich hier nicht lesen und fallen weg`, src: { cmd: "ERE → JS", line: `${mine.length} von ${fs.length} Mustern brauchbar` } } : null };
142
+ }
143
+
144
+ // ---- the search on a server: its own grep, in groups of patterns (see usable()).
145
+ export function scanGrep(roots, fs) {
146
+ const use = usable(fs);
147
+ if (!use.ran || !use.ok.length) return { ok: false, why: "Das grep dort versteht die Muster nicht" };
148
+ const groups = chunks(use.ok), found = new Set();
149
+ for (const g of groups) {
150
+ const g1 = run(`${pickFiles(roots)} | xargs -0 grep -lIsE ${eArgs(g)} 2>/dev/null | head -${MAX_FILES * 4}`, { timeout: SCAN_SECONDS * 1000 });
151
+ if (!g1.ran) return { ok: false, why: g1.errorMsg || "die Suche lief nicht zu Ende (Zeitlimit)" };
152
+ for (const h of lines(g1.out, MAX_FILES * 4)) found.add(h);
153
+ }
154
+ const files = [];
155
+ for (const file of [...found].slice(0, MAX_FILES * 4)) {
156
+ const hit = [];
157
+ for (const grp of groups) {
158
+ if (!run(`grep -qIE ${eArgs(grp)} ${sq(file)}`, { timeout: 15000 }).ok) continue;
159
+ for (const f of grp) {
160
+ const g = run(`l=$(grep -nIEm1 -e ${sq(f.re)} ${sq(file)}); [ -n "$l" ] || exit 0; if printf %s "$l" | cut -d: -f2- | grep -qiE ${sq(PLACEHOLDER)}; then echo ph; else echo "${"${l%%:*}"}"; fi`, { timeout: 15000 });
161
+ const n = (g.out || "").trim();
162
+ if (g.ran && /^\d+$/.test(n)) hit.push({ f, n: Number(n) });
163
+ }
164
+ }
165
+ if (hit.length) files.push({ file, hit });
166
+ }
167
+ return { ok: true, files, seen: found.size, used: use.ok.length, cmd: `grep -lIE <${use.ok.length} Formate> ${roots.join(" ")}`, note: use.refused ? { claim: `${use.refused} Muster versteht das grep dort nicht und fallen weg`, src: { cmd: "printf '' | grep -E <muster>", line: `${use.ok.length} von ${fs.length} Mustern brauchbar` } } : null };
168
+ }
169
+
170
+ export function formatsCheck(rule) {
171
+ return {
172
+ id: rule.id, know: rule.know || rule.id, title: rule.title, aliases: rule.aliases || [], cve: rule.cve || [], deep: true,
173
+
174
+ run() {
175
+ const m = loc(rule), id = rule.id, title = m.title, ev = [];
176
+ const sr = scanRoots();
177
+ if (!sr.ran) return result({ id, title, verdict: UNKNOWN, was: "Konnte das Ziel nicht abfragen.", wie: sr.line || "Der Suchbefehl lief nicht.", wo: "Ziel nicht erreichbar.", evidence: [] });
178
+ if (!sr.roots.length) {
179
+ ev.push(evidence("Keine Projekt- und Web-Ordner bekannt", { cmd: "web roots + user.md", line: "(keine Ordner zum Durchsuchen)" }));
180
+ return result({ id, title, verdict: UNKNOWN, was: "Es gibt keinen Ordner, den ich durchsuchen dürfte.", wie: "Lass mich mich umschauen (user.md), dann kenne ich deine Projektordner.", wo: "Dieser Rechner.", evidence: ev });
181
+ }
182
+ const route = routeSystems(sr.roots);
183
+ ev.push(evidence("Router: diese Systeme sind da", { cmd: "find <ordner> -name package.json -o -name composer.json …", line: route.systems.join(", ") + " · " + route.line }));
184
+ const fs = rule.formats.filter((f) => route.systems.includes(f.system));
185
+ if (!fs.length) return result({ id, title, verdict: UNKNOWN, was: "Für diese Systeme habe ich keine Muster.", wie: "Die Prüf-Bib kennt keins der gefundenen Systeme.", wo: "Dieser Rechner.", evidence: ev });
186
+
187
+ // The search itself. On this machine it runs in Node (BSD grep is far too slow with hundreds of patterns); on a
188
+ // server it runs with the server's own grep, whose patterns are proven usable first. Either way: a search
189
+ // that did not finish is UNKNOWN, never "clean".
190
+ const sc = targetLabel() === "localhost" ? scanNode(sr.roots, fs) : scanGrep(sr.roots, fs);
191
+ if (!sc.ok) return result({ id, title, verdict: UNKNOWN, was: "Konnte die Ordner nicht (vollständig) durchsuchen.", wie: sc.why + " — ohne saubere Suche sage ich nichts.", wo: "Dieser Rechner.", evidence: ev });
192
+ if (sc.note) ev.push(evidence(sc.note.claim, sc.note.src));
193
+ if (!sc.files.length) {
194
+ ev.push(evidence("Ordner durchsucht, keine Geheimnisse gefunden", { cmd: sc.cmd, line: `(keine Treffer in ${sr.roots.length} Ordner(n), ${sc.used} Formate, ${sc.seen} Dateien gelesen)` }));
195
+ return result({ id, title, verdict: SAFE, was: m.ok || "Keine Schlüssel oder Passwörter im Code gefunden.", wie: m.ok_how || "Die Ordner wurden mit den Formaten der Prüf-Bib durchsucht.", wo: "Dieser Rechner.", evidence: ev });
196
+ }
197
+
198
+ // The drill-down on every hit file (rights, git). Never the value.
199
+ let worst = SAFE, normal = 0;
200
+ const steps = [];
201
+ for (const { file, hit } of sc.files.slice(0, MAX_FILES)) {
202
+ const d = drill(file);
203
+ const names = hit.slice(0, 3).map((h) => `${fname(h.f)} (Zeile ${h.n})`).join("; ");
204
+ let level = SAFE;
205
+ if (d.git === "tracked" || d.git === "history") level = RISK;
206
+ else if (d.worldReadable || d.git === "untracked") level = WARN;
207
+ else if (!d.ran) level = WARN;
208
+ if (level === SAFE) normal++;
209
+ const gitWord = { tracked: "im Git eingecheckt", history: "war mal im Git", ignored: "git-ignoriert", untracked: "nicht ignoriert, nicht eingecheckt", nogit: "kein Git" }[d.git];
210
+ ev.push(evidence(`${names} — ${gitWord}, Rechte ${d.mode || "?"}`, { cmd: `grep -nE <format> ${file}; ls -ld; git ls-files/check-ignore/log`, line: `${file}:${hit[0].n} · ${d.line}` }));
211
+ if (level === RISK) { worst = RISK; steps.push({ desc: `Schlüssel in ${file} erneuern (er gilt als bekannt), dann aus Git nehmen: ${d.git === "tracked" ? "git rm --cached" : "History prüfen"}`, script: d.git === "tracked" ? `git -C ${sq(file.replace(/\/[^/]*$/, "") || "/")} rm --cached ${sq(file)}` : `git -C ${sq(file.replace(/\/[^/]*$/, "") || "/")} log --oneline -- ${sq(file)}` }); }
212
+ else if (level === WARN && worst !== RISK) { worst = WARN; if (d.worldReadable) steps.push({ desc: `Leserecht für andere entziehen: ${file}`, script: `chmod go-rwx ${sq(file)}` }); }
213
+ }
214
+ if (worst === SAFE) {
215
+ return result({ id, title, verdict: SAFE, was: "Schlüssel liegen nur dort, wo sie hingehören.", wie: `${normal} Datei(en) mit Schlüsseln, alle privat und aus Git herausgehalten — das ist normal.`, wo: "Dieser Rechner.", evidence: ev });
216
+ }
217
+ return result({ id, title, verdict: worst, was: m.bad || "Schlüssel oder Passwörter stehen offen im Code.", wie: m.bad_how || "Die Zeilen unten nennen Datei, Zeile, Art und Git-Lage — den Wert zeigt Dante nie.", wo: "Dieser Rechner.", evidence: ev,
218
+ fix: steps.length ? { why: "Ein Schlüssel, der im Git stand, gilt als bekannt: erst erneuern, dann aufräumen.", steps } : null });
219
+ },
220
+ };
221
+ }