Туториали

Symfony Security Monitoring: Email Small Businesses When Website Scores Drop

Symfony Безбедносно следење: Испраќајте е-пошта до малите бизниси кога оценките на веб-страниците ќе паднат

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

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

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

Завршете го воведувањето во услугата пред да пишувате интеграциски код:

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

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

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

Потврдете ја крајната точка пред да ја изградите функционалноста

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

curl --request POST \
  '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"}'

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

Инсталирајте ги Symfony компонентите од прва страна што ги користи проектот:

composer require symfony/http-client symfony/mailer symfony/cache symfony/lock
composer require --dev symfony/test-pack

Ставете ги акредитивите и адресите специфични за распоредувањето во .env.local за локален развој. Не ја предавајте оваа датотека во контрола на изворниот код. Во продукција, внесете ги истите променливи преку хостинг-платформата или управувачот со тајни.

SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_WEBSITE_URL=https://www.example.com
[email protected]
[email protected]

MAILER_DSN=smtp://USERNAME:[email protected]:587
LOCK_DSN=flock

Намерно мала архитектура

На неделната проверка не ѝ е потребен message broker само за да изгледа софистицирано. Symfony console команда работи добро со cron или распоредувач на платформа, додека заклучувањето спречува преклопени извршувања.

  • WebsiteSecurityAnalyzer ја поседува границата со оддалечениот HTTP и политиката за повторни обиди.
  • SecuritySnapshot го мапира и валидира одговорот на доменот.
  • SecurityMonitor споредува резултати, испраќа е-пошта и ја зачувува новата почетна состојба.
  • CheckWebsiteSecurityCommand обезбедува заклучување, излезен статус и излез за операторот.

Примерот ја складира состојбата во Symfony cache pool. Датотечен pool и flock се соодветни за еден постојан сервер. Повеќе реплики на апликацијата бараат заеднички cache и backend-и за заклучување; во спротивно два хоста би можеле да споредуваат различни почетни состојби и да испратат дупликат предупредувања.

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

src/
  Command/CheckWebsiteSecurityCommand.php
  Security/AnalyzerException.php
  Security/SecurityMonitor.php
  Security/SecuritySnapshot.php
  Security/WebsiteAnalyzer.php
  Security/WebsiteSecurityAnalyzer.php
tests/
  Security/WebsiteSecurityAnalyzerTest.php
  Security/SecurityMonitorTest.php

Конфигурирајте ја инфраструктурата на Symfony

Создадете scoped client за токенот, основниот URL и временските ограничувања да останат надвор од логиката на апликацијата. Времето на неактивност и вкупното времетраење спречуваат неделен процес да виси неограничено.

# config/packages/security_monitor.yaml
framework:
  http_client:
    scoped_clients:
      security_analyzer.client:
        base_uri: 'https://ai.mihajlo.mk/api/website-security-analyzer-api'
        headers:
          Authorization: 'Bearer %env(SECURITY_ANALYZER_TOKEN)%'
        timeout: 10
        max_duration: 20

  cache:
    pools:
      security_monitor.cache:
        adapter: cache.adapter.filesystem
        default_lifetime: 0

  lock: '%env(LOCK_DSN)%'
  mailer:
    dsn: '%env(MAILER_DSN)%'

Поврзете ги именуваните услуги и вредностите од околината преку dependency injection:

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    bind:
      $securityAnalyzerClient: '@security_analyzer.client'
      $securityMonitorCache: '@security_monitor.cache'
      $websiteUrl: '%env(SECURITY_WEBSITE_URL)%'
      $ownerEmail: '%env(SECURITY_OWNER_EMAIL)%'
      $fromEmail: '%env(SECURITY_FROM_EMAIL)%'

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

  App\Security\WebsiteAnalyzer:
    alias: App\Security\WebsiteSecurityAnalyzer

Мапирајте го одговорот на границата на API-то

DTO прифаќа нумерички резултати, но не измислува опсег на резултати. Ги проверува четирите ветени области на одговорот и ги зачувува вгнездените податоци чија точна структура може да се развива.

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

