Vodiči

Native PHP 8.3: Build Client Security Dashboards with Automated Website Audits

Native PHP 8.3: Izradite nadzorne ploče sigurnosti klijenata s automatiziranim revizijama web-mjesta

Sigurnosna ocjena korisna je u trenutku. Sigurnosna nadzorna ploča korisna je svakog tjedna.

Za malu web agenciju praktični izazov nije samo otkrivanje sigurnosnih problema preglednika. Potrebno je održavati razumljivu povijest za svakog klijenta, pretvarati preporuke u zadatke i znati kada revizija nije uspjela umjesto da se tiho prikazuju zastarjeli rezultati.

Ovaj vodič izrađuje taj tijek rada u izvornom PHP-u 8.3. Zakazana naredba provjerava odobrene klijentske web-lokacije, pohranjuje normalizirane rezultate u SQLite, stvara zadatke za otklanjanje problema i puni malu nadzornu ploču renderiranu na poslužitelju. Integracija koristi Website Security Analyzer za ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosnog stanja preglednika. Klijentima se ne smije predstavljati kao penetracijski test, skeniranje ranjivosti ili jamstvo sigurnosti.

Dobijte pristup i kopirajte servisni token

Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.

Otvorite stranicu usluge Website Security Analyzer. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju. Odabir plana treba odražavati broj klijentskih web-lokacija i planiranu učestalost provjera, a ne utjecati na logiku aplikacije.

Zatim otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte tamo prikazani token za tu uslugu. Ova usluga zahtijeva autentifikaciju. Ponovno generiranje tokena opoziva prethodno aktivni token, stoga rotacija mora uključivati ažuriranje okruženja za implementaciju prije sljedećeg pokretanja provjere.

API prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer token jer vjerodajnicu drži izvan URL-ova, zapisnika pristupa i povijesti preglednika. Nemojte istodobno slati više oblika autentifikacije.

Potvrdite točnu krajnju točku

Integracija šalje ovaj zahtjev:

POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website

Pošaljite JSON tijelo koje sadrži url. Prije pisanja koda aplikacije, pošaljite jedan minimalni zahtjev iz pouzdanog terminala:

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

Zamijenite samo rezervirano mjesto i primjer URL-a. Nikada ne lijepite dovršenu naredbu u tiket, prijepis ljuske, snimku zaslona ili repozitorij izvornog koda.

Pohranite vjerodajnicu u .env.local, isključite tu datoteku iz kontrole verzija i prepustite operacijskom sustavu da njezine vrijednosti izloži PHP-u:

ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
APP_DB=/srv/security-dashboard/var/app.sqlite
APP_ENV=production

Izvorni PHP ne učitava dotenv datoteke automatski. Za lokalni razvoj izvezite datoteku u trenutačnu ljusku pomoću set -a; . ./.env.local; set +a. U produkciji konfigurirajte iste varijable putem upravitelja procesa ili spremišta tajni.

Arhitektura prilagođena maloj agenciji

Dizajn namjerno izbjegava posrednika za red čekanja i JavaScript aplikaciju. PHP naredba pokrenuta cron-om odabire web-lokacije na redu, poziva analizator i bilježi uspješnu provjeru ili strukturirani neuspjeh. Nadzorna ploča čita samo lokalne podatke, tako da spor vanjski zahtjev nikada ne odgađa klijentsku stranicu.

  • Granica analizatora: cURL prijenos, politika ponovnih pokušaja, JSON dekodiranje i obrambeno mapiranje odgovora.
  • Pohrana: klijenti, web-lokacije s dopuštene liste, povijest provjera i zadaci za otklanjanje problema u SQLiteu.
  • Pokretač: naredba namijenjena za cron ili systemd timer.
  • Nadzorna ploča: povijest renderirana na poslužitelju i otvoreni zadaci, bez mogućnosti slanja proizvoljnih URL-ova.

SQLite je prikladan za jednu instancu aplikacije i skroman raspored provjera. Ako više radnika ili web čvorova mora pisati istodobno, zadržite istu domensku granicu, ali migrirajte repozitorij na PostgreSQL.

Raspored projekta namjerno je malen:

security-dashboard/
├── bin/audit.php
├── public/index.php
├── src/Analyzer.php
├── tests/AnalyzerTest.php
├── var/app.sqlite
├── composer.json
└── schema.sql

