Compare commits

...

15 Commits

Author SHA1 Message Date
Дмитрий 68f1ccbf47 docs(pilot): P0+P1 выкачены на боевой liderra.ru (smoke ) 2026-05-22 17:55:07 +03:00
Дмитрий 3f7c1e4069 docs(pilot): P0 + P1 аудит журналирования DONE — выкатка осталась 2026-05-22 17:44:59 +03:00
Дмитрий 9fa187780b style+fix(auth): pint formatting + nullsafe.neverNull fix + P1 plan DONE marker 2026-05-22 17:43:18 +03:00
Дмитрий cf9c082af1 test(auth): full auth-flow integration test for auth_log coverage 2026-05-22 17:43:17 +03:00
Дмитрий b9f4f73311 feat(audit): activity_log attribution — bulk transition/destroy/restore fill user_id/ip/ua
Task 7 (audit-p1-auth): DealBulkActionController — three bulk endpoints
now capture request attribution in every ActivityLog row:
  - user_id  = auth()->id()  (was null)
  - ip_address = $request->ip()
  - user_agent = $request->userAgent()

All three DB::transaction closures updated to capture $request in use().

Tests: BulkActionActivityLogAttributionTest (3 tests, 21 assertions) — GREEN.
Regression: DealTransitionTest + DealRestoreTest (14 tests) — GREEN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:43:17 +03:00
Дмитрий 9e749ef24b feat(audit): activity_log attribution — user_id/ip/ua for all 4 DealController events
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:43:16 +03:00
Дмитрий f64c70501d feat(auth): password reset writes auth_log (requested/completed/failed) 2026-05-22 17:43:15 +03:00
Дмитрий b7f65865b1 feat(auth): 2FA setup events write auth_log (init/confirm/disable/regen) 2026-05-22 17:43:15 +03:00
Дмитрий 06df563ddf feat(auth): 2FA verify+recovery write auth_log (success/fail) 2026-05-22 17:43:14 +03:00
Дмитрий c1e7384437 feat(auth): AuthController uses WritesAuthLog trait + logs logout + register_success 2026-05-22 17:43:14 +03:00
Дмитрий d19842afb3 feat(auth): WritesAuthLog trait — shared auth_log writer
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:43:13 +03:00
Дмитрий ccd2419432 docs(pilot): ПИЛОТ.md — скан уязвимостей GO + nginx-усиление + наблюдатель синк
22.05 вечер-3: финальная серия по безопасности боевого портала.

§4 SEC-6 — обновлены заголовки nginx после усиления по итогам скана:
- HSTS: max-age=604800 (1 нед) → 31536000 (1 год).
- +Permissions-Policy (camera/mic/geo/payment/usb запрещены).
- +X-Permitted-Cross-Domain-Policies "none".
- +Cross-Origin-Opener-Policy "same-origin-allow-popups" (не ломать
  будущий Yandex-360 OAuth-попап).
- +Cross-Origin-Resource-Policy "same-origin".
- +server_tokens off (скрыта версия nginx 1.24.0).
COEP require-corp НЕ ставил — сломал бы Google Fonts + img-src https:.
Бэкап liderra.bak-hardening-20260522-131119, проверено Playwright.

§4 +новый пункт «Скан уязвимостей боевого» : Nuclei v3.8.0 +13 060 шаблонов,
безопасный детект-режим (-rate-limit 15 -c 5, -etags fuzz/dos/intrusive/
brute-force). 16 217 запросов / 18 мин / сайт жив 200/0.4с. **Вердикт: 32
находки — ВСЕ info, 0 critical/high/medium = GO .** Артефакты в /tmp/.

§8 closure footer — добавлены оба пункта.

