Vodiči

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

Symfony sigurnosno upozorenje: Automatizirajte tjedna skeniranja web-mjesta i vlasnicima e-poštom javite o padu rezultata

Sigurnosna regresija rijetko dolazi s dramatičnom stranicom pogreške. Češće se promijeni postavka certifikata, nestane zaštitno zaglavlje ili implementacija neprimjetno oslabi sigurnosni položaj inače zdrave web-stranice prema pregledniku. Korisno pitanje nije sjeća li se netko provjeriti. Pitanje je događa li se provjera automatski i dolazi li do osobe koja može djelovati.

Ovaj vodič izrađuje produkcijski orijentiran Symfony nadzornik za web-stranicu male tvrtke. Jednom tjedno, konzolna naredba šalje javni HTTPS URL na ograničenu, neinvazivnu analizu, uspoređuje vraćenu sigurnosnu ocjenu s prethodnim uspješnim rezultatom i vlasniku šalje e-poštu samo kada ocjena padne. Analiza obuhvaća javni HTTPS i sigurnosni položaj preglednika; ne smije se opisivati ni tretirati kao penetracijski test.

Dobijte pristup Website Security Analyzeru

Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.

Otvorite stranicu usluge Website Security Analyzer, odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju. Zatim posjetite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.

Usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo oblik Bearer jer vjerodajnicu zadržava izvan URL-a, gdje parametre upita mogu zabilježiti zapisnici pristupa i sustavi za nadzor.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao koordiniranu implementaciju: ažurirajte tajnu aplikacije, implementirajte je i provjerite sljedeći zahtjev, umjesto da stari token ostavite u zaboravljenoj datoteci okruženja.

Potvrdite krajnju točku prije pisanja Symfony koda

Točan zahtjev je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Njegovo JSON tijelo sadrži jednu vrijednost url. Pošaljite jedan minimalni zahtjev iz pouzdanog terminala:

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

Zamijenite zamjenski URL web-stranicom tvrtke. Ne lijepite stvarni token u povijest ljuske na dijeljenom računalu; varijabla okruženja ili privremena zaštićena datoteka s vjerodajnicom sigurniji su za ovu ručnu provjeru.

Spremite tajne aplikacije u .env.local za lokalni razvoj. Symfony obično ne predaje tu datoteku u repozitorij. U produkciji umjesto toga ubrizgajte ista imena putem hosting platforme ili upravitelja tajni:

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

Postotno kodirajte rezervirane znakove u SMTP vjerodajnicama. Nikada ne stavljajte stvarni token usluge u izvorni kod, testne podatke, zapisnike, snimke zaslona ili predanu datoteku .env.

Preduvjeti i arhitektura

Projekt zahtijeva PHP 8.3 ili noviji, Composer, JSON i PDO SQLite ekstenzije, SMTP transport i Symfony aplikaciju. Instalirajte HTTP klijent prve strane, komponentu za e-poštu, komponentu za zaključavanje i podršku za testiranje:

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

Implementacija ostaje sinkrona. Zakazana konzolna naredba već je izvan HTTP zahtjeva, pa bi dodavanje Messengera uvelo dodatnog radnika, transport i način kvara bez poboljšanja ovog malog tjednog opterećenja.

Naredba stječe zaključavanje, zahtijeva analizu, preslikava odgovor u domenski objekt, učitava prethodnu ocjenu iz SQLitea, šalje upozorenje pri strogom smanjenju i bilježi novu ocjenu. Neuspjele analize nikada ne zamjenjuju posljednju poznatu dobru osnovnu vrijednost. Ako dostava e-pošte ne uspije, ocjena također ostaje nepromijenjena, što kasnijem ponavljanju naredbe omogućuje da ponovno pokuša poslati upozorenje.

Relevantne datoteke su:

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

Konfigurirajte ubrizgavanje ovisnosti i zaključavanje

Omogućite lokalno zaključavanje datotečnog sustava. Ono sprječava da preklapajuća pokretanja raspoređivača šalju duplicirana upozorenja:

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

Izričito povežite vrijednosti iz okruženja. SQLite datoteka u produkciji mora biti na trajnoj pohrani; u suprotnom bi novo izdanje moglo izbrisati osnovnu vrijednost za usporedbu.

# 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)%'

Preslikajte API odgovor na granici

Udaljeni JSON ne bi se trebao širiti aplikacijom kao neprovjeren niz. Dostavljeni ugovor uključuje ocjenu, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke. Mapper zahtijeva te vrijednosti najviše razine, ali namjerno izbjegava izmišljanje sheme za njihove unutarnje pojedinosti.

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

Izradite ograničen API klijent koji uzima u obzir ponavljanja

Klijent postavlja ograničenja veze i ukupnog odgovora. Ponovno pokušava prolazne greške transporta i pogreške poslužitelja uz kratko eksponencijalno odgađanje. Neuspjesi autentifikacije i validacije vraćaju se odmah. Odgovor ograničenja stope ponovno se pokušava samo kada poslužitelj navede kratki brojčani Retry-After; dugo odgađanje kvote pripada raspoređivaču, a ne uspavanom PHP procesu.

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