Upotrijebite Composer samo za automatsko učitavanje i razvojne testove:

{
  "name": "agency/security-dashboard",
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "Agency\\Security\\": "src/"
    }
  }
}

Pokrenite composer install, a zatim stvorite bazu podataka pomoću sqlite3 var/app.sqlite < schema.sql:

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;

CREATE TABLE clients (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE sites (
    id INTEGER PRIMARY KEY,
    client_id INTEGER NOT NULL REFERENCES clients(id),
    url TEXT NOT NULL UNIQUE,
    enabled INTEGER NOT NULL DEFAULT 1,
    next_audit_at TEXT NOT NULL
);

CREATE TABLE audits (
    id INTEGER PRIMARY KEY,
    site_id INTEGER NOT NULL REFERENCES sites(id),
    status TEXT NOT NULL CHECK (status IN ('succeeded', 'failed')),
    score REAL,
    findings_json TEXT,
    tls_json TEXT,
    recommendations_json TEXT,
    failure_code TEXT,
    created_at TEXT NOT NULL
);

CREATE TABLE tasks (
    id INTEGER PRIMARY KEY,
    audit_id INTEGER NOT NULL REFERENCES audits(id),
    site_id INTEGER NOT NULL REFERENCES sites(id),
    description TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'open'
        CHECK (status IN ('open', 'done')),
    UNIQUE (audit_id, description)
);

CREATE INDEX audits_site_created
    ON audits(site_id, created_at DESC);
CREATE INDEX tasks_site_status
    ON tasks(site_id, status);

Izradite strogu API granicu

Ugovor odgovora pruža ocjenu, nalaze grupirane po ozbiljnosti, TLS pojedinosti i preporuke. Vanjski JSON i dalje može biti nepotpun ili promijeniti oblik tijekom uzvodnog neuspjeha. Stoga mapper provjerava svaku vrijednost najviše razine i neočekivani popis nalaza smješta pod unclassified umjesto da nagađa nedokumentirana ugniježđena polja.

<?php
namespace Agency\Security;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public string $body,
        public array $headers = [],
        public ?string $transportError = null,
    ) {}
}

interface Transport
{
    public function postJson(string $url, array $headers, string $body): HttpResponse;
}

final class CurlTransport implements Transport
{
    public function postJson(string $url, array $headers, string $body): HttpResponse
    {
        $received = [];
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$received): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $received[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

        $bodyOut = curl_exec($handle);
        $error = $bodyOut === false ? curl_error($handle) : null;
        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse(
            $status,
            is_string($bodyOut) ? $bodyOut : '',
            $received,
            $error,
        );
    }
}

final readonly class AuditResult
{
    public function __construct(
        public ?float $score,
        public array $findingsBySeverity,
        public array $tls,
        public array $recommendations,
    ) {}
}

final class ApiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $failureCode,
        public readonly ?int $httpStatus = null,
        string $message = 'Analyzer request failed',
    ) {
        parent::__construct($message);
    }
}

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

    public function __construct(
        private Transport $transport,
        private string $token,
        private \Closure $sleep,
    ) {}

    public function analyze(string $url): AuditResult
    {
        $payload = json_encode(
            ['url' => $url],
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
        );

        for ($attempt = 0; $attempt < 3; $attempt++) {
            $response = $this->transport->postJson(self::ENDPOINT, [
                'Authorization: Bearer ' . $this->token,
                'Content-Type: application/json',
                'Accept: application/json',
            ], $payload);

            if ($response->transportError === null
                && $response->status >= 200
                && $response->status < 300) {
                return $this->map($response->body);
            }

            $retryable = $response->transportError !== null
                || $response->status === 429
                || $response->status >= 500;

            if (!$retryable) {
                throw new ApiFailure(
                    'non_retryable_http',
                    $response->status,
                    'Authentication, authorization, or request validation failed'
                );
            }

            if ($attempt === 2) {
                throw new ApiFailure(
                    $response->status === 429 ? 'rate_limited' : 'upstream_unavailable',
                    $response->status ?: null
                );
            }

            $retryAfter = $response->headers['retry-after'] ?? null;
            $delayMs = ctype_digit((string) $retryAfter)
                ? min(30000, (int) $retryAfter * 1000)
                : 250 * (2 ** $attempt);

            ($this->sleep)($delayMs);
        }

        throw new ApiFailure('unexpected_state');
    }

    private function map(string $body): AuditResult
    {
        try {
            $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            throw new ApiFailure('invalid_json');
        }

        if (!is_array($data)) {
            throw new ApiFailure('invalid_response');
        }

        $findings = is_array($data['findings'] ?? null)
            ? $data['findings'] : [];

        if (array_is_list($findings) && $findings !== []) {
            $findings = ['unclassified' => $findings];
        }

        return new AuditResult(
            is_numeric($data['score'] ?? null) ? (float) $data['score'] : null,
            $findings,
            is_array($data['tls'] ?? null) ? $data['tls'] : [],
            is_array($data['recommendations'] ?? null)
                ? array_values($data['recommendations']) : [],
        );
    }
}

