Туториали

Symfony Security Alert: Automate Weekly Website Scans and Email Owners on Score Drops

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

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

Ова упатство создава продукциски ориентиран Symfony монитор за веб-страница на мал бизнис. Еднаш неделно, конзолна команда ја поднесува јавната HTTPS URL-адреса за ограничена, неинвазивна анализа, го споредува вратениот безбедносен резултат со претходниот успешен резултат и му испраќа е-пошта на сопственикот само кога резултатот ќе се намали. Анализата ги опфаќа јавната HTTPS и безбедносната поставеност кон прелистувачот; не смее да се опишува или третира како пенетрационен тест.

Добијте пристап до Website Security Analyzer

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

Отворете ја страницата на услугата Website Security Analyzer, изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација. Потоа посетете ја официјалната документација за услугата. Најдете го панелот Service token и копирајте го токенот со опсег на услугата што е прикажан таму.

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

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

Потврдете ја крајната точка пред да пишувате Symfony код

Точното барање е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Неговото JSON тело содржи една вредност 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://www.example-business.test"}'

Заменете ја URL-адресата со резервно место со веб-страницата на бизнисот. Не внесувајте вистински токен во историјата на школката на споделена машина; околинска променлива или привремена заштитена датотека со акредитации е побезбедна за оваа рачна проверка.

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

SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_SITE_URL=https://www.example-business.test
[email protected]
[email protected]
MAILER_DSN=smtp://SMTP_USER:[email protected]:587

Процентуално кодирајте ги резервираните знаци во SMTP акредитациите. Никогаш не ставајте вистински токен на услугата во изворен код, тест-фикстури, дневници, слики од екран или во предадена .env датотека.

Предуслови и архитектура

Проектот бара PHP 8.3 или понов, Composer, екстензии JSON и PDO SQLite, SMTP транспорт и Symfony апликација. Инсталирајте ги HTTP клиентот од прва страна, испраќачот на пошта, компонентата за заклучување и поддршката за тестови:

composer create-project symfony/skeleton security-monitor
cd security-monitor
composer require symfony/http-client symfony/mailer symfony/lock
composer require --dev symfony/test-pack

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

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

Релевантните датотеки се:

config/packages/framework.yaml
config/services.yaml
src/Security/WebsiteAnalysis.php
src/Security/AnalyzerException.php
src/Security/WebsiteSecurityAnalyzer.php
src/Security/SecurityScoreStore.php
src/Security/ScoreDropPolicy.php
src/Command/SecurityScanCommand.php
tests/Security/WebsiteSecurityAnalyzerTest.php
tests/Security/ScoreDropPolicyTest.php

Конфигурирајте вбризгување зависности и заклучување

Овозможете локално заклучување на датотечниот систем. Тоа спречува преклопувачките повици на распоредувачот да испраќаат дупликат предупредувања:

# config/packages/framework.yaml
framework:
    lock: 'flock'

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

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

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

    App\Security\WebsiteSecurityAnalyzer:
        arguments:
            $token: '%env(SECURITY_ANALYZER_TOKEN)%'

    App\Security\SecurityScoreStore:
        arguments:
            $databasePath: '%kernel.project_dir%/var/security-monitor.sqlite'

    App\Command\SecurityScanCommand:
        arguments:
            $siteUrl: '%env(resolve:SECURITY_SITE_URL)%'
            $ownerEmail: '%env(OWNER_EMAIL)%'
            $fromEmail: '%env(ALERT_FROM)%'

Пресликајте го API одговорот на границата

