OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

AuditChainTest.php

8 KB
<?php

declare(strict_types=1);

use OpenNIT\Audit\HashChain;
use OpenNIT\Core\Database;

/**
 * Deckt die Integritätsprüfung des Audit-Logs ab.
 *
 * Schwerpunkte: echte Manipulation wird erkannt, parallel geschriebene
 * Einträge (eigener Verkettungspunkt im Feld prev_checksum) gelten als gültig,
 * und ein Verweis auf einen nicht mehr vorhandenen Vorgänger schlägt fehl.
 */
return static function (): void {

    $_ENV['APP_KEY'] = 'test-key-für-audit-kette';

    /** Baut eine Eintragszeile inklusive gültiger HMAC-Prüfsumme. */
    $makeEntry = function (int $id, string $prevChecksum, array $overrides = [], ?string $key = null) : array {
        $entry = array_merge([
            'id'              => $id,
            'event_id'        => sprintf('%08d-0000-4000-8000-000000000000', $id),
            'event_timestamp' => sprintf('2026-08-12 10:%02d:00.000000', $id),
            'user_id'         => 1,
            'action_category' => 'auth',
            'action_type'     => 'login',
            'object_type'     => 'user',
            'object_id'       => 1,
            'object_name'     => 'Administrator',
            'details_json'    => null,
            'old_value_json'  => null,
            'new_value_json'  => null,
            'result'          => 'success',
            'risk_level'      => 'low',
        ], $overrides);

        $data = implode('|', [
            $entry['event_id'], $entry['event_timestamp'], $entry['user_id'] ?? '',
            $entry['action_category'], $entry['action_type'], $entry['object_type'] ?? '',
            $entry['object_id'] ?? '', $entry['details_json'] ?? '', $entry['result'],
            $entry['object_name'] ?? '', $entry['old_value_json'] ?? '',
            $entry['new_value_json'] ?? '', $entry['risk_level'], $prevChecksum,
        ]);
        $entry['checksum']      = hash_hmac('sha256', $data, $key ?? $_ENV['APP_KEY']);
        $entry['prev_checksum'] = $prevChecksum;
        return $entry;
    };

    /** Datenbank-Attrappe, die die Prüfung mit festen Zeilen bedient. */
    $db = function (array $rows): Database {
        return new class($rows) extends Database {
            /** @param array<int,array<string,mixed>> $rows */
            public function __construct(private array $rows) { parent::__construct([]); }

            public function fetchAll(string $sql, array $params = []): array
            {
                if (str_contains($sql, "action_type = 'setup_encryption'")) {
                    $hits = array_filter($this->rows, fn($r) => ($r['action_type'] ?? '') === 'setup_encryption');
                    return array_reverse(array_values($hits));
                }
                if (str_contains($sql, 'FROM audit_log') && str_contains($sql, 'ORDER BY id ASC')) {
                    return $this->rows;
                }
                if (str_contains($sql, 'FROM audit_log') && str_contains($sql, 'ORDER BY id DESC')) {
                    $before = array_filter($this->rows, fn($r) => $r['id'] < ($params[0] ?? PHP_INT_MAX));
                    return array_map(fn($r) => ['checksum' => $r['checksum']], array_reverse($before));
                }
                return [];
            }

            public function fetchOne(string $sql, array $params = []): ?array
            {
                // Anker-Tabellen sind in der Attrappe nicht vorhanden
                if (str_contains($sql, 'audit_chain_anchor') || str_contains($sql, 'audit_hmac_anchor')) {
                    return null;
                }
                foreach ($this->rows as $r) {
                    if (str_contains($sql, 'WHERE id = ?') && $r['id'] === ($params[0] ?? null)) {
                        return $r;
                    }
                }
                if (str_contains($sql, 'id < ?')) {
                    $before = array_filter($this->rows, fn($r) => $r['id'] < ($params[0] ?? 0));
                    return $before ? end($before) : null;
                }
                return null;
            }

            public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
            {
                if (str_contains($sql, 'COUNT(*)')) {
                    return count($this->rows);
                }
                if (str_contains($sql, 'WHERE checksum = ?')) {
                    foreach ($this->rows as $r) {
                        if ($r['checksum'] === ($params[0] ?? null)) { return 1; }
                    }
                    return false;
                }
                return false;
            }
        };
    };

    test('Unveränderte Kette gilt als integer', function () use ($makeEntry, $db) {
        $a = $makeEntry(1, 'GENESIS');
        $b = $makeEntry(2, $a['checksum']);
        $result = (new HashChain($db([$a, $b])))->verify();
        assertTrue($result['valid']);
        assertSame(0, count($result['invalid']));
        assertSame(0, count($result['forks']));
    });

    test('Nachträglich geänderter Inhalt wird als Integritätsfehler erkannt', function () use ($makeEntry, $db) {
        $a = $makeEntry(1, 'GENESIS');
        $b = $makeEntry(2, $a['checksum']);
        $b['object_name'] = 'Manipuliert';
        $result = (new HashChain($db([$a, $b])))->verify();
        assertTrue($result['valid'] === false);
        assertSame(2, (int)$result['invalid'][0]['id']);
    });

    test('Parallel geschriebener Eintrag gilt als gültig, nicht als Manipulation', function () use ($makeEntry, $db) {
        $a = $makeEntry(1, 'GENESIS');
        $b = $makeEntry(2, $a['checksum']);
        // Dritter Eintrag verkettet auf denselben Vorgänger wie der zweite
        $c = $makeEntry(3, $a['checksum']);
        $result = (new HashChain($db([$a, $b, $c])))->verify();
        assertTrue($result['valid']);
        assertSame(0, count($result['invalid']));
        assertSame(1, count($result['forks']));
        assertSame(3, (int)$result['forks'][0]['id']);
    });

    test('Verweis auf fehlenden Vorgänger bleibt ein Integritätsfehler', function () use ($makeEntry, $db) {
        $a = $makeEntry(1, 'GENESIS');
        $missing = hash('sha256', 'nie-vorhanden');
        $c = $makeEntry(3, $missing);
        $result = (new HashChain($db([$a, $c])))->verify();
        assertTrue($result['valid'] === false);
        assertSame(3, (int)$result['invalid'][0]['id']);
    });

    test('Einträge von vor der Schlüssel-Einrichtung gelten nicht als Manipulation', function () use ($makeEntry, $db) {
        // Ohne Anwendungsschlüssel signiert (leerer Schlüssel)
        $a = $makeEntry(1, 'GENESIS', [], '');
        $b = $makeEntry(2, $a['checksum'], [], '');
        // Die Einrichtung selbst trägt bereits den aktuellen Schlüssel
        $setup = $makeEntry(3, $b['checksum'], [
            'action_category' => 'admin',
            'action_type'     => 'setup_encryption',
            'object_type'     => 'system',
            'object_id'       => 0,
            'object_name'     => 'APP_KEY erzeugt',
            'risk_level'      => 'high',
        ]);
        $after = $makeEntry(4, $setup['checksum']);

        $result = (new HashChain($db([$a, $b, $setup, $after])))->verify();
        assertTrue($result['valid']);
        assertSame(0, count($result['invalid']));
        assertSame(2, count($result['pre_key']));
    });

    test('Ohne Schlüssel signierte Einträge NACH der Einrichtung bleiben ungültig', function () use ($makeEntry, $db) {
        $a = $makeEntry(1, 'GENESIS', [], '');
        $setup = $makeEntry(2, $a['checksum'], [
            'action_category' => 'admin',
            'action_type'     => 'setup_encryption',
            'object_name'     => 'APP_KEY erzeugt',
        ]);
        // Nachträglich ohne Schlüssel eingeschobener Eintrag
        $forged = $makeEntry(3, $setup['checksum'], [], '');

        $result = (new HashChain($db([$a, $setup, $forged])))->verify();
        assertTrue($result['valid'] === false);
        assertSame(3, (int)$result['invalid'][0]['id']);
    });

    test('Einzelprüfung meldet parallel geschriebenen Eintrag als gültig', function () use ($makeEntry, $db) {
        $a = $makeEntry(1, 'GENESIS');
        $b = $makeEntry(2, $a['checksum']);
        $c = $makeEntry(3, $a['checksum']);
        $single = (new HashChain($db([$a, $b, $c])))->verifyEntry(3);
        assertTrue($single['valid']);
        assertTrue($single['fork']);
    });
};