Vodiči

Native PHP 8.3: Automate Post-Deployment Website Security Audits

Izvorni PHP 8.3: automatizirajte sigurnosne provjere web-mjesta nakon implementacije

Implementacija u produkciji nije završena samo zato što novo izdanje prolazi zdravstvene provjere. Promjene preusmjeravanja, proxy pravila, certifikata, zaglavlja odgovora ili pravila sigurnosti sadržaja mogu neprimjetno oslabiti javnu stranicu dok sama aplikacija ostaje zdrava.

Ovaj vodič izrađuje Native PHP 8.3 naredbu koja se pokreće nakon svake implementacije u produkciji, šalje javni HTTPS URL Website Security Analyzeru, preslikava rezultat u mali domenski objekt i zapisuje atomarni JSON artefakt revizije. Integracija koristi izvorni cURL, ograničene ponovne pokušaje, determinističke testove i eksplicitna stanja neuspjeha.

Analizator provodi ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosnog stanja preglednika. Njegov izlaz tretirajte kao povratnu informaciju o implementaciji, a ne kao penetracijski test, procjenu ranjivosti ili dokaz da je stranica sigurna.

Dobijte pristup i kopirajte servisni token

  1. Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
  2. Otvorite stranicu usluge Website Security Analyzer.
  3. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  6. Pohranite ga u konfiguraciju okruženja za implementaciju projekta. Ponovno generiranje tokena opoziva prethodno aktivni token, stoga implementacije i spremišta tajni morate ažurirati zajedno.

Ova usluga zahtijeva autentikaciju; za ovu integraciju ne postoji način rada bez tokena. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo oblik Bearer jer parametri upita mogu biti zabilježeni u zapisnicima pristupa, proxyjima i sustavima za nadzor.

Provjerite krajnju točku prije pisanja PHP-a

Točan poziv je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Njegovo JSON tijelo sadrži url:

export WEBSITE_SECURITY_ANALYZER_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --request POST \
  --header "Authorization: Bearer ${WEBSITE_SECURITY_ANALYZER_TOKEN}" \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://www.example.com"}' \
  https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website

Upotrijebite jednokratnu ljusku ili ubrizgavanje CI tajni umjesto stavljanja stvarnog tokena u povijest terminala. Ugovor odgovora uključuje rezultat, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke. Nećemo pretpostavljati nedokumentirana polja unutar tih kolekcija.

Arhitektura i struktura projekta

Dizajn namjerno ostaje malen: kuka za implementaciju poziva naredbu, naredba poziva namjenskog API klijenta, a klijent delegira mrežni I/O transportnom sučelju. To sučelje čini PHPUnit testove determinističkima bez kontaktiranja usluge.

security-audit/
├── bin/audit-production.php
├── src/AnalyzerClient.php
├── src/AnalyzerResult.php
├── src/Http/CurlTransport.php
├── src/Http/HttpResponse.php
├── src/Http/HttpTransport.php
├── tests/AnalyzerClientTest.php
├── composer.json
└── phpunit.xml

Naredba se pokreće sinkrono nakon implementacije. To čini dovršetak vidljivim CI-ju i izbjegava dodavanje reda čekanja samo za jednu kratku integraciju. Budući da izdanje možda već radi, neuspješna revizija označava tijek implementacije kao neuspješan, ali se ne pretvara da automatski vraća infrastrukturu na prethodno stanje.

Preduvjeti i konfiguracija okruženja

  • PHP 8.3 ili noviji s proširenjima cURL i JSON.
  • Composer.
  • Javni HTTPS URL produkcije dostupan analizatoru.
  • Token ograničen na uslugu pohranjen izvan kontrole verzija.

Instalirajte PHPUnit 11, koji podržava PHP 8.3, i konfigurirajte PSR-4 automatsko učitavanje:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
composer dump-autoload
mkdir -p var/security-audit

Na produkcijskom računalu stvorite datoteku okruženja čitljivu samo računu za implementaciju. Nemojte je predavati u repozitorij:

# /etc/my-site/security-audit.env
WEBSITE_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
PRODUCTION_PUBLIC_URL=https://www.example.com
SECURITY_AUDIT_OUTPUT=/srv/my-site/current/var/security-audit/latest.json

Primijenite restriktivne dozvole datoteka putem svojeg sustava za provisioning. Izbjegavajte ispisivanje ove datoteke ili uključivanje praćenja ljuske dok je učitana.