Оддалечениот JSON не треба да се шири низ апликацијата како непотврдена низа. Доставениот договор вклучува резултат, наоди групирани според сериозност, TLS детали и препораки. Пресликувачот ги бара тие вредности на највисоко ниво, но намерно избегнува да измислува шема за нивните внатрешни детали.

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

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

    public static function fromArray(array $data): self
    {
        foreach (['score', 'findings', 'tls', 'recommendations'] as $field) {
            if (!array_key_exists($field, $data)) {
                throw new \UnexpectedValueException("Missing response field: {$field}");
            }
        }

        if (!is_numeric($data['score']) || !is_finite((float) $data['score'])) {
            throw new \UnexpectedValueException('The score is not a finite number.');
        }

        if (!is_array($data['findings']) || !is_array($data['tls'])
            || !is_array($data['recommendations'])) {
            throw new \UnexpectedValueException('Invalid analysis collections.');
        }

        foreach ($data['findings'] as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new \UnexpectedValueException('Invalid severity-grouped findings.');
            }
        }

        foreach ($data['recommendations'] as $recommendation) {
            if (!is_string($recommendation)) {
                throw new \UnexpectedValueException('Invalid recommendation.');
            }
        }

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

Изградете ограничен API клиент свесен за повторни обиди

Клиентот поставува ограничувања за поврзување и за вкупно време на одговор. Повторно се обидува при минливи неуспеси на транспортот и грешки на серверот со кратко експоненцијално одложување. Неуспесите на автентикација и валидација се враќаат веднаш. Одговор со ограничување на стапката се повторува само кога серверот обезбедува краток нумерички Retry-After; долго одложување поради квота припаѓа во распоредувачот, а не во PHP процес што спие.

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

final class AnalyzerException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct("Analyzer failure: {$kind}", 0, $previous);
    }
}
<?php
// src/Security/WebsiteSecurityAnalyzer.php
namespace App\Security;

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

final class WebsiteSecurityAnalyzer
{
    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): WebsiteAnalysis
    {
        if (parse_url($url, PHP_URL_SCHEME) !== 'https') {
            throw new AnalyzerException('invalid_target');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'auth_bearer' => $this->token,
                    'headers' => ['Accept' => 'application/json'],
                    'json' => ['url' => $url],
                    'timeout' => 8.0,
                    'max_duration' => 15.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    $data = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR,
                    );

                    return WebsiteAnalysis::fromArray($data);
                }

                if ($status === 401 || $status === 403) {
                    throw new AnalyzerException('authentication', $status);
                }

                if ($status === 400 || $status === 422) {
                    throw new AnalyzerException('validation', $status);
                }

                if ($status === 429) {
                    $value = $response->getHeaders(false)['retry-after'][0] ?? null;
                    $seconds = is_numeric($value) ? (float) $value : null;

                    if ($attempt < 3 && $seconds !== null && $seconds <= 5) {
                        usleep((int) ($seconds * 1_000_000));
                        continue;
                    }

                    throw new AnalyzerException('rate_limited', $status);
                }

                if ($status >= 500 && $attempt < 3) {
                    $this->backoff($attempt, $status);
                    continue;
                }

                throw new AnalyzerException('remote_error', $status);
            } catch (AnalyzerException $e) {
                throw $e;
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new AnalyzerException('transport', null, $e);
                }

                $this->backoff($attempt, null);
            } catch (\JsonException|\UnexpectedValueException $e) {
                throw new AnalyzerException('malformed_response', null, $e);
            }
        }

        throw new AnalyzerException('transport');
    }

    private function backoff(int $attempt, ?int $status): void
    {
        $milliseconds = 250 * (2 ** ($attempt - 1));
        $this->logger->warning('security_analyzer.retry', [
            'attempt' => $attempt,
            'status' => $status,
            'delay_ms' => $milliseconds,
        ]);
        usleep($milliseconds * 1000);
    }
}

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

Зачувајте го последниот успешен резултат

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

final class SecurityScoreStore
{
    private \PDO $pdo;

