Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (R179 a/b)
(a) Probe-Push verworfen. Er haette nur den geprobten Ref beurteilt, die eigene Push-Identitaet gemessen statt die des Angreifers (Bypass-Rechte fuer Admins gehen genau dann auseinander, wenn es zaehlt), nur einen Zeitpunkt abgedeckt - und einen zerstoerungsfreien Force-Push gibt es nicht: die bestaetigende Beobachtung waere derselbe Vorgang wie der Schaden. Stattdessen ein Fast-Forward-Waechter: Der beobachtete Remote-Kopf wird ausserhalb des Klons festgehalten; beim naechsten Lauf muss der neue Kopf ein Nachfahre des alten sein. Das erkennt das Ereignis statt die Regel abzufragen und wirkt unabhaengig von serverseitigem Schutz. Ein belegter Fast-Forward gilt als Nachweis und blendet den Rewind-Vorbehalt aus. (b) Code 3 als Tor vor dem Anhaengen statt als Status danach: Der Schreiblauf signiert mit dem neuen Checkpoint zugleich ueber den Bestand darunter - ist die Basis ungeklaert, waere das Anhaengen selbst das Waschmittel. Grundlage nicht feststellbar -> nichts anhaengen, exit 3. Erster Lauf -> Basislinie, ehrlich gemeldet, exit 3. Anhaengen geklappt, Push gescheitert -> exit 3 mit "erstellt, aber NICHT verankert". Nur Anhaengen + Push + belegte Verankerung -> exit 0. Verifiziert: Basislinie exit 3; Folgelauf exit 0 ohne Vorbehalt; Rewind aus einem frischen Auditoren-Klon ohne MIN_SEQ und ohne Zusicherung -> Alarm exit 2 (bisher stilles Gruen); kaputtes Push-Ziel -> "erstellt, aber nicht verankert" exit 3, Folgelauf haelt den ungepushten Commit fail-closed an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,8 +23,9 @@
|
||||
* fehl, wird die Datei zurueckgerollt (Pentest R175-03).
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, basename } from 'node:path';
|
||||
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, basename, join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const CRM_URL = process.env.CRM_URL;
|
||||
const CRM_TOKEN = process.env.CRM_TOKEN;
|
||||
@@ -102,6 +103,27 @@ if (MIN_SEQ_ROH) {
|
||||
// ~168 hinterherhinkt – jeder Rewind in dieses Fenster passiert lautlos, und
|
||||
// der gewaschene Stand wird sogar ausdruecklich bestaetigt.
|
||||
const REWIND_GESCHUETZT = process.env.NOTARY_REWIND_PROTECTED === 'true';
|
||||
|
||||
// Beobachtungsspeicher fuer den Fast-Forward-Monitor (Pentest R179 (a)).
|
||||
//
|
||||
// Statt die POLICY abzufragen (Probe-Push), wird das EREIGNIS erkannt, das die
|
||||
// Policy verhindern soll. Bei jedem Lauf wird der beobachtete Remote-Kopf
|
||||
// festgehalten; beim naechsten Mal muss der neue Kopf ein Nachfahre des alten
|
||||
// sein. Ist er das nicht, wurde zurueckgespult – unabhaengig davon, ob es einen
|
||||
// serverseitigen Schutz gibt oder nicht.
|
||||
//
|
||||
// Ein Probe-Push waere hier der falsche Weg: Er misst die eigene
|
||||
// Push-Identitaet, nicht die des Angreifers (Bypass-Rechte fuer Admins gehen
|
||||
// genau dann auseinander, wenn es darauf ankommt), er gilt nur fuer den
|
||||
// geprobten Ref, und die bestaetigende Beobachtung waere derselbe Vorgang wie
|
||||
// der Schaden.
|
||||
//
|
||||
// Die Datei gehoert bewusst NICHT in den Klon – sonst nimmt ein Neu-Klonen ihr
|
||||
// die Erinnerung. Idealerweise liegt sie auf separatem Speicher.
|
||||
const STATE_FILE = resolve(
|
||||
process.env.NOTARY_STATE_FILE ||
|
||||
join(homedir(), '.opencrm-notary', 'beobachtungen.jsonl'),
|
||||
);
|
||||
if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschuetzt-ist') {
|
||||
console.error(
|
||||
'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' +
|
||||
@@ -206,7 +228,7 @@ const OHNE_ANKER = () =>
|
||||
// signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung,
|
||||
// sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist.
|
||||
const REWIND_VORBEHALT = () =>
|
||||
REWIND_GESCHUETZT
|
||||
REWIND_GESCHUETZT || ffBelegt
|
||||
? ''
|
||||
: '\n Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' +
|
||||
'lässt sich hier nicht ausschließen.' +
|
||||
@@ -228,7 +250,11 @@ const REWIND_VORBEHALT = () =>
|
||||
// feststellbar“, dieselbe Bedeutung wie beim unerreichbaren Remote und
|
||||
// bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen.
|
||||
const abschliessendSicher = (anzahl) =>
|
||||
REWIND_GESCHUETZT || (Number.isFinite(MIN_SEQ) && anzahl >= MIN_SEQ);
|
||||
REWIND_GESCHUETZT ||
|
||||
// Belegte Fast-Forward-Kette seit der ersten Beobachtung: das ist ein
|
||||
// Nachweis, kein Versprechen – deshalb zaehlt er (R179 (a)).
|
||||
ffBelegt ||
|
||||
(Number.isFinite(MIN_SEQ) && anzahl >= MIN_SEQ);
|
||||
|
||||
const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); };
|
||||
|
||||
@@ -298,6 +324,70 @@ if (ref !== 'HEAD') {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1b) Fast-Forward-Monitor: wurde der Remote-Kopf zurueckgespult?
|
||||
// ---------------------------------------------------------------------------
|
||||
const remoteKopf = git('rev-parse', ref);
|
||||
const remoteKennung = gitStill('remote', 'get-url', REMOTE).out || zweig;
|
||||
|
||||
function beobachtungenLesen() {
|
||||
if (!existsSync(STATE_FILE)) return [];
|
||||
return readFileSync(STATE_FILE, 'utf8')
|
||||
.split('\n')
|
||||
.filter((z) => z.trim())
|
||||
.map((z) => {
|
||||
try { return JSON.parse(z); } catch { return null; }
|
||||
})
|
||||
.filter((e) => e && e.remote === remoteKennung && e.zweig === zweig);
|
||||
}
|
||||
|
||||
const beobachtungen = beobachtungenLesen();
|
||||
const letzteBeobachtung = beobachtungen[beobachtungen.length - 1];
|
||||
let ffBelegt = false;
|
||||
|
||||
if (letzteBeobachtung) {
|
||||
if (letzteBeobachtung.sha === remoteKopf) {
|
||||
ffBelegt = true;
|
||||
} else {
|
||||
// Ist der zuletzt gesehene Kopf ein Vorfahre des jetzigen? Dann ist die
|
||||
// Kette gewachsen. Sonst wurde sie zurueckgespult oder ersetzt.
|
||||
const vorfahre = gitStill('merge-base', '--is-ancestor', letzteBeobachtung.sha, remoteKopf);
|
||||
if (vorfahre.ok) {
|
||||
ffBelegt = true;
|
||||
} else {
|
||||
alarm(
|
||||
`Der Remote-Kopf ist KEIN Nachfahre des zuletzt beobachteten Standes – ` +
|
||||
`die Reihe wurde zurückgespult oder ersetzt.\n` +
|
||||
` zuletzt gesehen: ${letzteBeobachtung.sha.slice(0, 12)} (${letzteBeobachtung.ts})\n` +
|
||||
` jetzt : ${remoteKopf.slice(0, 12)}\n` +
|
||||
`Genau das soll der serverseitige Rewind-Schutz verhindern. Prüfen, wer ` +
|
||||
`force-pushen konnte – und ob die Datenbank dazu passend gekürzt wurde.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const beobachtungMerken = () => {
|
||||
try {
|
||||
mkdirSync(dirname(STATE_FILE), { recursive: true });
|
||||
appendFileSync(
|
||||
STATE_FILE,
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
remote: remoteKennung,
|
||||
zweig,
|
||||
sha: git('rev-parse', ref),
|
||||
}) + '\n',
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`HINWEIS: Beobachtung konnte nicht festgehalten werden (${STATE_FILE}).\n` +
|
||||
` Ohne sie erkennt der nächste Lauf ein Zurückspulen nicht. Grund: ` +
|
||||
`${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2) Signaturen der GESAMTEN Historie pruefen – das war der fehlende Leser.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -436,9 +526,32 @@ if (NUR_PRUEFEN) {
|
||||
(letzter ? `, CRM stimmt mit Checkpoint ${letzter.seq} überein (maxId ${letzter.maxId}).` : '.') +
|
||||
OHNE_ANKER() + REWIND_VORBEHALT(),
|
||||
);
|
||||
beobachtungMerken();
|
||||
process.exit(sicher ? 0 : 3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5) Tor VOR dem Anhaengen (Pentest R179 (b)).
|
||||
//
|
||||
// Der Schreiblauf erweitert nicht nur die Kette – er signiert damit zugleich
|
||||
// ueber den Bestand darunter. Ist die Basis nicht feststellbar, waere das
|
||||
// Anhaengen selbst das Waschmittel: eine frische Signatur ueber einen
|
||||
// ungeklaerten Vorzustand beglaubigt alles darunter mit.
|
||||
//
|
||||
// Ausnahme ist der allererste Lauf: Was man nie gesehen hat, kann man nicht
|
||||
// verifizieren. Diese Basislinie wird gesetzt, aber ehrlich als solche
|
||||
// gemeldet – nicht als gruenes Licht.
|
||||
// ---------------------------------------------------------------------------
|
||||
const basislinie = !letzteBeobachtung;
|
||||
if (!abschliessendSicher(bisher.length) && !basislinie) {
|
||||
console.error(
|
||||
'Die Grundlage für einen neuen Checkpoint ist nicht abschließend feststellbar –\n' +
|
||||
'es wird nichts angehängt. Ein Checkpoint über einen ungeklärten Vorzustand würde\n' +
|
||||
'diesen mitbeglaubigen.' + REWIND_VORBEHALT(),
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const eintrag = {
|
||||
seq: bisher.length + 1,
|
||||
ts: aktuell.ts,
|
||||
@@ -495,19 +608,36 @@ if (SIGN) {
|
||||
}
|
||||
if (!SIGN) console.warn('WARNUNG: Commit wurde NICHT signiert – das Gegenbuch ist ungeschützt.');
|
||||
|
||||
let verankert = false;
|
||||
if (PUSH && !NUR_LOKAL) {
|
||||
const p = gitStill('push', REMOTE, zweig);
|
||||
if (!p.ok) {
|
||||
// NICHT exit 0 und nicht exit 1: Der Checkpoint wurde zwar erstellt, ist
|
||||
// aber nicht verankert. „Mechanisch erweitert“ ist nicht „nachweislich
|
||||
// verankert“ – diese Verwechslung waere der Waschgang (R179 (b)).
|
||||
console.error(
|
||||
`Push fehlgeschlagen – der Checkpoint liegt nur lokal und gilt nicht als beglaubigt.\n${p.out}`,
|
||||
`Checkpoint ${eintrag.seq} wurde erstellt, aber NICHT verankert – der Push ist ` +
|
||||
`fehlgeschlagen.\n` +
|
||||
`Er liegt nur lokal und zählt nicht als beglaubigt. Der nächste Lauf wird ihn als ` +
|
||||
`"nie gepusht" melden, bis er verankert ist.\n git-Meldung: ${p.out}`,
|
||||
);
|
||||
process.exit(1);
|
||||
process.exit(3);
|
||||
}
|
||||
verankert = true;
|
||||
beobachtungMerken();
|
||||
} else if (NUR_LOKAL) {
|
||||
beobachtungMerken();
|
||||
}
|
||||
|
||||
const basisHinweis = basislinie
|
||||
? '\n Erste Beobachtung dieses Gegenbuchs – ab jetzt fällt ein Zurückspulen auf. ' +
|
||||
'Für diesen Lauf selbst gibt es noch nichts zu vergleichen.'
|
||||
: '';
|
||||
|
||||
console.log(
|
||||
`OK: Checkpoint ${eintrag.seq} beglaubigt (maxId ${eintrag.maxId}).` + OHNE_ANKER() +
|
||||
REWIND_VORBEHALT() +
|
||||
`${verankert || NUR_LOKAL ? 'OK' : 'HINWEIS'}: Checkpoint ${eintrag.seq} ` +
|
||||
`${verankert ? 'beglaubigt' : 'erstellt'} (maxId ${eintrag.maxId}).` +
|
||||
OHNE_ANKER() + REWIND_VORBEHALT() + basisHinweis +
|
||||
`\n Zuletzt bekannte Nummer für die nächste Prüfung: NOTARY_MIN_SEQ=${eintrag.seq}`,
|
||||
);
|
||||
process.exit(abschliessendSicher(eintrag.seq) ? 0 : 3);
|
||||
process.exit(!basislinie && abschliessendSicher(eintrag.seq) ? 0 : 3);
|
||||
|
||||
Reference in New Issue
Block a user