Produkcijski zapisnik trebao bi bilježiti identifikator web-lokacije, broj pokušaja, trajanje, HTTP status, kod neuspjeha i identifikator korelacije ako je vraćen. Nikada ne smije bilježiti zaglavlje Authorization ni cijelo tijelo odgovora. Čak i klijentski URL-ovi mogu biti poslovno osjetljivi, stoga u rutinskim zapisnicima prednost dajte internim ID-ovima web-lokacija.

Pokrenite provjere i stvorite praktične zadatke

Klijentske URL-ove treba upisati administrator, potvrditi kao HTTPS i provjeriti da njihove razriješene adrese jesu javne. Zakazani proces čita samo ovu dopuštenu listu. Time se sprječava da nadzorna ploča postane relej za krivotvorenje zahtjeva na strani poslužitelja za proizvoljan korisnički unos.

Pokretač uspjeh i neuspjeh pohranjuje odvojeno. Preporuke postaju provjerljivi zadaci povezani s točnim rezultatom koji ih je stvorio:

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

use Agency\Security\Analyzer;
use Agency\Security\ApiFailure;
use Agency\Security\CurlTransport;

$token = getenv('ANALYZER_TOKEN');
$dbPath = getenv('APP_DB');

if (!is_string($token) || $token === '' || !is_string($dbPath) || $dbPath === '') {
    throw new RuntimeException('Required environment configuration is missing');
}

$pdo = new PDO('sqlite:' . $dbPath, null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA busy_timeout = 5000');

$analyzer = new Analyzer(
    new CurlTransport(),
    $token,
    static fn (int $ms) => usleep($ms * 1000),
);

$sites = $pdo->query(
    "SELECT id, url FROM sites
     WHERE enabled = 1 AND next_audit_at <= datetime('now')
     ORDER BY next_audit_at LIMIT 20"
)->fetchAll(PDO::FETCH_ASSOC);

foreach ($sites as $site) {
    try {
        $result = $analyzer->analyze($site['url']);

        $pdo->beginTransaction();
        $insert = $pdo->prepare(
            "INSERT INTO audits
             (site_id, status, score, findings_json, tls_json,
              recommendations_json, created_at)
             VALUES (?, 'succeeded', ?, ?, ?, ?, datetime('now'))"
        );
        $insert->execute([
            $site['id'],
            $result->score,
            json_encode($result->findingsBySeverity, JSON_THROW_ON_ERROR),
            json_encode($result->tls, JSON_THROW_ON_ERROR),
            json_encode($result->recommendations, JSON_THROW_ON_ERROR),
        ]);
        $auditId = (int) $pdo->lastInsertId();

        $task = $pdo->prepare(
            "INSERT OR IGNORE INTO tasks
             (audit_id, site_id, description) VALUES (?, ?, ?)"
        );
        foreach ($result->recommendations as $recommendation) {
            $description = is_string($recommendation)
                ? trim($recommendation)
                : json_encode($recommendation, JSON_THROW_ON_ERROR);

            if ($description !== '') {
                $task->execute([$auditId, $site['id'], $description]);
            }
        }

        $pdo->prepare(
            "UPDATE sites SET next_audit_at = datetime('now', '+7 days')
             WHERE id = ?"
        )->execute([$site['id']]);
        $pdo->commit();
    } catch (ApiFailure $failure) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        $pdo->prepare(
            "INSERT INTO audits
             (site_id, status, failure_code, created_at)
             VALUES (?, 'failed', ?, datetime('now'))"
        )->execute([$site['id'], $failure->failureCode]);

        error_log(json_encode([
            'event' => 'website_audit_failed',
            'site_id' => (int) $site['id'],
            'failure_code' => $failure->failureCode,
            'http_status' => $failure->httpStatus,
        ], JSON_THROW_ON_ERROR));
    }
}

