OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

AuditResultRecoveryTest.php

9 KB
<?php

declare(strict_types=1);

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

/**
 * Deckt Ergebniswert und Risikostufe eines Audit-Eintrags ab.
 *
 * Beide Spalten nahmen früher nur eine feste Werteliste an und legten jeden
 * anderen Wert leer ab – signiert war der Eintrag dann über einen Wert, der so
 * nicht mehr gespeichert ist. Geprüft wird deshalb zweierlei: dass der Logger
 * nur noch Werte schreibt, über die er auch signiert, und dass bestehende
 * Einträge mit leerem Feld über die Signatur wieder belegbar sind.
 */
return static function (): void {

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

    /** Baut einen Eintrag, dessen Signatur über die angegebenen Werte gebildet wird. */
    $makeEntry = function (string $result, string $riskLevel, string $kategorie = 'admin'): array {
        $json  = '{"aktiv":true}'; // bereits kanonisch – Speicherformat-Nachsicht scheidet aus
        $entry = [
            'id'              => 1,
            'event_id'        => '00000001-0000-4000-8000-000000000000',
            'event_timestamp' => '2026-08-28 11:15:00.000000',
            'user_id'         => 1,
            'action_category' => $kategorie,
            'action_type'     => 'intune_sync',
            'object_type'     => 'system',
            'object_id'       => 0,
            'object_name'     => 'Microsoft Intune Sync',
            'details_json'    => $json,
            'old_value_json'  => null,
            'new_value_json'  => null,
            'result'          => $result,
            'risk_level'      => $riskLevel,
        ];

        $data = implode('|', [
            $entry['event_id'], $entry['event_timestamp'], $entry['user_id'],
            $kategorie, $entry['action_type'], $entry['object_type'],
            $entry['object_id'], JsonCanonicalizer::canonicalize($json), $result,
            $entry['object_name'], '', '', $riskLevel, 'GENESIS',
        ]);

        $entry['checksum']      = hash_hmac('sha256', $data, $_ENV['APP_KEY']);
        $entry['prev_checksum'] = 'GENESIS';
        return $entry;
    };

    /** Datenbank-Attrappe mit signiertem Grenz-Anker. */
    $db = function (array $rows, ?int $boundary = null): Database {
        return new class ($rows, $boundary) extends Database {
            public function __construct(private array $rows, private ?int $boundary)
            {
                parent::__construct([]);
            }

            public function fetchAll(string $sql, array $params = []): array
            {
                if (str_contains($sql, "action_type = 'setup_encryption'")) {
                    return [];
                }
                if (str_contains($sql, 'DISTINCT action_category')) {
                    return [['action_category' => 'admin']];
                }
                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
            {
                if (str_contains($sql, 'audit_canonical_anchor')) {
                    if ($this->boundary === null) {
                        return null;
                    }
                    return [
                        'boundary_id' => $this->boundary,
                        'signature'   => hash_hmac(
                            'sha256',
                            'audit_canonical_anchor|' . $this->boundary,
                            $_ENV['APP_KEY'] ?? ''
                        ),
                    ];
                }
                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;
                    }
                }
                return null;
            }

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

    // ----------------------------------------------------------------
    // Normalisierung im Logger
    // ----------------------------------------------------------------

    test('Ein Teilerfolg bleibt als eigener Ergebniswert erhalten', function () {
        assertSame('warning', AuditLogger::normalizeResult('warning'));
    });

    test('Gebräuchliche Schreibweisen werden auf die Wertemenge zurückgeführt', function () {
        assertSame('failure', AuditLogger::normalizeResult('error'));
        assertSame('failure', AuditLogger::normalizeResult('failed'));
        assertSame('success', AuditLogger::normalizeResult('ok'));
        assertSame('denied', AuditLogger::normalizeResult('blocked'));
        assertSame('warning', AuditLogger::normalizeResult('partial'));
    });

    test('Schreibweise und Leerzeichen spielen keine Rolle', function () {
        assertSame('success', AuditLogger::normalizeResult(' SUCCESS '));
        assertSame('high', AuditLogger::normalizeRiskLevel('High'));
    });

    test('Unbekannte Werte fallen auf einen zulässigen Wert zurück', function () {
        assertSame('failure', AuditLogger::normalizeResult('völlig unbekannt'));
        assertSame('low', AuditLogger::normalizeRiskLevel('gigantisch'));
        assertSame('failure', AuditLogger::normalizeResult(''));
        assertSame('low', AuditLogger::normalizeRiskLevel(''));
    });

    test('Die zulässigen Werte enthalten die von der Anwendung genutzten', function () {
        foreach (['success', 'failure', 'denied', 'warning'] as $wert) {
            assertTrue(in_array($wert, AuditLogger::RESULTS, true), $wert);
        }
        foreach (['low', 'medium', 'high', 'critical'] as $wert) {
            assertTrue(in_array($wert, AuditLogger::RISK_LEVELS, true), $wert);
        }
    });

    // ----------------------------------------------------------------
    // Rückgewinnung bestehender Einträge
    // ----------------------------------------------------------------

    test('Ein verworfener Ergebniswert wird zurückgewonnen', function () use ($makeEntry, $db) {
        // Signiert wurde über 'warning'; die Spalte kannte den Wert damals nicht.
        $a = $makeEntry('warning', 'medium');
        $a['result'] = '';

        $chain  = new HashChain($db([$a], 5));
        $result = $chain->verify();

        assertTrue($result['valid']);
        assertSame(0, count($result['invalid']));
        assertSame('warning', $chain->recoveredValues(1)['result'] ?? null);
    });

    test('Eine verworfene Risikostufe wird zurückgewonnen', function () use ($makeEntry, $db) {
        $a = $makeEntry('success', 'critical');
        $a['risk_level'] = '';

        $chain  = new HashChain($db([$a], 5));
        $result = $chain->verify();

        assertTrue($result['valid']);
        assertSame('critical', $chain->recoveredValues(1)['risk_level'] ?? null);
    });

    test('Mehrere verworfene Felder werden gemeinsam zurückgewonnen', function () use ($makeEntry, $db) {
        $a = $makeEntry('warning', 'medium', 'admin');
        $a['action_category'] = '';
        $a['result']          = '';

        $chain  = new HashChain($db([$a], 5));
        $result = $chain->verify();

        assertTrue($result['valid']);
        $zurueck = $chain->recoveredValues(1);
        assertSame('admin', $zurueck['action_category'] ?? null);
        assertSame('warning', $zurueck['result'] ?? null);
    });

    test('Ein gefüllter Ergebniswert wird nicht durchprobiert', function () use ($makeEntry, $db) {
        // Inhaltlich verändert: die Rückgewinnung darf hier nicht greifen
        $a = $makeEntry('success', 'low');
        $a['result'] = 'denied';

        $result = (new HashChain($db([$a], 5)))->verify();
        assertFalse($result['valid']);
    });

    test('Ein leeres Feld belegt keine Manipulation an anderer Stelle', function () use ($makeEntry, $db) {
        // Ergebniswert leer UND Name verändert: keine Fassung darf passen
        $a = $makeEntry('warning', 'medium');
        $a['result']      = '';
        $a['object_name'] = 'Manipuliert';

        $result = (new HashChain($db([$a], 5)))->verify();
        assertFalse($result['valid']);
    });
};