Laravel Security Watch: Неделни ревизии на веб-страници и известувања до сопствениците со ВИ
Веб-страницата може да остане онлајн додека нејзината безбедносна поставеност тивко се влошува. Промената на сертификатот, ажурирањето на проксито, отстранетото заглавие или брзото распоредување можеби нема да ја прекинат страницата, но можат да ја намалат заштитата што ја добиваат посетителите.
Ова упатство создава Laravel безбедносен надзор за таа потивка класа на неуспеси. Еднаш неделно, Artisan командата испраќа мала деловна веб-страница до Website Security Analyzer, го потврдува и зачувува резултатот, го споредува неговиот резултат со претходната успешна проверка и става е-пошта во редица кога резултатот ќе се намали.
Анализаторот врши ограничена, неинвазивна анализа на јавниот HTTPS и безбедносната поставеност на прелистувачот. Неговиот излез е корисен оперативен доказ, но не е пенетрационен тест и никогаш не треба да се претставува како таков.
Предуслови и дизајн
Ви треба PHP 8.3 или понов, Laravel апликација, поддржана база на податоци, работна SMTP конфигурација и queue worker. Веб-страницата мора да биде јавно достапна преку HTTPS.
Дизајнот намерно останува мал:
- Artisan команда е сопственик на неделниот работен тек.
- Посветен клиент го изолира договорот со надворешното API.
- DTO ги отфрла неисправните одговори на границата.
- Еден ред во базата на податоци ја содржи последната успешна основна состојба.
- Задача во редица ја испраќа е-поштата, овозможувајќи повторни обиди за испорака без повторување на анализата.
Првото успешно извршување воспоставува основна состојба и не испраќа предупредување. Подоцнежните извршувања предупредуваат само кога новиот нумерички резултат е понизок. Наодите групирани по сериозност, TLS деталите и препораките се задржуваат со резултатот за дијагноза.
Добијте пристап до анализаторот
- Регистрирајте се на 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-то и вообичаените логови за пристап.
Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како координирано распоредување: ажурирајте ја тајната на апликацијата, повторно изградете ја кешираната конфигурација, потврдете едно барање, а потоа потврдете дека закажаните извршувања се во добра состојба.
Потврдете ја точната крајна точка
API повикот е 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 "Accept: application/json" \
--header "Content-Type: application/json" \
--data '{"url":"https://www.example-business.test"}'
Заменете го примерниот домен со вистинската јавна HTTPS страница. Не го ставајте токенот во изворната контрола, shell скрипти, слики од екранот, логови или тест фикстури.
Чувајте го во распоредената околина. Додајте ги следните местодржачи во локалната .env датотека и обезбедете еквивалентни тајни преку вашата продукциска платформа:
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"
Додајте го овој запис во низата што ја враќа 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'),
],
Кодот на апликацијата треба да чита config(), а никогаш директно да не повикува env(). Таа разлика е важна откако Laravel конфигурацијата ќе се кешира.
Создајте складиште на состојба
Датотеките специфични за проектот ќе бидат компактни:
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
Создајте ги миграцијата и моделот со Artisan, а потоа дефинирајте ја табелата:
<?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',
];
}
}
Заштитете ја границата на апликацијата
Надворешниот JSON е недоверлив влез, дури и кога доаѓа од услуга со која намерно работите. DTO подолу ги прифаќа резултатот од договорот, наодите групирани по сериозност, TLS деталите и препораките, додека одбива податоци што недостигаат или се структурно невалидни.
<?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,
];
}
}
Чувајте го преводот на договорот во оваа една класа. Ако официјалната документација ја промени обвивката на одговорот, остатокот од апликацијата не треба да мора да го разбира тој транспортен детал.
Изградете ограничен HTTP клиент
Клиентот го користи вградениот HTTP клиент на Laravel, со петсекундно временско ограничување за поврзување и дваесетсекундно вкупно временско ограничување за одговор. Повторно се обидува при неуспеси на поврзувањето, HTTP 408, HTTP 429 и неуспеси на серверот. Автентикацијата, дозволите, валидацијата и другите клиентски грешки не се повторуваат слепо.
<?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)));
}
}
Ограниченото чекање за Retry-After спречува неделната команда да зафаќа worker на неодредено време. Постојаниот 429 останува видлив неуспех и треба да поттикне преглед на планот или закажувањето. Телата на одговорите и токените намерно отсуствуваат од исклучоците и логовите.
Споредете ги резултатите и ставете го предупредувањето во редица
Командата потврдува дека конфигурираната цел е HTTPS, го извршува далечинското барање пред да отвори трансакција со базата на податоци и го заклучува редот со основната состојба само при споредбата и зачувувањето.
<?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;
}
}
}
Користете задача во редица затоа што SMTP е посебен домен на неуспех. Зачуваната основна состојба не треба да се враќа назад само затоа што серверот за пошта е привремено недостапен.
<?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.');
}
}
Испораката преку редица вообичаено е најмалку еднаш, така што необичен неуспех на worker по SMTP прифаќањето може да произведе дупликат е-пошта. Тоа е подобро од тивко губење значајно предупредување; додајте идентификатор на предупредување и идемпотентност на ниво на давател само ако потиснувањето дупликати стане важно.
Закажете го неделното извршување
Додајте го распоредот во routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('security:audit')
->weekly()
->withoutOverlapping(30)
->onOneServer();
withoutOverlapping() штити од бавно претходно извршување. onOneServer() спречува дуплирано извршување низ повеќе јазли на апликацијата и бара споделено, поддржано кеш-складиште. Извршете php artisan security:audit рачно за да ја воспоставите првата основна состојба.
Тестирајте ги патеките на успех и неуспех
Http::fake() на Laravel ги прави овие тестови детерминистички: не се вклучени вистински токен, мрежно барање или е-пошта на сопственикот.
<?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();
}
}
Додајте unit тест што враќа HTTP 401 и повикајте го клиентот директно. Потврдете дека е испратено точно едно барање и дека е фрлен AnalyzerException. Тоа ја штити важната политика дека невалидните акредитиви никогаш не се повторуваат. Посебна лажна секвенца од HTTP 500, проследен со успех, може да ја потврди патеката за повторен обид при привремен неуспех.
Продукциска безбедност и распоредување
- Чувајте го URL-то на целта контролирано од околината. Не изложувајте крајна точка што им дозволува на произволни корисници да испраќаат URL-адреси.
- Дајте им на идентитетите за распоредување и извршување пристап само до сервисниот токен што им е потребен.
- Не логирајте заглавија за авторизација, целосни тела на одговори, SMTP акредитиви или URL-адреси што содржат токен.
- Ограничете го пристапот до зачуваните извештаи бидејќи наодите може да откриваат одбранбени слабости.
- Користете споделен кеш за заклучувања на распоредувачот и траен queue driver во распоредувања со повеќе јазли.
- Следете ги неуспесите на командите, застарените вредности на
checked_at, неуспешните задачи во редица и повторените HTTP 429 одговори.
Распоредете ги кодот и тајните од околината, а потоа извршете:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan security:audit
php artisan schedule:list
Ако на апликацијата ѝ недостига миграција за табела со задачи, генерирајте ја миграцијата за queue табела на framework-от пред мигрирањето. Одржувајте надгледуван процес php artisan queue:work --tries=5 --timeout=60 активен. Конфигурирајте cron да извршува php artisan schedule:run секоја минута; Laravel одлучува кога неделната задача треба да се изврши.
Вообичаени неуспеси
- HTTP 401 или 403: потврдете ја активацијата на планот и токенот ограничен на услугата. Повторно генериран токен веднаш го поништува претходно активниот токен.
- HTTP 422: потврдете дека JSON содржи
urlи дека конфигурираната страница е јавна HTTPS URL-адреса. Поправете го барањето наместо да го повторувате. - HTTP 429: прегледајте ја зачестеноста на закажувањето и ограничувањата на планот. Кратките повторни обиди може да апсорбираат привремено ограничување, но постојаното исцрпување на квотата бара оперативна промена.
- Нема неделно извршување: проверете ги cron,
schedule:list, очекувањата за временската зона, поврзаноста со споделениот кеш и застарените заклучувања за преклопување. - Нема е-пошта: проверете ги неуспешните задачи и логовите на worker-от, потоа тестирајте SMTP независно. Осигурете се дека queue worker навистина ја обработува конфигурираната врска.
- Неисправен одговор: споредете ја официјалната документација со
SecurityReport::fromArray(). Ажурирајте го само граничниот мапер ако документираниот облик на одговорот се промени.
Конечна листа за проверка
- Токенот постои само во конфигурација поддржана од околината.
- Минималното POST барање успева на точната крајна точка на анализаторот.
- Првото Artisan извршување зачувува основна состојба без да му испрати е-пошта на сопственикот.
- Понизок лажен резултат става во редица едно предупредување што ги содржи претходниот и тековниот резултат.
- Неуспесите при автентикација и валидација не се повторуваат.
- Привремените неуспеси на поврзувањето, ограничувањето на стапката и серверот користат ограничено повлекување.
- Распоредувачот има споделено заклучување, cron е активен и queue worker-от е надгледуван.
- Логовите ги идентификуваат исходите без да изложуваат акредитиви или целосни безбедносни извештаи.
Неделниот безбедносен резултат не е замена за закрпување, преглед на код, управување со зависности, одговор на инциденти или професионално безбедносно тестирање. Неговата вредност е потесна и прекрасно практична: тивкото поместување на безбедносната поставеност го претвора во видлив настан. Со одбранбено мапирана API граница, трајна основна состојба и патека за е-пошта со повторни обиди, мал бизнис добива систем за рано предупредување кој е доволно скромен за одржување и доволно сигурен за да биде важен.