Quelltext des aktuellen Standes
<?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']);
});
};