Laravel: Automatizirajte tjedna sigurnosna skeniranja web-mjesta i obavijestite vlasnike
Web-stranica malog poduzeća može neprimjetno prijeći u slabije sigurnosno stanje, a da nitko ne uvede očito opasnu promjenu. Promijeni se konfiguracija certifikata, proxy prestane slati važno zaglavlje pregledniku ili migracija hostinga tiho izmijeni ponašanje HTTPS-a. Koristan odgovor nije još jedna nadzorna ploča koju se netko mora sjetiti otvoriti. To je tjedna provjera s trajnom osnovnom vrijednošću i e-pošta samo kada rezultat padne.
Ovaj vodič izrađuje taj tijek rada u Laravelu na PHP-u 8.3. Planirana Artisan naredba poziva Website Security Analyzer, provjerava njegov odgovor na granici aplikacije, uspoređuje rezultat s prethodnim uspješnim rezultatom i šalje e-poštu vlasniku web-mjesta kada je novi rezultat niži.
Analizator provodi ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosnog stanja preglednika. Njegov je izlaz koristan za praćenje i određivanje prioriteta, ali ne smije se opisivati kao penetracijski test niti tretirati kao njegova zamjena.
Dobijte pristup prije pisanja integracijskog koda
- Izradite račun na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.
- Otvorite stranicu usluge Website Security Analyzer.
- Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.
Ova usluga nije bez tokena: svaki zahtjev zahtijeva autentikaciju pomoću Bearer tokena, zaglavlja X-API-Token ili parametra upita token. Upotrijebit ćemo standardni oblik Bearer. Ponovno generiranje tokena usluge opoziva prethodno aktivni token, stoga rotaciju uskladite s implementacijom umjesto da ga nepromišljeno ponovno generirate.
Potvrdite točan API poziv
Integracija šalje POST zahtjeve na https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Tijelo JSON-a sadrži jednu obaveznu vrijednost, url.
curl --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"}'
Upotrijebite javni HTTPS URL koji posjedujete ili za čije ste praćenje ovlašteni. Nikada ne stavljajte stvarni token u povijest ljuske dijeljenu s drugim korisnicima, dokumentaciju, snimke zaslona, testne podatke ili kontrolu izvornog koda.
Smjestite vjerodajnice u konfiguraciju podržanu okruženjem
Dodajte vrijednosti specifične za implementaciju u .env. U implementiranoj aplikaciji upotrijebite stvarnu javnu domenu.
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_MONITOR_URL=https://www.example-business.test
[email protected]
Dodajte API postavke u config/services.php:
'security_analyzer' => [
'endpoint' => env(
'SECURITY_ANALYZER_ENDPOINT',
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
),
'token' => env('SECURITY_ANALYZER_TOKEN'),
],
Izradite config/security-monitor.php za nadzirano web-mjesto:
<?php
return [
'url' => env('SECURITY_MONITOR_URL'),
'owner_email' => env('SECURITY_MONITOR_OWNER'),
];
Konfiguracijske datoteke mogu se predati u kontrolu verzija jer sadrže samo dohvaćanja iz okruženja. Popunjena datoteka .env mora ostati izvan kontrole verzija.
Odaberite namjerno malu arhitekturu
Aplikacija treba četiri komponente: API klijent, objekt odgovora domene, zapis baze podataka koji sadrži posljednji uspješni rezultat i planiranu naredbu. Naredba šalje poštu sinkrono jer ovaj projekt skenira jedno web-mjesto jednom tjedno. Uvođenje reda čekanja dodalo bi radnike, umnožavanje ponovnih pokušaja i više stanja implementacije bez poboljšanja ovog skromnog opterećenja.
Relevantna struktura projekta je:
app/Services/SecurityAnalyzer.phpapp/Data/SecurityReport.phpapp/Exceptions/AnalyzerException.phpapp/Models/WebsiteMonitor.phpapp/Console/Commands/ScanWebsiteSecurity.phpapp/Mail/SecurityScoreDropped.phpresources/views/mail/security-score-dropped.blade.phproutes/console.php
Generirajte dijelove u vlasništvu okvira, a zatim ih uredite kako je prikazano u nastavku.
php artisan make:model WebsiteMonitor -m
php artisan make:command ScanWebsiteSecurity
php artisan make:mail SecurityScoreDropped --markdown=mail.security-score-dropped
Trajno pohranite posljednji uspješni rezultat
Osnovna vrijednost pripada bazi podataka, a ne varijabli lokalnoj za proces ili kratkotrajnoj predmemoriji. Upotrijebite stabilan ključ kako promjena URL-a slučajno ne bi stvorila drugi nadzor.
<?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_monitors', function (Blueprint $table): void {
$table->id();
$table->string('key', 100)->unique();
$table->text('url');
$table->string('owner_email');
$table->decimal('last_score', 8, 2)->nullable();
$table->timestamp('last_checked_at')->nullable();
$table->string('last_status', 30)->nullable();
$table->string('last_error', 100)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('website_monitors');
}
};
Dopustite ta polja u WebsiteMonitor i pretvorite pohranjene vrijednosti:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class WebsiteMonitor extends Model
{
protected $fillable = [
'key', 'url', 'owner_email', 'last_score',
'last_checked_at', 'last_status', 'last_error',
];
protected function casts(): array
{
return [
'last_score' => 'float',
'last_checked_at' => 'immutable_datetime',
];
}
}
Provjerite odgovor analizatora na granici
Udaljeni JSON je nepouzdan ulaz čak i kada dolazi od usluge koju ste odabrali. Ugovor pruža rezultat, nalaze grupirane po ozbiljnosti, TLS pojedinosti i preporuke. Jednom mapirajte te vrijednosti i odbacite neispravne odgovore prije nego što dođu do poslovne logike.
<?php
namespace App\Data;
use UnexpectedValueException;
final readonly class SecurityReport
{
public function __construct(
public float $score,
public array $findingsBySeverity,
public array $tls,
public array $recommendations,
) {}
public static function fromPayload(array $payload): self
{
foreach (['score', 'findings', 'tls', 'recommendations'] as $field) {
if (! array_key_exists($field, $payload)) {
throw new UnexpectedValueException("Missing response field: {$field}");
}
}
if (! is_int($payload['score']) && ! is_float($payload['score'])) {
throw new UnexpectedValueException('The score must be numeric.');
}
if (! is_array($payload['findings'])
|| ! is_array($payload['tls'])
|| ! is_array($payload['recommendations'])) {
throw new UnexpectedValueException('Invalid analyzer response shape.');
}
foreach ($payload['findings'] as $severity => $items) {
if (! is_string($severity) || ! is_array($items)) {
throw new UnexpectedValueException(
'Findings must be grouped by severity.'
);
}
}
return new self(
score: (float) $payload['score'],
findingsBySeverity: $payload['findings'],
tls: $payload['tls'],
recommendations: $payload['recommendations'],
);
}
}
Izgradite ograničen HTTP klijent svjestan ponovnih pokušaja
Klijent koristi Laravelov ugrađeni HTTP facade s odvojenim vremenskim ograničenjima za povezivanje i ukupan odgovor. Ponovno pokušava samo kod neuspjelih povezivanja, ograničenja brzine i pogrešaka poslužitelja. Neuspjesi autentikacije i provjere su deterministički i ne bi smjeli trošiti dodatnu kvotu slijepim ponovnim pokušajima.
<?php
namespace App\Services;
use App\Data\SecurityReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
use UnexpectedValueException;
final class SecurityAnalyzer
{
public function analyze(string $url): SecurityReport
{
$token = config('services.security_analyzer.token');
$endpoint = config('services.security_analyzer.endpoint');
if (! is_string($token) || $token === '') {
throw new RuntimeException('Security analyzer token is not configured.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->withToken($token)
->connectTimeout(5)
->timeout(20)
->post($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new RuntimeException(
'Security analyzer connection failed.',
previous: $exception
);
}
usleep(250000 * $attempt);
continue;
}
if ($response->status() === 429 || $response->serverError()) {
if ($attempt === 3) {
throw new RuntimeException(
$response->status() === 429
? 'Security analyzer quota or rate limit reached.'
: 'Security analyzer is temporarily unavailable.'
);
}
$retryAfter = $response->header('Retry-After');
$milliseconds = is_numeric($retryAfter)
? min(5000, max(0, (int) $retryAfter * 1000))
: 250 * $attempt;
Log::warning('Security analyzer request will be retried.', [
'attempt' => $attempt,
'status' => $response->status(),
]);
usleep($milliseconds * 1000);
continue;
}
if (in_array($response->status(), [401, 403], true)) {
throw new RuntimeException(
'Security analyzer authentication was rejected.'
);
}
if ($response->status() === 422) {
throw new RuntimeException(
'Security analyzer rejected the website URL.'
);
}
if ($response->failed()) {
throw new RuntimeException(
"Security analyzer returned HTTP {$response->status()}."
);
}
$payload = $response->json();
if (! is_array($payload)) {
throw new RuntimeException('Security analyzer returned invalid JSON.');
}
try {
return SecurityReport::fromPayload($payload);
} catch (UnexpectedValueException $exception) {
throw new RuntimeException(
'Security analyzer returned an unexpected response.',
previous: $exception
);
}
}
throw new RuntimeException('Security analyzer request did not complete.');
}
}
Klijent namjerno ne uključuje tijela odgovora, tokene ni autorizacijska zaglavlja u zapisnike. Njegova odgoda ponovnog pokušaja poštuje brojčanu vrijednost Retry-After, a istodobno ograničava stanku na pet sekundi, čime sprječava neograničeno spavanje jedne planirane invokacije.
Usporedite rezultate i upozorite vlasnika
Prvo uspješno skeniranje uspostavlja osnovnu vrijednost bez slanja uznemirujuće e-pošte o „padu”. Kasnija skeniranja šalju poštu samo kada je novi rezultat strogo niži. Osnovna vrijednost sprema se nakon uspješne dostave e-pošte, tako da neuspjeh prijenosa pošte ne potroši upozorenje neprimjetno.
<?php
namespace App\Console\Commands;
use App\Mail\SecurityScoreDropped;
use App\Models\WebsiteMonitor;
use App\Services\SecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
final class ScanWebsiteSecurity extends Command
{
protected $signature = 'security:scan-weekly';
protected $description = 'Analyze the configured website and alert on a score drop';
public function handle(SecurityAnalyzer $analyzer): int
{
$url = config('security-monitor.url');
$owner = config('security-monitor.owner_email');
if (! is_string($url) || ! filter_var($url, FILTER_VALIDATE_URL)
|| ! str_starts_with(strtolower($url), 'https://')
|| ! is_string($owner) || ! filter_var($owner, FILTER_VALIDATE_EMAIL)) {
$this->error('Monitor URL or owner email is invalid.');
return self::FAILURE;
}
$monitor = WebsiteMonitor::updateOrCreate(
['key' => 'primary'],
['url' => $url, 'owner_email' => $owner]
);
try {
$report = $analyzer->analyze($url);
$previous = $monitor->last_score;
if ($previous !== null && $report->score < $previous) {
$findingCount = array_sum(
array_map('count', $report->findingsBySeverity)
);
Mail::to($owner)->send(new SecurityScoreDropped(
siteUrl: $url,
previousScore: $previous,
currentScore: $report->score,
findingCount: $findingCount,
));
}
$monitor->update([
'last_score' => $report->score,
'last_checked_at' => now(),
'last_status' => 'ok',
'last_error' => null,
]);
$this->info("Security score recorded: {$report->score}");
return self::SUCCESS;
} catch (Throwable $exception) {
$monitor->update([
'last_status' => 'failed',
'last_error' => 'scan_or_delivery_failed',
]);
Log::error('Weekly website security check failed.', [
'monitor_id' => $monitor->id,
'exception_type' => $exception::class,
]);
$this->error('Security check failed; inspect application logs.');
return self::FAILURE;
}
}
}
Mailable zadržava fokus e-pošte na odluci koju vlasnik mora donijeti. Detaljni nalazi, TLS podaci i preporuke ostaju dostupni kodu aplikacije bez pretvaranja uobičajene e-pošte u spremište sigurnosnih informacija.
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
final class SecurityScoreDropped extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $siteUrl,
public float $previousScore,
public float $currentScore,
public int $findingCount,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Website security score dropped');
}
public function content(): Content
{
return new Content(markdown: 'mail.security-score-dropped');
}
public function attachments(): array
{
return [];
}
}
Zamijenite generirani Markdown prikaz e-pošte sljedećim:
@component('mail::message')
# Website security score dropped
The weekly check for {{ $siteUrl }} reported a lower score.
Previous score: {{ $previousScore }}
Current score: {{ $currentScore }}
Reported findings: {{ $findingCount }}
Review the analyzer results and verify important changes before altering production.
Thanks,
{{ config('app.name') }}
@endcomponent
Zakažite tjedno pokretanje
U routes/console.php zakažite ponedjeljak ujutro u vremenskoj zoni aplikacije i spriječite preklapanje izvršavanja:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('security:scan-weekly')
->weeklyOn(1, '08:00')
->timezone(config('app.timezone'))
->withoutOverlapping(120);
Ako se raspoređivač aplikacije izvodi na više poslužitelja, dodajte onOneServer() i konfigurirajte zajednički upravljački program predmemorije koji podržava atomska zaključavanja. Na jednom poslužitelju dovoljan je uobičajeni Laravelov unos raspoređivača u cronu:
* * * * * cd /var/www/example-app && php artisan schedule:run >> /dev/null 2>&1
Testirajte ponašanje bez pozivanja stvarne usluge
Http::fake() čini test determinističkim, dok Mail::fake() dokazuje da pad rezultata proizvodi namjeravanu obavijest.
<?php
namespace Tests\Feature;
use App\Mail\SecurityScoreDropped;
use App\Models\WebsiteMonitor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
final class WeeklySecurityScanTest extends TestCase
{
use RefreshDatabase;
public function test_it_emails_the_owner_when_the_score_drops(): void
{
config([
'security-monitor.url' => 'https://shop.example.test',
'security-monitor.owner_email' => '[email protected]',
'services.security_analyzer.token' => 'test-token',
]);
WebsiteMonitor::create([
'key' => 'primary',
'url' => 'https://shop.example.test',
'owner_email' => '[email protected]',
'last_score' => 92,
]);
Http::fake([
'https://ai.mihajlo.mk/*' => Http::response([
'score' => 81,
'findings' => [
'high' => [['message' => 'Example finding']],
'medium' => [],
'low' => [],
],
'tls' => ['enabled' => true],
'recommendations' => ['Review the reported finding.'],
], 200),
]);
Mail::fake();
$this->artisan('security:scan-weekly')->assertSuccessful();
$this->assertDatabaseHas('website_monitors', [
'key' => 'primary',
'last_score' => 81,
'last_status' => 'ok',
]);
Mail::assertSent(
SecurityScoreDropped::class,
fn (SecurityScoreDropped $mail): bool =>
$mail->hasTo('[email protected]')
&& $mail->previousScore === 92.0
&& $mail->currentScore === 81.0
);
Http::assertSent(fn ($request): bool =>
$request->method() === 'POST'
&& $request['url'] === 'https://shop.example.test'
&& $request->hasHeader('Authorization', 'Bearer test-token')
);
}
}
Dodajte popratne testove za osnovnu vrijednost pri prvom pokretanju, nepromijenjen rezultat, neispravan JSON, HTTP 401, HTTP 422, ograničavanje brzine i iscrpljene ponovne pokušaje nakon pogrešaka poslužitelja. Testovi autentikacije i provjere trebaju potvrditi da je napravljen točno jedan zahtjev.
Sigurnost i operacije u produkciji
Tijekom implementacije pokrenite php artisan migrate --force, konfigurirajte stvarni prijenos pošte, namjerno postavite APP_TIMEZONE i pokrenite php artisan config:cache tek nakon što su vrijednosti okruženja prisutne. Nakon rotacije tokena ažurirajte implementiranu tajnu i ponovno izgradite predmemoriju konfiguracije jer prethodni token prestaje raditi.
Pratite i raspoređivač i naredbu. Zdrav zapisnik aplikacije trebao bi prepoznati uspjeh putem pohranjene vremenske oznake, a neuspjehe putem strukturiranih poruka, bez bilježenja tijela odgovora ili vjerodajnica. Operativno upozorite kada last_checked_at postane stariji od očekivanog tjednog intervala; inače pokvareni cron proces može izgledati kao tjedan bez događaja.
Uobičajeni obrasci neuspjeha jednostavni su: 401 ili 403 obično znači da token nedostaje, opozvan je ili pripada pogrešnoj usluzi; 422 upućuje na poslani URL; 429 zahtijeva pregled plana ili rasporeda; ponovljeni odgovori 5xx upućuju na privremeni neuspjeh uzvodne usluge; a uspješna skeniranja bez e-pošte obično znače da rezultat nije pao ili da je prvo pokretanje samo uspostavilo osnovnu vrijednost.
Završni kontrolni popis za provjeru
- Nadzirana adresa javni je HTTPS URL koji ste ovlašteni provjeravati.
- Token ograničen na uslugu postoji samo u pohrani tajni podržanoj okruženjem.
- Migracija je izvršena, a konfigurirani prijenos pošte može isporučivati vanjskim primateljima.
php artisan security:scan-weeklyuspješno se dovršava pri ručnom pokretanju.- Lažni niži rezultat šalje jednu e-poštu i bilježi novu osnovnu vrijednost.
- Neuspjesi autentikacije i provjere ne pokušavaju se ponovno.
- Ograničenja brzine, neuspjesi povezivanja i neuspjesi poslužitelja zaustavljaju se nakon ograničenih ponovnih pokušaja.
- Sistemski cron pokreće Laravelov raspoređivač svake minute.
- Zapisnici i nadzor otkrivaju propuštena skeniranja bez izlaganja vjerodajnica.
Najvrjedniji dio ove integracije nije tjedni API poziv. To je disciplina oko njega: provjerena granica, trajna osnovna vrijednost, suzdržani ponovni pokušaji i obavijest vezana uz značajnu promjenu. To pretvara sigurnosni rezultat iz još jednog broja na još jednoj nadzornoj ploči u tihi operativni signal na koji vlasnik poduzeća zaista može djelovati.