Туториали

Symfony Security Dashboard: Client Audits, History, and Remediation with AI Analyzer

Symfony Security Dashboard: Клиентски ревизии, историја и отстранување проблеми со AI Analyzer

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

Овој туторијал ја гради таа функција во PHP 8.3+ Symfony апликација. Секое скенирање се извршува во заднина, го повикува Website Security Analyzer, складира нормализирана снимка и ги прикажува оценката, наодите групирани по сериозност, TLS-деталите и задачите за отстранување на проблемите. Резултатот намерно е опишан како ограничен преглед на јавната HTTPS и безбедносната состојба во прелистувачот — не како пенетрационен тест или доказ дека сајтот е безбеден.

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

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

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

Потврдете го точниот HTTP договор

Операцијата е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Прифаќа JSON што содржи url. Автентикацијата може да користи Bearer токен, заглавие X-API-Token или параметар за токен во барањето. Овој проект ја користи Bearer формата за акредитивите да не се појавуваат во URL-адреси или во вообичаени дневници за пристап.

curl --fail-with-body \
  --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://client.example"}'

Извршете го тоа барање со сајт за кој сте овластени да направите проценка. Потоа ставете ги акредитивите во непредадена .env.local датотека:

WEBSITE_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default
DATABASE_URL="postgresql://app:[email protected]:5432/agency?serverVersion=16&charset=utf8"

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

Барањето од прелистувач не треба да чека додека се извршува надворешна анализа. Затоа контролерот бележи ревизија во ред и испраќа Messenger порака. Worker ја повикува API-услугата, мапер на границата го валидира одговорот, а мала услуга за перзистенција ја завршува или неуспешно ја означува ревизијата. Контролната табла чита само локални податоци, па останува одзивна при доцнење или прекини на API-услугата.

Перзистирањето JSON-снимки е прагматичен компромис. Безбедносните наоди и TLS-деталите може да се развиваат побрзо од шемата на контролната табла, додека оценката, статусот, URL-адресата и временските ознаки остануваат корисни релациски колони. Ако известувањето подоцна бара пребарувања низ поединечни наоди, пренесете ги тие вредности во наменски табели.

Релевантната структура на проектот е:

src/
  Controller/SecurityDashboardController.php
  Message/AnalyzeWebsite.php
  MessageHandler/AnalyzeWebsiteHandler.php
  Security/AnalysisResult.php
  Security/WebsiteAnalyzer.php
  Security/AnalyzerException.php
  Repository/AuditStore.php
templates/security/index.html.twig
tests/Security/WebsiteAnalyzerTest.php
config/packages/messenger.yaml
config/services.yaml

Почнувајќи од Symfony апликација што веќе го автентицира персоналот на агенцијата, инсталирајте ги компонентите од прва страна:

composer require symfony/http-client symfony/messenger symfony/doctrine-messenger \
  symfony/orm-pack symfony/twig-bundle symfony/security-bundle
composer require --dev symfony/test-pack
php bin/console doctrine:migrations:migrate

Создадете audit миграција со колони за id, url, status, nullable score, findings_json, tls_json, tasks_json, nullable error_code, created_at и nullable completed_at. Користете го JSON-типот на вашата платформа за база на податоци каде што е достапен и индексирајте ги created_at и status.

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

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

<?php
// src/Security/AnalysisResult.php
namespace App\Security;

final readonly class AnalysisResult
{
    public function __construct(
        public float $score,
        public array $findingsBySeverity,
        public array $tls,
        public array $tasks,
    ) {}

    public static function fromApi(array $data): self
    {
        if (!is_numeric($data['score'] ?? null)
            || !is_array($data['findings'] ?? null)
            || !is_array($data['tls'] ?? null)
            || !is_array($data['recommendations'] ?? null)) {
            throw new AnalyzerException('invalid_response');
        }

        $tasks = [];
        foreach ($data['recommendations'] as $recommendation) {
            if (is_string($recommendation) && trim($recommendation) !== '') {
                $tasks[] = ['title' => trim($recommendation), 'status' => 'open'];
            }
        }

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

Овој мапер намерно не измислува полиња во наодите или TLS-податоците. Ги зачувува тие документирани делови од одговорот, истовремено одбивајќи недостасувачки или погрешно типизирани вредности на највисоко ниво. Ако официјалната документација го промени примерот на payload, ажурирајте ја оваа единствена граница и нејзините тестови.

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

<?php
// src/Security/WebsiteAnalyzer.php
namespace App\Security;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

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

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $token,
    ) {}

    public function analyze(string $url, string $auditId): AnalysisResult
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->token,
                        'Accept' => 'application/json',
                    ],
                    'json' => ['url' => $url],
                    'timeout' => 20.0,
                    'max_duration' => 30.0,
                ]);
                $status = $response->getStatusCode();
                $body = $response->getContent(false);
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new AnalyzerException('transport_failure', previous: $e);
                }
                $this->backoff($attempt);
                continue;
            }

            if ($status >= 200 && $status < 300) {
                try {
                    $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
                } catch (\JsonException $e) {
                    throw new AnalyzerException('invalid_json', previous: $e);
                }

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

                return AnalysisResult::fromApi($data);
            }

            if (in_array($status, [429, 502, 503, 504], true) && $attempt < 3) {
                $this->logger->warning('Analyzer request will be retried', [
                    'audit_id' => $auditId,
                    'status' => $status,
                    'attempt' => $attempt,
                ]);
                $this->backoff($attempt);
                continue;
            }

            $code = match ($status) {
                401, 403 => 'authentication_failure',
                400, 422 => 'request_rejected',
                429 => 'rate_limited',
                default => 'upstream_failure',
            };
            throw new AnalyzerException($code);
        }

        throw new AnalyzerException('upstream_failure');
    }

    private function backoff(int $attempt): void
    {
        usleep(250_000 * (2 ** ($attempt - 1)));
    }
}

