mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-31 19:03:43 +01:00
A commit security review flagged that regex redaction is a denylist: a secret in a form the patterns do not match (an encoding differential) would ride a prefilled URL straight into a public, indexed issue. So the log is no longer attached to the issue URL at all. The page shows the redacted, capped tail (still gated on panel_logs) with a Copy button; the admin pastes it into the issue and reviews it in GitHub's editor before submitting. Redaction is now described as best-effort, not a guarantee. Version and environment, which are low-sensitivity, stay prefilled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AneTzAHPPkYWPQjwyZ2td9
139 lines
6.3 KiB
PHP
139 lines
6.3 KiB
PHP
<?php
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use XcVm\Core\Cluster\NodeRole;
|
|
use XcVm\Core\Logging\FileLogger;
|
|
use XcVm\Public\Controllers\Admin\ReportIssueController;
|
|
|
|
/**
|
|
* Report an Issue (ReportIssueController + Views/admin/report_issue.php): the
|
|
* panel prefills GitHub's issue forms, so the field ids it sends must be the
|
|
* ids those forms actually have — a form that renames a field would otherwise
|
|
* drop the prefill silently. Security is never filed as a public issue.
|
|
*/
|
|
final class ReportIssueTest extends TestCase {
|
|
/** Category => [template file, the field ids the page prefills on it]. */
|
|
private const PREFILL = [
|
|
'bug_report.yml' => ['version', 'build_type', 'environment', 'description'],
|
|
'feature_request.yml' => ['description'],
|
|
'documentation.yml' => ['description'],
|
|
'other.yml' => ['version', 'issuedescription'],
|
|
];
|
|
|
|
private function view(): string {
|
|
return (string) file_get_contents(MAIN_HOME . 'Public/Views/admin/report_issue.php');
|
|
}
|
|
|
|
/** The `id:` values of an issue form. */
|
|
private function formFieldIds(string $rFile): array {
|
|
$rPath = dirname(__DIR__, 2) . '/.github/ISSUE_TEMPLATE/' . $rFile;
|
|
$this->assertFileExists($rPath, $rFile);
|
|
preg_match_all('/^\s+id:\s*(\S+)\s*$/m', (string) file_get_contents($rPath), $rM);
|
|
return $rM[1];
|
|
}
|
|
|
|
public function testEveryPrefilledFieldIsOneTheFormHas(): void {
|
|
foreach (self::PREFILL as $rFile => $rFields) {
|
|
$rIds = $this->formFieldIds($rFile);
|
|
foreach ($rFields as $rField) {
|
|
$this->assertContains($rField, $rIds, "{$rFile} has no field '{$rField}' to prefill");
|
|
}
|
|
}
|
|
}
|
|
|
|
public function testTheViewSendsExactlyTheseTemplatesAndFields(): void {
|
|
$rView = $this->view();
|
|
foreach (self::PREFILL as $rFile => $rFields) {
|
|
$this->assertStringContainsString($rFile, $rView, "view does not link {$rFile}");
|
|
}
|
|
// The description field ids both forms use, and the one other.yml uses.
|
|
$this->assertStringContainsString("descField: 'issuedescription'", $rView);
|
|
$this->assertStringContainsString("descField: 'description'", $rView);
|
|
}
|
|
|
|
public function testSecurityIsPrivateNotAPublicIssue(): void {
|
|
$rView = $this->view();
|
|
$this->assertStringContainsString('/security/advisories/new', $rView, 'security must route to a private advisory');
|
|
$this->assertStringNotContainsString('security.yml', $rView, 'security must not be a filable category');
|
|
$this->assertStringNotContainsString('incident.yml', $rView, 'incident is an internal template, not for the panel');
|
|
}
|
|
|
|
public function testEnvironmentReportsTheVersionAndBuild(): void {
|
|
if (!defined('XC_VM_VERSION')) {
|
|
define('XC_VM_VERSION', '9.9.9');
|
|
}
|
|
$rMethod = new ReflectionMethod(ReportIssueController::class, 'environment');
|
|
$rMethod->setAccessible(true);
|
|
try {
|
|
NodeRole::useServers(static fn(): array => [SERVER_ID => ['is_main' => 1]]);
|
|
$rMain = $rMethod->invoke(null);
|
|
$this->assertSame(XC_VM_VERSION, $rMain['version']);
|
|
$this->assertSame('MAIN (single server)', $rMain['build_type']);
|
|
$this->assertStringContainsString('XC_VM: ' . XC_VM_VERSION, $rMain['environment']);
|
|
$this->assertStringContainsString('PHP: ' . PHP_VERSION, $rMain['environment']);
|
|
$this->assertStringContainsString('OS: ', $rMain['environment']);
|
|
|
|
NodeRole::useServers(static fn(): array => [SERVER_ID => ['is_main' => 0]]);
|
|
$this->assertSame('LoadBalancer (multi-server)', $rMethod->invoke(null)['build_type']);
|
|
} finally {
|
|
NodeRole::useServers(null);
|
|
}
|
|
}
|
|
|
|
public function testRedactHidesSecretsAndKeepsOrdinaryLines(): void {
|
|
$rIn = implode("\n", [
|
|
'DB password=S3cr3tPass! connecting',
|
|
'api_key: abcdEF1234567890',
|
|
'Authorization: Bearer tok_live_9f8e7d',
|
|
'dsn mysql://xcvm:hunter2@10.0.0.5:3306/xc_vm',
|
|
'PHP Fatal error: Call to a member function q() on null in /home/xc_vm/Core/X.php:42',
|
|
'viewer 203.0.113.9 requested playlist',
|
|
]);
|
|
$rOut = ReportIssueController::redact($rIn);
|
|
foreach (['S3cr3tPass!', 'abcdEF1234567890', 'tok_live_9f8e7d', 'hunter2'] as $rSecret) {
|
|
$this->assertStringNotContainsString($rSecret, $rOut, $rSecret . ' must be redacted');
|
|
}
|
|
// The trace, the file path and a viewer IP are kept — that is the debugging value.
|
|
$this->assertStringContainsString('Call to a member function q() on null', $rOut);
|
|
$this->assertStringContainsString('/home/xc_vm/Core/X.php:42', $rOut);
|
|
$this->assertStringContainsString('203.0.113.9', $rOut);
|
|
$this->assertStringContainsString('[redacted]', $rOut);
|
|
}
|
|
|
|
public function testErrorLogTailTakesTheLastLinesRedactedAndCapped(): void {
|
|
$rFile = sys_get_temp_dir() . '/xcvm-reportlog-' . bin2hex(random_bytes(4)) . '.log';
|
|
$rLines = [];
|
|
for ($i = 0; $i < 200; $i++) {
|
|
$rLines[] = 'line ' . $i . ' token=deadbeefdeadbeef';
|
|
}
|
|
file_put_contents($rFile, implode("\n", $rLines) . "\n");
|
|
FileLogger::setLogFile($rFile);
|
|
try {
|
|
$rTail = ReportIssueController::errorLogTail();
|
|
} finally {
|
|
FileLogger::setLogFile(null);
|
|
@unlink($rFile);
|
|
}
|
|
$this->assertStringNotContainsString('deadbeefdeadbeef', $rTail, 'secrets are redacted in the tail too');
|
|
$this->assertStringContainsString('line 199', $rTail, 'the most recent line is kept');
|
|
$this->assertStringNotContainsString('line 0 ', $rTail, 'only the last lines are taken');
|
|
$this->assertLessThanOrEqual(4200, strlen($rTail), 'the tail is capped');
|
|
}
|
|
|
|
public function testTheLogIsNeverPutInTheUrlOnlyCopiedAndIsGated(): void {
|
|
$rView = $this->view();
|
|
// The log is a copy-and-paste helper; it must not be prefilled into the
|
|
// issue URL, where regex redaction alone would decide what reaches a
|
|
// public issue. The admin pastes it and reviews it in GitHub's editor.
|
|
$this->assertStringNotContainsString('logField', $rView, 'the log must not be attached to the issue URL');
|
|
$this->assertStringNotContainsString("params.set('extra_information'", $rView);
|
|
$this->assertStringContainsString('issue-log-copy', $rView, 'there is a copy control');
|
|
// Reading the log is gated on the panel_logs permission.
|
|
$this->assertStringContainsString('$rCanAttachLog', $rView);
|
|
$rController = (string) file_get_contents(MAIN_HOME . 'Public/Controllers/Admin/ReportIssueController.php');
|
|
$this->assertStringContainsString("Authorization::check('adv', 'panel_logs')", $rController);
|
|
// The admin is told the redaction is best-effort and to review it.
|
|
$this->assertStringContainsString('report_issue_log_note', $rView);
|
|
}
|
|
}
|