    public function __construct(string $databasePath)
    {
        $this->pdo = new \PDO('sqlite:'.$databasePath, null, null, [
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
        ]);

        $this->pdo->exec(
            'CREATE TABLE IF NOT EXISTS security_score (
                site_url TEXT PRIMARY KEY,
                score REAL NOT NULL,
                scanned_at TEXT NOT NULL
            )'
        );
    }

    public function previous(string $url): ?float
    {
        $statement = $this->pdo->prepare(
            'SELECT score FROM security_score WHERE site_url = :url'
        );
        $statement->execute(['url' => $url]);
        $value = $statement->fetchColumn();

        return $value === false ? null : (float) $value;
    }

    public function save(string $url, float $score): void
    {
        $statement = $this->pdo->prepare(
            'INSERT INTO security_score (site_url, score, scanned_at)
             VALUES (:url, :score, :scanned_at)
             ON CONFLICT(site_url) DO UPDATE SET
                score = excluded.score,
                scanned_at = excluded.scanned_at'
        );

        $statement->execute([
            'url' => $url,
            'score' => $score,
            'scanned_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
        ]);
    }
}

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

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

final class ScoreDropPolicy
{
    public static function shouldNotify(?float $previous, float $current): bool
    {
        return $previous !== null && $current < $previous;
    }
}

Извршете го скенирањето и испратете му е-пошта на сопственикот

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

use App\Security\AnalyzerException;
use App\Security\ScoreDropPolicy;
use App\Security\SecurityScoreStore;
use App\Security\WebsiteSecurityAnalyzer;
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;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

#[AsCommand(name: 'app:security-scan')]
final class SecurityScanCommand extends Command
{
    public function __construct(
        private WebsiteSecurityAnalyzer $analyzer,
        private SecurityScoreStore $store,
        private MailerInterface $mailer,
        private LockFactory $lockFactory,
        private LoggerInterface $logger,
        private string $siteUrl,
        private string $ownerEmail,
        private string $fromEmail,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $lock = $this->lockFactory->createLock(
            'weekly-security-scan-'.hash('sha256', $this->siteUrl),
            900,
            false,
        );

        if (!$lock->acquire()) {
            $this->logger->notice('security_scan.already_running');
            return Command::SUCCESS;
        }

        try {
            $analysis = $this->analyzer->analyze($this->siteUrl);
            $previous = $this->store->previous($this->siteUrl);
            $alertSent = false;

            if (ScoreDropPolicy::shouldNotify($previous, $analysis->score)) {
                $counts = [];
                foreach ($analysis->findingsBySeverity as $severity => $items) {
                    $counts[] = sprintf('%s: %d', $severity, count($items));
                }

                $recommendations = array_slice($analysis->recommendations, 0, 5);
                $body = sprintf(
                    "The security score for %s dropped from %s to %s.\n\nFindings: %s",
                    $this->siteUrl,
                    $previous,
                    $analysis->score,
                    $counts === [] ? 'no grouped entries returned' : implode(', ', $counts),
                );

                if ($recommendations !== []) {
                    $body .= "\n\nRecommendations:\n- ".implode("\n- ", $recommendations);
                }

                $this->mailer->send(
                    (new Email())
                        ->from($this->fromEmail)
                        ->to($this->ownerEmail)
                        ->subject('Website security score decreased')
                        ->text($body)
                );
                $alertSent = true;
            }

            $this->store->save($this->siteUrl, $analysis->score);
            $this->logger->info('security_scan.completed', [
                'previous_score' => $previous,
                'current_score' => $analysis->score,
                'alert_sent' => $alertSent,
            ]);

            return Command::SUCCESS;
        } catch (AnalyzerException $e) {
            $this->logger->error('security_scan.analyzer_failed', [
                'kind' => $e->kind,
                'status' => $e->status,
            ]);
            return Command::FAILURE;
        } catch (\Throwable $e) {
            $this->logger->error('security_scan.failed', [
                'exception' => $e::class,
            ]);
            return Command::FAILURE;
        } finally {
            $lock->release();
        }
    }
}

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

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

MockHttpClient ги прави тестовите детерминистички и спречува вистински API повици:

