php/src/Core/Actions/ScheduledActionScanner.php
Snider a0a0727c88 fix(actions): harden scheduled actions — security allowlists, trait verification, scan safety
- Add ALLOWED_NAMESPACES prefix allowlist to ScheduleServiceProvider
- Add ALLOWED_FREQUENCIES method allowlist (prevents arbitrary method dispatch)
- Verify Action trait on scheduled classes before dispatch
- Move try/catch inside foreach for per-action isolation
- Add empty-scan guard to ScheduleSyncCommand (prevents disabling all rows)
- Consolidate ScheduledActionScanner to single tokenisation pass
- Cast numeric frequency args via ctype_digit() in ScheduledAction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:56:14 +00:00

157 lines
4.1 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Actions;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
/**
* Scans directories for Action classes with the #[Scheduled] attribute.
*
* Unlike ModuleScanner (which scans Boot.php files), this scanner finds
* any PHP class with the #[Scheduled] attribute in the given directories.
*
* It uses PHP's native reflection to read attributes — no file parsing.
*
* @see Scheduled The attribute this scanner discovers
* @see \Core\ModuleScanner Similar pattern for Boot.php discovery
*/
class ScheduledActionScanner
{
/**
* Scan directories for classes with #[Scheduled] attribute.
*
* @param array<string> $paths Directories to scan recursively
* @return array<class-string, Scheduled> Map of class name to attribute instance
*/
public function scan(array $paths): array
{
$results = [];
foreach ($paths as $path) {
if (! is_dir($path)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$class = $this->classFromFile($file->getPathname());
if ($class === null || ! class_exists($class)) {
continue;
}
$attribute = $this->extractScheduled($class);
if ($attribute !== null) {
$results[$class] = $attribute;
}
}
}
return $results;
}
/**
* Extract the #[Scheduled] attribute from a class.
*/
private function extractScheduled(string $class): ?Scheduled
{
try {
$ref = new ReflectionClass($class);
$attrs = $ref->getAttributes(Scheduled::class);
if (empty($attrs)) {
return null;
}
return $attrs[0]->newInstance();
} catch (\ReflectionException) {
return null;
}
}
/**
* Derive fully qualified class name from a PHP file.
*
* Reads the file's namespace declaration and class name.
*/
private function classFromFile(string $file): ?string
{
$contents = file_get_contents($file);
if ($contents === false) {
return null;
}
$tokens = token_get_all($contents);
$namespace = null;
$class = null;
$count = count($tokens);
for ($i = 0; $i < $count; $i++) {
$token = $tokens[$i];
if (! is_array($token)) {
continue;
}
if ($token[0] === T_NAMESPACE) {
$namespaceParts = [];
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if (is_array($t) && in_array($t[0], [T_NAME_QUALIFIED, T_STRING, T_NS_SEPARATOR], true)) {
$namespaceParts[] = $t[1];
} elseif ($t === ';' || $t === '{') {
break;
}
}
$namespace = implode('', $namespaceParts) ?: null;
}
if ($token[0] === T_CLASS) {
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if (is_array($t) && $t[0] === T_WHITESPACE) {
continue;
}
if (is_array($t) && $t[0] === T_STRING) {
$class = $t[1];
}
break;
}
break;
}
}
if ($class === null) {
return null;
}
return $namespace !== null ? "{$namespace}\\{$class}" : $class;
}
}