final readonly class SecuritySnapshot
{
    public function __construct(
        public float $score,
        public array $findings,
        public array $tls,
        public array $recommendations,
    ) {}

    public static function fromArray(array $data): self
    {
        $score = $data['score'] ?? null;

        if ((!is_int($score) && !is_float($score))
            || !is_finite((float) $score)
            || !is_array($data['findings'] ?? null)
            || !is_array($data['tls'] ?? null)
            || !is_array($data['recommendations'] ?? null)
        ) {
            throw new AnalyzerException('Анализаторот врати невалиден одговор.');
        }

        foreach ($data['findings'] as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new AnalyzerException('Наодите не се групирани по сериозност.');
            }
        }

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

Дефинирајте тесен интерфејс за политиката за следење да може да се тестира без HTTP повици:

<?php
namespace App\Security;

interface WebsiteAnalyzer
{
    public function analyze(string $url): SecuritySnapshot;
}

final class AnalyzerException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly bool $temporary = false,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Изградете ограничен, селективен HTTP клиент

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

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

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

final class WebsiteSecurityAnalyzer implements WebsiteAnalyzer
{
    public function __construct(
        private HttpClientInterface $securityAnalyzerClient,
        private LoggerInterface $logger,
        private ?\Closure $sleep = null,
    ) {}

    public function analyze(string $url): SecuritySnapshot
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->securityAnalyzerClient->request(
                    'POST',
                    '/v1/analyze-website',
                    ['json' => ['url' => $url]],
                );
                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new AnalyzerException(
                        'До анализаторот не може да се пристапи.',
                        true,
                        $e,
                    );
                }

                $this->pause($attempt, null);
                continue;
            }

            if ($status >= 200 && $status < 300) {
                try {
                    $payload = $response->toArray(false);
                } catch (DecodingExceptionInterface $e) {
                    throw new AnalyzerException(
                        'Анализаторот врати невалиден JSON.',
                        false,
                        $e,
                    );
                }

                return SecuritySnapshot::fromArray($payload);
            }

            if ($status === 401 || $status === 403) {
                throw new AnalyzerException('Автентикацијата на анализаторот не успеа.');
            }

            if ($status === 400 || $status === 422) {
                throw new AnalyzerException('Барањето за веб-страницата беше одбиено.');
            }

            $retryable = $status === 429
                || in_array($status, [502, 503, 504], true);

            if ($retryable && $attempt < 3) {
                $headers = $response->getHeaders(false);
                $retryAfter = $headers['retry-after'][0] ?? null;

                $this->logger->warning('Безбедносната анализа ќе биде повторена.', [
                    'status' => $status,
                    'attempt' => $attempt,
                ]);

                $this->pause($attempt, $retryAfter);
                continue;
            }

            throw new AnalyzerException(
                'Барањето до анализаторот не успеа со HTTP '.$status.'.',
                $retryable || $status >= 500,
            );
        }

        throw new AnalyzerException('Обидите со анализаторот се исцрпени.', true);
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = is_string($retryAfter) && ctype_digit($retryAfter)
            ? min(30000, (int) $retryAfter * 1000)
            : min(2000, 250 * (2 ** ($attempt - 1)));

        if ($this->sleep !== null) {
            ($this->sleep)($milliseconds);
            return;
        }

        usleep($milliseconds * 1000);
    }
}

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

Споредете ги резултатите и испратете го предупредувањето

Првото успешно извршување воспоставува почетна состојба без да испраќа е-пошта. Подоцнежните успешни извршувања ја ажурираат таа почетна состојба. Ако резултатот падне, е-поштата се испраќа пред да се зачува состојбата. Тоа создава однесување на предупредување „најмалку еднаш“: неуспех на поштата ја остава старата почетна состојба недопрена за подоцнежно извршување да може повторно да се обиде.

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

use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

final class SecurityMonitor
{
    public function __construct(
        private WebsiteAnalyzer $analyzer,
        private CacheItemPoolInterface $securityMonitorCache,
        private MailerInterface $mailer,
        private LoggerInterface $logger,
        private string $websiteUrl,
        private string $ownerEmail,
        private string $fromEmail,
    ) {}

