Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.51% covered (warning)
85.51%
118 / 138
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReleaseAuditorService
85.40% covered (warning)
85.40%
117 / 137
20.00% covered (danger)
20.00%
2 / 10
65.76
0.00% covered (danger)
0.00%
0 / 1
 auditDirectory
91.43% covered (success)
91.43%
32 / 35
0.00% covered (danger)
0.00%
0 / 1
9.05
 auditZip
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
6
 inspectZipEntry
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
6.07
 inspectFile
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 checkPathRules
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
13.33
 checkVendorEntry
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 scanContentForLeaks
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
6.97
 inspectComposerJson
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 validateComposerContent
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 isTextFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Packaging\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Packaging\Dto\AuditReportDto;
12use RecursiveDirectoryIterator;
13use RecursiveIteratorIterator;
14use RuntimeException;
15use SplFileInfo;
16use ZipArchive;
17
18/**
19 * Deep security, credential leakage and integrity auditor for production releases and directories.
20 *
21 * @package App\Core\Packaging\Service
22 */
23final class ReleaseAuditorService
24{
25    /** @var list<string> Allowed executable binaries in bin/ folder. */
26    private const array ALLOWED_BINARIES = [
27        'bin/cron',
28        'bin/download-geoip',
29        'bin/geocode-records',
30        'bin/install',
31        'bin/logs',
32        'bin/mail-scanner',
33        'bin/yii',
34    ];
35
36    /** @var list<string> Forbidden path patterns. */
37    private const array FORBIDDEN_PATHS = [
38        '.agents',
39        '.gemini',
40        '.phpunit.cache',
41        'ENVIRONMENT.md',
42        'clover.xml',
43        'coverage_report.txt',
44        'database/test',
45        'phpstan.neon',
46        'phpunit.xml',
47        'setup_dev_environment.ps1',
48        'tests',
49    ];
50
51    /** @var list<string> Sensitive filenames strictly prohibited in release. */
52    private const array PROHIBITED_FILES = [
53        'config/common/api_auth.php',
54        'storage/app.key',
55    ];
56
57    /**
58     * Audits a directory tree against all enterprise security and zero-leak release rules.
59     *
60     * @param string $directoryPath Absolute path to target directory.
61     * @return AuditReportDto Comprehensive audit report.
62     */
63    public function auditDirectory(string $directoryPath): AuditReportDto
64    {
65        $realRoot = realpath($directoryPath);
66        if ($realRoot === false || !is_dir($realRoot)) {
67            return new AuditReportDto(
68                passed: false,
69                scannedFilesCount: 0,
70                violations: [sprintf('Target directory does not exist: %s', $directoryPath)]
71            );
72        }
73
74        $violations = [];
75        $scannedCount = 0;
76        $canonicalRoot = str_replace('\\', '/', $realRoot);
77
78        $iterator = new RecursiveIteratorIterator(
79            new RecursiveDirectoryIterator($realRoot, RecursiveDirectoryIterator::SKIP_DOTS),
80            RecursiveIteratorIterator::LEAVES_ONLY
81        );
82
83        /** @var SplFileInfo $file */
84        foreach ($iterator as $file) {
85            if (!$file->isFile()) {
86                continue;
87            }
88
89            $realPath = $file->getRealPath();
90            if ($realPath === false) {
91                continue;
92            }
93
94            $canonicalReal = str_replace('\\', '/', $realPath);
95            $relativePath = ltrim(substr($canonicalReal, strlen($canonicalRoot)), '/');
96
97            // Skip VCS internal files and build outputs
98            if (str_starts_with($relativePath, '.git/') || str_starts_with($relativePath, 'build/')) {
99                continue;
100            }
101
102            $scannedCount++;
103            $fileViolations = $this->inspectFile($canonicalReal, $relativePath);
104            foreach ($fileViolations as $violation) {
105                $violations[] = $violation;
106            }
107        }
108
109        $violations = array_merge($violations, $this->inspectComposerJson($realRoot));
110
111        return new AuditReportDto(
112            passed: empty($violations),
113            scannedFilesCount: $scannedCount,
114            violations: array_values(array_unique($violations)),
115            stats: ['violations_count' => count($violations)]
116        );
117    }
118
119    /**
120     * Audits an assembled release ZIP archive.
121     *
122     * @param string $zipFilePath Absolute path to ZIP archive.
123     * @return AuditReportDto Comprehensive audit report.
124     */
125    public function auditZip(string $zipFilePath): AuditReportDto
126    {
127        if (!file_exists($zipFilePath)) {
128            return new AuditReportDto(
129                passed: false,
130                scannedFilesCount: 0,
131                violations: [sprintf('Target ZIP file does not exist: %s', $zipFilePath)]
132            );
133        }
134
135        $zip = new ZipArchive();
136        $openResult = $zip->open($zipFilePath);
137        if ($openResult !== true) {
138            return new AuditReportDto(
139                passed: false,
140                scannedFilesCount: 0,
141                violations: [sprintf('Failed to open ZIP package: %s (code %d)', $zipFilePath, $openResult)]
142            );
143        }
144
145        $violations = [];
146        $scannedCount = 0;
147
148        for ($i = 0; $i < $zip->numFiles; $i++) {
149            $entryViolations = $this->inspectZipEntry($zip, $i);
150            if ($entryViolations === null) {
151                continue;
152            }
153
154            $scannedCount++;
155            foreach ($entryViolations as $ev) {
156                $violations[] = $ev;
157            }
158        }
159
160        $zip->close();
161
162        return new AuditReportDto(
163            passed: empty($violations),
164            scannedFilesCount: $scannedCount,
165            violations: array_values(array_unique($violations)),
166            stats: ['violations_count' => count($violations)]
167        );
168    }
169
170    /**
171     * @return list<string>|null Null if directory or invalid stat, otherwise list of violations.
172     */
173    private function inspectZipEntry(ZipArchive $zip, int $index): ?array
174    {
175        $stat = $zip->statIndex($index);
176        if ($stat === false) {
177            return null;
178        }
179
180        $entryName = str_replace('\\', '/', $stat['name']);
181        if (str_ends_with($entryName, '/')) {
182            return null;
183        }
184
185        $violations = $this->checkPathRules($entryName);
186
187        $content = (string) $zip->getFromIndex($index);
188        $contentViolations = $this->scanContentForLeaks($entryName, $content);
189        foreach ($contentViolations as $cv) {
190            $violations[] = $cv;
191        }
192
193        if ($entryName === 'composer.json') {
194            $composerViolations = $this->validateComposerContent($content);
195            foreach ($composerViolations as $comV) {
196                $violations[] = $comV;
197            }
198        }
199
200        return $violations;
201    }
202
203    /**
204     * Inspects a single physical file on disk.
205     *
206     * @param string $absolutePath Absolute filesystem path.
207     * @param string $relativePath Normalized relative path.
208     * @return list<string> List of detected violations.
209     */
210    private function inspectFile(string $absolutePath, string $relativePath): array
211    {
212        $violations = $this->checkPathRules($relativePath);
213
214        // Skip binary files and vendor contents from deep regex content scanning
215        if (str_starts_with($relativePath, 'vendor/')) {
216            $vendorViolations = $this->checkVendorEntry($relativePath);
217            return array_merge($violations, $vendorViolations);
218        }
219
220        if ($this->isTextFile($relativePath) && filesize($absolutePath) < 5_000_000) {
221            $content = (string) file_get_contents($absolutePath);
222            $contentViolations = $this->scanContentForLeaks($relativePath, $content);
223            $violations = array_merge($violations, $contentViolations);
224        }
225
226        return $violations;
227    }
228
229    /**
230     * Verifies path-level isolation and naming conventions.
231     *
232     * @param string $relativePath Normalized relative path.
233     * @return list<string> Violations list.
234     */
235    private function checkPathRules(string $relativePath): array
236    {
237        $violations = [];
238
239        foreach (self::FORBIDDEN_PATHS as $forbidden) {
240            if ($relativePath === $forbidden || str_starts_with($relativePath, $forbidden . '/')) {
241                $violations[] = sprintf('Forbidden development path present: %s', $relativePath);
242            }
243        }
244
245        foreach (self::PROHIBITED_FILES as $prohibited) {
246            if ($relativePath === $prohibited) {
247                $violations[] = sprintf('Sensitive credential file present: %s', $relativePath);
248            }
249        }
250
251        if (str_starts_with($relativePath, 'bin/') && !in_array($relativePath, self::ALLOWED_BINARIES, true)) {
252            $violations[] = sprintf('Unapproved CLI script present in bin/: %s', $relativePath);
253        }
254
255        if (str_ends_with($relativePath, 'Test.php')) {
256            $violations[] = sprintf('Unit test file present in production: %s', $relativePath);
257        }
258
259        if (str_ends_with($relativePath, '.py') || str_ends_with($relativePath, '.ps1')) {
260            $violations[] = sprintf('Forbidden maintenance script present: %s', $relativePath);
261        }
262
263        // Storage files must only be .gitkeep placeholders
264        if (str_starts_with($relativePath, 'storage/documents/') && !str_ends_with($relativePath, '.gitkeep')) {
265            $violations[] = sprintf('User document residue present in storage: %s', $relativePath);
266        }
267
268        return $violations;
269    }
270
271    /**
272     * Inspects vendor directory entries to ensure dev packages were completely purged.
273     *
274     * @param string $relativePath Relative path within vendor.
275     * @return list<string> Violations list.
276     */
277    private function checkVendorEntry(string $relativePath): array
278    {
279        $devVendorPackages = ['vendor/phpunit/', 'vendor/phpstan/', 'vendor/sebastian/', 'vendor/theseer/'];
280        foreach ($devVendorPackages as $pkg) {
281            if (str_starts_with($relativePath, $pkg)) {
282                return [sprintf('Development composer dependency left in vendor: %s', $relativePath)];
283            }
284        }
285
286        return [];
287    }
288
289    /**
290     * Scans file text content for secrets, tokens, API keys and leftover demo strings.
291     *
292     * @param string $relativePath Path for contextual reporting.
293     * @param string $content      File content string.
294     * @return list<string> Violations list.
295     */
296    private function scanContentForLeaks(string $relativePath, string $content): array
297    {
298        $violations = [];
299
300        // Check live API keys
301        if (preg_match('/ak_(?:live|test)_[a-zA-Z0-9]{20,}/', $content, $m)) {
302            $violations[] = sprintf('Live Mapbox/Map API key detected in %s: %s', $relativePath, $m[0]);
303        }
304
305        // Check SonarQube tokens
306        if (preg_match('/squ_[a-zA-Z0-9]{30,}/', $content, $m)) {
307            $violations[] = sprintf('SonarQube authentication token detected in %s: %s', $relativePath, $m[0]);
308        }
309
310        // Check hardcoded private keys
311        if (str_contains($content, '-----BEGIN') && str_contains($content, 'PRIVATE KEY-----')) {
312            $violations[] = sprintf('Cryptographic private key detected in %s', $relativePath);
313        }
314
315        // Check gatekeeper test fallback secret
316        if (str_contains($content, 'ammonly_default_gatekeeper_secret_change_in_production')) {
317            $violations[] = sprintf('Insecure default gatekeeper secret present in %s', $relativePath);
318        }
319
320        return $violations;
321    }
322
323    /**
324     * Inspects composer.json on disk.
325     *
326     * @param string $directoryPath Root directory.
327     * @return list<string> Violations list.
328     */
329    private function inspectComposerJson(string $directoryPath): array
330    {
331        $composerPath = $directoryPath . '/composer.json';
332        if (!file_exists($composerPath)) {
333            return ['composer.json is missing in production directory.'];
334        }
335
336        $content = (string) file_get_contents($composerPath);
337        return $this->validateComposerContent($content);
338    }
339
340    /**
341     * Validates that composer.json content has no dev dependencies or test autoloading.
342     *
343     * @param string $content JSON string content.
344     * @return list<string> Violations list.
345     */
346    private function validateComposerContent(string $content): array
347    {
348        $data = json_decode($content, true);
349        if (!is_array($data)) {
350            return ['composer.json is invalid JSON syntax.'];
351        }
352
353        $violations = [];
354
355        if (isset($data['require-dev']) && count((array) $data['require-dev']) > 0) {
356            $violations[] = 'composer.json contains require-dev dependencies in production release.';
357        }
358
359        if (isset($data['autoload-dev']) && count((array) $data['autoload-dev']) > 0) {
360            $violations[] = 'composer.json contains autoload-dev section in production release.';
361        }
362
363        return $violations;
364    }
365
366    /**
367     * Determines whether a file is a readable text format.
368     *
369     * @param string $path File relative path.
370     * @return bool True if text format.
371     */
372    private function isTextFile(string $path): bool
373    {
374        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
375        return in_array($ext, ['php', 'sql', 'js', 'json', 'yml', 'yaml', 'twig', 'html', 'md', 'env', 'txt'], true);
376    }
377}