Не евидентирајте ги телата на одговорите неселективно: наодите може да откријат оперативни детали, а телата на грешки може да вклучуваат контекст на барањето. Идентификаторите на ревизиите, статусните кодови, обидите, траењата и стабилните кодови за неуспех се доволни за повеќето оперативни дијагнози.

Поврзете го токенот преку Symfony-контејнерот:

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true

  App\:
    resource: '../src/'

  App\Security\WebsiteAnalyzer:
    arguments:
      $token: '%env(WEBSITE_ANALYZER_TOKEN)%'

Ставете ги скенирањата во ред и зачувајте го нивниот исход

AuditStore, complete($id, AnalysisResult $result), fail($id, $code) и recent(). Имплементирајте ги со инјектирана Doctrine DBAL Connection, параметризирани изрази, json_encode(..., JSON_THROW_ON_ERROR) и непроменливи UTC временски ознаки. Никогаш не ја конкатенирајте поднесената URL-адреса во SQL.

<?php
// src/Message/AnalyzeWebsite.php
namespace App\Message;

final readonly class AnalyzeWebsite
{
    public function __construct(public string $auditId, public string $url) {}
}

// src/MessageHandler/AnalyzeWebsiteHandler.php
namespace App\MessageHandler;

use App\Message\AnalyzeWebsite;
use App\Repository\AuditStore;
use App\Security\AnalyzerException;
use App\Security\WebsiteAnalyzer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class AnalyzeWebsiteHandler
{
    public function __construct(
        private WebsiteAnalyzer $analyzer,
        private AuditStore $audits,
    ) {}

    public function __invoke(AnalyzeWebsite $message): void
    {
        try {
            $result = $this->analyzer->analyze(
                $message->url,
                $message->auditId
            );
            $this->audits->complete($message->auditId, $result);
        } catch (AnalyzerException $e) {
            $this->audits->fail($message->auditId, $e->getMessage());
        }
    }
}

Клиентот веќе ја поседува својата кратка политика за повторување, па Messenger не смее да ги мултиплицира тие обиди:

# config/packages/messenger.yaml
framework:
  messenger:
    transports:
      async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        retry_strategy:
          max_retries: 0
    routing:
      App\Message\AnalyzeWebsite: async

Додајте ја заштитената контролна табла

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

<?php
// src/Controller/SecurityDashboardController.php
namespace App\Controller;

use App\Message\AnalyzeWebsite;
use App\Repository\AuditStore;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{Request, Response};
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;

#[IsGranted('ROLE_AUDITOR')]
final class SecurityDashboardController extends AbstractController
{
    #[Route('/security', name: 'security_dashboard', methods: ['GET'])]
    public function index(AuditStore $audits): Response
    {
        return $this->render('security/index.html.twig', [
            'audits' => $audits->recent(),
        ]);
    }

    #[Route('/security/audits', name: 'security_audit_create', methods: ['POST'])]
    public function create(
        Request $request,
        AuditStore $audits,
        MessageBusInterface $bus,
    ): Response {
        if (!$this->isCsrfTokenValid('new-audit', $request->request->getString('_token'))) {
            throw $this->createAccessDeniedException();
        }

        $url = trim($request->request->getString('url'));
        $parts = parse_url($url);
        if (!filter_var($url, FILTER_VALIDATE_URL)
            || ($parts['scheme'] ?? '') !== 'https'
            || isset($parts['user'])
            || !isset($parts['host'])) {
            throw $this->createNotFoundException('A public HTTPS URL is required.');
        }

        $id = Uuid::v7()->toRfc4122();
        $audits->create($id, $url);
        $bus->dispatch(new AnalyzeWebsite($id, $url));

        return $this->redirectToRoute('security_dashboard');
    }
}