Neočekivane programske iznimke ili iznimke baze podataka trebaju se propagirati kako bi planer označio pokretanje kao neuspješno. Hvatanje svakog Throwable učinilo bi da operativni nedostaci izgledaju kao obični API neuspjesi.

Prikažite povijest bez pozivanja API-ja

Ruta nadzorne ploče prihvaća ID klijenta, a ne URL. U stvarnoj aplikaciji izvedite taj ID klijenta iz autorizacijskog opsega autentificiranog korisnika umjesto da vjerujete samo nizu upita.

<?php
$clientId = filter_input(INPUT_GET, 'client', FILTER_VALIDATE_INT);
if (!$clientId) {
    http_response_code(400);
    exit('Invalid client');
}

$stmt = $pdo->prepare(
    "SELECT s.url, a.status, a.score, a.failure_code, a.created_at
     FROM sites s
     JOIN audits a ON a.site_id = s.id
     WHERE s.client_id = ?
     ORDER BY a.created_at DESC LIMIT 50"
);
$stmt->execute([$clientId]);
$audits = $stmt->fetchAll(PDO::FETCH_ASSOC);

$tasks = $pdo->prepare(
    "SELECT t.description, s.url
     FROM tasks t JOIN sites s ON s.id = t.site_id
     WHERE s.client_id = ? AND t.status = 'open'
     ORDER BY t.id DESC"
);
$tasks->execute([$clientId]);

function h(mixed $value): string {
    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

echo '<h2>Security posture history</h2><ul>';
foreach ($audits as $audit) {
    $summary = $audit['status'] === 'succeeded'
        ? 'Score: ' . ($audit['score'] ?? 'not supplied')
        : 'Audit unavailable: ' . $audit['failure_code'];

    echo '<li><strong>' . h($audit['url']) . '</strong> — '
        . h($summary) . ' — ' . h($audit['created_at']) . '</li>';
}
echo '</ul><h2>Open remediation tasks</h2><ul>';
foreach ($tasks as $task) {
    echo '<li><strong>' . h($task['url']) . '</strong> — '
        . h($task['description']) . '</li>';
}
echo '</ul>';

Isto pravilo izbjegavanja mora se primijeniti pri renderiranju pojedinosti nalaza i TLS-a. Sve uzvodne nizove tretirajte kao nepouzdan sadržaj, iako su došli iz autentificiranog API-ja.

Testirajte ponovne pokušaje i mapiranje bez mrežnog pristupa

Deterministički lažni prijenos čini putanje neuspjeha brzima i ponovljivima. Ovaj test provjerava oporavak od ograničenja brzine, Bearer autentifikaciju, obrambeno grupiranje i izostanak nepotrebnih dodatnih poziva:

<?php
use Agency\Security\Analyzer;
use Agency\Security\HttpResponse;
use Agency\Security\Transport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public array $calls = [];

    public function __construct(private array $responses) {}

    public function postJson(string $url, array $headers, string $body): HttpResponse
    {
        $this->calls[] = compact('url', 'headers', 'body');
        return array_shift($this->responses);
    }
}