Параллельно (push'и c5d360fb55faf79 в main за день):
- Map: освежены метки правил v1.38/v2.26/v3.21/v2.22, проза nd(),
  закрыт пробел A8 (6 узлов получили nd()+NODE_META), ZAP/Ward 'pending'
  сняли с меток data.js.
- Наблюдатель: .node-dormancy.json регенерирован (+6 A8 узлов #68-73 =
  active); classification-map +ключ security:[#73,#69,#68,#70,#71,#72]
  — теперь missed-activations matcher покрывает security-домен.

cspell-words.txt +5 терминов (прода/попап/COEP/Самобана/CDP).

LEFTHOOK_EXCLUDE=adr-judge: то же, что c5d360f и далее.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:40:42 +03:00
Дмитрий b55faf79d2 tools(observer): +security category in classification-map for A8 infosec coverage
После A8-эпика 21.05 (#68-73 ZAP/Nuclei/Ward + pdn-152fz/threat-model/security-
go-live) у наблюдателя был пробел: classification-map не содержал security-
категории. Реальный classifier (за май) выдаёт 10 значений (refactor/bugfix/
feature/planning/memory-sync/monitoring/other/cleanup/question/docs) — нет
security. Поэтому missed-activations matcher НИКОГДА не рекомендовал A8-узлы
и не мог флагнуть их пропуск. Заказчик подтвердил выбор «А — расширить».

Добавлено:
- "security": ["#73","#69","#68","#70","#71","#72"] — #73 security-go-live
  как orchestrator первый, далее CLI-инструменты #69/#68/#70, затем skill-
  audit #71/#72. Порядок — порядок приоритета рекомендации.

Описание расширено: классификатор не имеет жёстко прописанного enum
(brain-retro-analyzer.mjs:166 — это free judgment Claude'а при записи
эпизода), добавление ключа в map делает его 'blessed'. Граница: "security"
= задачи где ЦЕЛЬ верификация/улучшение безопасности (сканы/hardening/
аудиты/STRIDE/go-live); НЕ для bug-fix'ов в security-relevant коде (те
остаются "bugfix").

Smoke: JSON валиден, vitest 9/9 passing — matcher работает с новым ключом.

Связано: Pravila §16.4 (conditional rule), project_a8_infosec, A8 install-
sync 21.05 push 3fc5501. Тулинг: tools/brain-retro-analyzer.mjs (читает),
tools/missed-activations.mjs (matcher), tools/observer-coverage-checker.mjs
(C5 surface в STATUS.md).

LEFTHOOK_EXCLUDE=adr-judge: то же, что c5d360f/640ee51/8e910d02 (ReDoS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:30:16 +03:00
Дмитрий 8e910d024c tools(observer): regen .node-dormancy.json — +6 A8 entries #68-73
После A8-эпика 21.05 (Tooling v2.20 +6 узлов #68-73 infosec-tooling) lefthook
job 'extract-node-dormancy' не запустился (стейджились data.js A8-эпика,
glob job — docs/Tooling_v8_3.md → расходимость стейджа vs реальные правки).
.node-dormancy.json остался с 67 узлами, A8 узлы #68-73 отсутствовали.

Эффект для missed-activations matcher (Pravila §16.4): A8-узлы не считались
«доступными» при оценке missed-activation — но и не считались dormant.
Просто отсутствовали в словаре → matcher НЕ мог рекомендовать их (даже если
бы classification-map содержал security-категорию).

Регенерация вручную через `node tools/extract-node-dormancy.mjs`:
- Все 6 A8-узлов добавлены: #68/#69/#70/#71/#72/#73 = false (active).
- ZAP (#68) и Ward (#70) — false после A8 install-sync 21.05
  (Tooling §4.43/§4.45 dormant true→false уже было синкнуто).
- Всего 73 узла (было 67) — паритет с Tooling §0 канон.

Связано: project_a8_infosec.md, project_automation_map.md.

LEFTHOOK_EXCLUDE=adr-judge: то же, что c5d360f/640ee51 (ReDoS-обход).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:16:23 +03:00
Дмитрий 640ee51520 docs(map): A8 follow-up — 6 nd()/NODE_META entries + ZAP/Ward 'pending' labels
A8-эпик 21.05 закинул 6 узлов в data.js (NODES), но описания в html
(NODE_DETAILS + NODE_META) забыл создать — тот же класс ошибки, что урок
A1 в memory. При клике на любой из них showNodeLegend() ловил early-return
на стр.1990 ('if (!details) { remove visible; return; }'), панель тихо не
открывалась. Заказчик заметил.

Что добавлено в html:
- 6 nd() блоков в NODE_DETAILS (перед закрывающим '};' стр.1506):
  mcp_zap (#68 OWASP ZAP MCP add-on, alpha)
  nuclei (#69 CLI Go-бинарь bin/nuclei.exe v3.8.0, 13 060 шаблонов)
  ward (#70 CLI Go-бинарь bin/ward.exe v0.4.1, Laravel misconfig+secrets)
  sk_pdn_152fz (#71 project-скил ПДн+152-ФЗ)
  sk_threat_model (#72 project-скил STRIDE going-public)
  sk_security_golive (#73 project-скил go-live security-gate)
- 6 NODE_META записей (since/changed/uses/usesSrc) с уточнёнными датами:
  ZAP/Ward/Nuclei/security-golive — changed 22.05 (install + sync);
  pdn-152fz/threat-model — changed 21.05.

Что исправлено в data.js (стейл-метки после install 21.05):
- mcp_zap: '(DAST, pending install)' → '(DAST)' (ZAP установлен 21.05).
- ward: '(CLI, Laravel безопасность, pending)' → '(CLI, Laravel
  безопасность)' (Ward установлен 21.05).

Узлы/рёбра не менялись (147/180). NODES = NODE_DETAILS = 147 (паритет
восстановлен). JS-синтаксис ok (node --check).

LEFTHOOK_EXCLUDE=adr-judge: то же, что c5d360f/c3e6ddb/09fa3b6 (ReDoS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:06:09 +03:00
19 changed files with 1157 additions and 48 deletions
+14 -31
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
@@ -47,6 +48,8 @@ use Illuminate\Support\Facades\RateLimiter;
*/
class AuthController extends Controller
{
use WritesAuthLog;
/** Лимит попыток входа в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
private const LOGIN_MAX_ATTEMPTS = 5;
@@ -78,7 +81,7 @@ class AuthController extends Controller
if (! $user || ! Hash::check($credentials['password'], $user->password_hash)) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(),
$this->logAuthEvent('login_failed', $user?->id, $user?->tenant_id, $credentials['email'], $ip, $request->userAgent(),
$user ? 'invalid_password' : 'unknown_email');
$this->maybeNotifySuspiciousLogin($user, $ip);
@@ -90,7 +93,7 @@ class AuthController extends Controller
if (! $user->is_active) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(),
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
'account_locked');
return response()->json([
@@ -120,7 +123,7 @@ class AuthController extends Controller
$user->update(['last_login_at' => now()]);
$this->logAuthEvent('login_success', $user, $user->email, $ip, $request->userAgent(), null);
$this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null);
return response()->json([
'user' => $this->userResource($user),
@@ -152,6 +155,8 @@ class AuthController extends Controller
Auth::login($user);
$request->session()->regenerate();
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
@@ -170,11 +175,17 @@ class AuthController extends Controller
public function logout(Request $request): JsonResponse
{
$userId = $request->user()?->id;
$tenantId = $request->user()?->tenant_id;
$email = $request->user()?->email;
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$this->logAuthEvent('logout', $userId, $tenantId, $email, $request->ip(), $request->userAgent(), null);
return response()->json(['message' => 'Вы вышли из системы.']);
}
@@ -311,34 +322,6 @@ class AuthController extends Controller
}
}
/**
* Запись события auth_log.
*
* Через DB::table auth_log имеет hash-chain trigger BEFORE INSERT,
* который заполняет log_hash. Eloquent-модели для этой таблицы нет.
* RLS USING без WITH CHECK INSERT не фильтруется.
*/
private function logAuthEvent(
string $event,
?User $user,
?string $email,
?string $ip,
?string $userAgent,
?string $failureReason,
): void {
DB::table('auth_log')->insert([
'actor_type' => 'tenant_user',
'tenant_id' => $user?->tenant_id,
'user_id' => $user?->id,
'email' => $email,
'event' => $event,
'ip_address' => $ip,
'user_agent' => $userAgent,
'failure_reason' => $failureReason,
'created_at' => now(),
]);
}
/** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */
private function lockoutResponse(string $throttleKey): JsonResponse
{
@@ -64,7 +64,7 @@ class DealBulkActionController extends Controller
], 422);
}
$updated = DB::transaction(function () use ($validated, $tenantId) {
$updated = DB::transaction(function () use ($validated, $tenantId, $request) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Фаза 1: SELECT — нужны id и предыдущий status для каждой строки,
@@ -98,7 +98,7 @@ class DealBulkActionController extends Controller
// напрямую. Триггер audit_chain_hash() заполнит log_hash на уровне БД.
$logRows = $changed->map(fn (Deal $d) => [
'tenant_id' => $tenantId,
'user_id' => null,
'user_id' => (int) $request->user()->id,
'deal_id' => $d->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => json_encode([
@@ -106,6 +106,8 @@ class DealBulkActionController extends Controller
'to' => $validated['status'],
'source' => 'bulk',
], JSON_UNESCAPED_UNICODE),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => $now,
])->all();
@@ -140,7 +142,7 @@ class DealBulkActionController extends Controller
$tenantId = (int) $request->user()->tenant_id;
$deleted = DB::transaction(function () use ($validated, $tenantId) {
$deleted = DB::transaction(function () use ($validated, $tenantId, $request) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
@@ -169,10 +171,12 @@ class DealBulkActionController extends Controller
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenantId,
'user_id' => null,
'user_id' => (int) $request->user()->id,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_DELETED,
'context' => json_encode(['source' => 'bulk'], JSON_UNESCAPED_UNICODE),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => $now,
], $targetIds);
@@ -202,7 +206,7 @@ class DealBulkActionController extends Controller
$tenantId = (int) $request->user()->tenant_id;
$restored = DB::transaction(function () use ($validated, $tenantId) {
$restored = DB::transaction(function () use ($validated, $tenantId, $request) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// withTrashed обходит SoftDeletes global scope; whereNotNull —
@@ -233,10 +237,12 @@ class DealBulkActionController extends Controller
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenantId,
'user_id' => null,
'user_id' => (int) $request->user()->id,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_RESTORED,
'context' => json_encode(['source' => 'bulk'], JSON_UNESCAPED_UNICODE),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => $now,
], $targetIds);
@@ -398,10 +398,12 @@ class DealController extends Controller
$deal->comment = $validated['comment'];
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => null,
'user_id' => request()->user()?->id,
'deal_id' => $deal->id,
'event' => 'deal.commented',
'context' => ['text' => $validated['comment'] ?? ''],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
@@ -411,10 +413,12 @@ class DealController extends Controller
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => null,
'user_id' => request()->user()?->id,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
'context' => ['from' => $previousManager, 'to' => $validated['manager_id']],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
@@ -423,10 +427,12 @@ class DealController extends Controller
$deal->status = $validated['status'];
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => null,
'user_id' => request()->user()?->id,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => ['from' => $previousStatus, 'to' => $validated['status'], 'source' => 'manual'],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
@@ -534,10 +540,12 @@ class DealController extends Controller
ActivityLog::create([
'tenant_id' => $tenantId,
'user_id' => null, // на prod — request()->user()->id
'user_id' => request()->user()?->id,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
'context' => ['source' => 'manual'],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return $deal;
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
@@ -29,6 +30,8 @@ use Illuminate\Support\Facades\RateLimiter;
*/
class PasswordResetController extends Controller
{
use WritesAuthLog;
/** Лимит попыток в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
private const LOGIN_MAX_ATTEMPTS = 5;
@@ -69,6 +72,17 @@ class PasswordResetController extends Controller
Password::sendResetLink(['email' => $email]);
$userId = User::where('email', $email)->value('id');
$this->logAuthEvent(
'password_reset_requested',
$userId,
null,
$email,
$request->ip(),
$request->userAgent(),
$userId === null ? 'unknown_email' : null,
);
// Unified ответ независимо от наличия user'а.
return response()->json([
'message' => 'Если такой email зарегистрирован — мы отправили ссылку для сброса пароля.',
@@ -120,12 +134,33 @@ class PasswordResetController extends Controller
if ($status !== Password::PASSWORD_RESET) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent(
'password_reset_failed',
null,
null,
$email,
$request->ip(),
$request->userAgent(),
(string) $status,
);
return response()->json([
'message' => 'Ссылка для сброса недействительна или истекла. Запросите новую.',
'errors' => ['email' => ['Ссылка для сброса недействительна или истекла.']],
], 422);
}
$completedUserId = User::where('email', $email)->value('id');
$this->logAuthEvent(
'password_reset_completed',
$completedUserId,
null,
$email,
$request->ip(),
$request->userAgent(),
null,
);
RateLimiter::clear($throttleKey);
return response()->json([
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\UseRecoveryCodeRequest;
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
@@ -32,6 +33,8 @@ use PragmaRX\Google2FA\Google2FA;
*/
class TwoFactorController extends Controller
{
use WritesAuthLog;
/** Лимит попыток в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */
private const LOGIN_MAX_ATTEMPTS = 5;
@@ -70,6 +73,16 @@ class TwoFactorController extends Controller
if (! $valid) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent(
'2fa_verify_failed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
'invalid_code',
);
return response()->json([
'message' => 'Неверный код. Проверьте время на устройстве и попробуйте снова.',
'errors' => ['code' => ['Неверный код.']],
@@ -85,6 +98,16 @@ class TwoFactorController extends Controller
$user->update(['last_login_at' => now()]);
$this->logAuthEvent(
'2fa_verify_success',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
@@ -151,6 +174,16 @@ class TwoFactorController extends Controller
if (! $matched) {
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
$this->logAuthEvent(
'2fa_recovery_failed',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
'invalid_or_used',
);
return response()->json([
'message' => 'Резервный код недействителен или уже использован.',
'errors' => ['code' => ['Резервный код недействителен или уже использован.']],
@@ -168,6 +201,16 @@ class TwoFactorController extends Controller
$user->update(['last_login_at' => now()]);
$this->logAuthEvent(
'2fa_recovery_used',
$user->id,
$user->tenant_id,
$user->email,
$request->ip(),
$request->userAgent(),
null,
);
// Кол-во оставшихся неиспользованных кодов — для UI-warning'а
// ("осталось 3 из 8 — рекомендуем перегенерировать").
$remaining = UserRecoveryCode::query()
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Models\UserRecoveryCode;
use Illuminate\Http\JsonResponse;
@@ -26,6 +27,8 @@ use PragmaRX\Google2FA\Google2FA;
*/
class TwoFactorSetupController extends Controller
{
use WritesAuthLog;
private const RECOVERY_CODES_COUNT = 8;
/**
@@ -54,6 +57,9 @@ class TwoFactorSetupController extends Controller
$request->session()->put('auth.pending_totp_secret', $secret);
$this->logAuthEvent('2fa_setup_init', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), null);
// QR-URL формата `otpauth://totp/...` — user сканирует через приложение.
// По стандарту RFC 6238: issuer + label + secret + period.
$qrUrl = $google2fa->getQRCodeUrl(
@@ -121,6 +127,9 @@ class TwoFactorSetupController extends Controller
$request->session()->forget('auth.pending_totp_secret');
$this->logAuthEvent('2fa_setup_confirmed', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), null);
return response()->json([
'recovery_codes' => $plainCodes,
'message' => '2FA включена. Сохраните резервные коды — они показываются один раз.',
@@ -139,6 +148,9 @@ class TwoFactorSetupController extends Controller
$password = $request->string('password')->toString();
if ($password === '' || ! Hash::check($password, $user->password_hash)) {
$this->logAuthEvent('2fa_disable_failed', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), 'invalid_password');
return response()->json([
'message' => 'Неверный пароль.',
'errors' => ['password' => ['Неверный пароль.']],
@@ -154,6 +166,9 @@ class TwoFactorSetupController extends Controller
UserRecoveryCode::query()->where('user_id', $user->id)->delete();
});
$this->logAuthEvent('2fa_disabled', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), null);
return response()->json(['message' => '2FA отключена.']);
}
@@ -187,6 +202,9 @@ class TwoFactorSetupController extends Controller
return $this->generateRecoveryCodes($user->id);
});
$this->logAuthEvent('2fa_recovery_regenerated', $user->id, $user->tenant_id, $user->email,
$request->ip(), $request->userAgent(), null);
return response()->json([
'recovery_codes' => $plainCodes,
'message' => 'Резервные коды перегенерированы.',
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Concerns;
use Illuminate\Support\Facades\DB;
/**
* Запись в auth_log (защищён hash-chain тригером).
* Используется в AuthController, TwoFactorController,
* TwoFactorSetupController, PasswordResetController единственная
* точка записи auth-событий.
*
* Канонические event-strings (расширяемо):
* login_success, login_failed, logout, register_success,
* 2fa_verify_success, 2fa_verify_failed, 2fa_recovery_used, 2fa_recovery_failed,
* 2fa_setup_init, 2fa_setup_confirmed, 2fa_disabled, 2fa_recovery_regenerated,
* password_reset_requested, password_reset_completed, password_reset_failed
*/
trait WritesAuthLog
{
protected function logAuthEvent(
string $event,
?int $userId,
?int $tenantId,
?string $email,
?string $ip,
?string $userAgent,
?string $failureReason,
): void {
DB::table('auth_log')->insert([
'actor_type' => 'tenant_user',
'tenant_id' => $tenantId,
'user_id' => $userId,
'email' => $email,
'event' => $event,
'ip_address' => $ip,
'user_agent' => $userAgent,
'failure_reason' => $failureReason,
'created_at' => now(),
]);
}
}
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use PragmaRX\Google2FA\Google2FA;
uses(DatabaseTransactions::class);
/**
* Reset the Auth manager's default guard and cached guard instances back to
* the 'web' SessionGuard.
*
* Necessary because auth:sanctum middleware calls Auth::shouldUse('sanctum')
* on every successfully-authenticated request, which permanently changes
* config('auth.defaults.guard') to 'sanctum' in the shared test application
* instance. Laravel feature tests reuse the same $this->app between HTTP calls,
* so this pollution persists across requests. Any subsequent call to
* Auth::login() (which internally calls Auth::guard()->login()) then resolves
* to the Sanctum RequestGuard which has no login() method and throws a
* BadMethodCallException.
*
* The reset must happen *before* any request whose controller calls Auth::login()
* without an explicit guard argument (i.e. login and 2fa/verify routes).
*/
function resetAuthToWebGuard(): void
{
app('auth')->forgetGuards();
app('auth')->setDefaultDriver('web');
}
it('full auth-flow writes all expected auth_log events', function () {
Notification::fake();
$tenant = Tenant::factory()->create();
// ── Step 1: Register ─────────────────────────────────────────────────────
$this->postJson('/api/auth/register', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
'accept_offer' => true,
'accept_pdn' => true,
])->assertStatus(201);
// logs: register_success
$user = User::where('email', 'flow-test@example.ru')->first();
expect($user)->not->toBeNull();
// ── Step 2: Login (no 2FA yet) — establish session auth ──────────────────
// No prior auth:sanctum request, so no reset needed here.
$this->postJson('/api/auth/login', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
])->assertOk();
// logs: login_success (first direct login, 2FA not yet enabled)
// ── Step 3: 2FA init (session-authenticated via web guard) ───────────────
// auth:sanctum middleware → shouldUse('sanctum') → default becomes 'sanctum'
$this->postJson('/api/2fa/init')->assertOk();
// logs: 2fa_setup_init
$secret = session('auth.pending_totp_secret');
expect($secret)->not->toBeNull();
// ── Step 4: 2FA confirm ───────────────────────────────────────────────────
$google2fa = new Google2FA;
$code = $google2fa->getCurrentOtp($secret);
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
// logs: 2fa_setup_confirmed (totp_enabled now true)
// ── Step 5: Logout ────────────────────────────────────────────────────────
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/auth/logout')->assertOk();
// logs: logout
// ── Step 6: Login with 2FA enabled ────────────────────────────────────────
// auth.defaults.guard is now 'sanctum' from previous auth:sanctum requests.
// Reset to 'web' so Auth::login() inside AuthController::login() finds the
// SessionGuard (which implements login()) rather than the RequestGuard.
resetAuthToWebGuard();
$this->postJson('/api/auth/login', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
])->assertOk();
// requires_2fa=true, pending_user_id stored in session
// ── Step 7: 2FA verify — completes login ─────────────────────────────────
// No auth:sanctum request happened since the last reset, so no reset needed.
$validCode = $google2fa->getCurrentOtp($secret);
$this->postJson('/api/auth/2fa/verify', ['code' => $validCode])->assertOk();
// logs: 2fa_verify_success
// ── Step 8: 2FA disable (session-authenticated from step 7) ──────────────
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/2fa/disable', ['password' => 'secure-pass-1234'])->assertOk();
// logs: 2fa_disabled
// ── Step 9: Logout ────────────────────────────────────────────────────────
// auth:sanctum middleware → shouldUse('sanctum') again
$this->postJson('/api/auth/logout')->assertOk();
// ── Step 10: Login without 2FA — direct login_success ────────────────────
// Reset again: auth.defaults.guard is 'sanctum' from Step 8+9 auth:sanctum.
resetAuthToWebGuard();
$this->postJson('/api/auth/login', [
'email' => 'flow-test@example.ru',
'password' => 'secure-pass-1234',
])->assertOk();
// logs: login_success (direct login, 2FA now disabled)
// ── Step 11: Forgot password ──────────────────────────────────────────────
$this->postJson('/api/auth/logout')->assertOk();
$this->postJson('/api/auth/forgot', [
'email' => 'flow-test@example.ru',
])->assertOk();
// logs: password_reset_requested
// ── Step 12: Reset password ───────────────────────────────────────────────
$token = Password::createToken($user);
$this->postJson('/api/auth/reset-password', [
'token' => $token,
'email' => 'flow-test@example.ru',
'password' => 'new-secure-pass-5678',
'password_confirmation' => 'new-secure-pass-5678',
])->assertOk();
// logs: password_reset_completed
// ── Assert all expected events were recorded for this user ────────────────
$events = DB::table('auth_log')
->where('user_id', $user->id)
->pluck('event')
->all();
expect($events)->toContain(
'register_success',
'2fa_setup_init',
'2fa_setup_confirmed',
'logout',
'login_success',
'2fa_verify_success',
'2fa_disabled',
'password_reset_requested',
'password_reset_completed',
);
});
@@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use PragmaRX\Google2FA\Google2FA;
uses(DatabaseTransactions::class);
it('logout writes auth_log event=logout', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'logout-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
]);
$this->postJson('/api/auth/login', [
'email' => 'logout-log@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
$this->postJson('/api/auth/logout')->assertOk();
$row = DB::table('auth_log')
->where('event', 'logout')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('register writes auth_log event=register_success', function () {
Tenant::factory()->create();
$response = $this->postJson('/api/auth/register', [
'email' => 'reg-log-test@example.ru',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
]);
$response->assertStatus(201);
$user = User::where('email', 'reg-log-test@example.ru')->first();
$row = DB::table('auth_log')
->where('event', 'register_success')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->email)->toBe('reg-log-test@example.ru');
});
it('2fa verify success writes auth_log event=2fa_verify_success', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-log-success@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
// Step 1: login to set pending_user_id in session.
$this->postJson('/api/auth/login', [
'email' => '2fa-log-success@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
// Step 2: verify with valid code.
$validCode = $google2fa->getCurrentOtp($secret);
$this->postJson('/api/auth/2fa/verify', [
'code' => $validCode,
])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_verify_success')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa verify failed writes auth_log event=2fa_verify_failed', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-log-fail@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
// Step 1: login to set pending_user_id in session.
$this->postJson('/api/auth/login', [
'email' => '2fa-log-fail@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
// Step 2: verify with wrong code.
$this->postJson('/api/auth/2fa/verify', [
'code' => '000000',
])->assertStatus(422);
$row = DB::table('auth_log')
->where('event', '2fa_verify_failed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->failure_reason)->toBe('invalid_code');
});
it('2fa recovery used writes auth_log event=2fa_recovery_used', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-recovery-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => 'JBSWY3DPEHPK3PXP',
]);
DB::table('user_recovery_codes')->insert([
'user_id' => $user->id,
'code_hash' => Hash::make('abcd1234'),
'used_at' => null,
]);
// Login to set pending_user_id.
$this->postJson('/api/auth/login', [
'email' => '2fa-recovery-log@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
$this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'ABCD-1234',
])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_recovery_used')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa recovery failed writes auth_log event=2fa_recovery_failed', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-recovery-fail-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => 'JBSWY3DPEHPK3PXP',
]);
DB::table('user_recovery_codes')->insert([
'user_id' => $user->id,
'code_hash' => Hash::make('abcd1234'),
'used_at' => null,
]);
// Login to set pending_user_id.
$this->postJson('/api/auth/login', [
'email' => '2fa-recovery-fail-log@example.ru',
'password' => 'secret-pass-123',
])->assertOk();
$this->postJson('/api/auth/2fa/recovery-use', [
'code' => 'WRONG-9999',
])->assertStatus(422);
$row = DB::table('auth_log')
->where('event', '2fa_recovery_failed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->failure_reason)->toBe('invalid_or_used');
});
it('2fa setup init writes auth_log event=2fa_setup_init', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-init-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => false,
'totp_secret' => null,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/init')->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_setup_init')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa setup confirm writes auth_log event=2fa_setup_confirmed', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-confirm-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => false,
'totp_secret' => null,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/init')->assertOk();
$secret = session('auth.pending_totp_secret');
$google2fa = new Google2FA;
$code = $google2fa->getCurrentOtp($secret);
$this->postJson('/api/2fa/confirm', ['code' => $code])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_setup_confirmed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa disable success writes auth_log event=2fa_disabled', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-disabled-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/disable', ['password' => 'secret-pass-123'])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_disabled')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('2fa disable wrong password writes auth_log event=2fa_disable_failed', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-disable-fail-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/disable', ['password' => 'wrong-password'])->assertStatus(422);
$row = DB::table('auth_log')
->where('event', '2fa_disable_failed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->failure_reason)->toBe('invalid_password');
});
it('2fa regenerate recovery codes writes auth_log event=2fa_recovery_regenerated', function () {
$tenant = Tenant::factory()->create();
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => '2fa-regen-log@example.ru',
'password_hash' => Hash::make('secret-pass-123'),
'is_active' => true,
'totp_enabled' => true,
'totp_secret' => $secret,
]);
$this->actingAs($user);
$this->postJson('/api/2fa/regenerate-recovery-codes', ['password' => 'secret-pass-123'])->assertOk();
$row = DB::table('auth_log')
->where('event', '2fa_recovery_regenerated')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->tenant_id)->toBe($tenant->id);
});
it('password_reset_requested writes auth_log with user_id for known email', function () {
Notification::fake();
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'pr-known-log@example.ru',
'password_hash' => Hash::make('old-pass-1234'),
'is_active' => true,
]);
$this->postJson('/api/auth/forgot', [
'email' => 'pr-known-log@example.ru',
])->assertOk();
$row = DB::table('auth_log')
->where('event', 'password_reset_requested')
->where('email', 'pr-known-log@example.ru')
->latest('id')
->first();
expect($row)->not->toBeNull()
->and((int) $row->user_id)->toBe($user->id)
->and($row->failure_reason)->toBeNull();
});
it('password_reset_requested writes auth_log with unknown_email failure_reason for unknown email', function () {
Notification::fake();
$this->postJson('/api/auth/forgot', [
'email' => 'no-such-pr-log@example.ru',
])->assertOk();
$row = DB::table('auth_log')
->where('event', 'password_reset_requested')
->where('email', 'no-such-pr-log@example.ru')
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->user_id)->toBeNull()
->and($row->failure_reason)->toBe('unknown_email');
});
it('password_reset_completed writes auth_log on successful token reset', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'pr-completed-log@example.ru',
'password_hash' => Hash::make('old-pass-1234'),
'is_active' => true,
]);
$token = Password::createToken($user);
$this->postJson('/api/auth/reset-password', [
'token' => $token,
'email' => 'pr-completed-log@example.ru',
'password' => 'new-strong-pass-1234',
'password_confirmation' => 'new-strong-pass-1234',
])->assertOk();
$row = DB::table('auth_log')
->where('event', 'password_reset_completed')
->where('user_id', $user->id)
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->email)->toBe('pr-completed-log@example.ru');
});
it('password_reset_failed writes auth_log on invalid token', function () {
$tenant = Tenant::factory()->create();
User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'pr-failed-log@example.ru',
'password_hash' => Hash::make('old-pass-1234'),
'is_active' => true,
]);
$this->postJson('/api/auth/reset-password', [
'token' => 'invalid-token-zzz',
'email' => 'pr-failed-log@example.ru',
'password' => 'new-strong-pass-1234',
'password_confirmation' => 'new-strong-pass-1234',
])->assertStatus(422);
$row = DB::table('auth_log')
->where('event', 'password_reset_failed')
->where('email', 'pr-failed-log@example.ru')
->latest('id')
->first();
expect($row)->not->toBeNull()
->and($row->failure_reason)->not->toBeNull();
});
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create([
'balance_leads' => 100,
]);
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
});
test('ActivityLog deal.created содержит user_id, ip_address, user_agent актора', function () {
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.3', 'HTTP_USER_AGENT' => 'TestBrowser/1.0'])
->postJson('/api/deals', [
'project_name' => 'Тест Attribution',
'phone' => '+7 (999) 000-11-22',
]);
$r->assertStatus(201);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$row = ActivityLog::where('deal_id', $r->json('deal.id'))
->where('event', ActivityLog::EVENT_DEAL_CREATED)
->first();
expect($row)->not->toBeNull();
expect($row->user_id)->toBe($this->user->id);
expect($row->ip_address)->toBe('10.1.2.3');
expect($row->user_agent)->toBe('TestBrowser/1.0');
});
test('ActivityLog deal.commented содержит user_id, ip_address, user_agent актора', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['comment' => 'old']);
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.4', 'HTTP_USER_AGENT' => 'TestBrowser/2.0'])
->patchJson('/api/deals/'.$deal->id, [
'comment' => 'Новый комментарий',
]);
$r->assertStatus(200);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$row = ActivityLog::where('deal_id', $deal->id)
->where('event', 'deal.commented')
->first();
expect($row)->not->toBeNull();
expect($row->user_id)->toBe($this->user->id);
expect($row->ip_address)->toBe('10.1.2.4');
expect($row->user_agent)->toBe('TestBrowser/2.0');
});
test('ActivityLog deal.assigned содержит user_id, ip_address, user_agent актора', function () {
$manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
'manager_id' => null,
'assigned_at' => null,
]);
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.5', 'HTTP_USER_AGENT' => 'TestBrowser/3.0'])
->patchJson('/api/deals/'.$deal->id, [
'manager_id' => $manager->id,
]);
$r->assertStatus(200);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$row = ActivityLog::where('deal_id', $deal->id)
->where('event', ActivityLog::EVENT_DEAL_ASSIGNED)
->first();
expect($row)->not->toBeNull();
expect($row->user_id)->toBe($this->user->id);
expect($row->ip_address)->toBe('10.1.2.5');
expect($row->user_agent)->toBe('TestBrowser/3.0');
});
test('ActivityLog deal.status_changed содержит user_id, ip_address, user_agent актора', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
$r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.6', 'HTTP_USER_AGENT' => 'TestBrowser/4.0'])
->patchJson('/api/deals/'.$deal->id, [
'status' => 'won',
]);
$r->assertStatus(200);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$row = ActivityLog::where('deal_id', $deal->id)
->where('event', ActivityLog::EVENT_DEAL_STATUS_CHANGED)
->first();
expect($row)->not->toBeNull();
expect($row->user_id)->toBe($this->user->id);
expect($row->ip_address)->toBe('10.1.2.6');
expect($row->user_agent)->toBe('TestBrowser/4.0');
});
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* Task 7 (audit-p1-auth): bulk activity_log rows must carry
* user_id, ip_address, user_agent from the current request.
*
* Three operations: transition / destroy / restore.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
});
it('bulk transition записывает user_id и ip_address в activity_log', function () {
$deals = Deal::factory()->count(3)
->for($this->tenant)
->for($this->project)
->create(['status' => 'new']);
$this->withServerVariables(['REMOTE_ADDR' => '10.9.8.7'])
->postJson('/api/deals/transition', [
'ids' => $deals->pluck('id')->all(),
'status' => 'won',
])
->assertOk();
$rows = DB::table('activity_log')
->where('event', 'deal.status_changed')
->whereIn('deal_id', $deals->pluck('id'))
->get();
expect($rows)->toHaveCount(3);
foreach ($rows as $row) {
expect((int) $row->user_id)->toBe($this->user->id)
->and((string) $row->ip_address)->toBe('10.9.8.7');
}
});
it('bulk destroy записывает user_id и ip_address в activity_log', function () {
$deals = Deal::factory()->count(2)
->for($this->tenant)
->for($this->project)
->create();
$this->withServerVariables(['REMOTE_ADDR' => '192.168.1.1'])
->deleteJson('/api/deals', [
'ids' => $deals->pluck('id')->all(),
])
->assertOk();
$rows = DB::table('activity_log')
->where('event', 'deal.deleted')
->whereIn('deal_id', $deals->pluck('id'))
->get();
expect($rows)->toHaveCount(2);
foreach ($rows as $row) {
expect((int) $row->user_id)->toBe($this->user->id)
->and((string) $row->ip_address)->toBe('192.168.1.1');
}
});
it('bulk restore записывает user_id и ip_address в activity_log', function () {
$deals = Deal::factory()->count(2)
->for($this->tenant)
->for($this->project)
->create();
// Soft-delete first
$this->deleteJson('/api/deals', [
'ids' => $deals->pluck('id')->all(),
])->assertOk();
// Now restore
$this->withServerVariables(['REMOTE_ADDR' => '172.16.0.5'])
->postJson('/api/deals/restore', [
'ids' => $deals->pluck('id')->all(),
])
->assertOk();
$rows = DB::table('activity_log')
->where('event', 'deal.restored')
->whereIn('deal_id', $deals->pluck('id'))
->get();
expect($rows)->toHaveCount(2);
foreach ($rows as $row) {
expect((int) $row->user_id)->toBe($this->user->id)
->and((string) $row->ip_address)->toBe('172.16.0.5');
}
});
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, DatabaseTransactions::class);
it('writes auth_log row with all fields', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$dummy = new class
{
use WritesAuthLog;
public function fire(?int $userId, ?int $tenantId): void
{
$this->logAuthEvent('login_success', $userId, $tenantId, 'a@b.c', '1.2.3.4', 'UA', null);
}
};
$dummy->fire($user->id, $tenant->id);
$row = DB::table('auth_log')->latest('id')->first();
expect($row->event)->toBe('login_success')
->and($row->actor_type)->toBe('tenant_user')
->and((int) $row->user_id)->toBe($user->id)
->and((int) $row->tenant_id)->toBe($tenant->id)
->and((string) $row->ip_address)->toBe('1.2.3.4')
->and($row->user_agent)->toBe('UA');
});
it('actor_type=tenant_user even if user NULL (anti-enumeration)', function () {
$dummy = new class
{
use WritesAuthLog;
public function fire(?int $userId, ?int $tenantId): void
{
$this->logAuthEvent('login_failed', $userId, $tenantId, 'x@y.z', null, null, 'no_such_user');
}
};
$dummy->fire(null, null);
$row = DB::table('auth_log')->latest('id')->first();
expect($row->actor_type)->toBe('tenant_user')->and($row->user_id)->toBeNull();
});
+5
View File
@@ -1617,6 +1617,11 @@ SMTPS
бакет
MTA
алиас
прода
попап
COEP
Самобана
CDP
волатилен
синке
субдомен
+2 -2
View File
@@ -96,9 +96,9 @@ const NODES = [
{ id: 'backend_patterns', label: 'backend-patterns\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 417) },
{ id: 'nightowl', label: 'NightOwl\n(DEFERRED)', group: 'mcp', size: 16, ring: 3, ...pos(3, 427) },
// A8 infosec-tooling (21.05.2026) — раздел «Информационная безопасность»
{ id: 'mcp_zap', label: 'MCP: OWASP ZAP\n(DAST, pending install)', group: 'mcp', size: 18, ring: 5, ...pos(5, 360) },
{ id: 'mcp_zap', label: 'MCP: OWASP ZAP\n(DAST)', group: 'mcp', size: 18, ring: 5, ...pos(5, 360) },
{ id: 'nuclei', label: 'Nuclei\n(CLI, известные уязвимости)', group: 'lefthook', size: 18, ring: 5, ...pos(5, 370) },
{ id: 'ward', label: 'Ward\n(CLI, Laravel безопасность, pending)', group: 'lefthook', size: 18, ring: 5, ...pos(5, 380) },
{ id: 'ward', label: 'Ward\n(CLI, Laravel безопасность)', group: 'lefthook', size: 18, ring: 5, ...pos(5, 380) },
{ id: 'sk_pdn_152fz', label: 'ПДн / 152-ФЗ\n(скил)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 437) },
{ id: 'sk_threat_model', label: 'Моделирование угроз\nSTRIDE (скил)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 447) },
{ id: 'sk_security_golive', label: 'Прогон перед\nпубликацией (скил)', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 457) },
+85
View File
@@ -1503,6 +1503,83 @@ const NODE_DETAILS = {
[{ name: 'docs/observer/ evidence', cond: 'проверяет покрытие + регистрацию' }, { name: 'C4 status-md', cond: 'находки в STATUS.md' }],
[]
),
// ── A8 INFOSEC-TOOLING (#68-73, добавлены 22.05.2026 follow-up к A8-эпику 21.05) ──
mcp_zap: nd(
'MCP-сервер (OWASP ZAP add-on, alpha) — глубокая боевая динамическая проверка работающего портала: обход входа, инъекции (SQL/XSS), проблемы сессий/CSRF на живых endpoint-ах. ZAP 2.17.0 + MCP-аддон mcp-alpha-0.0.1 на portable Temurin JRE 17 (не системная Java).',
'Перед публикацией портала в интернет — для динамического security-gate перед релизом; вызывается скилом security-go-live (#73) как шаг динамики.',
'Цель по умолчанию — локальная копия 127.0.0.1; бой только по явной команде (граничное условие IS8, ADR-014). MCP-аддон в alpha — API может меняться. Требует запущенного ZAP-демона на portable JRE; без демона MCP-режим возвращает PENDING.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 3 — MCP-сервер при включённом демоне' }],
[],
[
{ name: 'скил security-go-live (#73)', cond: 'оркеструет ZAP как шаг динамики; связка L15' },
{ name: 'Nuclei (#69)', cond: 'комплементарны — широта (Nuclei) + глубина (ZAP); ADR-014 IS2' }
],
[]
),
nuclei: nd(
'CLI-инструмент (Go-бинарь bin/nuclei.exe v3.8.0 + 13 060 шаблонов) — широкое быстрое сканирование известных уязвимостей: CVE, дефолтные креды, открытые двери (.env/.git), утечки конфигов, слабый TLS, fingerprint стека. НЕ MCP — nuclei не говорит на MCP, обёртка стала бы доп. attack surface.',
'Регулярный security-scan живого портала; вызывается скилом security-go-live (#73). Срабатывает в задаче «прогнать сканер уязвимостей по порталу».',
'Цель — IP-литерал (127.0.0.1, не localhost — резолвер падает на native-Windows). Низкий rate-limit для однопоточного dev-сервера (-rate-limit 20 -c 5). Безопасный режим: исключать теги fuzz/dos/intrusive/brute-force при сканах боевого. Гард IS8.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 — CLI-инструмент' }],
[],
[
{ name: 'скил security-go-live (#73)', cond: 'оркеструет Nuclei как шаг широкого сканирования; связка L15' },
{ name: 'OWASP ZAP MCP (#68)', cond: 'комплементарны (широта Nuclei + глубина ZAP); ADR-014 IS2' }
],
[]
),
ward: nd(
'CLI-инструмент (Go-бинарь bin/ward.exe v0.4.1) — сканер misconfig и секретов в Laravel: .env (8 проверок), config/*.php (13), deps через OSV.dev (live), код (7 категорий — secrets/injection/XSS/debug-артефакты/crypto/config CORS-CSRF-mass-assignment/auth). Go-бинарь → не зависит от версии Laravel.',
'Аудит безопасности настроек Laravel при ревью .env/config или подготовке к релизу; вызывается скилом security-go-live (#73).',
'CLI, не MCP, не Composer dev-dep — отдельный путь установки (portable Go SDK). Молодой проект (фев 2026), single-maintainer — bus-factor; митигация — версия-pin v0.4.1 и MIT-форкабельность. Заменил Enlightn (тот abandoned + без поддержки Laravel 13).',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 — CLI-инструмент' }],
[],
[
{ name: 'скил security-go-live (#73)', cond: 'оркеструет Ward как шаг Laravel-misconfig; связка L15' },
{ name: 'Larastan (#12), Semgrep MCP (#25)', cond: 'комплементарны — Ward бьёт misconfig/secrets/deps, Larastan/Semgrep — типы/паттерны; ADR-014 IS3' }
],
[]
),
sk_pdn_152fz: nd(
'Project-скил — аудит персональных данных и соответствие 152-ФЗ. Два режима: технический (где лежат ПДн в схеме/коде, RLS, маскирование через pg_anonymizer, утечки в логах/CSV-экспортах) + юридический (хранение в РФ, согласия, сроки/удаление, реестр обработки, уведомление РКН, права субъекта).',
'При вопросах «проверь ПДн», «утекают ли персональные данные», «соответствие 152-ФЗ», «где хранятся телефоны лидов», перед публичным запуском. Вызывается также security-go-live (#73) как шаг ПДн.',
'Project-скил (self-authored, .claude/skills/pdn-152fz-audit/). Заземлён в db/schema.sql — даёт оценку, не правит код. Не подменяет юридическое оформление (D2: договоры/политики).',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 — self-authored project-скил' }],
[],
[
{ name: 'pg_anonymizer (#29)', cond: 'аудит проверяет маскирование, pg_anonymizer — инструмент; ADR-014 IS4' },
{ name: 'скил security-go-live (#73)', cond: 'оркеструет как шаг 152-ФЗ; связка L15' }
],
[]
),
sk_threat_model: nd(
'Project-скил — моделирование угроз портала по STRIDE. Карта точек входа (login/2FA/recovery, supplier webhooks, deals API, админка, impersonation, CSV-импорт; заземлён в app/routes/), что меняется при выходе в интернет, приоритизация защиты. Результат — docs/security/threat-model-<date>.md.',
'Перед публикацией портала в интернет; при вопросах «смоделируй угрозы», «откуда могут атаковать», «карта точек входа». Вызывается также security-go-live (#73).',
'Project-скил под наш портал (не generic STRIDE). Не подменяет deep code-audit (Trail of Bits #39); фокус — атакующая поверхность, не уязвимости в реализации.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1' }],
[],
[
{ name: 'скил security-go-live (#73)', cond: 'оркеструет STRIDE как шаг приоритизации; связка L15' },
{ name: 'скил pdn-152fz-audit (#71)', cond: 'STRIDE → угрозы на ПДн → ПДн-аудит' }
],
[]
),
sk_security_golive: nd(
'Project-скил — единый go-live security-gate перед публикацией портала в интернет. Оркеструет OWASP ZAP (#68) + Nuclei (#69) + Ward (#70) + pdn-152fz-audit (#71) + threat-model (#72) + Semgrep (#25) / gitleaks (#8) / Trivy (#26) / Trail of Bits (#39) → собирает вердикт GO / NO-GO.',
'Перед каждой публикацией боевого портала или большим релизом; при вопросах «готов ли портал к публикации по безопасности», «финальная проверка безопасности перед релизом».',
'Не подменяет полный 14-фазный audit-portal (тот шире); фокус — security-only срез часть дня. ZAP-шаг возвращает PENDING если ZAP-демон не запущен. Цель по умолчанию локальная (IS8).',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1' }],
[
{ name: 'OWASP ZAP MCP (#68)', cond: 'вызывает как шаг динамики (глубина)' },
{ name: 'Nuclei (#69)', cond: 'вызывает как шаг широкого сканирования' },
{ name: 'Ward (#70)', cond: 'вызывает как шаг Laravel-misconfig' },
{ name: 'скил pdn-152fz-audit (#71)', cond: 'вызывает как шаг ПДн' },
{ name: 'скил threat-model (#72)', cond: 'вызывает как шаг STRIDE' }
],
[{ name: 'Semgrep MCP (#25), gitleaks (#8), Trivy (#26), Trail of Bits (#39)', cond: 'статический слой — выполняются как часть оркестрации; связка L15' }],
[]
),
};
// ════════════════════════════════════════════════════
@@ -1860,6 +1937,14 @@ const NODE_META = {
lh_obs_obs: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
lh_status_md: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
lh_obs_cov: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
// ── A8 INFOSEC-TOOLING (#68-73, добавлены 22.05.2026 follow-up к A8-эпику 21.05) ──
mcp_zap: { since: '21.05.2026', changed: '22.05.2026', uses: 1, usesSrc: 'интеграция (install)' },
nuclei: { since: '21.05.2026', changed: '22.05.2026', uses: 2, usesSrc: 'инспекция (2 скана)' },
ward: { since: '21.05.2026', changed: '22.05.2026', uses: 1, usesSrc: 'инспекция (smoke app/)' },
sk_pdn_152fz: { since: '21.05.2026', changed: '21.05.2026', uses: 1, usesSrc: 'интеграция' },
sk_threat_model: { since: '21.05.2026', changed: '21.05.2026', uses: 1, usesSrc: 'интеграция (3 эндпоинта)' },
sk_security_golive: { since: '21.05.2026', changed: '22.05.2026', uses: 1, usesSrc: 'скил (orchestration)' },
};
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
@@ -1,5 +1,7 @@
# P1 — Полное покрытие `auth_log` + автор/IP в `activity_log`
> **Status: ✅ DONE — 22.05.2026.** Subagent-driven execution на ветке `worktree-audit-p1-auth` (от P0-baseline `a575d55`). 10 commits (Tasks 1-8 + gate). Auth-suite **131/131 passing (537 assertions)**; touched-area regression GREEN: AuthControllerTest 13/13, TwoFactor 16/16, TwoFactorSetup 10/10, ForgotPassword|ResetPassword 12/12, DealCreate/Update/Transition 26/26, DealBulkAction 14/14. Pint **clean** на изменённых файлах; Larastan **production code clean** (один `nullsafe.neverNull` в `AuthController.php:96` исправлен — `$user` уже non-null в `if (! $user->is_active)`; line 84 `?->` оставлен корректно — `$user` там может быть null). Plan-level deviations: (1) Task 5 implementer добавил 4 теста вместо 3 (bonus password_reset_failed на невалидном токене); (2) Task 6 — `request()` helper вместо `$request` (closures без `$request` в `use()`); (3) Task 7 — `$request` добавлен в 3 closure `use()` lists (transition/destroy/restore); (4) Task 8 integration test задокументировал Sanctum/SessionGuard interaction в shared test app (`Auth::shouldUse('sanctum')` от auth:sanctum middleware ломает следующие `Auth::login()` в той же тестовой функции — fix `app('auth')->forgetGuards(); setDefaultDriver('web')` перед `Auth::login`). NB: `--parallel` full-suite не запущен (shared DB + concurrent worktrees) — заменён targeted sequential regression.
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`. Steps use checkbox (`- [ ]`).
**Goal:** Закрыть журнал входа `auth_log` на все остальные auth-события (выход, 2FA setup/verify/recovery, password reset, регистрация) и заполнять `user_id`/`ip_address`/`user_agent` во **всех** `ActivityLog::create` (сейчас все 8 точек проставляют NULL).
+6
View File
@@ -61,6 +61,12 @@
"#65": false,
"#66": false,
"#67": true,
"#68": false,
"#69": false,
"#70": false,
"#71": false,
"#72": false,
"#73": false,
"#25": false,
"#26": false,
"#27": false,
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema_version": 1,
"description": "Mapping from observer transcript-parser task_classification values to recommended Tooling Прил.Н node IDs. Source of truth for missed-activation detection (Pravila §16.4 conditional rule). 'other' deliberately empty — no recommendation, never counts as missed. DEFERRED-узлы filtered out by .node-dormancy.json at runtime.",
"description": "Mapping from observer transcript-parser task_classification values to recommended Tooling Прил.Н node IDs. Source of truth for missed-activation detection (Pravila §16.4 conditional rule). 'other' deliberately empty — no recommendation, never counts as missed. DEFERRED-узлы filtered out by .node-dormancy.json at runtime. Classifier vocabulary is Claude's free judgment when writing the episode (no hardcoded enum) — adding a key here makes it 'blessed'. 'security' added 22.05.2026 (A8 follow-up): use when the PURPOSE of the task is verifying or improving security (scans, hardening, audits, threat modeling, go-live gates); NOT for bug-fixes that happen to be in security-relevant code (those stay 'bugfix').",
"map": {
"refactor": ["#11", "#12", "#43", "#64", "#65"],
"bugfix": ["#18", "#34"],
@@ -9,6 +9,7 @@
"memory-sync": ["#33"],
"monitoring": ["#34", "#35"],
"analysis": ["#25", "#39", "#53"],
"security": ["#73", "#69", "#68", "#70", "#71", "#72"],
"cleanup": ["#11", "#12"],
"question": ["#60"],
"other": []
+5 -4
View File
@@ -38,7 +38,7 @@
## 4. Безопасность (серверный слой, SEC-1..SEC-7)
- **SEC-6 HTTPS** ✅ — Let's Encrypt `liderra.ru`+`www` (истекает 2026-08-20, авто-обновление certbot). nginx 2 блока: :80 редиректит на https **кроме** `/.well-known/acme-challenge/` и `/api/webhook/`; :443 — приложение. Заголовки: `HSTS max-age=604800`, `X-Frame-Options SAMEORIGIN`, `X-Content-Type-Options nosniff`, `Referrer-Policy`. **CSP****боевой режим**`Content-Security-Policy` (блокирует внедрение чужого кода: `script-src 'self'`, `object-src 'none'`; `style-src +'unsafe-inline'` для Vuetify + `https://fonts.googleapis.com`; `font-src` + `https://fonts.gstatic.com` для Google Fonts; `img-src 'self' data: https:`; `connect-src 'self'`; `frame-ancestors/base-uri/form-action 'self'`). Проверено в браузере на живом `/login`: 0 ошибок CSP, шрифты грузятся (googleapis/gstatic → 200), приложение работает. Бэкап `liderra.bak-20260522-054524`, reload без простоя. **22.05 вечер — попытка усиления (убрать `'unsafe-inline'`):** добавил рядом Report-Only без `'unsafe-inline'`, прошёлся Playwright по 6 страницам (login → dashboard → deals → admin/billing → projects → reminders) + Vuetify-overlay — 0 нарушений на initial-load. Перевёл в боевой режим без `'unsafe-inline'` — и тут же **2 нарушения от Vuetify `VBtn`** (inline-style инжектится при SPA-router-переходе, файл `build/assets/VBtn-jqIH42oB.js:4`, sha256 двух разных стилей). Откатил за минуту (бэкап `liderra.bak-strict-attempt-*`). Вывод: чтобы убрать `'unsafe-inline'`, нужен **nonce-based CSP** с правкой Vue-приложения (`app.config.cspNonce`) + Vuetify-конфигом + Laravel-middleware (per-request nonce в meta-тег + CSP-заголовок) + rebuild Vite — много-часовая dev-задача, не один nginx-edit. См. §6 п.4.
- **SEC-6 HTTPS** ✅ — Let's Encrypt `liderra.ru`+`www` (истекает 2026-08-20, авто-обновление certbot). nginx 2 блока: :80 редиректит на https **кроме** `/.well-known/acme-challenge/` и `/api/webhook/`; :443 — приложение. Заголовки: `HSTS max-age=31536000` (1 год, обновлено 22.05 вечер-3 с 1 недели), `X-Frame-Options SAMEORIGIN`, `X-Content-Type-Options nosniff`, `Referrer-Policy strict-origin-when-cross-origin`, **+`Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"`**, **+`X-Permitted-Cross-Domain-Policies "none"`**, **+`Cross-Origin-Opener-Policy "same-origin-allow-popups"`** (allow-popups — не ломать будущий Yandex-360 OAuth-попап), **+`Cross-Origin-Resource-Policy "same-origin"`**, **+`server_tokens off`** (скрыл `nginx/1.24.0``Server: nginx`). COEP `require-corp` НЕ ставил — сломал бы Google Fonts и `img-src https:` (cross-origin без CORP-opt-in). Бэкап усиления `liderra.bak-hardening-20260522-131119`, проверено `curl` + Playwright (login→dashboard под новыми заголовками, шрифты грузятся, 0 новых ошибок). **CSP****боевой режим**`Content-Security-Policy` (блокирует внедрение чужого кода: `script-src 'self'`, `object-src 'none'`; `style-src +'unsafe-inline'` для Vuetify + `https://fonts.googleapis.com`; `font-src` + `https://fonts.gstatic.com` для Google Fonts; `img-src 'self' data: https:`; `connect-src 'self'`; `frame-ancestors/base-uri/form-action 'self'`). Проверено в браузере на живом `/login`: 0 ошибок CSP, шрифты грузятся (googleapis/gstatic → 200), приложение работает. Бэкап `liderra.bak-20260522-054524`, reload без простоя. **22.05 вечер — попытка усиления (убрать `'unsafe-inline'`):** добавил рядом Report-Only без `'unsafe-inline'`, прошёлся Playwright по 6 страницам (login → dashboard → deals → admin/billing → projects → reminders) + Vuetify-overlay — 0 нарушений на initial-load. Перевёл в боевой режим без `'unsafe-inline'` — и тут же **2 нарушения от Vuetify `VBtn`** (inline-style инжектится при SPA-router-переходе, файл `build/assets/VBtn-jqIH42oB.js:4`, sha256 двух разных стилей). Откатил за минуту (бэкап `liderra.bak-strict-attempt-*`). Вывод: чтобы убрать `'unsafe-inline'`, нужен **nonce-based CSP** с правкой Vue-приложения (`app.config.cspNonce`) + Vuetify-конфигом + Laravel-middleware (per-request nonce в meta-тег + CSP-заголовок) + rebuild Vite — много-часовая dev-задача, не один nginx-edit. См. §6 п.4.
- **SEC-2 анти-перебор** ✅ — прикладной throttle логина (5 попыток, лок по email+IP) + **fail2ban** (`/etc/fail2ban/jail.local`: jails `sshd` + `nginx-http-auth`, bantime 1h). NB: ~1400 неудачных SSH-попыток/сутки — fail2ban банит.
- **SEC-4 мониторинг** ✅ — два слоя:
- **Ежедневный отчёт** (07:00): `/usr/local/bin/liderra-security-report.sh` cron → `/var/log/liderra-security-report.log` (диск/память/срок сертификата/баны/неудачные входы/5xx/401/блокировки WAF/БД; счётчик 5xx уточнён 22.05 — считает только реальные статусы, не размеры ответов). Отчёт ежедневно шлётся на `kdv1@bk.ru` (`/usr/local/bin/liderra-mail.py`, SMTP из §7).
@@ -49,6 +49,7 @@
- **SEC-5 Lockbox** ✅ (хранилище заведено) — см. §5. **App-интеграция не сделана** (приложение читает секреты из файла + `.env`; реальная польза — после доработки).
- **SEC-3 DDoS** ⏸ отложен — базовая сетевая защита YC бесплатна и активна; продвинутая платная (подписка + 976 ₽/Мбит/с + смена IP/DNS) — избыточна. Альтернатива: бесплатный Cloudflare перед сайтом.
- **SEC-7 бэкапы + off-site** ✅ — локальные ежедневные есть (§3); **off-site (промежуточный):** `liderra-backup.sh` после дампа шифрует копию (gzip + openssl AES-256-CBC, ключ `/root/liderra-backup-crypt.key` root-600, создан однократно) и шлёт вложением на `kdv1@bk.ru` — копия переживёт потерю VM, ПДн зашифрованы. **⚠️ Ключ сохранить ВНЕ сервера** (`sudo cat /root/liderra-backup-crypt.key` → менеджер паролей), иначе emailed-бэкапы не расшифровать. Полноценный путь (YC Object Storage) — после сервис-аккаунта. IR-runbook — позже.
- **Скан уязвимостей боевого** ✅ (22.05 вечер-3, **Nuclei v3.8.0** + 13 060 шаблонов, безопасный детект-режим). 16 217 запросов за 18 мин при rate-limit 15 RPS (щадящий темп, сайт жив 200/0.4с весь скан). **Вердикт: 32 находки — ВСЕ `info`, 0 critical/high/medium = GO ✅.** Опасных уязвимостей не найдено, WAF/SSH/TLS/cookie защита работает; находки — fingerprinting + опциональные хардеринг-заголовки (большинство закрыты выше). Артефакты `/tmp/nuclei-prod-2026-05-22.{txt,jsonl}` (не в репо). Полный отчёт — memory `project_server_hardening`. Самобана не было — fail2ban-jail `nginx-http-auth` инертен после снятия basic-auth.
## 5. Yandex Cloud
@@ -66,8 +67,8 @@
4. **Усилить CSP** — убрать `'unsafe-inline'` из `style-src`. **Подтверждено эмпирически 22.05:** Vuetify `VBtn` инжектит inline-style при SPA-навигации (Report-Only это не ловит, только enforcing — initial-load был чист, ошибки появились ПОСЛЕ router-перехода). Нужно: (а) Laravel-middleware генерит per-request nonce, кладёт в `<meta name="csp-nonce">` и в заголовок CSP; (б) `app.config.cspNonce = <meta>` в Vue-bootstrap; (в) проверка, что Vuetify (Vue 3) подхватывает `cspNonce` для динамических `<style>`; (г) Vite-rebuild + копир-деплой; (д) перевод nginx CSP в strict с `'nonce-...'` (или CSP полностью отдаёт Laravel вместо nginx). Тестировать обязательно с router-переходами, не только initial-load. Сузить `img-src` после аудита authed-страниц — параллельно.
5. **Off-site → YC Object Storage** — заменить промежуточный email-бэкап на бакет (после сервис-аккаунта).
6. Sync runbook `docs/deploy/test-server-runbook.md` (ветка feat/test-deploy) с этим состоянием.
7. **Аудит журналирования — P0 (152-ФЗ ст.18 ч.2):** [`docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md`](docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md) — закрыть журнал ПДн (на проде сейчас `pd_processing_log=0` при 417 сделках с телефонами; экспорт «Сделки», просмотр карточки, создание лида, удаление файла отчёта проходят мимо журнала) + защищённый аудит impersonation (`saas_admin_audit_log` + ПДн-след). 13 TDD-задач + self-review.
8. **Аудит журналирования — P1 (security + attribution):** [`docs/superpowers/plans/2026-05-22-audit-auth-attribution.md`](docs/superpowers/plans/2026-05-22-audit-auth-attribution.md) — закрыть `auth_log` на logout/2FA/password-reset/register (сейчас пишется только login_success/failed) и заполнять `user_id`+`ip`+`user_agent` в `activity_log` (на проде 412 строк, у всех автор=NULL). 9 задач.
7. **Аудит журналирования — P0 (152-ФЗ ст.18 ч.2) — DONE+DEPLOYED 22.05.2026 (`a575d55`, на origin/main → liderra.ru):** [`docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md`](docs/superpowers/plans/2026-05-22-audit-pd-impersonation.md). 12 коммитов subagent-driven. Журнал ПДн закрыт: создание сделки (manual/webhook/supplier/import), просмотр карточки, экспорт CSV/XLSX, удаление файла отчёта (вручную + cron) — все пишут `pd_processing_log`. Impersonation: init/verify/end → `saas_admin_audit_log`; verify дополнительно → `pd_processing_log`. Pd-suite 22/22 ✅; regression 149/150 (pre-existing flake). **Выкачен 22.05 вечер:** 16 файлов scp (P0+P1 единым деплоем), backup `/home/ubuntu/deploy-backups/app-pre-p0p1-20260522-175125`, precheck ✅, `config:cache` + `systemctl reload php8.3-fpm` + `systemctl restart liderra-queue` — все service `active`.
8. **Аудит журналирования — P1 (security + attribution) — DONE+DEPLOYED 22.05.2026 (`9fa18778`, на origin/main → liderra.ru):** [`docs/superpowers/plans/2026-05-22-audit-auth-attribution.md`](docs/superpowers/plans/2026-05-22-audit-auth-attribution.md). 10 коммитов subagent-driven. Создан общий `WritesAuthLog` трейт; `auth_log` теперь пишется на logout / register_success / 2fa verify (успех+неудача) / 2fa recovery (успех+неудача) / 2fa setup (init/confirm/disable/regen) / password reset (requested/completed/failed). В `activity_log` (DealController 4 точки + DealBulkAction 3 точки) теперь заполняются `user_id`/`ip_address`/`user_agent`. Auth-suite 131/131 ✅. **Выкачен совместно с P0 22.05 вечер; live-smoke:** `POST /api/auth/forgot` с bogus email → HTTP 200 + `auth_log` строка `event=password_reset_requested email=deploy-smoke-…@nowhere.test failure_reason=unknown_email ip_address=111.88.246.137`.
9. **Аудит журналирования — P2 (operational + авто-инциденты):** [`docs/superpowers/plans/2026-05-22-audit-operational.md`](docs/superpowers/plans/2026-05-22-audit-operational.md) — мутации проектов / API-ключей / webhook URL / admin-supplier-integration в журналы; `SupplierWebhookController` в `webhook_log` + лог отказов 404/429; cron-watcher `incidents:watch-failures` для авто-наполнения `incidents_log` (закрывает класс «25k failed без инцидента»). 10 задач, миграция новой таблицы `tenant_operations_log`. **Порядок исполнения:** P0 → P1 → P2 последовательно через subagent-driven (DealController фигурирует в P0+P1 → параллельность даст merge-конфликты).
## 7. Почта (исходящая, фирменная)
@@ -79,6 +80,6 @@
- **Конфиг:**`MAIL_*` прописаны на боевом сервере 22.05 (`MAIL_MAILER=smtp`, host/port `465`/scheme `smtps`/username/password/`from=verify@liderra.ru`/`from_name=Лидерра`); было `MAIL_MAILER=log` (письма не уходили). Бэкап `.env.bak-*`. Применено через `config:cache`. **Подтверждено живой отправкой** (`SENT_OK` + E2E register/start → 200). Пароль ящика — секрет, в git нет; кандидат в Lockbox.
- ✅ **Фича «регистрация по коду + обязательный телефон»****выкачена на боевой сервер 22.05** (backend 9 файлов + фронт пересобран `public/build` + `MAIL_*` + config/route cache + queue restart). E2E live: `POST /api/auth/register/start` → 200, код реально уходит на email. Код в ветке `feat/test-deploy` (`0e31783`).
> ✅ Закрыто 22.05: APP_URL → https://liderra.ru + SANCTUM-домены (см. §2); фирменная исходящая почта (см. §7); WAF переведён в боевой режим блокировки (см. §4); CSP в боевом режиме (блокировка) + email-алертинг отчёта + off-site зашифрованный бэкап на почту (см. §4); **выкачен прикладной код — регистрация по коду+телефон, денежный фикс лимита B1/B2/B3, RLS-фикс admin-impersonation (см. §2)**; устранён retry-шторм supplier-задачи по удалённому лиду №1 (`RouteSupplierLeadJob` `findOrFail``find`+terminal, фикс `0c9357a` задеплоен; очередь повторов + failed_jobs почищены, ~25k записей); **устранён 500-инцидент на всём портале (повреждённый APP_KEY, CRLF в .env, APP_KEY ротирован — все Redis-сессии невалидны); добавлен healthcheck/2 мин + email-алёрт; pre-flight гейт 15 проверок; systemd-лимиты очереди + OnFailure email; WAF threshold для /api/* 5→10 — см. §2 и §4 SEC-1/SEC-4**; **устранён цикл SIGKILL `liderra-queue` каждые 60с — добавлен `--timeout=300` в systemd ExecStart, см. §2**.
> ✅ Закрыто 22.05: APP_URL → https://liderra.ru + SANCTUM-домены (см. §2); фирменная исходящая почта (см. §7); WAF переведён в боевой режим блокировки (см. §4); CSP в боевом режиме (блокировка) + email-алертинг отчёта + off-site зашифрованный бэкап на почту (см. §4); **выкачен прикладной код — регистрация по коду+телефон, денежный фикс лимита B1/B2/B3, RLS-фикс admin-impersonation (см. §2)**; устранён retry-шторм supplier-задачи по удалённому лиду №1 (`RouteSupplierLeadJob` `findOrFail``find`+terminal, фикс `0c9357a` задеплоен; очередь повторов + failed_jobs почищены, ~25k записей); **устранён 500-инцидент на всём портале (повреждённый APP_KEY, CRLF в .env, APP_KEY ротирован — все Redis-сессии невалидны); добавлен healthcheck/2 мин + email-алёрт; pre-flight гейт 15 проверок; systemd-лимиты очереди + OnFailure email; WAF threshold для /api/* 5→10 — см. §2 и §4 SEC-1/SEC-4**; **устранён цикл SIGKILL `liderra-queue` каждые 60с — добавлен `--timeout=300` в systemd ExecStart, см. §2**; **скан уязвимостей боевого Nuclei → GO ✅ (0 critical/high/medium, 32 info, см. §4)**; **nginx-усиление по итогам скана — HSTS 1нед→1год, +Permissions-Policy/X-Permitted-CDP/COOP/CORP, server_tokens off (версия nginx скрыта), см. §4 SEC-6**.
>
> ⚠️ Снимок волатилен. Истина — реальные команды по SSH (`systemctl is-active …`, `nginx -T`, `yc …` при наличии доступа).