Туториали

Native PHP 8.3: Agency Client Dashboard with Historical Security Scans and Remediation

Native PHP 8.3: Контролна табла за клиенти на агенција со историски безбедносни скенирања и санација

Безбедносната контролна табла станува корисна кога брзо одговара на три прашања: што се променило, што е важно сега и кој треба да го поправи. Еден единствен резултат не може да го стори тоа. На мала агенција ѝ се потребни историски скенирања, наоди групирани по сериозност, TLS контекст и задачи за отстранување што опстојуваат надвор од најновиот API одговор.

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

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

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Website Security Analyzer.
  3. Изберете го достапниот Free, Plus или Pro план и завршете ја активацијата.
  4. Отворете ја официјалната документација за услугата.
  5. Најдете го панелот Service token и копирајте го неговиот токен ограничен на услугата.

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

Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како распоредување: ажурирајте ја тајната на апликацијата, рестартирајте ги засегнатите workers, потврдете скенирање и дури потоа сметајте дека пуштањето е завршено.

Потврдете го точниот API повик

Интеграцијата користи POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website со JSON тело што содржи url. Пред да пишувате код за апликацијата, тестирајте го токенот со јавна HTTPS страница што ја контролирате:

curl --fail-with-body \
  --connect-timeout 3 \
  --max-time 15 \
  -X POST \
  -H "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://client.example"}' \
  https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website

Не лепете ги одговорот или токенот во јавни trackers за проблеми. Прегледајте ја тековната документација и успешниот одговор пред да го финализирате production мапирањето, особено ако услугата се развива.

Складирајте ја конфигурацијата надвор од контролата на изворниот код

Создадете .env за локален развој и исклучете го од Git. Во production, внесете ги истите имиња преку управувачот со процеси или складиштето за тајни.

SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_ANALYZER_ENDPOINT=https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
DATABASE_PATH=/var/lib/agency-security/dashboard.sqlite

Архитектура и распоред на проектот

Скенирањето припаѓа во закажана команда, а не во барање од прелистувач. Тоа ги држи доцнењето и ограничувањата на стапката од нагорниот извор подалеку од прикажувањето на страницата, додека контролната табла останува достапна за време на API прекин. SQLite е соодветен за еден мал процес за распоредување; користете серверска база на податоци ако неколку инстанци на апликацијата мора истовремено да запишуваат.

agency-security/
├── bin/scan.php
├── public/index.php
├── src/App.php
├── tests/AnalyzerClientTest.php
├── var/
├── .env
├── .gitignore
├── bootstrap.php
├── composer.json
└── phpunit.xml

Инсталирајте PHP 8.3 со cURL, PDO SQLite, JSON и Composer. PHPUnit е единствената зависност од трета страна:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "files": ["src/App.php"]
  }
}
composer install
mkdir -p var
chmod 750 var
printf '%s\n' '.env' 'var/*.sqlite*' > .gitignore

Bootstrap loader-от поддржува намерно тесен формат NAME=value. Во production не е потребна датотеката кога променливите веќе постојат.

<?php
// bootstrap.php
declare(strict_types=1);

$envFile = __DIR__ . '/.env';
if (is_file($envFile)) {
    foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
        if (str_starts_with(ltrim($line), '#') || !str_contains($line, '=')) {
            continue;
        }
        [$name, $value] = array_map('trim', explode('=', $line, 2));
        if (getenv($name) === false) {
            putenv($name . '=' . trim($value, "\"'"));
        }
    }
}
require __DIR__ . '/vendor/autoload.php';

Изградете дефанзивна API граница

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

Маперот ги користи областите од договорот score, findings, tls и recommendations, но ги валидира нивните типови на границата. Ако документираниот одговор ги обвиткува овие вредности, прилагодете само Analysis::fromPayload(); не распрскувајте претпоставки за одговорот низ контролерите и шаблоните.

<?php
// src/App.php
declare(strict_types=1);