Primijetite što nedostaje iz zapisnika: token, tijelo odgovora, SMTP vjerodajnice i potpuni nalazi. Oni mogu biti osjetljivi čak i kada je samo skeniranje neinvazivno.

Sačuvajte posljednju uspješnu ocjenu

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

Prvo uspješno pokretanje uspostavlja osnovnu vrijednost bez uzbunjivanja vlasnika. Kasnija pokretanja uspoređuju s neposredno prethodnom uspješnom ocjenom:

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

Pokrenite skeniranje i pošaljite e-poštu vlasniku

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

Dostava e-pošte i pohrana u bazu podataka ne mogu dijeliti jednu transakciju. Slanje prije spremanja daje prednost eventualnoj obavijesti: neuspjeh slanja e-pošte čuva staru osnovnu vrijednost. Rijedak neuspjeh baze podataka nakon uspješne dostave mogao bi uzrokovati duplikat pri ponavljanju. Transakcijski outbox može ukloniti tu nejasnoću, ali je obično nerazmjeran za jednog tjednog primatelja.

Testirajte granicu i pravilo upozorenja

MockHttpClient održava testove determinističkima i sprječava stvarne API pozive:

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

Pokrenite skup testova s php bin/phpunit. Dodajte ekvivalentne testne podatke za neispravan JSON, nedostajuća polja odgovora, neuspjeh autentifikacije, ograničenje stope i iscrpljena ponavljanja transporta. Testovi moraju koristiti zamjenske tokene i sintetičke nalaze.

Implementirajte, zakažite i nadzirite

Pokrenite naredbu jednom interaktivno u produkcijskom okruženju kako biste uspostavili osnovnu vrijednost:

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

Zatim je zakažite svakog ponedjeljka. Za tradicionalnu implementaciju na jednom hostu, ovaj cron unos pokreće se u 04:17 prema konfiguriranoj vremenskoj zoni poslužitelja:

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

Platforme kontejnera trebale bi koristiti vlastitu mogućnost zakazanih poslova i montirati var/security-monitor.sqlite na trajnu pohranu. Više replika također zahtijeva dijeljeno zaključavanje i dijeljenu bazu podataka umjesto ovdje prikazanog lokalnog SQLite i flock rasporeda.

Proslijedite Symfony zapisnike na svoje uobičajeno odredište zapisnika i upozorite na izlaz naredbe koji nije nula. Strukturirane vrste neuspjeha razlikuju neispravnu konfiguraciju, autentifikaciju, validaciju, ograničenje stope, poteškoće s transportom, neispravne odgovore i opće udaljene pogreške bez izlaganja tereta odgovora.

Uobičajeni produkcijski neuspjesi

  • Trenutačni 401 ili 403: provjerite ostaje li plan usluge aktivan i je li implementirani token aktualan. Ponovno generiranje tokena opoziva njegovog prethodnika.
  • Neuspjeh validacije: potvrdite da je konfigurirani cilj javni HTTPS URL i da JSON tijelo sadrži url.
  • Ponavljani odgovori 429: nemojte povećavati broj ponavljanja. Provjerite kapacitet plana i osigurajte da se ne izvode duplicirani raspoređivači.
  • Nema e-pošte: pregledajte status izlaza naredbe i zapisnike mailera, provjerite MAILER_DSN i zapamtite da prvo uspješno skeniranje samo uspostavlja osnovnu vrijednost.
  • Upozorenja nakon svake implementacije: SQLite datoteka nije trajna. Montirajte je izvan direktorija izdanja ili zamijenite spremište dijeljenom bazom podataka aplikacije.

Završni kontrolni popis za provjeru

  1. Potvrdite da aktivirani plan i token ograničen na uslugu pripadaju Website Security Analyzeru.
  2. Provjerite uspijeva li ručni POST zahtjev bez izlaganja tokena.
  3. Pokrenite automatizirane testove i potvrdite da nijedan zahtjev ne doseže aktivnu uslugu.
  4. Izvršite naredbu jednom i provjerite da pohranjuje osnovnu vrijednost bez slanja upozorenja.
  5. U neprodukcijskoj testnoj bazi podataka postavite višu prethodnu ocjenu i potvrdite da se generira točno jedna e-poruka.
  6. Potvrdite da neuspjesi vraćaju izlazni kôd koji nije nula i ne prepisuju prethodnu ocjenu.
  7. Provjerite raspoređivač, trajnu pohranu, zaključavanje, SMTP dostavu, prikupljanje zapisnika i postupak rotacije tokena.

Dovršeni nadzornik namjerno je skroman: jedna javna web-stranica, jedna tjedna naredba, jedna trajna osnovna vrijednost i jedno smisleno upozorenje. Ta suzdržanost značajka je pouzdanosti. Sigurnosna ocjena nije dokaz da je stranica sigurna, a ova analiza nije penetracijski test. Ona je signal ranog upozorenja koji tihu regresiju pretvara u konkretnu raspravu dok je promjena još dovoljno svježa da se ispravi.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.