final class AnalyzerTest extends TestCase
{
    public function testRetriesRateLimitAndMapsResult(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(429, '{}', ['retry-after' => '0']),
            new HttpResponse(200, json_encode([
                'score' => 82,
                'findings' => ['high' => [['message' => 'Example']]],
                'tls' => ['enabled' => true],
                'recommendations' => ['Review the reported finding'],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $delays = [];
        $analyzer = new Analyzer(
            $fake,
            'test-token',
            static function (int $ms) use (&$delays): void {
                $delays[] = $ms;
            },
        );

        $result = $analyzer->analyze('https://www.example.com');

        self::assertSame(82.0, $result->score);
        self::assertArrayHasKey('high', $result->findingsBySeverity);
        self::assertCount(2, $fake->calls);
        self::assertSame([0], $delays);
        self::assertContains(
            'Authorization: Bearer test-token',
            $fake->calls[0]['headers']
        );
    }
}

Dodajte popratne testove za nevažeći JSON, iscrpljene odgovore 500, pogreške prijenosa i trenutačni neuspjeh za 400, 401 ili 403. Pokrenite skup pomoću vendor/bin/phpunit tests. Testne datoteke moraju koristiti očito lažne tokene.

Sigurnost, operacije i implementacija

Zaštitite nadzornu ploču autentifikacijom i autorizacijom po klijentu. Upotrijebite HTTPS, sigurne kolačiće sesije, CSRF zaštitu pri ažuriranju zadataka, pripremljene SQL izraze, izbjegavanje izlaza i restriktivnu Content Security Policy. Upis treba odbiti ne-HTTPS URL-ove, vjerodajnice ugrađene u URL-ove, nazive localhosta te privatne, povratne, link-local ili rezervirane adrese nakon DNS razrješavanja.

Zakazujte samo jednu instancu pokretača odjednom. S cron-om koristite neblokirajuće zaključavanje kao što je flock -n /run/security-dashboard-audit.lock php /srv/security-dashboard/bin/audit.php. Omogućite pristup za pisanje samo direktoriju var; izvor, Composer datoteke i korijen web dokumenta ne smiju biti upisivi web procesu. Putem PHP-FPM-a i web poslužitelja poslužujte samo public. Ugrađeni PHP poslužitelj služi za lokalnu provjeru, a ne za produkciju.

Upozoravajte na uzastopne neuspjehe, povišeno ograničavanje brzine, neuobičajeno duga trajanja i web-lokacije čija zadnja uspješna provjera premašuje očekivani interval. Neuspješno pokretanje mora ostati vidljivo uz povijest; nikada nemojte zamijeniti najnoviju uspješnu ocjenu nulom.

Uobičajeni neuspjesi

  • 401 ili 403: provjerite aktivaciju plana i token za tu uslugu. Ako je ponovno generiran, prethodni token je opozvan. Nemojte naslijepo pokušavati ponovno.
  • Neuspjeh validacije klase 400: potvrdite da zahtjev koristi JSON s točno potrebnom vrijednošću url i odgovarajućom vrstom sadržaja.
  • 429: poštujte Retry-After kada je naveden, ograničite ponovne pokušaje i smanjite učestalost ili konkurentnost provjera.
  • Istek vremena ili odgovor klase 500: kratko ponovite s odmakom, zatim zabilježite uzvodni neuspjeh bez brisanja ranijih rezultata.
  • Nevažeći JSON ili nedostajuća polja: sačuvajte strukturirani neuspjeh ili nullable vrijednost. Nemojte izmišljati ocjenu, TLS stanje ili preporuku.
  • Kontencija zaključavanja SQLitea: održavajte transakcije kratkima, zadržite WAL i vremensko ograničenje zauzetosti te spriječite preklapanje pokretača.

Kontrolni popis završne provjere

  1. Aktivirajte Free, Plus ili Pro plan i pribavite token iz ploče Service token na stranici dokumentacije.
  2. Potvrdite da minimalni POST zahtjev uspijeva prema odobrenoj javnoj HTTPS web-lokaciji.
  3. Čuvajte token u konfiguraciji podržanoj okruženjem i provjerite da ga zapisnici nikada ne sadrže.
  4. Pokrenite shemu, upišite klijenta i web-lokaciju s dopuštene liste, a zatim izvršite bin/audit.php.
  5. Potvrdite da provjera obrambeno pohranjuje ocjenu, grupirane nalaze, TLS pojedinosti i preporuke.
  6. Potvrdite da se preporuke pojavljuju kao otvoreni zadaci i da ponovljena učitavanja stranice ne upućuju vanjske API pozive.
  7. Izvršite testove za 400, 401, 429, 500, istek vremena i neispravan odgovor.
  8. Prije pokretanja provjerite autentifikaciju, izolaciju klijenata, izbjegavanje izlaza, zaključavanje planera, sigurnosne kopije i upozorenja o neuspjehu.

Rezultat je više od kartice s ocjenom. To je suzdržani operativni krug: promatra sigurnosno stanje javne web-lokacije u pogledu HTTPS-a i preglednika, zadržava dokaze kroz vrijeme, pretvara preporuke u odgovorne zadatke i iskreno prikazuje nesigurnost kada se provjera ne može dovršiti. Upravo ta iskrenost čini nadzornu ploču korisnom. Pomaže agenciji poboljšati klijentske web-lokacije bez pretvaranja da je ograničena analiza web-lokacije nešto što nije.

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.