Izradite granicu izvornog cURL-a

Transport vraća samo status, zaglavlja odgovora i tijelo. Omogućuje provjeru certifikata, ograničava trajanje povezivanja i ukupno trajanje te namjerno izbjegava bilježenje zaglavlja zahtjeva.

<?php
// src/Http/HttpResponse.php
namespace App\Http;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

// src/Http/HttpTransport.php
namespace App\Http;

interface HttpTransport
{
    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse;
}
<?php
// src/Http/CurlTransport.php
namespace App\Http;

use JsonException;
use RuntimeException;

final class CurlTransport implements HttpTransport
{
    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        try {
            $payload = json_encode($body, JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw new RuntimeException('Could not encode request JSON.', 0, $e);
        }

        $handle = curl_init($url);
        if ($handle === false) {
            throw new RuntimeException('Could not initialize cURL.');
        }

        $responseHeaders = [];
        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return $length;
            },
        ]);

        $bodyText = curl_exec($handle);
        if ($bodyText === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new RuntimeException('Analyzer transport failed: ' . $message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $responseHeaders, $bodyText);
    }
}

Defenzivno preslikajte odgovor usluge

დომenski objekt prepoznaje samo dostavljeni ugovor. Zahtijeva četiri područja rezultata na najvišoj razini, ali čuva njihove nedokumentirane unutarnje podatke umjesto da izmišlja shemu.

<?php
// src/AnalyzerResult.php
namespace App;

use UnexpectedValueException;

final readonly class AnalyzerResult
{
    public function __construct(
        public int|float $score,
        public array $findingsBySeverity,
        public array $tls,
        public array $recommendations,
    ) {}

    public static function fromArray(array $data): self
    {
        if (!isset($data['score']) || !is_numeric($data['score'])) {
            throw new UnexpectedValueException('Response score is missing or invalid.');
        }
        if (!isset($data['findings']) || !is_array($data['findings'])) {
            throw new UnexpectedValueException('Response findings are missing or invalid.');
        }
        foreach ($data['findings'] as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new UnexpectedValueException('Findings are not grouped by severity.');
            }
        }
        if (!isset($data['tls']) || !is_array($data['tls'])) {
            throw new UnexpectedValueException('Response TLS details are missing or invalid.');
        }
        if (!isset($data['recommendations']) || !is_array($data['recommendations'])) {
            throw new UnexpectedValueException('Response recommendations are missing or invalid.');
        }

        return new self(
            $data['score'] + 0,
            $data['findings'],
            $data['tls'],
            $data['recommendations'],
        );
    }

    public function toArray(): array
    {
        return [
            'score' => $this->score,
            'findings' => $this->findingsBySeverity,
            'tls' => $this->tls,
            'recommendations' => $this->recommendations,
        ];
    }
}

Klijent ponovno pokušava kod neuspjeha transporta, HTTP-a 429 i pogrešaka poslužitelja. Ne pokušava ponovno autentikaciju ni druge pogreške klijenta: ponavljanje neispravnog tokena ili nevaljanog zahtjeva samo troši vrijeme i kvotu. I eksponencijalno povlačenje i odgode koje zahtijeva poslužitelj ograničene su.

<?php
// src/AnalyzerClient.php
namespace App;

use App\Http\HttpTransport;
use Closure;
use JsonException;
use RuntimeException;