namespace AgencySecurity;

record HttpResponse(int $status, array $headers, string $body) {}

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

final class CurlTransport implements Transport
{
    public function post(string $url, array $headers, string $body): HttpResponse
    {
        $responseHeaders = [];
        $handle = curl_init($url);
        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 12000,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
                use (&$responseHeaders): int {
                if (str_contains($line, ':')) {
                    [$name, $value] = explode(':', $line, 2);
                    $responseHeaders[strtolower(trim($name))] = trim($value);
                }
                return strlen($line);
            },
        ]);

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

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

    public static function fromPayload(array $payload): self
    {
        foreach (['score', 'findings', 'tls', 'recommendations'] as $key) {
            if (!array_key_exists($key, $payload)) {
                throw new \UnexpectedValueException("Missing response value: {$key}");
            }
        }
        if (!is_int($payload['score']) && !is_float($payload['score'])) {
            throw new \UnexpectedValueException('Score must be numeric');
        }
        if (!is_array($payload['findings']) ||
            !is_array($payload['tls']) ||
            !is_array($payload['recommendations'])) {
            throw new \UnexpectedValueException('Malformed analyzer response');
        }
        foreach ($payload['findings'] as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new \UnexpectedValueException('Findings must be grouped by severity');
            }
        }

        return new self(
            (float) $payload['score'],
            $payload['findings'],
            $payload['tls'],
            $payload['recommendations'],
            $payload
        );
    }
}

final class AnalyzerClient
{
    public function __construct(
        private Transport $transport,
        private string $endpoint,
        private string $token,
        private $sleep = null
    ) {
        $this->sleep ??= static fn(int $milliseconds) =>
            usleep($milliseconds * 1000);
    }

    public function analyze(string $url): Analysis
    {
        $requestBody = json_encode(['url' => $url], JSON_THROW_ON_ERROR);
        $retryable = [408, 429, 500, 502, 503, 504];

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->post($this->endpoint, [
                    'Authorization: Bearer ' . $this->token,
                    'Content-Type: application/json',
                    'Accept: application/json',
                ], $requestBody);
            } catch (\RuntimeException $error) {
                if ($attempt === 3) {
                    throw $error;
                }
                ($this->sleep)(250 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                $payload = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
                if (!is_array($payload)) {
                    throw new \UnexpectedValueException('Response is not a JSON object');
                }
                return Analysis::fromPayload($payload);
            }

            if (!in_array($response->status, $retryable, true) || $attempt === 3) {
                throw new \RuntimeException(
                    'Analyzer request failed with HTTP ' . $response->status
                );
            }

            $retryAfter = $response->headers['retry-after'] ?? null;
            $delay = is_string($retryAfter) && ctype_digit($retryAfter)
                ? min(5000, (int) $retryAfter * 1000)
                : 250 * (2 ** ($attempt - 1));
            ($this->sleep)($delay);
        }

        throw new \LogicException('Retry loop ended unexpectedly');
    }
}

Неуспесите при валидација и автентикација намерно не се повторуваат. Повторувањето на лош URL или поништен токен троши квота и одложува корисно предупредување. Повторливите транспортни и серверски неуспеси добиваат две ограничени одложувања со backoff; Retry-After се почитува кога е нумерички број секунди, со плафон од пет секунди за оваа foreground команда.

Зачувајте историја и задачи за отстранување

Секој успешен резултат е непроменлив. Препораките стануваат отворени задачи поврзани со тоа скенирање. Бидејќи договорот овде не ветува конкретна форма на објект за препорака, функцијата за ознака извлекува скаларни лисни вредности наместо да нагаѓа имиња на полиња.

<?php
// Append to src/App.php
namespace AgencySecurity;

