*/ 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 */ 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 $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> $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) ), ]; } }