final class AnalyzerClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';

    private Closure $sleep;

    public function __construct(
        private readonly HttpTransport $transport,
        private readonly string $token,
        ?Closure $sleep = null,
    ) {
        if ($token === '') {
            throw new RuntimeException('Analyzer token is empty.');
        }
        $this->sleep = $sleep ?? static fn(int $microseconds) => usleep($microseconds);
    }

    public function analyze(string $url): AnalyzerResult
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || parse_url($url, PHP_URL_SCHEME) !== 'https') {
            throw new RuntimeException('Production URL must be valid HTTPS.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: application/json',
                        'Content-Type: application/json',
                    ],
                    ['url' => $url],
                    3000,
                    20000,
                );
            } catch (RuntimeException $e) {
                if ($attempt === 3) {
                    throw $e;
                }
                ($this->sleep)(250000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 429 || $response->status >= 500) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        'Analyzer temporarily unavailable; HTTP ' . $response->status
                    );
                }
                $seconds = ctype_digit($response->headers['retry-after'] ?? '')
                    ? min(5, (int) $response->headers['retry-after'])
                    : 0;
                ($this->sleep)($seconds > 0
                    ? $seconds * 1000000
                    : 250000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new RuntimeException('Analyzer authentication was rejected.');
            }
            if ($response->status < 200 || $response->status >= 300) {
                throw new RuntimeException(
                    'Analyzer rejected the request; HTTP ' . $response->status
                );
            }

            try {
                $decoded = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
            } catch (JsonException $e) {
                throw new RuntimeException('Analyzer returned invalid JSON.', 0, $e);
            }
            if (!is_array($decoded)) {
                throw new RuntimeException('Analyzer returned an invalid document.');
            }

            return AnalyzerResult::fromArray($decoded);
        }

        throw new RuntimeException('Analyzer retry loop ended unexpectedly.');
    }
}

Izradite naredbu za implementaciju

Naredba bilježi ili completed ili unavailable. Piše putem privremene datoteke i preimenovanja, tako da čitatelji nikada ne vide napola zapisani JSON. Zapisnik pogrešaka sadrži cilj i razlog, ali nikada token ni tijelo odgovora.

<?php
// bin/audit-production.php
declare(strict_types=1);

use App\AnalyzerClient;
use App\Http\CurlTransport;

require dirname(__DIR__) . '/vendor/autoload.php';

$output = getenv('SECURITY_AUDIT_OUTPUT')
    ?: dirname(__DIR__) . '/var/security-audit/latest.json';
$target = getenv('PRODUCTION_PUBLIC_URL') ?: null;