final class Store
{
    public function __construct(private \PDO $db)
    {
        $this->db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        $this->db->exec(
            'CREATE TABLE IF NOT EXISTS scans (
                id INTEGER PRIMARY KEY,
                client TEXT NOT NULL,
                url TEXT NOT NULL,
                state TEXT NOT NULL,
                score REAL,
                payload TEXT,
                error TEXT,
                created_at TEXT NOT NULL
            );
             CREATE TABLE IF NOT EXISTS tasks (
                id INTEGER PRIMARY KEY,
                scan_id INTEGER NOT NULL REFERENCES scans(id),
                description TEXT NOT NULL,
                status TEXT NOT NULL DEFAULT "open"
            );'
        );
    }

    public function saveSuccess(string $client, string $url, Analysis $analysis): void
    {
        $this->db->beginTransaction();
        try {
            $statement = $this->db->prepare(
                'INSERT INTO scans(client,url,state,score,payload,created_at)
                 VALUES(?,?,"complete",?,?,?)'
            );
            $statement->execute([
                $client,
                $url,
                $analysis->score,
                json_encode($analysis->raw, JSON_THROW_ON_ERROR),
                gmdate('c'),
            ]);
            $scanId = (int) $this->db->lastInsertId();
            $task = $this->db->prepare(
                'INSERT INTO tasks(scan_id,description) VALUES(?,?)'
            );
            foreach ($analysis->recommendations as $recommendation) {
                $task->execute([$scanId, self::label($recommendation)]);
            }
            $this->db->commit();
        } catch (\Throwable $error) {
            $this->db->rollBack();
            throw $error;
        }
    }

    public function saveFailure(string $client, string $url, string $error): void
    {
        $statement = $this->db->prepare(
            'INSERT INTO scans(client,url,state,error,created_at)
             VALUES(?,?,"failed",?,?)'
        );
        $statement->execute([$client, $url, $error, gmdate('c')]);
    }

    public function scans(): array
    {
        return $this->db->query(
            'SELECT * FROM scans ORDER BY created_at DESC LIMIT 100'
        )->fetchAll(\PDO::FETCH_ASSOC);
    }

    public function tasks(int $scanId): array
    {
        $statement = $this->db->prepare(
            'SELECT description,status FROM tasks WHERE scan_id=? ORDER BY id'
        );
        $statement->execute([$scanId]);
        return $statement->fetchAll(\PDO::FETCH_ASSOC);
    }

    private static function label(mixed $value): string
    {
        if (is_scalar($value)) {
            return trim((string) $value);
        }
        if (is_array($value)) {
            $parts = [];
            array_walk_recursive($value, static function ($item) use (&$parts): void {
                if (is_scalar($item)) {
                    $parts[] = trim((string) $item);
                }
            });
            return implode(' — ', array_filter($parts));
        }
        return 'Review analyzer recommendation';
    }
}

Создадете ја командата за скенирање

Командата прифаќа име на клиент и URL. Таа бара HTTPS, отфрла буквални IP адреси, бележи неуспеси без да изложува тела на одговори или акредитиви и враќа излезен код различен од нула за закажувачите.

<?php
// bin/scan.php
declare(strict_types=1);

use AgencySecurity\{AnalyzerClient, CurlTransport, Store};

require dirname(__DIR__) . '/bootstrap.php';

[$script, $client, $url] = $argv + [null, null, null];
$host = is_string($url) ? parse_url($url, PHP_URL_HOST) : null;

if (!$client || !filter_var($url, FILTER_VALIDATE_URL) ||
    parse_url($url, PHP_URL_SCHEME) !== 'https' ||
    !is_string($host) || filter_var($host, FILTER_VALIDATE_IP)) {
    fwrite(STDERR, "Usage: php bin/scan.php CLIENT https://public-hostname\n");
    exit(2);
}

$token = getenv('SECURITY_ANALYZER_TOKEN');
$endpoint = getenv('SECURITY_ANALYZER_ENDPOINT');
$database = getenv('DATABASE_PATH');
if (!$token || !$endpoint || !$database) {
    fwrite(STDERR, "Required environment configuration is missing\n");
    exit(2);
}

