mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-03 10:36:49 +09:00
239 lines
7.6 KiB
PHP
239 lines
7.6 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Copyright 2022-2026 FOSSBilling
|
|
* SPDX-License-Identifier: Apache-2.0.
|
|
*
|
|
* @copyright FOSSBilling (https://www.fossbilling.org)
|
|
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Support;
|
|
|
|
use PhpParser\Node;
|
|
use PhpParser\Node\Expr\Array_;
|
|
use PhpParser\Node\Expr\ArrayDimFetch;
|
|
use PhpParser\Node\Expr\Assign;
|
|
use PhpParser\Node\Expr\Variable;
|
|
use PhpParser\Node\Stmt\ClassMethod;
|
|
use PhpParser\NodeFinder;
|
|
use PhpParser\ParserFactory;
|
|
use Symfony\Component\Finder\Finder;
|
|
|
|
/**
|
|
* Walks every `function toApiArray` under `src/modules` and reports fields
|
|
* that are assigned to `$data['xxx']` only inside conditional blocks. The
|
|
* toApiArray method is the canonical bridge between a service-layer model
|
|
* and the JSON returned to the API; if a template reads a key that is only
|
|
* set inside an `if ($identity instanceof \Box\Mod\Staff\Entity\Admin)` branch, the
|
|
* template raises `Key "xxx" does not exist` under strict_variables, or
|
|
* returns a silent empty value under permissive rendering, when the
|
|
* response is consumed by a non-admin caller.
|
|
*
|
|
* Only fields that are never assigned at the top level are reported. These
|
|
* fields need an explicit review because their absence is part of the API
|
|
* contract, rather than an accidental omission.
|
|
*/
|
|
final readonly class ToApiArrayAuditor
|
|
{
|
|
private \PhpParser\Parser $parser;
|
|
private NodeFinder $nodeFinder;
|
|
|
|
public function __construct(private string $srcDir)
|
|
{
|
|
// Parenthesise the `new` so the chained `->createForHostVersion()`
|
|
// call parses on PHP 8.3 as well as 8.4+. Without the parentheses,
|
|
// PHP 8.3 sees `new ParserFactory()->...` as a syntax error: the
|
|
// ability to omit them is a PHP 8.4 language change
|
|
// (https://wiki.php.net/rfc/new_without_parentheses).
|
|
$factory = new ParserFactory();
|
|
$this->parser = $factory->createForHostVersion();
|
|
$this->nodeFinder = new NodeFinder();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, array<string, list<string>>>
|
|
*/
|
|
public function audit(): array
|
|
{
|
|
$findings = [];
|
|
|
|
$finder = new Finder();
|
|
$finder->files()->in($this->srcDir . '/modules')->name('*.php');
|
|
|
|
foreach ($finder as $file) {
|
|
$code = $file->getContents();
|
|
|
|
try {
|
|
$ast = $this->parser->parse($code);
|
|
} catch (\Throwable) {
|
|
continue;
|
|
}
|
|
if ($ast === null) {
|
|
continue;
|
|
}
|
|
|
|
$methods = $this->nodeFinder->findInstanceOf($ast, ClassMethod::class);
|
|
foreach ($methods as $method) {
|
|
if ($method->name->toString() !== 'toApiArray') {
|
|
continue;
|
|
}
|
|
if ($method->stmts === null) {
|
|
continue;
|
|
}
|
|
|
|
$relPath = str_replace($this->srcDir . '/', '', $file->getPathName());
|
|
$fields = $this->auditMethodBody($method->stmts);
|
|
|
|
$conditional = [];
|
|
foreach ($fields as $name => $locations) {
|
|
if (!in_array('top', $locations, true)) {
|
|
$conditional[$name] = array_values(array_unique($locations));
|
|
}
|
|
}
|
|
if ($conditional !== []) {
|
|
$findings[$relPath] = $conditional;
|
|
}
|
|
}
|
|
}
|
|
|
|
ksort($findings);
|
|
|
|
return $findings;
|
|
}
|
|
|
|
/**
|
|
* Walk a list of statements and return a map of `$data['xxx']` field
|
|
* names to the set of places they are assigned.
|
|
*
|
|
* @param Node\Stmt[] $stmts
|
|
*
|
|
* @return array<string, list<string>>
|
|
*/
|
|
private function auditMethodBody(array $stmts): array
|
|
{
|
|
$result = [];
|
|
|
|
foreach ($stmts as $stmt) {
|
|
$this->collectAssignments($stmt, $result, 'top');
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, list<string>> $result
|
|
*/
|
|
private function collectAssignments(Node $node, array &$result, string $where): void
|
|
{
|
|
if ($node instanceof Node\Stmt\If_) {
|
|
$childWhere = 'if';
|
|
if ($node->elseifs !== null && count($node->elseifs) > 0) {
|
|
$childWhere = 'if (with elseifs)';
|
|
}
|
|
if ($node->else !== null) {
|
|
$childWhere = 'if/else';
|
|
}
|
|
foreach ($node->stmts as $child) {
|
|
$this->collectAssignments($child, $result, $childWhere);
|
|
}
|
|
foreach ($node->elseifs as $elseif) {
|
|
foreach ($elseif->stmts as $child) {
|
|
$this->collectAssignments($child, $result, $childWhere);
|
|
}
|
|
}
|
|
if ($node->else !== null) {
|
|
foreach ($node->else->stmts as $child) {
|
|
$this->collectAssignments($child, $result, $childWhere);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($node instanceof Node\Stmt\For_ || $node instanceof Node\Stmt\Foreach_ || $node instanceof Node\Stmt\While_ || $node instanceof Node\Stmt\Do_) {
|
|
$childWhere = 'loop';
|
|
foreach ($node->stmts as $child) {
|
|
$this->collectAssignments($child, $result, $childWhere);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($node instanceof Node\Stmt\Switch_) {
|
|
foreach ($node->cases as $case) {
|
|
foreach ($case->stmts as $child) {
|
|
$this->collectAssignments($child, $result, 'switch');
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($node instanceof Node\Stmt\TryCatch) {
|
|
$this->collectAssignments($node->try, $result, $where);
|
|
foreach ($node->catches as $catch) {
|
|
$this->collectAssignments($catch, $result, $where);
|
|
}
|
|
if ($node->finally !== null) {
|
|
$this->collectAssignments($node->finally, $result, $where);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($node instanceof Node\Stmt\Block) {
|
|
foreach ($node->stmts as $child) {
|
|
$this->collectAssignments($child, $result, $where);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($node instanceof Node\Stmt\Expression) {
|
|
$this->collectAssignments($node->expr, $result, $where);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($node instanceof Assign) {
|
|
if ($node->var instanceof Variable && $node->var->name === 'data' && $node->expr instanceof Array_) {
|
|
foreach ($node->expr->items as $item) {
|
|
if ($item?->key instanceof Node\Scalar\String_) {
|
|
$result[$item->key->value][] = $where;
|
|
}
|
|
}
|
|
}
|
|
|
|
$field = $this->extractDataFieldName($node->var);
|
|
if ($field !== null) {
|
|
$result[$field][] = $where;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the field name from a `$data['xxx']` or `$data['xxx'][]` access
|
|
* chain, or null if the chain doesn't terminate at a string-keyed
|
|
* `$data` access.
|
|
*/
|
|
private function extractDataFieldName(Node $node): ?string
|
|
{
|
|
if ($node instanceof ArrayDimFetch) {
|
|
if ($node->dim instanceof Node\Scalar\String_) {
|
|
$inner = $node->var;
|
|
if ($inner instanceof Variable && $inner->name === 'data') {
|
|
return $node->dim->value;
|
|
}
|
|
}
|
|
|
|
// Recurse in case of chained access like $data['x']['y'].
|
|
return $this->extractDataFieldName($node->var);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|