324 lines
9.8 KiB
PHP
324 lines
9.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Config;
|
|
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
use SplFileInfo;
|
|
|
|
final readonly class CorePatternAuditProvider
|
|
{
|
|
public function __construct(
|
|
private string $projectDir,
|
|
private GovernanceConfig $governanceConfig
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function audit(bool $includeReviewFindings = false): array
|
|
{
|
|
$sourceRoots = $this->governanceConfig->getCorePatternAuditSourceRoots();
|
|
$excludedPathPrefixes = $this->governanceConfig->getCorePatternAuditExcludedPathPrefixes();
|
|
$excludedPathPatterns = $this->governanceConfig->getCorePatternAuditExcludedPathPatterns();
|
|
$warningPathPrefixes = $this->governanceConfig->getCorePatternAuditWarningPathPrefixes();
|
|
$suspiciousCalls = $this->governanceConfig->getCorePatternAuditSuspiciousCalls();
|
|
$domainMarkers = $this->governanceConfig->getCorePatternAuditDomainMarkerTerms();
|
|
$allowedLiteralPatterns = $this->governanceConfig->getCorePatternAuditAllowedLiteralPatterns();
|
|
$maxSnippetLength = $this->governanceConfig->getCorePatternAuditMaxSnippetLength();
|
|
|
|
$sourceFiles = $this->collectSourceFiles($sourceRoots);
|
|
$skippedFiles = [];
|
|
$warningFindings = [];
|
|
$reviewFindings = [];
|
|
|
|
foreach ($sourceFiles as $relativePath => $absolutePath) {
|
|
if ($this->isExcludedPath($relativePath, $excludedPathPrefixes, $excludedPathPatterns)) {
|
|
$skippedFiles[] = $relativePath;
|
|
continue;
|
|
}
|
|
|
|
$content = file_get_contents($absolutePath);
|
|
if (!is_string($content)) {
|
|
continue;
|
|
}
|
|
|
|
$lines = preg_split('/\R/u', $content) ?: [];
|
|
foreach ($lines as $index => $line) {
|
|
$calls = $this->matchingCalls((string) $line, $suspiciousCalls);
|
|
if ($calls === []) {
|
|
continue;
|
|
}
|
|
|
|
$markers = $this->matchingMarkersInStringLiterals((string) $line, $domainMarkers);
|
|
if ($markers !== [] && $this->isAllowedLiteralFinding($relativePath, (string) $line, $allowedLiteralPatterns)) {
|
|
continue;
|
|
}
|
|
|
|
$severity = $markers !== [] && $this->isWarningPath($relativePath, $warningPathPrefixes)
|
|
? 'WARN'
|
|
: 'REVIEW';
|
|
$finding = [
|
|
'severity' => $severity,
|
|
'path' => $relativePath,
|
|
'line' => $index + 1,
|
|
'calls' => $calls,
|
|
'markers' => $markers,
|
|
'snippet' => $this->compactSnippet((string) $line, $maxSnippetLength),
|
|
];
|
|
|
|
if ($severity === 'WARN') {
|
|
$warningFindings[] = $finding;
|
|
} elseif ($includeReviewFindings) {
|
|
$reviewFindings[] = $finding;
|
|
}
|
|
}
|
|
}
|
|
|
|
$status = $warningFindings === [] ? 'OK' : 'WARN';
|
|
|
|
return [
|
|
'status' => $status,
|
|
'summary' => [
|
|
'source_files' => count($sourceFiles),
|
|
'scanned_files' => count($sourceFiles) - count($skippedFiles),
|
|
'skipped_files' => count($skippedFiles),
|
|
'warning_findings' => count($warningFindings),
|
|
'review_findings' => count($reviewFindings),
|
|
'total_reported_findings' => count($warningFindings) + count($reviewFindings),
|
|
],
|
|
'warnings' => $this->buildWarnings($warningFindings),
|
|
'warning_findings' => $warningFindings,
|
|
'review_findings' => $reviewFindings,
|
|
'skipped_files' => $skippedFiles,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param string[] $sourceRoots
|
|
* @return array<string, string>
|
|
*/
|
|
private function collectSourceFiles(array $sourceRoots): array
|
|
{
|
|
$files = [];
|
|
|
|
foreach ($sourceRoots as $sourceRoot) {
|
|
$sourceRoot = trim($sourceRoot, '/');
|
|
if ($sourceRoot === '') {
|
|
continue;
|
|
}
|
|
|
|
$absoluteRoot = $this->projectDir . '/' . $sourceRoot;
|
|
if (!is_dir($absoluteRoot)) {
|
|
continue;
|
|
}
|
|
|
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($absoluteRoot));
|
|
foreach ($iterator as $file) {
|
|
if (!$file instanceof SplFileInfo || !$file->isFile()) {
|
|
continue;
|
|
}
|
|
|
|
if ($file->getExtension() !== 'php') {
|
|
continue;
|
|
}
|
|
|
|
$absolutePath = $file->getPathname();
|
|
$relativePath = $this->relativePath($absolutePath);
|
|
$files[$relativePath] = $absolutePath;
|
|
}
|
|
}
|
|
|
|
ksort($files);
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $prefixes
|
|
* @param string[] $patterns
|
|
*/
|
|
private function isExcludedPath(string $relativePath, array $prefixes, array $patterns): bool
|
|
{
|
|
foreach ($prefixes as $prefix) {
|
|
$prefix = trim($prefix);
|
|
if ($prefix !== '' && str_starts_with($relativePath, $prefix)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
foreach ($patterns as $pattern) {
|
|
if (@preg_match($pattern, $relativePath) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** @param string[] $prefixes */
|
|
private function isWarningPath(string $relativePath, array $prefixes): bool
|
|
{
|
|
foreach ($prefixes as $prefix) {
|
|
$prefix = trim($prefix);
|
|
if ($prefix !== '' && str_starts_with($relativePath, $prefix)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param string[] $calls
|
|
* @return string[]
|
|
*/
|
|
private function matchingCalls(string $line, array $calls): array
|
|
{
|
|
$matches = [];
|
|
foreach ($calls as $call) {
|
|
$call = trim($call);
|
|
if ($call === '') {
|
|
continue;
|
|
}
|
|
|
|
if (str_contains($line, $call . '(')) {
|
|
$matches[] = $call;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($matches));
|
|
}
|
|
|
|
/**
|
|
* @param string[] $markers
|
|
* @return string[]
|
|
*/
|
|
private function matchingMarkersInStringLiterals(string $line, array $markers): array
|
|
{
|
|
$literals = $this->extractStringLiterals($line);
|
|
if ($literals === []) {
|
|
return [];
|
|
}
|
|
|
|
$normalizedLiterals = mb_strtolower(implode("\n", $literals), 'UTF-8');
|
|
$matches = [];
|
|
|
|
foreach ($markers as $marker) {
|
|
$marker = mb_strtolower(trim($marker), 'UTF-8');
|
|
if ($marker === '') {
|
|
continue;
|
|
}
|
|
|
|
if (str_contains($normalizedLiterals, $marker)) {
|
|
$matches[] = $marker;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($matches));
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function extractStringLiterals(string $line): array
|
|
{
|
|
$literals = [];
|
|
$length = strlen($line);
|
|
|
|
for ($i = 0; $i < $length; $i++) {
|
|
$quote = $line[$i];
|
|
if ($quote !== "'" && $quote !== '"') {
|
|
continue;
|
|
}
|
|
|
|
$buffer = '';
|
|
for ($j = $i + 1; $j < $length; $j++) {
|
|
$char = $line[$j];
|
|
if ($char === '\\') {
|
|
if ($j + 1 < $length) {
|
|
$buffer .= $line[$j + 1];
|
|
$j++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($char === $quote) {
|
|
$literals[] = $buffer;
|
|
$i = $j;
|
|
break;
|
|
}
|
|
|
|
$buffer .= $char;
|
|
}
|
|
}
|
|
|
|
return $literals;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{path:string, pattern:string, reason:string}> $allowedLiteralPatterns
|
|
*/
|
|
private function isAllowedLiteralFinding(string $relativePath, string $line, array $allowedLiteralPatterns): bool
|
|
{
|
|
foreach ($allowedLiteralPatterns as $allowed) {
|
|
$pathPrefix = trim($allowed['path']);
|
|
$pattern = trim($allowed['pattern']);
|
|
if ($pathPrefix === '' || $pattern === '') {
|
|
continue;
|
|
}
|
|
|
|
if (!str_starts_with($relativePath, $pathPrefix)) {
|
|
continue;
|
|
}
|
|
|
|
if (@preg_match($pattern, $line) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function relativePath(string $absolutePath): string
|
|
{
|
|
$projectDir = rtrim($this->projectDir, '/') . '/';
|
|
if (str_starts_with($absolutePath, $projectDir)) {
|
|
return str_replace('\\', '/', substr($absolutePath, strlen($projectDir)));
|
|
}
|
|
|
|
return str_replace('\\', '/', $absolutePath);
|
|
}
|
|
|
|
private function compactSnippet(string $line, int $maxLength): string
|
|
{
|
|
$snippet = trim(preg_replace('/\s+/u', ' ', $line) ?? $line);
|
|
if ($maxLength < 20 || mb_strlen($snippet, 'UTF-8') <= $maxLength) {
|
|
return $snippet;
|
|
}
|
|
|
|
return mb_substr($snippet, 0, $maxLength - 3, 'UTF-8') . '...';
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $warningFindings
|
|
* @return string[]
|
|
*/
|
|
private function buildWarnings(array $warningFindings): array
|
|
{
|
|
if ($warningFindings === []) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
sprintf(
|
|
'Core pattern audit found %d warning finding(s). Review whether these domain-sensitive patterns belong in YAML-backed configuration.',
|
|
count($warningFindings)
|
|
),
|
|
];
|
|
}
|
|
}
|