try {
    $token = getenv('WEBSITE_SECURITY_ANALYZER_TOKEN');
    if ($target === null || $token === false || $token === '') {
        throw new RuntimeException('Required audit environment is missing.');
    }

    $result = (new AnalyzerClient(new CurlTransport(), $token))->analyze($target);
    $record = [
        'status' => 'completed',
        'checked_at' => gmdate(DATE_ATOM),
        'target_url' => $target,
        'analysis' => $result->toArray(),
        'scope' => 'bounded public HTTPS and browser security posture analysis',
    ];
    $exitCode = 0;
} catch (Throwable $e) {
    $record = [
        'status' => 'unavailable',
        'checked_at' => gmdate(DATE_ATOM),
        'target_url' => $target,
        'reason' => $e->getMessage(),
    ];
    fwrite(STDERR, json_encode([
        'event' => 'website_security_audit_failed',
        'target_url' => $target,
        'reason' => $e->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    $exitCode = 2;
}

$directory = dirname($output);
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
    throw new RuntimeException('Could not create audit output directory.');
}

$json = json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$temporary = $output . '.tmp.' . getmypid();

if (file_put_contents($temporary, $json . PHP_EOL, LOCK_EX) === false
    || !rename($temporary, $output)) {
    throw new RuntimeException('Could not persist audit result.');
}

fwrite(STDOUT, json_encode([
    'event' => 'website_security_audit_finished',
    'status' => $record['status'],
    'target_url' => $target,
], JSON_THROW_ON_ERROR) . PHP_EOL);

exit($exitCode);

Testirajte preslikavanje, ponovne pokušaje i neuspjehe autentikacije

Lažni transport pruža odgovore u redu čekanja i broji pozive. Ubrizgani uspavljivač bez operacije održava testove ponovnih pokušaja brzim i determinističkima.

<?php
// tests/AnalyzerClientTest.php
use App\AnalyzerClient;
use App\Http\HttpResponse;
use App\Http\HttpTransport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class AnalyzerClientTest extends TestCase
{
    private function response(int $status, array $headers = []): HttpResponse
    {
        return new HttpResponse(
            $status,
            $headers,
            '{"score":91,"findings":{},"tls":{},"recommendations":[]}'
        );
    }

    public function testMapsDocument(): void
    {
        $fake = new FakeTransport([$this->response(200)]);
        $client = new AnalyzerClient($fake, 'test-token', static fn(int $us) => null);

        self::assertSame(91, $client->analyze('https://www.example.com')->score);
        self::assertSame(1, $fake->calls);
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $fake = new FakeTransport([
            $this->response(429, ['retry-after' => '1']),
            $this->response(200),
        ]);
        $client = new AnalyzerClient($fake, 'test-token', static fn(int $us) => null);

        $client->analyze('https://www.example.com');
        self::assertSame(2, $fake->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([$this->response(401)]);
        $client = new AnalyzerClient($fake, 'test-token', static fn(int $us) => null);

        $this->expectExceptionMessage('authentication was rejected');
        try {
            $client->analyze('https://www.example.com');
        } finally {
            self::assertSame(1, $fake->calls);
        }
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
  <testsuites>
    <testsuite name="security-audit">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>
vendor/bin/phpunit

Povežite je s implementacijom u produkciji

Pokrenite naredbu tek nakon što javno izdanje i njegova HTTPS ruta budu dostupni. Generička faza implementacije može učitati zaštićenu datoteku okruženja i pozvati reviziju:

set -eu
set -a
. /etc/my-site/security-audit.env
set +a

cd /srv/my-site/current
php bin/audit-production.php

Držite API poziv izvan zahtjeva preglednika. Implementacija je ispravna granica: korisnici je ne čekaju, ponovni pokušaji su kontrolirani, a nastali artefakt mogu prikupiti CI ili nadzor. Upozorite na strukturirani događaj website_security_audit_failed i zadržavajte datoteke revizije prema svojoj uobičajenoj operativnoj politici.

Nemojte automatski neuspješnima označavati izdanja na temelju pretpostavljenog značenja raspona rezultata. Najprije potvrdite dokumentiranu semantiku rezultata i definirajte eksplicitnu produktnu politiku. Neuspjeh transporta, nalaz i kršenje politike različita su stanja i trebaju ostati razlučiva.

Uobičajeni neuspjesi u produkciji

  • HTTP 401 ili 403: potvrdite aktivaciju plana i ubrizgavanje tokena. Ako je token ponovno generiran, svugdje zamijenite opozvanu vrijednost.
  • HTTP 429: klijent poštuje brojčanu vrijednost Retry-After do pet sekundi. Trajni pritisak na kvotu treba riješiti raspoređivanjem ili kapacitetom plana, a ne agresivnim ponovnim pokušajima.
  • Istek vremena ili pogreške poslužitelja: tri ograničena pokušaja pokrivaju kratkotrajni prekid. Trajni neuspjeh stvara artefakt unavailable i izlazni kôd 2.
  • Nevaljan oblik odgovora: odstupanje ugovora tretirajte kao neuspjeh integracije. Sačuvajte sirovi odgovor samo u strogo kontroliranom dijagnostičkom procesu; nemojte ga neselektivno ispisivati u zapisnike.
  • Privatni URL ili URL prije izdanja: usluga analizira javnu web-stranicu. Pokrenite je nakon što promjene DNS-a, TLS-a, proxyja i autentikacije učine predviđeni produkcijski URL javno dostupnim.

Završni kontrolni popis za provjeru

  • Konfigurirani cilj je kanonski javni HTTPS URL, a ne interni naziv računala.
  • Stvarni servisni token postoji samo u konfiguraciji tajni podržanoj okruženjem.
  • PHP ima omogućen cURL i JSON, Composer ovisnosti su instalirane, a PHPUnit prolazi.
  • Račun za implementaciju može pisati u direktorij revizije bez dodjeljivanja javnog pristupa webu.
  • Ručno pokretanje u produkciji stvara valjani JSON koji sadrži status, vremensku oznaku, cilj, rezultat, grupirane nalaze, TLS pojedinosti i preporuke.
  • Neuspjesi autentikacije ne pokušavaju se ponovno, dok ograničenja brzine i prolazni neuspjesi koriste ograničeno povlačenje.
  • Tijek implementacije pokreće naredbu nakon svakog produkcijskog izdanja i prikazuje izlazni kôd 2.
  • Operativna dokumentacija navodi da je rezultat ograničena analiza sigurnosnog stanja, a ne penetracijski test.

Korisna revizija implementacije nije najglasniji sigurnosni mehanizam u sustavu. Ona je pouzdana: usko ograničena, sigurno autentificirana, iskrena o neuspjehu i prisutna nakon svakog izdanja. S ovom naredbom javno sigurnosno stanje postaje dio isporuke softvera, umjesto povremenog zadatka kojeg se sjetite tek nakon što je nešto već pošlo po zlu.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.