Skip to content

Commit 950aefc

Browse files
committed
feat(admin-log): the Logs are tamper-resistant now
1 parent a52e9cd commit 950aefc

File tree

27 files changed

+508
-72
lines changed

27 files changed

+508
-72
lines changed

phpmyfaq/admin/assets/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
handleSessionsFilter,
2626
handleStatistics,
2727
handleTruncateSearchTerms,
28+
handleVerifyAdminLog,
2829
} from './statistics';
2930
import {
3031
handleConfiguration,
@@ -142,6 +143,7 @@ document.addEventListener('DOMContentLoaded', async (): Promise<void> => {
142143
// Statistics
143144
handleDeleteAdminLog();
144145
handleExportAdminLog();
146+
await handleVerifyAdminLog();
145147
handleStatistics();
146148
handleCreateReport();
147149
handleTruncateSearchTerms();

phpmyfaq/admin/assets/src/statistics/admin-log.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,69 @@ export const handleExportAdminLog = (): void => {
6868
}
6969
};
7070

71+
export const handleVerifyAdminLog = async (): Promise<void> => {
72+
const verifyButton = document.getElementById('pmf-button-verify-admin-log') as HTMLButtonElement;
73+
const resultContainer = document.getElementById('pmf-admin-log-verification-result') as HTMLDivElement;
74+
75+
if (!verifyButton || !resultContainer) {
76+
return;
77+
}
78+
79+
verifyButton.addEventListener('click', async (event: Event) => {
80+
event.preventDefault();
81+
82+
verifyButton.disabled = true;
83+
verifyButton.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Checking...';
84+
85+
resultContainer.classList.add('d-none');
86+
87+
try {
88+
const csrfToken = verifyButton.dataset.pmfCsrf;
89+
90+
if (!csrfToken) {
91+
throw new Error('CSRF Token not found');
92+
}
93+
94+
const response = await fetch(`./api/statistics/admin-log/verify?csrf=${csrfToken}`, {
95+
method: 'GET',
96+
headers: { Accept: 'application/json' },
97+
});
98+
99+
const data = await response.json();
100+
101+
resultContainer.classList.remove('d-none');
102+
103+
if (data.success && data.verification.valid) {
104+
resultContainer.className = 'alert alert-success';
105+
resultContainer.innerHTML = `
106+
<i class="bi bi-check-circle-fill"></i>
107+
<strong>Integrity verified</strong>
108+
<p class="mb-0">${data.verification.verified} of ${data.verification.total} entries successfully checked.</p>
109+
`;
110+
} else if (data.success && !data.verification.valid) {
111+
const errors = data.verification.errors.map((err: string) => `<li>${err}</li>`).join('');
112+
resultContainer.className = 'alert alert-danger';
113+
resultContainer.innerHTML = `
114+
<i class="bi bi-exclamation-triangle-fill"></i>
115+
<strong>⚠️ Manipulation erkannt!</strong>
116+
<p>${data.verification.verified} verifiziert, ${data.verification.failed} fehlgeschlagen</p>
117+
<ul>${errors}</ul>
118+
`;
119+
} else {
120+
resultContainer.className = 'alert alert-warning';
121+
resultContainer.textContent = data.error || 'Fehler bei der Verifikation';
122+
}
123+
} catch (error) {
124+
resultContainer.classList.remove('d-none');
125+
resultContainer.className = 'alert alert-danger';
126+
resultContainer.textContent = `Fehler: ${error instanceof Error ? error.message : 'Netzwerkfehler'}`;
127+
} finally {
128+
verifyButton.disabled = false;
129+
verifyButton.innerHTML = '<i class="bi bi-shield-check"></i> Integrität prüfen';
130+
}
131+
});
132+
};
133+
71134
export const handleDeleteAdminLog = (): void => {
72135
const buttonDeleteAdminLog = document.getElementById('pmf-delete-admin-log') as HTMLButtonElement | null;
73136

phpmyfaq/assets/src/configuration/update.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const handleUpdateInformation = async (): Promise<void> => {
8787
};
8888

8989
export const handleConfigBackup = async (): Promise<void> => {
90-
if (window.location.href.endsWith('/update/?step=2') || window.location.href.endsWith('/update/?step=2')) {
90+
if (window.location.href.endsWith('/update?step=2') || window.location.href.endsWith('/update/?step=2')) {
9191
const installedVersion = document.getElementById('phpmyfaq-update-installed-version') as HTMLInputElement | null;
9292

9393
if (!installedVersion) return;
@@ -115,7 +115,7 @@ export const handleConfigBackup = async (): Promise<void> => {
115115
};
116116

117117
export const handleDatabaseUpdate = async (): Promise<void> => {
118-
if (window.location.href.endsWith('/update/?step=3') || window.location.href.endsWith('/update/?step=3')) {
118+
if (window.location.href.endsWith('/update?step=3') || window.location.href.endsWith('/update/?step=3')) {
119119
const installedVersion = document.getElementById('phpmyfaq-update-installed-version') as HTMLInputElement | null;
120120

121121
if (!installedVersion) return;

phpmyfaq/assets/templates/admin/statistics/admin-log.twig

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,19 @@
1111
<button class="btn btn-outline-success" id="pmf-export-admin-log" data-pmf-csrf="{{ csrfExportAdminLogToken }}">
1212
<i aria-hidden="true" class="bi bi-download"></i> {{ buttonExportAdminLog }}
1313
</button>
14+
<button class="btn btn-success" id="pmf-button-verify-admin-log" data-pmf-csrf="{{ csrfVerifyAdminLogToken }}">
15+
<i class="bi bi-shield-check"></i> {{ 'msgAdminLogVerifyIntegrity' | translate }}
16+
</button>
1417
<button class="btn btn-outline-danger" id="pmf-delete-admin-log" data-pmf-csrf="{{ csrfDeleteAdminLogToken }}">
1518
<i aria-hidden="true" class="bi bi-trash"></i> {{ buttonDeleteAdminLog }}
1619
</button>
1720
</div>
1821
</div>
1922
</div>
2023

24+
{# Verification Result Alert (initially hidden) #}
25+
<div id="pmf-admin-log-verification-result" class="alert d-none" role="alert"></div>
26+
2127
<table class="table table-striped align-middle border shadow">
2228
<thead>
2329
<tr>
Binary file not shown.

phpmyfaq/src/admin-api-routes.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,16 +523,21 @@
523523
'methods' => 'POST',
524524
],
525525
// Statistics API
526-
'admin.api.statistics.adminlog.export' => [
526+
'admin.api.statistics.admin-log.export' => [
527527
'path' => '/statistics/admin-log/export',
528528
'controller' => [AdminLogController::class, 'export'],
529529
'methods' => 'POST',
530530
],
531-
'admin.api.statistics.adminlog.delete' => [
531+
'admin.api.statistics.admin-log.delete' => [
532532
'path' => '/statistics/admin-log',
533-
'controller' => [StatisticsController::class, 'deleteAdminLog'],
533+
'controller' => [AdminLogController::class, 'delete'],
534534
'methods' => 'DELETE',
535535
],
536+
'admin.api.statistics.admin-log.verify' => [
537+
'path' => '/statistics/admin-log/verify',
538+
'controller' => [AdminLogController::class, 'verify'],
539+
'methods' => 'POST',
540+
],
536541
'admin.api.statistics.ratings.clear' => [
537542
'path' => '/statistics/ratings/clear',
538543
'controller' => [StatisticsController::class, 'clearRatings'],

phpmyfaq/src/phpMyFAQ/Administration/AdminLog.php

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ public function log(User $user, string $logText = ''): bool
7272
}
7373

7474
$request = Request::createFromGlobals();
75-
return $this->adminLogRepository->add($user, $logText, $request);
75+
76+
// Get the hash of the last log entry for chaining
77+
$previousHash = $this->adminLogRepository->getLastHash();
78+
79+
return $this->adminLogRepository->add($user, $logText, $request, $previousHash);
7680
}
7781

7882
/**
@@ -83,4 +87,70 @@ public function delete(): bool
8387
$timestamp = (int) Request::createFromGlobals()->server->get(key: 'REQUEST_TIME') - (30 * 86400);
8488
return $this->adminLogRepository->deleteOlderThan($timestamp);
8589
}
90+
91+
/**
92+
* Verifies the integrity of the entire admin log chain.
93+
* @return array{valid: bool, errors: array<int, string>, total: int, verified: int}
94+
*/
95+
public function verifyChainIntegrity(): array
96+
{
97+
$logs = $this->getAll();
98+
$errors = [];
99+
$verified = 0;
100+
$total = count($logs);
101+
102+
if ($total === 0) {
103+
return [
104+
'valid' => true,
105+
'errors' => [],
106+
'total' => 0,
107+
'verified' => 0,
108+
];
109+
}
110+
111+
$previousHash = null;
112+
113+
foreach ($logs as $log) {
114+
// Verify the hash matches the stored hash
115+
if (!$log->verifyIntegrity()) {
116+
$errors[] = sprintf('Log ID %d: Hash verification failed - data has been tampered', $log->getId());
117+
continue;
118+
}
119+
120+
// Verify the chain (previous hash matches)
121+
if ($previousHash !== null && $log->getPreviousHash() !== $previousHash) {
122+
$errors[] = sprintf(
123+
'Log ID %d: Chain broken - previous hash mismatch (expected: %s, got: %s)',
124+
$log->getId(),
125+
substr($previousHash, 0, 8) . '...',
126+
substr($log->getPreviousHash() ?? 'NULL', 0, 8) . '...',
127+
);
128+
continue;
129+
}
130+
131+
// The first entry should have null previous hash
132+
if ($previousHash === null && $log->getPreviousHash() !== null) {
133+
$errors[] = sprintf('Log ID %d: First entry should have null previous hash', $log->getId());
134+
continue;
135+
}
136+
137+
$verified++;
138+
$previousHash = $log->getHash();
139+
}
140+
141+
return [
142+
'valid' => empty($errors),
143+
'errors' => $errors,
144+
'total' => $total,
145+
'verified' => $verified,
146+
];
147+
}
148+
149+
/**
150+
* Calculates hash for a single log entry (for migration or manual verification).
151+
*/
152+
public function calculateHash(AdminLogEntity $log): string
153+
{
154+
return $log->calculateHash();
155+
}
86156
}

phpmyfaq/src/phpMyFAQ/Administration/AdminLogRepository.php

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public function getAll(): array
4747
$data = [];
4848

4949
$query = sprintf(
50-
'SELECT id, time, usr AS user, text, ip FROM %sfaqadminlog ORDER BY id DESC',
50+
'SELECT id, time, usr AS user, text, ip, hash, previous_hash FROM %sfaqadminlog ORDER BY id DESC',
5151
Database::getTablePrefix(),
5252
);
5353

@@ -60,32 +60,52 @@ public function getAll(): array
6060
->setTime((int) $row->time)
6161
->setUserId((int) $row->user)
6262
->setText($row->text)
63-
->setIp($row->ip);
63+
->setIp($row->ip)
64+
->setHash($row->hash ?? null)
65+
->setPreviousHash($row->previous_hash ?? null);
6466
$data[$row->id] = $adminLog;
6567
}
6668

6769
return $data;
6870
}
6971

70-
public function add(User $user, string $logText, Request $request): bool
72+
/**
73+
* Adds a new logging entry with hash chain integrity.
74+
*
75+
* @param User $user User object
76+
* @param string $logText Logged string
77+
* @param Request $request Request object
78+
* @param string|null $previousHash Hash of the previous entry
79+
*/
80+
public function add(User $user, string $logText, Request $request, ?string $previousHash = null): bool
7181
{
72-
$table = Database::getTablePrefix() . 'faqadminlog';
73-
$id = $this->configuration->getDb()->nextId($table, 'id');
74-
$time = (int) $request->server->get('REQUEST_TIME');
82+
$time = (int) $request->server->get('REQUEST_TIME', time());
7583
$userId = $user->getUserId();
76-
$text = $this->configuration->getDb()->escape(nl2br($logText));
77-
$ip = $this->configuration->getDb()->escape((string) $request->getClientIp());
84+
$ip = $request->getClientIp() ?? '';
7885

79-
$query = strtr("INSERT INTO table: (id, time, usr, text, ip) VALUES (id:, time:, userId:, 'text:', 'ip:')", [
80-
'table:' => $table,
81-
'id:' => (string) $id,
82-
'time:' => (string) $time,
83-
'userId:' => (string) $userId,
84-
'text:' => $text,
85-
'ip:' => $ip,
86-
]);
86+
// Create a temporary entity to calculate hash
87+
$entity = new AdminLogEntity();
88+
$entity->setTime($time);
89+
$entity->setUserId($userId);
90+
$entity->setIp($ip);
91+
$entity->setText($logText);
92+
$entity->setPreviousHash($previousHash);
8793

88-
return (bool) $this->configuration->getDb()->query($query);
94+
// Calculate hash for this entry
95+
$hash = $entity->calculateHash();
96+
97+
$insert = sprintf(
98+
"INSERT INTO %sfaqadminlog (time, usr, ip, text, hash, previous_hash) VALUES (%d, %d, '%s', '%s', '%s', %s)",
99+
Database::getTablePrefix(),
100+
$time,
101+
$userId,
102+
$this->configuration->getDb()->escape($ip),
103+
$this->configuration->getDb()->escape($logText),
104+
$hash,
105+
$previousHash !== null ? "'" . $this->configuration->getDb()->escape($previousHash) . "'" : 'NULL',
106+
);
107+
108+
return (bool) $this->configuration->getDb()->query($insert);
89109
}
90110

91111
public function deleteOlderThan(int $timestamp): bool
@@ -98,4 +118,22 @@ public function deleteOlderThan(int $timestamp): bool
98118

99119
return (bool) $this->configuration->getDb()->query($query);
100120
}
121+
122+
/**
123+
* Returns the hash of the most recent log entry for chain linking.
124+
*
125+
* @return string|null Hash of the last entry or null if no entries exist
126+
*/
127+
public function getLastHash(): ?string
128+
{
129+
$query = sprintf('SELECT hash FROM %sfaqadminlog ORDER BY id DESC LIMIT 1', Database::getTablePrefix());
130+
131+
$result = $this->configuration->getDb()->query($query);
132+
133+
if ($result && ($row = $this->configuration->getDb()->fetchObject($result))) {
134+
return $row->hash;
135+
}
136+
137+
return null;
138+
}
101139
}

phpmyfaq/src/phpMyFAQ/Controller/Administration/AdminLogController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ public function index(Request $request): Response
7070
'buttonExportAdminLog' => Translation::get(key: 'msgAdminLogExportCsv'),
7171
'buttonDeleteAdminLog' => Translation::get(key: 'ad_adminlog_del_older_30d'),
7272
'csrfExportAdminLogToken' => Token::getInstance($this->session)->getTokenString('export-adminlog'),
73+
'csrfVerifyAdminLogToken' => Token::getInstance($this->session)->getTokenString('admin-log-verify'),
7374
'csrfDeleteAdminLogToken' => Token::getInstance($this->session)->getTokenString('delete-adminlog'),
7475
'currentLocale' => $this->configuration->getLanguage()->getLanguage(),
7576
'pagination' => $pagination->render(),

0 commit comments

Comments
 (0)