Chat's updates for security, db and workflow
Security and workflow regressions / regression (push) Has been cancelled

This commit is contained in:
2026-10-20 06:45:30 -04:00
parent 02223083f6
commit 1e63076123
116 changed files with 3252 additions and 2932 deletions
+28
View File
@@ -0,0 +1,28 @@
<?php
/** Scheduler entry point. Credentials are never passed on the process command line. */
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require __DIR__.'/../classes/Base.php';
$directory=realpath($argv[1]??'');$root=realpath(__DIR__.'/..');
if(!$directory||!is_dir($directory)||strtolower($directory)===strtolower($root)||str_starts_with(strtolower($directory),strtolower($root).DIRECTORY_SEPARATOR))throw new RuntimeException('Choose an existing protected backup directory outside the application tree.');
$base=new Base();$settings=$base->getSettings();$db=$base->connect();
$engines=$db->query("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN);
if(array_filter($engines,static fn($v)=>$v!=='InnoDB'))throw new RuntimeException('Nontransactional tables require a maintenance-window locked backup.');
$file=$directory.DIRECTORY_SEPARATOR.'hcr-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)).'.sql';
$partial=$file.'.partial';$handle=fopen($partial,'x');if(!$handle)throw new RuntimeException('Cannot create protected backup output.');fclose($handle);chmod($partial,0600);
$config=tempnam($directory,'hcr-connection-');chmod($config,0600);
$quote=static fn($value)=>'"'.str_replace(["\\",'"',"\r","\n"],["\\\\",'\\"','\\r','\\n'],(string)$value).'"';
$options="[client]\nhost=".$quote($settings->DatabaseServer)."\nport=".(int)($settings->DatabasePort??3306)."\nuser=".$quote($settings->DatabaseUser)."\npassword=".$quote($settings->DatabasePassword)."\n";
$ca=getenv('HCR_DB_TLS_CA');if($ca)$options.='ssl-ca='.$quote($ca)."\nssl-verify-server-cert\n";
try{
file_put_contents($config,$options);
$command=[getenv('HCR_DUMP_BINARY')?:'mariadb-dump','--defaults-extra-file='.$config,'--single-transaction','--quick','--routines','--events','--triggers','--hex-blob','--databases',(string)$settings->DatabaseName,'--result-file='.$partial];
$process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
if(!is_resource($process))throw new RuntimeException('Backup process could not start.');
fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);
if(proc_close($process)!==0){error_log('Backup process failed: '.$error);throw new RuntimeException('Backup failed; inspect the protected backup error log.');}
if(!is_file($partial)||filesize($partial)<100||!str_contains(file_get_contents($partial),'Dump completed on'))throw new RuntimeException('Backup completion could not be confirmed.');
if(!rename($partial,$file))throw new RuntimeException('Cannot finalize completed backup.');
chmod($file,0600);
$metadata=['created_utc'=>gmdate(DATE_ATOM),'bytes'=>filesize($file),'sha256'=>hash_file('sha256',$file)];file_put_contents($file.'.json',json_encode($metadata,JSON_PRETTY_PRINT));chmod($file.'.json',0600);
echo json_encode(['backup'=>$file]+$metadata),"\n";
}finally{if(is_file($config))unlink($config);if(is_file($partial))unlink($partial);}
+27
View File
@@ -0,0 +1,27 @@
"""Build a clean deployment zip from current workspace files, including new files."""
import argparse,hashlib,json,zipfile
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
parser=argparse.ArgumentParser();parser.add_argument('output',type=Path);args=parser.parse_args()
output=args.output.resolve()
if output.exists():raise SystemExit('Output already exists; choose a new release filename.')
files=[]
for directory in ['classes','css','images','js','html','migrations','reports','vendor','views','xsl']:
for file in (ROOT/directory).rglob('*'):
if file.is_file() and not file.is_symlink() and file.suffix.lower() not in ['.sql','.php','.js','.css','.xsl'] and directory not in ['vendor','images','css','html']:continue
if file.is_file() and not file.is_symlink() and not any(p.startswith('.') for p in file.relative_to(ROOT).parts) and '_orig.' not in file.name:files.append(file)
for name in ['main.php','.htaccess','web.config','composer.json','composer.lock','xml/settings.xml','xml/constants.xml']:
files.append(ROOT/name)
for name in ['migrate-auth.php','migrate-roster.php','migrate-production.php','cleanup-auth.php','check-production.py','backup-database.php','check-health.php']:
file=ROOT/'bin'/name
if file.exists():files.append(file)
if not (ROOT/'vendor/autoload.php').is_file():raise SystemExit('Install locked Composer dependencies before building a release.')
manifest={file.relative_to(ROOT).as_posix():hashlib.sha256(file.read_bytes()).hexdigest() for file in sorted(set(files))}
for name in manifest:
if name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml'):raise SystemExit('Private file would enter the release.')
output.parent.mkdir(parents=True,exist_ok=True)
with zipfile.ZipFile(output,'x',zipfile.ZIP_DEFLATED) as archive:
for name in manifest:archive.writestr(name,(ROOT/name).read_bytes())
archive.writestr('release-manifest.json',json.dumps(manifest,indent=2))
print(json.dumps({'release':str(output),'files':len(manifest),'sha256':hashlib.sha256(output.read_bytes()).hexdigest()}))
+9
View File
@@ -0,0 +1,9 @@
<?php
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require __DIR__.'/../classes/Base.php';
try{
$db=(new Base())->connect();$db->query('SELECT 1')->fetchColumn();
if(!(new RosterService())->ready())throw new RuntimeException('Required schema is missing.');
foreach(['user_email_verified_at'] as $column){$columns=$db->query('SHOW COLUMNS FROM users')->fetchAll(PDO::FETCH_COLUMN);if(!in_array($column,$columns,true))throw new RuntimeException('Account schema is missing.');}
echo "Database and required application schema are available.\n";
}catch(Throwable $e){fwrite(STDERR,"Health check failed. Review the protected server logs.\n");error_log((string)$e);exit(1);}
+22
View File
@@ -0,0 +1,22 @@
"""Read-only checks of a deployed HTTPS host; never sends account email or writes data."""
import argparse,http.cookiejar,ssl,urllib.error,urllib.parse,urllib.request
parser=argparse.ArgumentParser();parser.add_argument('url');args=parser.parse_args()
url=args.url.rstrip('/');parts=urllib.parse.urlsplit(url)
if parts.scheme!='https' or parts.query or parts.fragment or parts.username:raise SystemExit('Use the trusted HTTPS application base URL.')
client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),urllib.request.HTTPSHandler(context=ssl.create_default_context()))
def get(path):
try:
with client.open(url+'/'+path,timeout=25) as response:return response.status,response.headers,response.read(),response.geturl()
except urllib.error.HTTPError as error:return error.code,error.headers,error.read(),error.geturl()
failures=[]
status,headers,body,final=get('main.php')
if status!=200 or urllib.parse.urlsplit(final).scheme!='https':failures.append('HTTPS login page unavailable.')
for name in ['Strict-Transport-Security','Content-Security-Policy','X-Content-Type-Options','Referrer-Policy','X-Frame-Options']:
if not headers.get(name):failures.append('Missing '+name)
cookie=';'.join(headers.get_all('Set-Cookie') or [])
if not all(flag in cookie.lower() for flag in ['secure','httponly','samesite=lax']):failures.append('Session cookie flags are missing.')
for path in ['xml/settings.local.xml','xml/settings.xml','.tools/backups/probe.sql','.git/config','vendor/autoload.php','bin/migrate-production.php','classes/Base.php','tests/http_fixture.php','composer.lock','release-manifest.json']:
if get(path)[0] not in [403,404]:failures.append('Private path was not denied: '+path)
for failure in failures:print('FAIL: '+failure)
if failures:raise SystemExit(1)
print('HTTPS, security headers, cookies, and private-path checks passed. Verify inbox delivery and operational jobs separately.')
+1
View File
@@ -3,5 +3,6 @@ if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
require_once __DIR__ . '/../classes/Base.php';
$db = (new Base())->connect();
$db->exec('DELETE FROM password_resets WHERE expires_at < UTC_TIMESTAMP()');
$db->exec('DELETE FROM email_verifications WHERE expires_at < UTC_TIMESTAMP()');
$db->exec('DELETE FROM auth_attempts WHERE window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)');
echo "Expired authentication records removed.\n";
+22
View File
@@ -0,0 +1,22 @@
<?php
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require_once __DIR__.'/../classes/Base.php';
$db=(new Base())->connect();
foreach([
'users'=>['user_email_verified_at'=>'DATETIME NULL'],
'club_dues'=>['recorded_by'=>'BIGINT UNSIGNED NULL'],
'club_payments'=>['recorded_by'=>'BIGINT UNSIGNED NULL','payment_method'=>"VARCHAR(20) NOT NULL DEFAULT 'unspecified'",'payment_reference'=>"VARCHAR(100) NOT NULL DEFAULT ''"],
] as $table=>$columns){
$existing=$db->query('SHOW COLUMNS FROM `'.$table.'`')->fetchAll(PDO::FETCH_COLUMN);
foreach($columns as $column=>$definition)if(!in_array($column,$existing,true))$db->exec('ALTER TABLE `'.$table.'` ADD COLUMN `'.$column.'` '.$definition);
}
// Mandatory audit writes must participate in financial transactions.
$db->exec('ALTER TABLE journal ENGINE=InnoDB');
$db->exec('ALTER TABLE journal MODIFY journal_entry TEXT NULL');
foreach(['club_dues','club_payments'] as $table){
$stmt=$db->prepare("SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME='recorded_by' AND REFERENCED_TABLE_NAME='users'");$stmt->execute([$table]);
if(!(int)$stmt->fetchColumn())$db->exec('ALTER TABLE `'.$table.'` ADD FOREIGN KEY (recorded_by) REFERENCES users(user_serial) ON DELETE RESTRICT');
}
foreach(explode(';',file_get_contents(__DIR__.'/../migrations/003_production.sql')) as $sql)if(trim($sql))$db->exec($sql);
require __DIR__.'/../views/view_users.php';$db->exec(str_replace('create view','CREATE OR REPLACE VIEW',$SQL));
echo "Production safeguards schema is ready. Existing emails remain unverified until ownership is proven.\n";
+6
View File
@@ -0,0 +1,6 @@
<?php
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require_once __DIR__.'/../classes/Base.php';
$db=(new Base())->connect();
foreach(explode(';',file_get_contents(__DIR__.'/../migrations/002_roster.sql')) as $sql){if(trim($sql))$db->exec($sql);}
echo "Club roster schema is ready. Existing accounts and administrative records were preserved.\n";