Laravel sigurnosni nadzor: tjedne revizije web-mjesta i upozorenja vlasnicima uz AI
Web-mjesto može ostati dostupno dok se njegova sigurnosna pozicija neprimjetno pogoršava. Promjena certifikata, ažuriranje proxyja, uklonjeno zaglavlje ili užurbana implementacija možda neće pokvariti stranicu, ali mogu smanjiti zaštitu koju posjetitelji primaju.
Ovaj vodič izrađuje Laravel sigurnosni nadzor za tu tišu vrstu kvara. Jednom tjedno Artisan naredba šalje malo poslovno web-mjesto u Website Security Analyzer, provjerava i pohranjuje rezultat, uspoređuje njegovu ocjenu s prethodnom uspješnom provjerom te stavlja e-poruku u red čekanja kada ocjena padne.
Analizator provodi ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosne pozicije preglednika. Njegov izlaz koristan je operativni dokaz, ali nije penetracijski test i nikada se ne smije tako predstavljati.
Preduvjeti i dizajn
Potrebni su vam PHP 8.3 ili noviji, Laravel aplikacija, podržana baza podataka, ispravna SMTP konfiguracija i queue worker. Web-mjesto mora biti javno dostupno putem HTTPS-a.
Dizajn namjerno ostaje malen:
- Artisan naredba upravlja tjednim tijekom rada.
- Namjenski klijent izolira ugovor s vanjskim API-jem.
- DTO odbacuje neispravne odgovore na granici.
- Jedan red u bazi podataka čuva najnoviju uspješnu referentnu vrijednost.
- Posao u redu čekanja šalje e-poruku, omogućujući ponovne pokušaje isporuke bez ponavljanja analize.
Prvo uspješno pokretanje uspostavlja referentnu vrijednost i ne šalje upozorenje. Kasnija pokretanja šalju upozorenje samo kada je nova numerička ocjena niža. Nalazi grupirani prema ozbiljnosti, TLS pojedinosti i preporuke zadržavaju se zajedno s ocjenom radi dijagnostike.
Dobijte pristup analizatoru
- 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 aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
Ova usluga nije bez tokena. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer oblik kako bi vjerodajnica ostala izvan URL-a i uobičajenih zapisnika pristupa.
Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao koordiniranu implementaciju: ažurirajte tajnu aplikacije, ponovno izgradite predmemoriranu konfiguraciju, provjerite jedan zahtjev, a zatim potvrdite da su zakazana pokretanja ispravna.
Provjerite točnu krajnju točku
API poziv je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Prima JSON tijelo koje sadrži url. Izvršite jedan minimalan zahtjev prije izrade zakazane značajke:
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 "Accept: application/json" \
--header "Content-Type: application/json" \
--data '{"url":"https://www.example-business.test"}'
Zamijenite primjer domene stvarnim javnim HTTPS web-mjestom. Nemojte lijepiti token u kontrolu izvornog koda, shell skripte, snimke zaslona, zapisnike ili testne fixtureove.
Pohranite ga u implementirano okruženje. Dodajte sljedeće rezervirana mjesta u lokalnu datoteku .env i osigurajte ekvivalentne tajne putem svoje produkcijske platforme:
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_ANALYZER_URL=https://www.example-business.test
[email protected]
QUEUE_CONNECTION=database
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.test
MAIL_PORT=587
MAIL_USERNAME=YOUR_SMTP_USERNAME
MAIL_PASSWORD=YOUR_SMTP_PASSWORD
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="Website Security Watch"
Dodajte ovaj unos unutar polja koje vraća config/services.php:
'security_analyzer' => [
'endpoint' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
'token' => env('SECURITY_ANALYZER_TOKEN'),
'url' => env('SECURITY_ANALYZER_URL'),
'owner_email' => env('SECURITY_OWNER_EMAIL'),
],
Kôd aplikacije treba čitati config(), a nikada izravno pozivati env(). Ta je razlika važna nakon što se Laravel konfiguracija predmemorira.
Izradite spremište stanja
Datoteke specifične za projekt bit će sažete:
app/
Console/Commands/AuditWebsiteSecurity.php
Data/SecurityReport.php
Exceptions/AnalyzerException.php
Jobs/SendScoreDropAlert.php
Models/WebsiteSecurityCheck.php
Notifications/ScoreDropped.php
Services/WebsiteSecurityAnalyzer.php
database/migrations/..._create_website_security_checks_table.php
tests/Feature/AuditWebsiteSecurityTest.php
tests/Unit/WebsiteSecurityAnalyzerTest.php
Izradite migraciju i model pomoću Artisana, zatim definirajte tablicu:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('website_security_checks', function (Blueprint $table) {
$table->id();
$table->string('target_url')->unique();
$table->decimal('last_score', 10, 2)->nullable();
$table->json('latest_report')->nullable();
$table->timestamp('checked_at')->nullable();
$table->string('last_error')->nullable();
$table->timestamp('last_error_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('website_security_checks');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class WebsiteSecurityCheck extends Model
{
protected $fillable = [
'target_url',
'last_score',
'latest_report',
'checked_at',
'last_error',
'last_error_at',
];
protected function casts(): array
{
return [
'last_score' => 'decimal:2',
'latest_report' => 'array',
'checked_at' => 'datetime',
'last_error_at' => 'datetime',
];
}
}
Zaštitite granicu aplikacije
Vanjski JSON je nepouzdan ulaz, čak i kada dolazi od usluge kojom namjerno upravljate. DTO u nastavku prihvaća ocjenu ugovora, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke, dok odbija podatke koji nedostaju ili su strukturno neispravni.
<?php
namespace App\Data;
use App\Exceptions\AnalyzerException;
final readonly class SecurityReport
{
public function __construct(
public float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {}
public static function fromArray(array $payload): self
{
foreach (['score', 'findings', 'tls', 'recommendations'] as $field) {
if (! array_key_exists($field, $payload)) {
throw new AnalyzerException("Analyzer response is missing {$field}.");
}
}
$score = filter_var($payload['score'], FILTER_VALIDATE_FLOAT);
if ($score === false) {
throw new AnalyzerException('Analyzer score is not numeric.');
}
if (! is_array($payload['findings'])
|| ! is_array($payload['tls'])
|| ! is_array($payload['recommendations'])) {
throw new AnalyzerException('Analyzer response has an invalid structure.');
}
foreach ($payload['findings'] as $severity => $items) {
if (! is_string($severity) || ! is_array($items)) {
throw new AnalyzerException('Findings are not grouped by severity.');
}
}
return new self(
(float) $score,
$payload['findings'],
$payload['tls'],
$payload['recommendations'],
);
}
public function toArray(): array
{
return [
'score' => $this->score,
'findings' => $this->findings,
'tls' => $this->tls,
'recommendations' => $this->recommendations,
];
}
}
Prijevod ugovora zadržite u ovoj jednoj klasi. Ako službena dokumentacija promijeni omotnicu odgovora, ostatak aplikacije ne bi trebao razumjeti tu pojedinost prijenosa.
Izradite ograničeni HTTP klijent
Klijent koristi Laravelov ugrađeni HTTP klijent, s vremenskim ograničenjem povezivanja od pet sekundi i ukupnim vremenskim ograničenjem odgovora od dvadeset sekundi. Ponovno pokušava kod neuspjelih povezivanja, HTTP 408, HTTP 429 i pogrešaka poslužitelja. Autentikacija, dozvole, provjera valjanosti i druge pogreške klijenta ne pokušavaju se naslijepo ponovno.
<?php
namespace App\Exceptions;
use RuntimeException;
final class AnalyzerException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
) {
parent::__construct($message);
}
}
<?php
namespace App\Services;
use App\Data\SecurityReport;
use App\Exceptions\AnalyzerException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
final class WebsiteSecurityAnalyzer
{
public function analyze(string $url): SecurityReport
{
$token = (string) config('services.security_analyzer.token');
$endpoint = (string) config('services.security_analyzer.endpoint');
if ($token === '') {
throw new AnalyzerException('Analyzer token is not configured.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->acceptJson()
->asJson()
->connectTimeout(5)
->timeout(20)
->post($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new AnalyzerException(
'Analyzer connection failed after retries.'
);
}
usleep(250000 * (2 ** ($attempt - 1)));
continue;
}
if ($response->successful()) {
$json = $response->json();
if (! is_array($json)) {
throw new AnalyzerException('Analyzer returned invalid JSON.');
}
return SecurityReport::fromArray($json);
}
$status = $response->status();
$retryable = $status === 408
|| $status === 429
|| $status >= 500;
if (! $retryable || $attempt === 3) {
throw new AnalyzerException(
"Analyzer request failed with HTTP {$status}.",
$status,
);
}
$this->pause($response, $attempt);
}
throw new AnalyzerException('Analyzer request did not complete.');
}
private function pause(Response $response, int $attempt): void
{
$header = (string) $response->header('Retry-After');
$seconds = ctype_digit($header) ? min((int) $header, 5) : 0;
if ($seconds > 0) {
sleep($seconds);
return;
}
usleep(250000 * (2 ** ($attempt - 1)));
}
}
Ograničeno čekanje Retry-After sprječava da tjedna naredba neograničeno zauzima worker. Trajni 429 ostaje vidljiv neuspjeh i trebao bi potaknuti provjeru plana ili rasporeda. Tijela odgovora i tokeni namjerno nisu uključeni u iznimke i zapisnike.
Usporedite ocjene i stavite upozorenje u red čekanja
Naredba provjerava je li konfigurirani cilj HTTPS, izvršava udaljeni zahtjev prije otvaranja transakcije baze podataka i zaključava red referentne vrijednosti samo tijekom usporedbe i pohrane.
<?php
namespace App\Console\Commands;
use App\Exceptions\AnalyzerException;
use App\Jobs\SendScoreDropAlert;
use App\Models\WebsiteSecurityCheck;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
final class AuditWebsiteSecurity extends Command
{
protected $signature = 'security:audit';
protected $description = 'Audit the configured website security posture';
public function handle(WebsiteSecurityAnalyzer $analyzer): int
{
$url = (string) config('services.security_analyzer.url');
if (! filter_var($url, FILTER_VALIDATE_URL)
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
$this->error('SECURITY_ANALYZER_URL must be a valid HTTPS URL.');
return self::FAILURE;
}
try {
$report = $analyzer->analyze($url);
$previous = DB::transaction(function () use ($url, $report) {
$state = WebsiteSecurityCheck::query()
->where('target_url', $url)
->lockForUpdate()
->first();
$previous = $state?->last_score;
$state ??= new WebsiteSecurityCheck(['target_url' => $url]);
$state->fill([
'last_score' => $report->score,
'latest_report' => $report->toArray(),
'checked_at' => now(),
'last_error' => null,
'last_error_at' => null,
])->save();
return $previous === null ? null : (float) $previous;
});
if ($previous !== null && $report->score < $previous) {
SendScoreDropAlert::dispatch(
(string) config('services.security_analyzer.owner_email'),
$url,
$previous,
$report->toArray(),
);
}
Log::info('Website security audit completed', [
'host' => parse_url($url, PHP_URL_HOST),
'score' => $report->score,
'previous_score' => $previous,
'alert_queued' => $previous !== null
&& $report->score < $previous,
]);
return self::SUCCESS;
} catch (Throwable $exception) {
$message = $exception instanceof AnalyzerException
? $exception->getMessage()
: 'Unexpected security audit failure.';
WebsiteSecurityCheck::query()->updateOrCreate(
['target_url' => $url],
['last_error' => $message, 'last_error_at' => now()],
);
Log::error('Website security audit failed', [
'host' => parse_url($url, PHP_URL_HOST),
'exception' => $exception::class,
'status' => $exception instanceof AnalyzerException
? $exception->status
: null,
]);
return self::FAILURE;
}
}
}
Upotrijebite posao u redu čekanja jer SMTP predstavlja zasebnu domenu neuspjeha. Spremljena referentna vrijednost ne bi se trebala poništiti samo zato što je poslužitelj e-pošte privremeno nedostupan.
<?php
namespace App\Jobs;
use App\Notifications\ScoreDropped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Notification;
final class SendScoreDropAlert implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public function __construct(
public string $ownerEmail,
public string $url,
public float $previousScore,
public array $report,
) {}
public function backoff(): array
{
return [60, 300, 900, 3600];
}
public function handle(): void
{
Notification::route('mail', $this->ownerEmail)->notify(
new ScoreDropped(
$this->url,
$this->previousScore,
$this->report,
)
);
}
}
<?php
namespace App\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
final class ScoreDropped extends Notification
{
public function __construct(
private string $url,
private float $previousScore,
private array $report,
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$mail = (new MailMessage)
->subject('Website security score dropped')
->line("The security score fell from {$this->previousScore} to {$this->report['score']}.");
foreach ($this->report['findings'] as $severity => $items) {
$mail->line(ucfirst($severity).': '.count($items).' finding(s)');
}
foreach (array_filter(
$this->report['recommendations'],
'is_string'
) as $recommendation) {
$mail->line($recommendation);
}
return $mail
->action('Review the website', $this->url)
->line('This is a bounded posture analysis, not a penetration test.');
}
}
Isporuka putem reda čekanja obično je barem jednom, pa neuobičajeni neuspjeh workera nakon SMTP prihvaćanja može proizvesti dvostruku e-poruku. To je bolje nego neprimjetno izgubiti značajno upozorenje; dodajte identifikator upozorenja i idempotentnost na razini pružatelja samo ako suzbijanje duplikata postane važno.
Zakažite tjedno pokretanje
Dodajte raspored u routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('security:audit')
->weekly()
->withoutOverlapping(30)
->onOneServer();
withoutOverlapping() štiti od sporog prethodnog pokretanja. onOneServer() sprječava dvostruko izvršavanje na više čvorova aplikacije i zahtijeva zajedničko, podržano spremište predmemorije. Ručno pokrenite php artisan security:audit kako biste uspostavili prvu referentnu vrijednost.
Testirajte uspješne i neuspješne putanje
Laravelov Http::fake() čini ove testove determinističkima: nisu uključeni stvarni token, mrežni zahtjev ni e-adresa vlasnika.
<?php
namespace Tests\Feature;
use App\Jobs\SendScoreDropAlert;
use App\Models\WebsiteSecurityCheck;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class AuditWebsiteSecurityTest extends TestCase
{
use RefreshDatabase;
public function test_it_queues_an_alert_when_the_score_drops(): void
{
config()->set('services.security_analyzer.token', 'test-token');
config()->set('services.security_analyzer.url', 'https://shop.test');
WebsiteSecurityCheck::create([
'target_url' => 'https://shop.test',
'last_score' => 90,
]);
Http::fake([
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
=> Http::response([
'score' => 82,
'findings' => ['high' => [['type' => 'header']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported high finding.'],
]),
]);
Queue::fake();
$this->assertSame(0, Artisan::call('security:audit'));
Queue::assertPushed(
SendScoreDropAlert::class,
fn (SendScoreDropAlert $job) =>
$job->previousScore === 90.0
&& $job->report['score'] === 82.0
);
Http::assertSent(fn ($request) =>
$request->hasHeader('Authorization', 'Bearer test-token')
&& $request['url'] === 'https://shop.test'
);
}
public function test_first_successful_run_only_sets_the_baseline(): void
{
config()->set('services.security_analyzer.token', 'test-token');
config()->set('services.security_analyzer.url', 'https://shop.test');
Http::fake([ '*' => Http::response([
'score' => 88,
'findings' => [],
'tls' => [],
'recommendations' => [],
]) ]);
Queue::fake();
$this->assertSame(0, Artisan::call('security:audit'));
Queue::assertNothingPushed();
}
}
Dodajte jedinični test koji vraća HTTP 401 i izravno pozovite klijent. Potvrdite da je poslan točno jedan zahtjev i da je podignut AnalyzerException. To štiti važno pravilo da se neispravne vjerodajnice nikada ponovno ne pokušavaju. Zaseban lažni slijed HTTP 500 nakon kojeg slijedi uspjeh može provjeriti putanju ponovnog pokušaja za prolazne pogreške.
Produkcijska sigurnost i implementacija
- Ciljni URL držite pod kontrolom okruženja. Nemojte izlagati krajnju točku koja proizvoljnim korisnicima omogućuje slanje URL-ova.
- იდენტitetima za implementaciju i izvođenje dodijelite pristup samo servisnom tokenu koji im je potreban.
- Nemojte bilježiti zaglavlja autorizacije, potpuna tijela odgovora, SMTP vjerodajnice ni URL-ove koji sadrže tokene.
- Ograničite pristup pohranjenim izvješćima jer nalazi mogu otkriti obrambene slabosti.
- U implementacijama s više čvorova upotrijebite zajedničku predmemoriju za zaključavanja raspoređivača i trajni queue driver.
- Nadzirite neuspjehe naredbe, zastarjele vrijednosti
checked_at, neuspjele poslove reda čekanja i ponovljene odgovore HTTP 429.
Implementirajte kôd i tajne okruženja, zatim pokrenite:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan security:audit
php artisan schedule:list
Ako aplikaciji nedostaje migracija tablice poslova, generirajte migraciju tablice reda čekanja okvira prije migriranja. Održavajte nadzirani proces php artisan queue:work --tries=5 --timeout=60 aktivnim. Konfigurirajte cron da izvršava php artisan schedule:run svake minute; Laravel odlučuje kada je tjedni zadatak na rasporedu.
Česti neuspjesi
- HTTP 401 ili 403: potvrdite aktivaciju plana i token ograničen na uslugu. Ponovno generirani token odmah poništava prethodno aktivni token.
- HTTP 422: provjerite sadrži li JSON
urli je li konfigurirano web-mjesto javni HTTPS URL. Ispravite zahtjev umjesto da ga ponovno pokušavate. - HTTP 429: provjerite učestalost rasporeda i ograničenja plana. Kratki ponovni pokušaji mogu apsorbirati prolazno ograničenje, ali trajno iscrpljivanje kvote zahtijeva operativnu promjenu.
- Nema tjednog izvršavanja: provjerite cron,
schedule:list, očekivanja vremenske zone, povezanost zajedničke predmemorije i zastarjela zaključavanja preklapanja. - Nema e-pošte: pregledajte neuspjele poslove i zapisnike workera, zatim neovisno testirajte SMTP. Osigurajte da queue worker doista obrađuje konfiguriranu vezu.
- Neispravan odgovor: usporedite službenu dokumentaciju s
SecurityReport::fromArray(). Ažurirajte samo mapper na granici ako se dokumentirani oblik odgovora promijeni.
Završni popis za provjeru
- Token postoji samo u konfiguraciji podržanoj okruženjem.
- Minimalni POST zahtjev uspijeva prema točnoj krajnjoj točki analizatora.
- Prvo Artisan pokretanje pohranjuje referentnu vrijednost bez slanja e-pošte vlasniku.
- Niža lažna ocjena stavlja jedno upozorenje u red čekanja koje sadrži prethodnu i trenutačnu ocjenu.
- Neuspjesi autentikacije i provjere valjanosti ne pokušavaju se ponovno.
- Prolazni neuspjesi povezivanja, ograničenja stope i poslužitelja upotrebljavaju ograničeni eksponencijalni povratni odmak.
- Raspoređivač ima zajedničko zaključavanje, cron je aktivan, a queue worker je nadziran.
- Zapisnici identificiraju ishode bez izlaganja vjerodajnica ili potpunih sigurnosnih izvješća.
Tjedna sigurnosna ocjena nije zamjena za krpanje, pregled koda, upravljanje ovisnostima, odgovor na incidente ili profesionalno sigurnosno testiranje. Njezina je vrijednost uža i izvanredno praktična: pretvara tihi pomak sigurnosne pozicije u vidljiv događaj. Uz obrambeno mapiranu API granicu, trajnu referentnu vrijednost i put e-pošte koji se može ponovno pokušati, malo poduzeće dobiva sustav ranog upozorenja koji je dovoljno skroman za održavanje i dovoljno pouzdan da bude važan.