$store = new Store(new PDO('sqlite:' . $database));
$clientApi = new AnalyzerClient(new CurlTransport(), $endpoint, $token);

try {
    $analysis = $clientApi->analyze($url);
    $store->saveSuccess($client, $url, $analysis);
    fwrite(STDOUT, "Scan stored with score {$analysis->score}\n");
} catch (Throwable $error) {
    $store->saveFailure($client, $url, $error->getMessage());
    error_log(json_encode([
        'event' => 'security_scan_failed',
        'client' => $client,
        'host' => $host,
        'error_type' => $error::class,
    ], JSON_THROW_ON_ERROR));
    fwrite(STDERR, "Scan failed; see application logs\n");
    exit(1);
}

Прикажете безбедна контролна табла само за читање

Избегнувајте секоја складирана вредност, вклучително и содржина од нагорниот извор. Контролната табла прикажува неодамнешна историја и отворена работа без да тврди дека добар резултат докажува дека страницата е безбедна.

<?php
// public/index.php
declare(strict_types=1);

use AgencySecurity\Store;

require dirname(__DIR__) . '/bootstrap.php';

$store = new Store(new PDO('sqlite:' . getenv('DATABASE_PATH')));
$escape = static fn(mixed $value): string =>
    htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

header('Content-Type: text/html; charset=utf-8');
echo '<h1>Client security history</h1>';
echo '<p>Non-invasive posture checks, not penetration-test results.</p>';

foreach ($store->scans() as $scan) {
    echo '<article>';
    echo '<h2>' . $escape($scan['client']) . '</h2>';
    echo '<p>' . $escape($scan['url']) . ' — ' .
         $escape($scan['created_at']) . '</p>';
    echo '<p>State: ' . $escape($scan['state']) . '</p>';

    if ($scan['state'] === 'complete') {
        echo '<p>Score: ' . $escape($scan['score']) . '</p>';
        $payload = json_decode($scan['payload'], true, 512, JSON_THROW_ON_ERROR);
        echo '<h3>Findings by severity</h3>';
        echo '<pre>' . $escape(json_encode(
            $payload['findings'],
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
        )) . '</pre>';
        echo '<h3>TLS details</h3>';
        echo '<pre>' . $escape(json_encode(
            $payload['tls'],
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
        )) . '</pre>';
        echo '<h3>Remediation tasks</h3><ul>';
        foreach ($store->tasks((int) $scan['id']) as $task) {
            echo '<li>' . $escape($task['status']) . ': ' .
                 $escape($task['description']) . '</li>';
        }
        echo '</ul>';
    } else {
        echo '<p>The scan failed. Operators should consult structured logs.</p>';
    }
    echo '</article>';
}

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

Детерминистички лажен објект ја проверува појдовната автентикација, JSON телото, бројот на повторни обиди и мапираниот доменски резултат. Ниту еден вистински токен не припаѓа во fixtures.

<?php
// tests/AnalyzerClientTest.php
declare(strict_types=1);

use AgencySecurity\{AnalyzerClient, HttpResponse, Transport};
use PHPUnit\Framework\TestCase;

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

    public function __construct(private array $responses) {}

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