    public function run(): string
    {
        $snapshot = $this->analyzer->analyze($this->websiteUrl);
        $key = 'website_security_'.hash('sha256', $this->websiteUrl);
        $item = $this->securityMonitorCache->getItem($key);
        $stored = $item->isHit() ? $item->get() : null;
        $previous = is_array($stored) && is_numeric($stored['score'] ?? null)
            ? (float) $stored['score']
            : null;

        $outcome = $previous === null ? 'baseline' : 'steady';

        if ($previous !== null && $snapshot->score < $previous) {
            $body = sprintf(
                "Веб-страница: %s\nПретходен резултат: %s\nТековен резултат: %s\n\n".
                "Наоди по сериозност:\n%s\n\nTLS детали:\n%s\n\n".
                "Препораки:\n%s\n",
                $this->websiteUrl,
                $previous,
                $snapshot->score,
                json_encode($snapshot->findings, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
                json_encode($snapshot->tls, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
                json_encode($snapshot->recommendations, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
            );

            $this->mailer->send(
                (new Email())
                    ->from($this->fromEmail)
                    ->to($this->ownerEmail)
                    ->subject('Безбедносниот резултат на веб-страницата падна')
                    ->text($body),
            );

            $outcome = 'alerted';
        }

        $item->set([
            'score' => $snapshot->score,
            'checked_at' => gmdate(DATE_ATOM),
        ]);

        if (!$this->securityMonitorCache->save($item)) {
            throw new \RuntimeException('Безбедносната почетна состојба не можеше да се зачува.');
        }

        $this->logger->info('Неделната проверка на безбедноста на веб-страницата е завршена.', [
            'website' => $this->websiteUrl,
            'score' => $snapshot->score,
            'outcome' => $outcome,
        ]);

        return $outcome;
    }
}

Изложете го работниот тек како заклучена команда

<?php
// src/Command/CheckWebsiteSecurityCommand.php
namespace App\Command;

use App\Security\SecurityMonitor;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;

#[AsCommand(name: 'app:security:check')]
final class CheckWebsiteSecurityCommand extends Command
{
    public function __construct(
        private SecurityMonitor $monitor,
        private LockFactory $lockFactory,
        private LoggerInterface $logger,
    ) {
        parent::__construct();
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        $lock = $this->lockFactory->createLock(
            'weekly-website-security',
            300.0,
        );

        if (!$lock->acquire()) {
            $output->writeln('Веќе се извршува безбедносна проверка.');
            return Command::SUCCESS;
        }

        try {
            $outcome = $this->monitor->run();
            $output->writeln('Безбедносната проверка е завршена: '.$outcome);
            return Command::SUCCESS;
        } catch (\Throwable $e) {
            $this->logger->error('Проверката на безбедноста на веб-страницата не успеа.', [
                'exception' => $e::class,
                'message' => $e->getMessage(),
            ]);
            $output->writeln('Безбедносната проверка не успеа; проверете ги дневниците на апликацијата.');
            return Command::FAILURE;
        } finally {
            $lock->release();
        }
    }
}

Тестирајте ги и границата и деловното правило

MockHttpClient го прави API тестот детерминистички и ги проверува точниот метод, крајната точка и телото на барањето.

<?php
namespace App\Tests\Security;

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

final class WebsiteSecurityAnalyzerTest extends TestCase
{
    public function testItMapsAValidResponse(): void
    {
        $client = new MockHttpClient(
            function (string $method, string $url, array $options): MockResponse {
                self::assertSame('POST', $method);
                self::assertSame(
                    'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
                    $url,
                );
                self::assertSame(
                    ['url' => 'https://www.example.com'],
                    json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR),
                );

                return new MockResponse(json_encode([
                    'score' => 84,
                    'findings' => ['high' => [], 'medium' => [['id' => 'header']]],
                    'tls' => ['available' => true],
                    'recommendations' => ['Прегледајте ги безбедносните заглавија на прелистувачот.'],
                ], JSON_THROW_ON_ERROR));
            },
            'https://ai.mihajlo.mk/api/website-security-analyzer-api',
        );

        $snapshot = (new WebsiteSecurityAnalyzer(
            $client,
            new NullLogger(),
            static function (int $milliseconds): void {},
        ))->analyze('https://www.example.com');

        self::assertSame(84.0, $snapshot->score);
        self::assertArrayHasKey('medium', $snapshot->findings);
    }
}

Вториот тест треба да го опфати исходот што е важен: воспоставување почетна состојба и испраќање точно една порака по пад.

<?php
public function testItEmailsOnlyAfterTheScoreDrops(): void
{
    $analyzer = $this->createMock(\App\Security\WebsiteAnalyzer::class);
    $analyzer->method('analyze')->willReturnOnConsecutiveCalls(
        new \App\Security\SecuritySnapshot(90, ['high' => []], [], []),
        new \App\Security\SecuritySnapshot(82, ['high' => [['id' => 'x']]], [], ['Прегледајте го наодот x.']),
    );

    $mailer = $this->createMock(\Symfony\Component\Mailer\MailerInterface::class);
    $mailer->expects(self::once())->method('send');

    $monitor = new \App\Security\SecurityMonitor(
        $analyzer,
        new \Symfony\Component\Cache\Adapter\ArrayAdapter(),
        $mailer,
        new \Psr\Log\NullLogger(),
        'https://www.example.com',
        '[email protected]',
        '[email protected]',
    );

    self::assertSame('baseline', $monitor->run());
    self::assertSame('alerted', $monitor->run());
}

Додадете и случаи за неисправен JSON, недостасувачки полиња во одговорот, HTTP 401 без повторни обиди, HTTP 429 со ограничени повторни обиди, неуспех на транспортот на е-пошта без ажурирање на почетната состојба и оштетена кеширана состојба.

Распоредете и закажете ја неделната проверка

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

php bin/phpunit
APP_ENV=prod APP_DEBUG=0 php bin/console cache:warmup
APP_ENV=prod APP_DEBUG=0 php bin/console app:security:check

На еден сервер, cron запис за понеделник може да ја извршува проверката во тивок период:

17 3 * * 1 cd /srv/example-app && APP_ENV=prod APP_DEBUG=0 php bin/console app:security:check

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

За распоредувања во контејнери или со повеќе реплики, не ја чувајте почетната состојба само во ефемерен контејнер. Користете трајно заедничко складиште за кешот и заеднички backend за заклучување. Извршувајте го распоредот од еден назначен worker или потпрете се на дистрибуираното заклучување.

Вообичаени продукциски неуспеси

  • 401 или 403: потврдете ги променливата од околината, активацијата на планот и ротацијата на токенот. Не ги повторувајте овие одговори слепо.
  • 400 или 422: потврдете дека SECURITY_WEBSITE_URL е валиден јавен HTTPS URL и дека JSON полето останува url.
  • 429: почитувајте го ограниченото однесување за повторни обиди, проверете ја употребата на планот и извршете повторно подоцна. Никогаш не претворајте го ограничувањето на стапката во бесконечна јамка.
  • Нема е-пошта по првото извршување: ова е намерно. Првиот резултат ја создава почетната состојба за споредба.
  • Повторени предупредувања: проверете дали е-поштата успева, но постојаната состојба не може да се зачува, или дали репликите користат одделни cache pool-ови.
  • Командата успева, но cron не: cron често има помала околина. Користете апсолутни патеки и осигурете се дека потребните променливи се достапни за закажаниот процес.

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

  • Токенот постои само во конфигурација поддржана од околината и отсуствува од контролата на изворниот код и дневниците.
  • Клиентот ја повикува точната POST крајна точка со JSON url.
  • Времињата за поврзување и за вкупниот одговор се ограничени.
  • Само минливите неуспеси и ограничувањата на стапката добиваат ограничени повторни обиди.
  • Првиот успешен резултат создава трајна почетна состојба без испраќање е-пошта.
  • Понизок подоцнежен резултат испраќа еден корисен извештај што содржи наоди, TLS детали и препораки.
  • Неуспесите на е-поштата или складирањето на состојбата создаваат резултат на команда различен од нула.
  • Распоредувачот се извршува неделно, а преклопените извршувања се заклучени.

Вредниот дел од овој монитор не е cron изразот, па дури ни е-поштата. Тој е внимателната граница околу промената: валидирајте несигурни надворешни податоци, запомнете го последниот доверлив резултат, известувајте само кога сигналот се влошува и неуспевајте видливо кога самиот систем за следење престанува да работи. Тоа претвора едноставен неделен API повик во сигурен механизам за рано предупредување што мал бизнис навистина може да го користи.

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

Mihajlo

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