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
package/src/bib/router.mjs
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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
|
|
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
|
|
52
|
-
// line the moment it finds a gap and checks again right away). Everything derived
|
|
53
|
-
// filler-word weight, trust
|
|
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
|
|
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
|
|
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
|
|
116
|
-
// (400 lines) then stop swallowing small ones (160 lines)
|
|
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
|
|
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
|
|
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+)/);
|
package/src/bib/system.mjs
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
50
|
-
// the real output. Small and hand-verified
|
|
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
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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";
|
package/src/checks/battery.mjs
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
const rules = rulesWith(packs);
|
|
38
|
+
return { battery: buildBattery(rules), deep: buildDeep(rules), packs, notices };
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
// muster intent
|
|
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
|
|
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: [] }; }
|
package/src/checks/common.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
//
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
}
|
package/src/checks/demo.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
+
}
|