final class AnalyzerClientTest extends TestCase
{
    public function testRetriesRateLimitAndMapsResponse(): void
    {
        $payload = [
            'score' => 82,
            'findings' => ['high' => [], 'medium' => [['check' => 'headers']]],
            'tls' => ['enabled' => true],
            'recommendations' => ['Review browser security headers'],
        ];
        $transport = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '1'], '{}'),
            new HttpResponse(200, [], json_encode($payload, JSON_THROW_ON_ERROR)),
        ]);
        $delays = [];
        $client = new AnalyzerClient(
            $transport,
            'https://service.test/analyze',
            'TEST_TOKEN',
            static function (int $ms) use (&$delays): void { $delays[] = $ms; }
        );

        $analysis = $client->analyze('https://client.example');

        self::assertSame(82.0, $analysis->score);
        self::assertCount(2, $transport->requests);
        self::assertSame([1000], $delays);
        self::assertContains(
            'Authorization: Bearer TEST_TOKEN',
            $transport->requests[0]['headers']
        );
        self::assertSame(
            ['url' => 'https://client.example'],
            json_decode($transport->requests[0]['body'], true)
        );
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(401, [], '{"error":"unauthorized"}'),
        ]);
        $client = new AnalyzerClient(
            $transport,
            'https://service.test/analyze',
            'TEST_TOKEN',
            static function (): void {}
        );

        $this->expectException(RuntimeException::class);
        try {
            $client->analyze('https://client.example');
        } finally {
            self::assertCount(1, $transport->requests);
        }
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="bootstrap.php" colors="true">
  <testsuites>
    <testsuite name="Agency Security">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

Распоредување, набљудливост и чести неуспеси

Извршете ги тестовите, направете едно контролирано скенирање и сервирајте го само директориумот public. Закажете скенирања со фреквенција поддржана од активниот план, распоредувајќи ги клиентите наместо да создавате нагол наплив.

vendor/bin/phpunit
php bin/scan.php "Acme Bakery" https://client.example
php -S 127.0.0.1:8080 -t public

# Example cron entry: one client every day at 02:17
17 2 * * * cd /srv/agency-security && /usr/bin/php bin/scan.php \
  "Acme Bakery" https://client.example >>/var/log/agency-security.log 2>&1

Заштитете ја контролната табла со автентикацијата на веб-серверот и HTTPS. Дајте му на PHP корисникот пристап за запишување само до директориумот на базата на податоци, чувајте го .env надвор од document root, правете резервна копија од SQLite базата на податоци и никогаш не запишувајте токени, заглавија за авторизација или целосни тела од нагорниот извор во логови. Предупредувајте за повторени настани security_scan_failed и за клиенти чиј последен успешен скен неочекувано станува стар.

HTTP 401 или 403 обично значи дека токенот недостасува, е поништен, неправилно копиран или не е активен за оваа услуга. HTTP 400 укажува на валидација на барањето и не треба да се повторува. HTTP 429 значи дека на тековната квота или ограничување на стапката на планот му треба внимание. Временските истекувања и избраните 5xx одговори добиваат ограничени повторни обиди, но трајните неуспеси остануваат видливи како историја на неуспешни скенирања. Исклучок при мапирање значи дека живиот одговор повеќе не се совпаѓа со договорот на границата; споредете го со официјалната документација пред да го менувате маперот.

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

  • Сметката и Free, Plus или Pro сервисниот план се активни.
  • Токенот ограничен на услугата доаѓа од панелот Service token во документацијата.
  • Ниту една вистинска акредитација не се појавува во Git, тестови, логови, слики од екранот или URL-адреси.
  • Барањето е JSON POST што содржи url на точно документираната крајна точка.
  • Времињата за поврзување и за целосен одговор се ограничени.
  • Неуспесите при автентикација и валидација не се повторуваат.
  • Ограничувањата на стапката и минливите неуспеси користат ограничен backoff.
  • Резултатот, наодите групирани по сериозност, TLS деталите и препораките се валидираат на една граница.
  • И успешните и неуспешните скенирања создаваат корисни историски записи.
  • Препораките стануваат видливи задачи за отстранување.
  • Контролната табла ја избегнува содржината од нагорниот извор и складираната содржина.
  • Интерфејсот јасно кажува дека резултатите не се пенетрационен тест.

Трајната вредност не е најновиот резултат. Таа е синџирот од набљудување до одговорна работа: ограничено скенирање, зачуван историски запис, практична низа за отстранување и доказ дека следното скенирање ги подобрило — или ги оспорило — претпоставките на тимот.

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

Mihajlo

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