Туториали

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

Native PHP 8.3: Изградете контролни табли за безбедноста на клиентите со автоматизирани ревизии на веб-страници

Безбедносната оценка е корисна во моментот. Безбедносната контролна табла е корисна секоја недела.

За мала веб-агенција, практичниот предизвик не е само откривање проблеми со безбедноста на прелистувачот. Тој е одржување разбирлива историја за секој клиент, претворање на препораките во работа и знаење кога ревизијата не успеала наместо тивко да се прикажуваат застарени резултати.

Овој туторијал го гради тој работен тек во Native PHP 8.3. Закажана команда ги ревидира одобрените веб-страници на клиентите, складира нормализирани резултати во SQLite, создава задачи за отстранување проблеми и напојува мала контролна табла рендерирана на серверот. Интеграцијата го користи Website Security Analyzer за ограничена, неинвазивна анализа на јавно достапни HTTPS и безбедносната состојба на прелистувачот. Не смее да им се претставува на клиентите како тест за пенетрација, скенирање на ранливости или гаранција за безбедност.

Добијте пристап и копирајте го сервисниот токен

Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.

Отворете ја страницата на услугата Website Security Analyzer. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација. Изборот на планот треба да го одразува бројот на веб-страници на клиенти и предвидената зачестеност на ревизиите, наместо да влијае врз логиката на апликацијата.

Потоа, отворете ја официјалната документација на услугата. Најдете го панелот Service token и копирајте го таму прикажаниот токен ограничен на услугата. Оваа услуга бара автентикација. Регенерирањето на токенот го повлекува претходно активниот токен, па ротацијата мора да вклучува ажурирање на околината за распоредување пред следното извршување на ревизијата.

API-то прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Ќе користиме Bearer токен бидејќи ги држи акредитивите надвор од URL-адресите, дневниците за пристап и историјата на прелистувачот. Не испраќајте повеќе облици на автентикација одеднаш.

Потврдете ја точната крајна точка

Интеграцијата го прави следново барање:

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

Испратете JSON тело што содржи url. Пред да напишете код за апликацијата, направете едно минимално барање од доверлив терминал:

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"}'

Заменете ги само местодржачот и примерната URL-адреса. Никогаш не ја лепете завршената команда во тикет, транскрипт од школка, слика од екранот или изворно складиште.

Складирајте ги акредитивите во .env.local, исклучете ја таа датотека од контрола на верзии и дозволете оперативниот систем да ги изложи нејзините вредности на PHP:

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

Native PHP не вчитува dotenv-датотеки автоматски. За локален развој, извезете ја датотеката во тековната школка со set -a; . ./.env.local; set +a. Во продукција, конфигурирајте ги истите променливи преку вашиот управувач со процеси или складиште за тајни.

Архитектура што одговара на мала агенција

Дизајнот намерно избегнува посредник за редици и JavaScript апликација. PHP команда активирана од cron избира веб-страници што се на ред, го повикува анализаторот и бележи или успешна ревизија или структурирана грешка. Контролната табла чита само локални податоци, па бавно надворешно барање никогаш не одложува страница за клиент.

  • Граница на анализаторот: cURL транспорт, политика за повторни обиди, JSON декодирање и одбранбено мапирање на одговорот.
  • Складирање: клиенти, веб-страници од списокот на дозволени, историја на ревизии и задачи за отстранување проблеми во SQLite.
  • Извршувач: команда наменета за cron или systemd тајмер.
  • Контролна табла: историја рендерирана на серверот и отворени задачи, без можност за испраќање произволни URL-адреси.

SQLite е соодветен за една инстанца на апликацијата и умерен распоред на ревизии. Ако неколку работници или веб-јазли мора да пишуваат истовремено, задржете ја истата доменска граница, но мигрирајте го складиштето во PostgreSQL.

Распоредот на проектот е намерно мал:

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

Користете Composer само за автоматско вчитување и тестови за развој:

{
  "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/"
    }
  }
}

Извршете composer install, потоа создајте ја базата со 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);

Изградете строга API граница

Договорот за одговор обезбедува оценка, наоди групирани по сериозност, TLS детали и препораки. Надворешниот JSON сепак може да биде нецелосен или да ја промени формата при неуспех нагоре по системот. Затоа маперот ја потврдува секоја вредност од највисоко ниво и поставува неочекувана листа на наоди под unclassified наместо да погодува недокументирани вгнездени полиња.

<?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']) : [],
        );
    }
}

Продукцискиот дневник треба да ги бележи идентификаторот на веб-страницата, бројот на обидот, траењето, HTTP статусот, кодот на неуспех и идентификатор за корелација ако е вратен. Никогаш не треба да ги бележи заглавието Authorization или целосното тело на одговорот. Дури и URL-адресите на клиентите може да бидат комерцијално чувствителни, па во рутинските дневници претпочитајте внатрешни ID на веб-страниците.

Извршувајте ревизии и создавајте практични задачи

URL-адресите на клиентите треба да ги запише администратор, да се потврдат како HTTPS и да се проверат за да се осигури дека нивните разрешени адреси се јавни. Закажаниот процес чита само од овој список на дозволени. Тоа спречува контролната табла да стане посредник за фалсификување барања од страна на серверот за произволен внес од корисници.

Извршувачот ги складира успехот и неуспехот одделно. Препораките стануваат проверливи задачи поврзани со точниот резултат што ги создал:

<?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));
    }
}

Неочекуваните исклучоци при програмирање или база на податоци треба да излезат надвор, за распоредувачот да го означи извршувањето како неуспешно. Фаќањето на секој Throwable би направило оперативните дефекти да изгледаат како обични API неуспеси.

Рендерирајте историја без повикување на API-то

Рутата на контролната табла прифаќа ID на клиент, а не URL-адреса. Во вистинска апликација, изведете го тој ID на клиент од опсегот на овластување на автентицираниот корисник, наместо да му верувате само на низата за пребарување.

<?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>';

Истото правило за екранско заштитување мора да се примени при рендерирање на детали за наоди и TLS. Третирајте ги сите низи од горниот систем како недоверлива содржина, иако потекнуваат од автентицирано API.

Тестирајте повторни обиди и мапирање без пристап до мрежа

Детерминистички лажен транспорт ги прави патеките на неуспех брзи и репродуцируеми. Овој тест ги потврдува закрепнувањето од ограничување на стапката, Bearer автентикацијата, одбранбеното групирање и отсуството на непотребни дополнителни повици:

<?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']
        );
    }
}

Додадете придружни тестови за невалиден JSON, исцрпени одговори 500, транспортни грешки и непосреден неуспех при 400, 401 или 403. Извршете го пакетот со vendor/bin/phpunit tests. Тест-фасциклите мора да користат очигледно лажни токени.

Безбедност, операции и распоредување

Заштитете ја контролната табла со автентикација и овластување по клиент. Користете HTTPS, безбедни колачиња за сесија, CSRF заштита за ажурирања на задачи, подготвени SQL изјави, екранско заштитување на излезот и рестриктивна Content Security Policy. Запишувањето треба да одбива URL-адреси што не се HTTPS, акредитиви вградени во URL-адреси, имиња localhost и приватни, loopback, link-local или резервирани адреси по DNS-разрешување.

Закажете само една инстанца на извршувачот во исто време. Со cron, користете неблокирачко заклучување како flock -n /run/security-dashboard-audit.lock php /srv/security-dashboard/bin/audit.php. Обезбедете пристап за запишување само до var; изворот, Composer-датотеките и коренот на веб-документите не треба да бидат запишливи од веб-процесот. Служете само public преку PHP-FPM и вашиот веб-сервер. Вградениот PHP-сервер е за локална проверка, не за продукција.

Испраќајте предупредувања за последователни неуспеси, зголемено ограничување на стапката, невообичаено долги траења и веб-страници чија последна успешна ревизија го надминува очекуваниот интервал. Неуспешното извршување мора да остане видливо покрај историјата; никогаш не заменувајте ја најновата успешна оценка со нула.

Чести неуспеси

  • 401 или 403: потврдете ја активацијата на планот и токенот ограничен на услугата. Ако бил регенериран, претходниот токен е повлечен. Не обидувајте се повторно наслепо.
  • Неуспех на валидација од класата 400: потврдете дека барањето користи JSON со точно потребната вредност url и соодветен тип на содржина.
  • 429: почитувајте Retry-After кога е доставен, ограничете ги повторните обиди и намалете ја зачестеноста или паралелноста на ревизиите.
  • Истекување на времето или одговор од класата 500: накратко обидете се повторно со постепено зголемување на доцнењето, потоа забележете неуспех нагоре по системот без да ги избришете претходните резултати.
  • Невалиден JSON или полиња што недостигаат: зачувајте структурирана грешка или вредност што може да биде null. Не измислувајте оценка, TLS состојба или препорака.
  • Конфликт за заклучување на SQLite: одржувајте ги трансакциите кратки, задржете WAL и временско ограничување за зафатеност и спречете преклопување на извршувачите.

Конечна листа за проверка

  1. Активирајте Free, Plus или Pro план и земете го токенот од панелот Service token на страницата со документација.
  2. Потврдете дека минималното POST барање успева со одобрена јавна HTTPS веб-страница.
  3. Чувајте го токенот во конфигурација поддржана од околината и потврдете дека дневниците никогаш не го содржат.
  4. Извршете ја шемата, запишете клиент и веб-страница од списокот на дозволени, потоа извршете bin/audit.php.
  5. Потврдете дека ревизијата ги складира оценката, групираните наоди, TLS деталите и препораките на одбранбен начин.
  6. Потврдете дека препораките се појавуваат како отворени задачи и дека повторените вчитувања на страницата не прават надворешни API повици.
  7. Извршете тестови за 400, 401, 429, 500, истекување на времето и неправилно форматирани одговори.
  8. Потврдете ја автентикацијата, изолацијата на клиентите, екранското заштитување на излезот, заклучувањето на распоредувачот, резервните копии и предупредувањата за неуспех пред пуштањето во работа.

Резултатот е повеќе од картичка со оценки. Тоа е воздржан оперативен циклус: набљудувајте ја HTTPS и безбедносната состојба на прелистувачот на јавна веб-страница, задржувајте докази со текот на времето, претворајте ги препораките во одговорна работа и искрено прикажувајте ја неизвесноста кога ревизијата не може да заврши. Токму таа искреност ја прави контролната табла корисна. Таа ѝ помага на агенцијата да ги подобри веб-страниците на клиентите без да се преправа дека ограничената анализа на веб-страница е нешто што не е.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.