Twig-шаблонот може да прикаже формулар за поднесување проследен со неодамнешни ревизии. За завршените редови, прикажете ја оценката, итерирајте низ findings_json по сериозност, дефанзивно прикажете TLS-податоци клуч/вредност и покажете ја секоја ставка во tasks_json со нејзиниот статус. За редовите во ред и неуспешните редови, прикажете ја складираната состојба и безбедниот код за неуспех наместо празен извештај. Автоматското екранување на Twig мора да остане овозможено.

Тестирајте успех и неуспех детерминистички

MockHttpClient го проверува излезниот договор без да ја контактира услугата:

<?php
// tests/Security/WebsiteAnalyzerTest.php
namespace App\Tests\Security;

use App\Security\WebsiteAnalyzer;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteAnalyzerTest extends TestCase
{
    public function testMapsAValidResponse(): void
    {
        $response = new MockResponse(json_encode([
            'score' => 82,
            'findings' => ['high' => [], 'medium' => [['name' => 'Example']]],
            'tls' => ['enabled' => true],
            'recommendations' => ['Review the reported browser policy.'],
        ], JSON_THROW_ON_ERROR), ['http_code' => 200]);

        $http = new MockHttpClient(function (string $method, string $url, array $options) use ($response) {
            self::assertSame('POST', $method);
            self::assertSame(
                'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
                $url
            );
            self::assertSame(['url' => 'https://client.example'], $options['json']);
            self::assertContains('Authorization: Bearer test-token', $options['headers']);
            return $response;
        });

        $result = (new WebsiteAnalyzer($http, new NullLogger(), 'test-token'))
            ->analyze('https://client.example', 'audit-1');

        self::assertSame(82.0, $result->score);
        self::assertSame('open', $result->tasks[0]['status']);
    }
}

Додајте случаи за невалиден JSON, недостасувачки задолжителен дел, непосреден неуспех при 401 и ограничени повторни обиди при 429. Тестирајте го контролерот со автентициран корисник со ROLE_AUDITOR, вклучувајќи невалидни CSRF-токени, HTTP URL-адреси, URL-адреси со акредитиви и неовластен пристап. Тестирајте го handler-от со лажни соработници за analyzer и store за транзициите на успех и неуспех да бидат експлицитни.

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

Извршете миграции при распоредувањето, инјектирајте WEBSITE_ANALYZER_TOKEN преку складиштето за тајни на хостинг-платформата и рестартирајте ги и веб-процесите и worker-ите по ротацијата. Извршете го потрошувачот под systemd, Supervisor или механизмот за worker-и на платформата:

php bin/console messenger:consume async \
  --time-limit=3600 \
  --memory-limit=256M

Поставете предупредувања за растечка длабочина на редот, повторени authentication_failure, покачени исходи rate_limited и скенирања заглавени во queued подолго од очекуваниот оперативен период. Чувајте ја историјата на ревизиите според договорите со клиентите, ограничете го пристапот до базата на податоци и дефинирајте политика за бришење наместо неограничено да акумулирате наоди.

Вообичаените неуспеси обично се разбирливи: authentication_failure укажува на недостасувачки, поништен или застарен токен; request_rejected укажува на поднесената URL-адреса или тековниот документиран договор; rate_limited значи дека работата треба да се распореди според активниот план; а invalid_response бара споредување на маперот на границата со официјалната документација. Трајно ревизија во ред обично значи дека Messenger worker-от отсуствува, е запрен или е поврзан со друга база на податоци.

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

  • Токенот потекнува од панелот Service token на страницата со документација и постои само во конфигурација поддржана од околински променливи.
  • Клиентот испраќа POST до точниот endpoint на analyzer-от со JSON што содржи url.
  • Само овластен персонал може да поднесува скенирања или да ја прегледува историјата на клиентите.
  • Барањата имаат ограничени временски рокови, а само привремените неуспеси добиваат ограничени повторни обиди со backoff.
  • Оценките, наодите групирани по сериозност, TLS-деталите и задачите за отстранување на проблемите преживуваат рестартирања на worker-ите како локални снимки.
  • Тестовите покриваат валидно мапирање, невалидни податоци, неуспех при автентикација, ограничување на стапката, валидација на контролерот и транзиции на состојба.
  • Контролната табла го нарекува резултатот ограничена, неинвазивна анализа на безбедносната состојба — не пенетрационен тест.

Највредниот излез не е оценката на врвот од страницата. Тоа е трајниот синџир од набљудување до препорака, од препорака до задача и од една ревизија до следната. Тоа го претвора далечинскиот analyzer во одговорна услуга за клиентите: разбирлива денес, споредлива утре и корисна долго откако ќе заврши првото скенирање.

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

Mihajlo

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