<?php
// tests/Security/WebsiteSecurityAnalyzerTest.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 testItMapsAValidAnalysis(): void
    {
        $transport = new MockHttpClient(function ($method, $url, $options) {
            self::assertSame('POST', $method);
            self::assertSame(
                'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
                $url,
            );
            self::assertSame(
                ['url' => 'https://shop.example.test'],
                json_decode($options['body'], true),
            );

            return new MockResponse(json_encode([
                'score' => 84,
                'findings' => ['high' => [], 'medium' => [['id' => 'x']]],
                'tls' => ['available' => true],
                'recommendations' => ['Review the reported medium finding.'],
            ], JSON_THROW_ON_ERROR), ['http_code' => 200]);
        });

        $client = new WebsiteSecurityAnalyzer(
            $transport,
            new NullLogger(),
            'test-token',
        );

        $result = $client->analyze('https://shop.example.test');

        self::assertSame(84.0, $result->score);
        self::assertCount(1, $result->findingsBySeverity['medium']);
    }
}
<?php
// tests/Security/ScoreDropPolicyTest.php
namespace App\Tests\Security;

use App\Security\ScoreDropPolicy;
use PHPUnit\Framework\TestCase;

final class ScoreDropPolicyTest extends TestCase
{
    public function testOnlyARealDropTriggersAnAlert(): void
    {
        self::assertFalse(ScoreDropPolicy::shouldNotify(null, 80));
        self::assertFalse(ScoreDropPolicy::shouldNotify(80, 80));
        self::assertFalse(ScoreDropPolicy::shouldNotify(80, 85));
        self::assertTrue(ScoreDropPolicy::shouldNotify(80, 79));
    }
}

Извршете го пакетот со php bin/phpunit. Додадете еквивалентни фикстури за неисправен JSON, полиња што недостигаат во одговорот, неуспех на автентикација, ограничување на стапката и исцрпени повторни обиди на транспортот. Тестовите мора да користат токени со резервно место и синтетички наоди.

Распоредете, закажете и набљудувајте

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

APP_ENV=prod php bin/console app:security-scan --no-interaction

Потоа закажете ја секој понеделник. За традиционално распоредување на еден хост, овој cron запис се извршува во 04:17 во конфигурираната временска зона на серверот:

17 4 * * 1 cd /srv/security-monitor && APP_ENV=prod php bin/console app:security-scan --no-interaction

Контејнерските платформи треба да ја користат нивната вградена можност за закажани задачи и да монтираат var/security-monitor.sqlite на трајно складиште. На повеќе реплики исто така им треба споделено заклучување и споделена база на податоци, наместо локалниот SQLite и аранжманот flock прикажани тука.

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

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

  • Непосреден 401 или 403: потврдете дека планот на услугата останува активен и дека распоредениот токен е тековен. Повторното генерирање на токенот го поништува неговиот претходник.
  • Неуспех на валидација: потврдете дека конфигурираната цел е јавна HTTPS URL-адреса и дека JSON телото содржи url.
  • Повторени одговори 429: не зголемувајте ги повторните обиди. Проверете го капацитетот на планот и уверете се дека не работат дупликат распоредувачи.
  • Нема е-пошта: проверете ги излезниот статус на командата и дневниците на испраќачот на пошта, потврдете MAILER_DSN и имајте предвид дека првото успешно скенирање само воспоставува основа.
  • Предупредувања по секое распоредување: SQLite датотеката не е трајна. Монтирајте ја надвор од директориумот на изданието или заменете го складиштето со споделената база на податоци на апликацијата.

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

  1. Потврдете дека активираниот план и токенот со опсег на услугата припаѓаат на Website Security Analyzer.
  2. Потврдете дека рачното POST барање успева без изложување на токенот.
  3. Извршете ги автоматизираните тестови и потврдете дека ниту едно барање не стигнува до активната услуга.
  4. Извршете ја командата еднаш и потврдете дека таа зачувува основа без да испрати предупредување.
  5. Во непродукциска тест-база, внесете повисок претходен резултат и потврдете дека се создава точно една е-пошта.
  6. Потврдете дека неуспесите враќаат излезен код различен од нула и не го презапишуваат претходниот резултат.
  7. Потврдете ги распоредувачот, трајното складиште, заклучувањето, SMTP испораката, собирањето дневници и постапката за ротација на токени.

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

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

Mihajlo

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