build([ ['technician_id' => 11, 'technician_name' => 'Own Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 2.25, 'counts_toward_sla' => true], ['technician_id' => 12, 'technician_name' => 'Other Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 9.50, 'counts_toward_sla' => true], ]); security_regression_assert_same(2, count($activity), 'Activity aggregation must keep technicians distinct before route-level ownership filtering.'); security_regression_assert_same(2.25, $activity[1]['hours'], 'Own-technician report fixtures must preserve the own-technician total.'); security_regression_assert_same(9.5, $activity[0]['hours'], 'Cross-technician fixture must remain distinguishable for isolation assertions.'); $checks++; // Credentials use the canonical ciphertext field and never persist/return plaintext. $key = base64_encode(random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES)); $vault = new CredentialVault($key); $secret = 'cross-client-secret-' . bin2hex(random_bytes(8)); $stored = $vault->encryptCredential(['id' => 4, 'category' => 'hosting', 'label' => 'Production', 'username' => 'deploy', 'notes' => 'private', 'secret' => $secret]); security_regression_assert(!array_key_exists('secret', $stored), 'Stored credentials must not contain a plaintext secret field.'); security_regression_assert(isset($stored['secret_ciphertext']) && is_string($stored['secret_ciphertext']), 'Stored credentials must use secret_ciphertext as the canonical field.'); security_regression_assert(!str_contains(serialize($stored), $secret), 'Serialized stored credentials must not contain the plaintext secret.'); security_regression_assert_same($secret, $vault->decryptCredential($stored)['secret'], 'Canonical ciphertext must decrypt only with the owning vault key.'); security_regression_assert(!array_key_exists('secret', $vault->projectMetadata($stored)), 'Metadata projections must exclude plaintext secrets.'); $checks++; // Upload metadata must reject traversal, executable double extensions, MIME mismatches, // oversized files, and unapproved client-visible state. $attachments = new AttachmentValidator(1_000); $unsafe = [ ['name' => '../outside.pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10], ['name' => 'invoice.php.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 10], ['name' => 'photo.jpg', 'mime_type' => 'application/x-php', 'size_bytes' => 10], ['name' => 'photo.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 1_001], ['name' => 'photo.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 10, 'client_visible' => true, 'client_approved' => false], ]; foreach ($unsafe as $payload) { security_regression_assert($attachments->validate($payload)['valid'] === false, 'Unsafe attachment metadata must be rejected.'); } $safe = $attachments->validate(['original_name' => ' evidence.PNG ', 'mime' => 'IMAGE/PNG', 'size' => '42']); security_regression_assert_same(true, $safe['valid'], 'Safe attachment metadata should be accepted.'); security_regression_assert_same(['name' => 'evidence.PNG', 'extension' => 'png', 'mime_type' => 'image/png', 'size_bytes' => 42, 'client_visible' => false, 'client_approved' => false], array_intersect_key($safe, array_flip(['name', 'extension', 'mime_type', 'size_bytes', 'client_visible', 'client_approved'])), 'Attachment metadata must normalize to safe canonical fields.'); $checks++; // Healthcheck schema coverage must include every table required by the current schema. final class SecurityRegressionFakePdo extends PDO { /** @var list */ public array $queries = []; public function __construct() {} public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false { $this->queries[] = $query; return false; } } $fakePdo = new SecurityRegressionFakePdo(); security_regression_assert(deployment_check_schema($fakePdo), 'Healthcheck schema probe should succeed for all required table probes.'); $probedTables = array_map(static fn(string $query): string => trim(str_replace(['SELECT 1 FROM', '`', 'LIMIT 1'], '', $query)), $fakePdo->queries); preg_match_all('/CREATE TABLE IF NOT EXISTS ([a-z0-9_]+)/i', $schema, $schemaMatches); $schemaTables = array_values(array_unique(array_map('strtolower', $schemaMatches[1]))); security_regression_assert_same($schemaTables, $probedTables, 'Healthcheck schema requirements must cover exactly the bootstrap schema tables.'); $checks++; // Every state-changing route/form must retain the central CSRF guard assumption. foreach (['logout', 'login', 'jobcard', 'client', 'clients', 'users'] as $route) { security_regression_assert(str_contains($frontController, "\$route === '{$route}'") || ($route === 'login' && str_contains($frontController, "\$route === 'login'")), "Expected explicit {$route} route in front controller."); } security_regression_assert(substr_count($frontController, 'verify_csrf();') >= 7, 'All supported POST route paths must call verify_csrf before mutation.'); security_regression_assert(str_contains($frontController, "if (\$route === 'logout')") && str_contains($frontController, "if ((\$_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST')"), 'Logout must remain POST-only and CSRF-protected.'); $checks++; // Upgrade coverage is additive and must include each feature table introduced after // the original foundation, plus the SLA uniqueness remediation guard. foreach (['technical_information', 'credentials', 'jobcard_sequences', 'jobcard_status_history', 'attachments', 'notifications'] as $table) { security_regression_assert((bool)preg_match('/CREATE TABLE IF NOT EXISTS ' . preg_quote($table, '/') . '\\b/i', $upgrade), "Migration must cover {$table}."); } security_regression_assert(str_contains($upgrade, 'sla_client_unique') && str_contains($upgrade, 'sla_duplicate_count'), 'Migration must safely remediate duplicate SLA rows before adding the unique constraint.'); security_regression_assert(str_contains($upgrade, 'INSERT IGNORE INTO permissions') && str_contains($upgrade, 'attachments.view'), 'Migration must cover permissions for migrated feature modules.'); $checks++; printf("Security regression tests: %d passed\n", $checks);