laravel-security-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # 🐾 Capi Guard — Laravel Security Agent
2
+
3
+ ![Capi Guard](public/readme.png)
4
+
5
+ > *"Your friendly capybara keeping your Laravel app safe."*
6
+
7
+ A one-command security setup for Laravel projects. Capi Guard installs an AI-powered security audit agent, hardens your `.gitignore`, and adds a pre-commit hook that blocks sensitive files before they reach GitHub.
8
+
9
+ ## Usage
10
+
11
+ Run at the root of your Laravel project:
12
+
13
+ ```bash
14
+ npx laravel-security-agent
15
+ ```
16
+
17
+ Capi Guard will ask what you want to install and handle everything interactively.
18
+
19
+ ## What gets installed
20
+
21
+ | Item | What it does |
22
+ |------|-------------|
23
+ | `SECURITY.md` | Security audit agent — drop it in any project and tell your AI to "audit security" |
24
+ | `.gitignore` entries | Protects `deploy.php`, `.env`, SSH keys, and certificates from being committed |
25
+ | Pre-commit hook | Blocks commits of sensitive files and hardcoded credentials automatically |
26
+
27
+ ## Using the security agent
28
+
29
+ After installing `SECURITY.md`, open the project in **Claude Code** and say:
30
+
31
+ > **"audit security"**
32
+
33
+ Capi Guard will:
34
+
35
+ 1. Map all controllers, routes, and models in the project
36
+ 2. Audit 13 security categories (IDOR, SQL Injection, uploads, credentials, and more)
37
+ 3. Generate a report with 🔴 Critical / 🟡 Important / 🟢 Suggestion
38
+ 4. Ask which issues to fix — and wait for your approval before touching any code
39
+
40
+ The agent automatically responds **in your language** — write in English, Portuguese, Spanish, or any other language and it will reply in kind.
41
+
42
+ ## Security categories audited
43
+
44
+ | Category | What's checked |
45
+ |----------|---------------|
46
+ | IDOR | Policies, `authorize()`, ownership scoping |
47
+ | SQL Injection | Raw queries, dynamic `orderBy`, file imports |
48
+ | Field validation | `min`/`max` on every field, FormRequests |
49
+ | File uploads | `mimetypes:` vs `mimes:`, private storage, UUID filenames |
50
+ | Mass Assignment | `$fillable` scope, role escalation via request |
51
+ | Authorization | Route middleware groups, role checks |
52
+ | CSRF | `VerifyCsrfToken`, Sanctum stateful domains |
53
+ | XSS | `v-html` usage, HTML purification |
54
+ | Rate Limiting | Login, uploads, critical endpoints |
55
+ | Session | Secure cookie, same-site, encryption |
56
+ | Security Headers | CSP, X-Frame-Options, server header removal |
57
+ | Credentials | `Hash::check`, `APP_DEBUG`, log sanitization |
58
+ | Git secrets | `.env` in history, hardcoded IPs, deploy paths |
59
+
60
+ ## Pre-commit hook protection
61
+
62
+ Once installed, the hook blocks commits of:
63
+
64
+ - `.env` and `deploy.php` files
65
+ - SSH keys and certificates (`.pem`, `.key`, `.p12`)
66
+ - Diffs containing hardcoded passwords or API keys
67
+
68
+ ## Requirements
69
+
70
+ - Node.js 18+
71
+ - Laravel project (`composer.json` at the root)
72
+ - Claude Code (for the AI audit agent)
73
+
74
+ ## License
75
+
76
+ MIT
package/bin/index.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ intro, outro, multiselect, confirm,
4
+ spinner, note, cancel, isCancel
5
+ } from '@clack/prompts';
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { copySecurity } from '../src/copy-security.js';
9
+ import { applyGitignore } from '../src/update-gitignore.js';
10
+ import { installHook } from '../src/install-hook.js';
11
+
12
+ const cwd = process.cwd();
13
+
14
+ intro('🐾 Capi Guard — Laravel Security Agent');
15
+
16
+ // Warn if not running at a Laravel project root
17
+ if (!existsSync(join(cwd, 'composer.json'))) {
18
+ note(
19
+ 'composer.json not found.\nMake sure you run this at the root of your Laravel project.',
20
+ 'Warning'
21
+ );
22
+ }
23
+
24
+ const options = await multiselect({
25
+ message: 'What would you like to install?',
26
+ initialValues: ['security', 'gitignore', 'hook'],
27
+ options: [
28
+ { value: 'security', label: 'SECURITY.md', hint: 'AI security audit agent' },
29
+ { value: 'gitignore', label: 'Update .gitignore', hint: 'protects deploy.php, .env, SSH keys' },
30
+ { value: 'hook', label: 'Pre-commit hook', hint: 'blocks commits of sensitive files' },
31
+ ],
32
+ });
33
+
34
+ if (isCancel(options)) {
35
+ cancel('Installation cancelled.');
36
+ process.exit(0);
37
+ }
38
+
39
+ const s = spinner();
40
+
41
+ // --- SECURITY.md ---
42
+ if (options.includes('security')) {
43
+ const dest = join(cwd, 'SECURITY.md');
44
+ let overwrite = false;
45
+
46
+ if (existsSync(dest)) {
47
+ const answer = await confirm({
48
+ message: 'SECURITY.md already exists. Overwrite?',
49
+ initialValue: false,
50
+ });
51
+ if (isCancel(answer)) { cancel('Cancelled.'); process.exit(0); }
52
+ overwrite = answer;
53
+ }
54
+
55
+ s.start('Copying SECURITY.md...');
56
+ const result = copySecurity(cwd, overwrite);
57
+ s.stop(result.skipped
58
+ ? 'SECURITY.md kept (not overwritten)'
59
+ : '✔ SECURITY.md installed');
60
+ }
61
+
62
+ // --- .gitignore ---
63
+ if (options.includes('gitignore')) {
64
+ s.start('Updating .gitignore...');
65
+ const result = applyGitignore(cwd);
66
+ s.stop(result.added > 0
67
+ ? `✔ .gitignore updated (${result.added} entries added)`
68
+ : '✔ .gitignore already up to date');
69
+ }
70
+
71
+ // --- pre-commit hook ---
72
+ if (options.includes('hook')) {
73
+ s.start('Installing pre-commit hook...');
74
+ const result = installHook(cwd);
75
+ s.stop(result.gitNotFound
76
+ ? '⚠ .git not found — initialize git first, then run this again'
77
+ : '✔ pre-commit hook installed at .git/hooks/pre-commit');
78
+ }
79
+
80
+ note(
81
+ 'Open this project in Claude Code and say:\n"audit security" or "run SECURITY.md"',
82
+ 'Next step'
83
+ );
84
+
85
+ const capybara = `
86
+      /)─―ヘ
87
+    _/    \
88
+   /    ●   ●丶
89
+  |       ▼ |
90
+  |       亠ノ
91
+   U ̄U ̄ ̄ ̄ ̄U ̄U
92
+
93
+ Thanks for installing Capi Guard! 🐾
94
+ If this helped you, please give us a star:
95
+ ★ https://github.com/LeandroLDomingos/laravel-security-agent
96
+ `;
97
+
98
+ console.log(capybara);
99
+
100
+ outro('Done! Capi Guard is watching. 🐾');
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "laravel-security-agent",
3
+ "version": "1.0.0",
4
+ "description": "Capi Guard — a security audit agent for Laravel projects",
5
+ "type": "module",
6
+ "bin": {
7
+ "laravel-security-agent": "bin/index.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "templates",
13
+ "public"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test test/copy-security.test.js test/update-gitignore.test.js test/install-hook.test.js",
17
+ "start": "node bin/index.js",
18
+ "prepublishOnly": "node --test test/copy-security.test.js test/update-gitignore.test.js test/install-hook.test.js"
19
+ },
20
+ "dependencies": {
21
+ "@clack/prompts": "^0.9.1"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "keywords": [
27
+ "laravel",
28
+ "security",
29
+ "audit",
30
+ "idor",
31
+ "sql-injection",
32
+ "capi-guard"
33
+ ],
34
+ "author": "leandroldomingos",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/LeandroLDomingos/laravel-security-agent.git"
38
+ },
39
+ "license": "MIT"
40
+ }
Binary file
@@ -0,0 +1,19 @@
1
+ // src/copy-security.js
2
+ import { existsSync, copyFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
7
+ const TEMPLATE = join(__dirname, '../templates/SECURITY.md');
8
+
9
+ export function shouldCopy(dest, overwrite) {
10
+ if (!existsSync(dest)) return true;
11
+ return overwrite;
12
+ }
13
+
14
+ export function copySecurity(destDir, overwrite = false) {
15
+ const dest = join(destDir, 'SECURITY.md');
16
+ if (!shouldCopy(dest, overwrite)) return { skipped: true };
17
+ copyFileSync(TEMPLATE, dest);
18
+ return { skipped: false, path: dest };
19
+ }
@@ -0,0 +1,21 @@
1
+ // src/install-hook.js
2
+ import { existsSync, copyFileSync, chmodSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
7
+ const TEMPLATE = join(__dirname, '../templates/pre-commit');
8
+
9
+ export function installHook(projectDir) {
10
+ const gitHooksDir = join(projectDir, '.git', 'hooks');
11
+
12
+ if (!existsSync(gitHooksDir)) {
13
+ return { installed: false, gitNotFound: true };
14
+ }
15
+
16
+ const dest = join(gitHooksDir, 'pre-commit');
17
+ copyFileSync(TEMPLATE, dest);
18
+ chmodSync(dest, 0o755);
19
+
20
+ return { installed: true, path: dest };
21
+ }
@@ -0,0 +1,44 @@
1
+ // src/update-gitignore.js
2
+ import { existsSync, readFileSync, appendFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export const DEFAULT_ENTRIES = [
6
+ '',
7
+ '# laravel-security-agent (Capi Guard)',
8
+ '.env',
9
+ '.env.*',
10
+ '!.env.example',
11
+ 'deploy.php',
12
+ 'deployer.php',
13
+ '*.pem',
14
+ '*.key',
15
+ '*.p12',
16
+ '*.pfx',
17
+ 'id_rsa',
18
+ 'id_ed25519',
19
+ ];
20
+
21
+ export function buildLinesToAdd(existingContent, entries) {
22
+ return entries.filter(entry => {
23
+ // Always include structural lines (blank lines and comments)
24
+ if (entry === '' || entry.startsWith('#')) return true;
25
+ const lines = existingContent.split('\n').map(l => l.trim());
26
+ return !lines.includes(entry.trim());
27
+ });
28
+ }
29
+
30
+ export function applyGitignore(destDir, entries = DEFAULT_ENTRIES) {
31
+ const path = join(destDir, '.gitignore');
32
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
33
+ const toAdd = buildLinesToAdd(existing, entries);
34
+
35
+ // Only proceed if there are real entries to add (ignore blank lines and comments)
36
+ const meaningful = toAdd.filter(e => e !== '' && !e.startsWith('#'));
37
+ if (meaningful.length === 0) return { added: 0, created: false };
38
+
39
+ const created = !existsSync(path);
40
+ const linesToWrite = (created && toAdd[0] === '') ? toAdd.slice(1) : toAdd;
41
+ const prefix = created ? '' : '\n';
42
+ appendFileSync(path, prefix + linesToWrite.join('\n') + '\n');
43
+ return { added: meaningful.length, created };
44
+ }
@@ -0,0 +1,1063 @@
1
+ # SECURITY.md — Laravel Application Defense Guide
2
+
3
+ <!--
4
+ ╔══════════════════════════════════════════════════════════════════╗
5
+ ║ AI AGENT INSTRUCTIONS ║
6
+ ╚══════════════════════════════════════════════════════════════════╝
7
+
8
+ You are a security audit agent for Laravel projects.
9
+ This file is your reference manual and execution script.
10
+
11
+ ## LANGUAGE
12
+
13
+ Detect the language of the user's message and respond entirely in that language.
14
+ If the user writes in Portuguese, respond in Portuguese.
15
+ If the user writes in English, respond in English.
16
+ If the user writes in Spanish, respond in Spanish.
17
+ Apply this rule to every message: reports, questions, confirmations, and code comments.
18
+
19
+ ## REQUIRED BEHAVIOR
20
+
21
+ 1. NEVER modify code without asking the user first.
22
+ 2. For each issue found, present:
23
+ - File and line where the problem is
24
+ - Why it is a risk (category: IDOR, SQL Injection, etc.)
25
+ - What you intend to do to fix it
26
+ - Wait for explicit approval ("yes", "go ahead", "ok") before editing.
27
+ 3. If you find multiple issues, list ALL of them first, then ask
28
+ which ones the user wants to fix and in what order.
29
+ 4. After each fix, show the diff and confirm with the user.
30
+
31
+ ## HOW TO RUN THE AUDIT
32
+
33
+ When the user says "audit security", "run SECURITY.md",
34
+ "check security" or similar, execute this script in order:
35
+
36
+ ### STEP 1 — Project reconnaissance
37
+ Before any analysis, map:
38
+ - Stack: Laravel version, auth packages (Fortify, Sanctum, Breeze)
39
+ - Routes: `routes/web.php` and `routes/api.php`
40
+ - Controllers: list all in `app/Http/Controllers/`
41
+ - Models: list all in `app/Models/`
42
+ - Deploy files: `deploy.php`, `deployer.php`, `.env`, `.env.*`
43
+
44
+ ### STEP 2 — Audit by category (in this order)
45
+ Run each check and record findings before moving to the next:
46
+
47
+ [ ] 1. GIT / EXPOSED SECRETS
48
+ - Is `deploy.php` in .gitignore?
49
+ - Is `.env` in .gitignore?
50
+ - Search for hardcoded IPs: grep -r "host\(" deploy.php
51
+ - Search history: git log --all -- .env deploy.php
52
+ - Keys/certificates (.pem, .key) in the repository?
53
+
54
+ [ ] 2. IDOR
55
+ - Does every resource controller use `$this->authorize()` or a Policy?
56
+ - Do queries filter by the authenticated user?
57
+ - Are sequential IDs exposed in public routes?
58
+ - Is the `role` field accepted via $request->all()?
59
+
60
+ [ ] 3. SQL INJECTION
61
+ - Is `DB::unprepared()` used with user input?
62
+ - Are `orderBy` / `groupBy` using dynamic values without a whitelist?
63
+ - Do file imports execute SQL directly?
64
+
65
+ [ ] 4. FIELD VALIDATION
66
+ - Do all FormRequests have `max:` on text fields?
67
+ - Is `$request->all()` used without prior validation?
68
+ - Do numeric fields have `min:` and `max:`?
69
+
70
+ [ ] 5. FILE UPLOADS
71
+ - Do rules use `mimetypes:` (real bytes) or only `mimes:` (extension)?
72
+ - Are files stored on the `private` disk or in `public`?
73
+ - Is the filename generated server-side (UUID) or taken from the client?
74
+ - Are external image URLs validated against a domain whitelist?
75
+
76
+ [ ] 6. MASS ASSIGNMENT
77
+ - Are there Models with `$guarded = []`?
78
+ - Does `$fillable` include sensitive fields (role, is_admin)?
79
+
80
+ [ ] 7. AUTHORIZATION & ROUTES
81
+ - Are admin routes protected with role middleware?
82
+ - Are there Controllers missing `$this->authorize()` on destructive methods?
83
+
84
+ [ ] 8. CSRF
85
+ - Does `VerifyCsrfToken` have improper exclusions (non-webhooks)?
86
+ - Is `SANCTUM_STATEFUL_DOMAINS` configured correctly?
87
+
88
+ [ ] 9. SESSION & HEADERS
89
+ - Are `SESSION_SECURE_COOKIE`, `SESSION_SAME_SITE`, `SESSION_ENCRYPT` in .env?
90
+ - Is the `SecurityHeaders` middleware registered globally?
91
+
92
+ [ ] 10. CREDENTIALS IN CODE
93
+ - Are passwords compared with `Hash::check()` or with `===`?
94
+ - Are `APP_DEBUG` and `APP_ENV` correct for production?
95
+ - Do logs record sensitive fields (password, token)?
96
+
97
+ ### STEP 3 — Findings report
98
+ After completing all checks, present a report in this format:
99
+
100
+ ```
101
+ ## Security Report — [Project Name]
102
+
103
+ ### 🔴 Critical (fix immediately)
104
+ - [file:line] Issue description
105
+
106
+ ### 🟡 Important (fix before next deploy)
107
+ - [file:line] Issue description
108
+
109
+ ### 🟢 Suggestion (recommended improvement)
110
+ - [file:line] Issue description
111
+
112
+ Total: X critical | Y important | Z suggestions
113
+ ```
114
+
115
+ ### STEP 4 — Wait for approval before fixing
116
+ Ask: "Which items would you like me to fix now?"
117
+ Wait for the response. Fix one at a time, showing the diff for each change.
118
+
119
+ ## GOLDEN RULES
120
+ - Never edit two files at the same time without approval
121
+ - Never force push without explicit user confirmation
122
+ - If you find a credential already committed, warn immediately and do not touch the code before the user responds
123
+ - Whenever you remove something, explain what was removed and why
124
+ -->
125
+
126
+ > **Core principle: Never trust the frontend. All validation, authorization, and sanitization happens on the server.**
127
+
128
+ ---
129
+
130
+ ## Table of Contents
131
+
132
+ 1. [IDOR — Insecure Direct Object Reference](#1-idor--insecure-direct-object-reference)
133
+ 2. [SQL Injection](#2-sql-injection)
134
+ 3. [Field and Size Validation](#3-field-and-size-validation)
135
+ 4. [Image and File Uploads](#4-image-and-file-uploads)
136
+ 5. [Mass Assignment](#5-mass-assignment)
137
+ 6. [Authorization and Roles](#6-authorization-and-roles)
138
+ 7. [CSRF](#7-csrf)
139
+ 8. [XSS — Cross-Site Scripting (Inertia/Vue)](#8-xss--cross-site-scripting-inertiavue)
140
+ 9. [Rate Limiting](#9-rate-limiting)
141
+ 10. [Session](#10-session)
142
+ 11. [Security Headers](#11-security-headers)
143
+ 12. [Passwords and Credentials](#12-passwords-and-credentials)
144
+ 13. [What Never Goes to Git](#13-what-never-goes-to-git)
145
+ 14. [Pre-Deploy Checklist](#14-pre-deploy-checklist)
146
+
147
+ ---
148
+
149
+ ## 1. IDOR — Insecure Direct Object Reference
150
+
151
+ **Problem:** The user manipulates IDs in the URL/request to access other users' resources.
152
+
153
+ ### Rule: Always scope queries to the authenticated user
154
+
155
+ ```php
156
+ // ❌ WRONG — any logged-in user can access any order
157
+ public function show(Order $order)
158
+ {
159
+ return $order;
160
+ }
161
+
162
+ // ✅ CORRECT — enforces ownership via Policy
163
+ public function show(Order $order)
164
+ {
165
+ $this->authorize('view', $order);
166
+ return $order;
167
+ }
168
+
169
+ // ✅ ALTERNATIVE — direct query scoping
170
+ public function show(int $id)
171
+ {
172
+ $order = auth()->user()->orders()->findOrFail($id);
173
+ return $order;
174
+ }
175
+ ```
176
+
177
+ ### Create Policies for every sensitive resource
178
+
179
+ ```bash
180
+ php artisan make:policy OrderPolicy --model=Order
181
+ ```
182
+
183
+ ```php
184
+ // app/Policies/OrderPolicy.php
185
+ class OrderPolicy
186
+ {
187
+ public function view(User $user, Order $order): bool
188
+ {
189
+ return $user->id === $order->user_id
190
+ || $user->hasRole(['admin', 'super_admin']);
191
+ }
192
+
193
+ public function update(User $user, Order $order): bool
194
+ {
195
+ return $user->id === $order->user_id;
196
+ }
197
+
198
+ public function delete(User $user, Order $order): bool
199
+ {
200
+ return $user->hasRole('admin');
201
+ }
202
+ }
203
+ ```
204
+
205
+ ```php
206
+ // Register in AppServiceProvider
207
+ Gate::policy(Order::class, OrderPolicy::class);
208
+ ```
209
+
210
+ ### Call authorize() at the top of every controller method
211
+
212
+ ```php
213
+ public function update(Request $request, Order $order)
214
+ {
215
+ // Always at the top — before any business logic
216
+ $this->authorize('update', $order); // throws 403 automatically
217
+ // ...
218
+ }
219
+ ```
220
+
221
+ ### Prevent role escalation via request
222
+
223
+ ```php
224
+ // ❌ WRONG — user can send role=admin in the body
225
+ $user->update($request->all());
226
+
227
+ // ✅ CORRECT — never accept role from the request
228
+ $user->update($request->only(['name', 'email', 'phone']));
229
+ // role is only changed by super_admin via a dedicated flow
230
+ ```
231
+
232
+ ---
233
+
234
+ ## 2. SQL Injection
235
+
236
+ **Problem:** User input is interpolated directly into SQL queries.
237
+
238
+ ### Always use Eloquent or Query Builder with bindings
239
+
240
+ ```php
241
+ // ❌ WRONG — direct SQL injection
242
+ DB::select("SELECT * FROM users WHERE email = '{$request->email}'");
243
+
244
+ // ✅ CORRECT — parameterized binding
245
+ DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);
246
+
247
+ // ✅ BEST — use Eloquent
248
+ User::where('email', $request->email)->first();
249
+ ```
250
+
251
+ ### Never execute raw SQL from user-uploaded files
252
+
253
+ ```php
254
+ // ❌ CRITICAL — allows execution of arbitrary SQL
255
+ $content = file_get_contents($request->file('import'));
256
+ DB::unprepared($content);
257
+
258
+ // ✅ CORRECT — parse CSV with a field whitelist
259
+ $rows = array_map('str_getcsv', file($request->file('import')->path()));
260
+ $allowed = ['name', 'email', 'phone']; // explicit whitelist
261
+ $allowedCount = count($allowed);
262
+
263
+ foreach ($rows as $row) {
264
+ // Skip rows with the wrong number of columns
265
+ if (count($row) !== $allowedCount) {
266
+ continue;
267
+ }
268
+
269
+ $data = array_combine($allowed, $row);
270
+ User::create($data); // $fillable protects the rest
271
+ }
272
+ ```
273
+
274
+ ### Dynamic orderBy and LIKE — always use a whitelist
275
+
276
+ ```php
277
+ // ❌ WRONG — orderBy with unsanitized dynamic column
278
+ $query->orderBy($request->sort_by);
279
+
280
+ // ✅ CORRECT — whitelist of allowed columns
281
+ $allowedSorts = ['name', 'created_at', 'price'];
282
+ $sortBy = in_array($request->sort_by, $allowedSorts, true)
283
+ ? $request->sort_by
284
+ : 'created_at';
285
+ $query->orderBy($sortBy);
286
+
287
+ // For LIKE, the Query Builder already escapes automatically:
288
+ $query->where('name', 'like', '%' . $request->search . '%');
289
+ ```
290
+
291
+ ---
292
+
293
+ ## 3. Field and Size Validation
294
+
295
+ **Rule: Validate on the server. Frontend validation is UX, not security.**
296
+
297
+ ### Explicit limits on every text field
298
+
299
+ ```php
300
+ // app/Http/Requests/StoreUserRequest.php
301
+ public function rules(): array
302
+ {
303
+ return [
304
+ 'name' => ['required', 'string', 'min:2', 'max:100'],
305
+ // Note: 'email:rfc,dns' performs a DNS lookup at validation time.
306
+ // In environments without external DNS (CI, isolated staging), use 'email:rfc' only.
307
+ 'email' => ['required', 'email:rfc', 'max:255', 'unique:users,email'],
308
+ 'phone' => ['nullable', 'string', 'max:20'],
309
+ 'bio' => ['nullable', 'string', 'max:1000'],
310
+ // bcrypt silently truncates above 72 bytes — max:72 is the real limit
311
+ 'password' => ['required', 'string', 'min:8', 'max:72', 'confirmed'],
312
+ // Numeric fields always need min/max
313
+ 'age' => ['required', 'integer', 'min:0', 'max:150'],
314
+ 'amount' => ['required', 'numeric', 'min:0', 'max:999999.99'],
315
+ ];
316
+ }
317
+ ```
318
+
319
+ ### Never use $request->all() without prior validation
320
+
321
+ ```php
322
+ // ❌ WRONG
323
+ $data = $request->all();
324
+ User::create($data);
325
+
326
+ // ✅ CORRECT — FormRequest with explicit rules
327
+ public function store(StoreUserRequest $request)
328
+ {
329
+ User::create($request->validated());
330
+ }
331
+ ```
332
+
333
+ ### Sanitize inputs when necessary
334
+
335
+ ```php
336
+ // For HTML fields (rich text) — use a purifier
337
+ // composer require mews/purifier
338
+ $clean = Purifier::clean($request->content);
339
+
340
+ // For plain strings — strip_tags is enough
341
+ $name = strip_tags($request->name);
342
+
343
+ // For slugs/identifiers
344
+ $slug = Str::slug($request->slug);
345
+ ```
346
+
347
+ ---
348
+
349
+ ## 4. Image and File Uploads
350
+
351
+ **Rule: Never trust the MIME type sent by the client. Validate the actual file bytes.**
352
+
353
+ ### The difference between `mimes:` and `mimetypes:`
354
+
355
+ ```
356
+ mimes:jpeg,png → validates the file EXTENSION (easy to bypass by renaming)
357
+ mimetypes:image/jpeg → validates the actual file BYTES via finfo (secure)
358
+ ```
359
+
360
+ Always use `mimetypes:` for security validation:
361
+
362
+ ```php
363
+ public function rules(): array
364
+ {
365
+ return [
366
+ 'avatar' => [
367
+ 'required',
368
+ 'file',
369
+ // mimetypes: uses finfo internally — reads real bytes, not the extension
370
+ 'mimetypes:image/jpeg,image/png,image/webp',
371
+ // Maximum size in KB (2MB = 2048)
372
+ 'max:2048',
373
+ // Minimum and maximum dimensions in pixels
374
+ 'dimensions:min_width=50,min_height=50,max_width=2000,max_height=2000',
375
+ ],
376
+ ];
377
+ }
378
+ ```
379
+
380
+ ### Additional finfo check (double guarantee)
381
+
382
+ ```php
383
+ use Illuminate\Http\UploadedFile;
384
+
385
+ public function validateImageMime(UploadedFile $file): bool
386
+ {
387
+ $allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
388
+
389
+ // finfo reads real bytes, independent of the request Content-Type
390
+ $finfo = new \finfo(FILEINFO_MIME_TYPE);
391
+ $realMime = $finfo->file($file->getPathname());
392
+
393
+ return in_array($realMime, $allowedMimes, true);
394
+ }
395
+ ```
396
+
397
+ ### Never store in a publicly accessible folder
398
+
399
+ ```php
400
+ // ❌ WRONG — an executable file can be served by the web server
401
+ $path = $request->file('avatar')->store('avatars', 'public');
402
+
403
+ // ✅ CORRECT — store outside public, serve via controller with auth check
404
+ $path = $request->file('avatar')->store('avatars', 'private');
405
+
406
+ // Controller to serve with authorization check
407
+ public function serveAvatar(User $user)
408
+ {
409
+ $this->authorize('view', $user);
410
+ return Storage::disk('private')->response($user->avatar_path);
411
+ }
412
+ ```
413
+
414
+ ### Rename the file (never use the original name)
415
+
416
+ ```php
417
+ // ❌ WRONG — original name may contain path traversal or overwrite existing files
418
+ $name = $request->file('avatar')->getClientOriginalName();
419
+
420
+ // ✅ CORRECT — generate a unique name server-side
421
+ // extension() detects the extension from the real MIME type, not the filename
422
+ $extension = $request->file('avatar')->extension();
423
+ $path = $request->file('avatar')->storeAs(
424
+ 'avatars/' . auth()->id(),
425
+ Str::uuid() . '.' . $extension,
426
+ 'private'
427
+ );
428
+ ```
429
+
430
+ ### Validate that an image belongs to the current domain (for external URLs)
431
+
432
+ ```php
433
+ // When accepting external image URLs (e.g., avatar via URL)
434
+ public function validateImageUrl(string $url): bool
435
+ {
436
+ $parsed = parse_url($url);
437
+
438
+ if (!isset($parsed['host'])) {
439
+ return false;
440
+ }
441
+
442
+ // Whitelist of allowed domains — define in .env or config
443
+ $allowedDomains = [
444
+ parse_url(config('app.url'), PHP_URL_HOST), // own domain
445
+ 'storage.googleapis.com',
446
+ 'your-bucket.s3.amazonaws.com',
447
+ ];
448
+
449
+ // str_ends_with prevents subdomain bypass (e.g., evil.storage.googleapis.com.evil.com)
450
+ foreach ($allowedDomains as $domain) {
451
+ if ($parsed['host'] === $domain || str_ends_with($parsed['host'], '.' . $domain)) {
452
+ return true;
453
+ }
454
+ }
455
+
456
+ return false;
457
+ }
458
+
459
+ // In the controller — download and re-validate before saving
460
+ public function updateAvatarUrl(Request $request)
461
+ {
462
+ $request->validate(['avatar_url' => 'required|url|max:2048']);
463
+
464
+ if (!$this->validateImageUrl($request->avatar_url)) {
465
+ abort(422, 'Image URL not allowed.');
466
+ }
467
+
468
+ // Check Content-Length before downloading (if available)
469
+ $maxBytes = 2 * 1024 * 1024; // 2MB
470
+ $head = Http::timeout(5)->head($request->avatar_url);
471
+ $contentLength = (int) $head->header('Content-Length');
472
+ if ($contentLength > $maxBytes) {
473
+ abort(422, 'Image too large.');
474
+ }
475
+
476
+ // Download with timeout — stream to temp file and enforce size limit
477
+ $tmpPath = tempnam(sys_get_temp_dir(), 'img_');
478
+ $response = Http::timeout(10)->withOptions([
479
+ 'sink' => $tmpPath,
480
+ 'stream' => true,
481
+ ])->get($request->avatar_url);
482
+
483
+ if (!$response->successful() || filesize($tmpPath) > $maxBytes) {
484
+ @unlink($tmpPath);
485
+ abort(422, 'Could not download the image or size exceeded.');
486
+ }
487
+
488
+ $body = file_get_contents($tmpPath);
489
+ @unlink($tmpPath);
490
+
491
+ // Validate real MIME of downloaded bytes — never trust the external server's Content-Type
492
+ $allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
493
+ $finfo = new \finfo(FILEINFO_MIME_TYPE);
494
+ $realMime = $finfo->buffer($body);
495
+
496
+ if (!in_array($realMime, $allowedMimes, true)) {
497
+ abort(422, 'The downloaded file is not a valid image.');
498
+ }
499
+
500
+ // Save with a server-generated filename
501
+ $extension = match ($realMime) {
502
+ 'image/jpeg' => 'jpg',
503
+ 'image/png' => 'png',
504
+ 'image/webp' => 'webp',
505
+ };
506
+
507
+ $path = 'avatars/' . auth()->id() . '/' . Str::uuid() . '.' . $extension;
508
+ Storage::disk('private')->put($path, $body);
509
+
510
+ auth()->user()->update(['avatar_path' => $path]);
511
+ }
512
+ ```
513
+
514
+ ### Limit size at the server level (nginx/php.ini)
515
+
516
+ ```nginx
517
+ # nginx.conf
518
+ client_max_body_size 10M;
519
+ ```
520
+
521
+ ```ini
522
+ ; php.ini
523
+ upload_max_filesize = 10M
524
+ post_max_size = 12M
525
+ ```
526
+
527
+ ---
528
+
529
+ ## 5. Mass Assignment
530
+
531
+ **Problem:** An overly broad `$fillable` allows users to write fields they shouldn't.
532
+
533
+ ```php
534
+ // ❌ DANGEROUS — empty $guarded accepts any field
535
+ class User extends Model
536
+ {
537
+ protected $guarded = [];
538
+ }
539
+
540
+ // ✅ CORRECT — explicit $fillable with only user-writable fields
541
+ class User extends Model
542
+ {
543
+ protected $fillable = [
544
+ 'name',
545
+ 'email',
546
+ 'phone',
547
+ // ❌ NEVER include: 'role', 'is_admin', 'email_verified_at'
548
+ ];
549
+ }
550
+
551
+ // Sensitive fields are updated explicitly via dedicated flows
552
+ public function promoteToAdmin(User $user): void
553
+ {
554
+ // Only super_admin can call this — protected by Policy
555
+ $user->update(['role' => 'admin']);
556
+ }
557
+ ```
558
+
559
+ ---
560
+
561
+ ## 6. Authorization and Roles
562
+
563
+ ### Protect routes in groups with middleware
564
+
565
+ ```php
566
+ // routes/web.php
567
+ Route::middleware(['auth', 'verified'])->group(function () {
568
+
569
+ // Admin routes
570
+ Route::middleware(['role:admin,super_admin'])->group(function () {
571
+ Route::resource('users', UserController::class);
572
+ Route::resource('settings', SettingController::class);
573
+ });
574
+
575
+ // Regular user routes
576
+ Route::resource('orders', OrderController::class);
577
+ });
578
+ ```
579
+
580
+ ### Check authorization at the top of every controller method
581
+
582
+ ```php
583
+ public function update(Request $request, Post $post)
584
+ {
585
+ $this->authorize('update', $post); // automatic 403 if unauthorized
586
+ $post->update($request->validated());
587
+ }
588
+ ```
589
+
590
+ ### Never expose sequential IDs in sensitive resources
591
+
592
+ ```php
593
+ // ❌ BAD — enumeration attack: /orders/1, /orders/2...
594
+ Route::get('/orders/{order}', [OrderController::class, 'show']);
595
+
596
+ // ✅ GOOD — use UUID
597
+ // Migration:
598
+ $table->uuid('id')->primary();
599
+
600
+ // Model — required when using UUID as PK:
601
+ class Order extends Model
602
+ {
603
+ public $incrementing = false;
604
+ protected $keyType = 'string';
605
+
606
+ protected static function boot(): void
607
+ {
608
+ parent::boot();
609
+ static::creating(fn ($model) => $model->id ??= (string) Str::uuid());
610
+ }
611
+ }
612
+ ```
613
+
614
+ ---
615
+
616
+ ## 7. CSRF
617
+
618
+ **Problem:** Forged requests from other domains execute authenticated actions on behalf of the user.
619
+
620
+ Laravel protects automatically via the `VerifyCsrfToken` middleware for web routes. **Never remove this middleware.**
621
+
622
+ ### Inertia.js — automatic protection via cookie
623
+
624
+ Laravel writes the `XSRF-TOKEN` cookie with `http_only: false` automatically (via `VerifyCsrfToken`). Inertia reads this cookie and sends the `X-XSRF-TOKEN` header on every request. **No additional configuration is needed** — do not touch `config/session.php` for this.
625
+
626
+ The session cookie (`laravel_session`) stays `http_only: true` by default and must never be changed.
627
+
628
+ ### Check middleware exclusions
629
+
630
+ ```php
631
+ // app/Http/Middleware/VerifyCsrfToken.php
632
+ class VerifyCsrfToken extends Middleware
633
+ {
634
+ protected $except = [
635
+ // ⚠️ Only exclude webhook routes with their own verification (e.g., Stripe signature)
636
+ 'webhooks/stripe',
637
+ // ❌ NEVER exclude routes used by authenticated users
638
+ ];
639
+ }
640
+ ```
641
+
642
+ ### REST APIs — use Sanctum with stateful authentication
643
+
644
+ ```php
645
+ // config/sanctum.php
646
+ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
647
+ '%s%s',
648
+ 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
649
+ env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : ''
650
+ ))),
651
+ ```
652
+
653
+ ---
654
+
655
+ ## 8. XSS — Cross-Site Scripting (Inertia/Vue)
656
+
657
+ **Inertia-specific problem:** PHP props are passed as JSON to Vue. If rendered with `v-html`, they execute malicious scripts.
658
+
659
+ ```vue
660
+ <!-- ❌ NEVER use v-html with user data -->
661
+ <div v-html="user.bio"></div>
662
+
663
+ <!-- ✅ CORRECT — default Vue interpolation escapes automatically -->
664
+ <div>{{ user.bio }}</div>
665
+
666
+ <!-- If you need to render HTML (e.g., rich text) — sanitize on the server first -->
667
+ <!-- And use a component with an allowed tags whitelist -->
668
+ ```
669
+
670
+ ### Sanitize rich HTML before saving (not only when displaying)
671
+
672
+ ```php
673
+ // composer require mews/purifier
674
+ // Configure allowed tags in config/purifier.php
675
+
676
+ $post->content = Purifier::clean($request->content);
677
+ $post->save();
678
+ ```
679
+
680
+ ### Content Security Policy reinforces the defense
681
+
682
+ The CSP configured in the headers section limits which scripts can execute even if XSS occurs.
683
+
684
+ ---
685
+
686
+ ## 9. Rate Limiting
687
+
688
+ ```php
689
+ // bootstrap/app.php (Laravel 11+) or RouteServiceProvider
690
+
691
+ RateLimiter::for('login', function (Request $request) {
692
+ return [
693
+ Limit::perMinute(5)->by($request->ip()),
694
+ Limit::perMinute(10)->by($request->input('email')),
695
+ ];
696
+ });
697
+
698
+ RateLimiter::for('api', function (Request $request) {
699
+ return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
700
+ });
701
+
702
+ RateLimiter::for('uploads', function (Request $request) {
703
+ // Use ?-> and IP fallback in case middleware runs before auth
704
+ return Limit::perHour(20)->by($request->user()?->id ?: $request->ip());
705
+ });
706
+ ```
707
+
708
+ ```php
709
+ // Apply to routes
710
+ Route::post('/login', ...)->middleware('throttle:login');
711
+ Route::post('/avatars', ...)->middleware(['auth', 'throttle:uploads']);
712
+ ```
713
+
714
+ ---
715
+
716
+ ## 10. Session
717
+
718
+ ```php
719
+ // .env — production
720
+ SESSION_DRIVER=database # or redis — never 'file' on multi-server setups
721
+ SESSION_LIFETIME=120 # minutes of inactivity
722
+ SESSION_ENCRYPT=true # encrypt session contents
723
+ SESSION_SECURE_COOKIE=true # HTTPS only
724
+ SESSION_SAME_SITE=lax # protects against cross-origin CSRF
725
+ ```
726
+
727
+ ```php
728
+ // config/session.php — verify
729
+ 'http_only' => true, // session cookie not accessible via JS
730
+ 'secure' => env('SESSION_SECURE_COOKIE', true),
731
+ 'same_site' => env('SESSION_SAME_SITE', 'lax'),
732
+ ```
733
+
734
+ ### Regenerate session ID after login
735
+
736
+ Laravel/Fortify does this automatically. If implementing manual authentication:
737
+
738
+ ```php
739
+ $request->session()->regenerate(); // after authenticating the user
740
+ ```
741
+
742
+ ---
743
+
744
+ ## 11. Security Headers
745
+
746
+ ```php
747
+ // app/Http/Middleware/SecurityHeaders.php
748
+ class SecurityHeaders
749
+ {
750
+ public function handle(Request $request, Closure $next): Response
751
+ {
752
+ $response = $next($request);
753
+
754
+ $response->headers->set('X-Content-Type-Options', 'nosniff');
755
+ $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
756
+ $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
757
+ $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
758
+
759
+ // 'unsafe-inline' in style-src is required for Vue with scoped styles.
760
+ // For stricter environments, use nonces or style hashes.
761
+ $response->headers->set(
762
+ 'Content-Security-Policy',
763
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;"
764
+ );
765
+
766
+ // Remove headers that expose server information
767
+ $response->headers->remove('X-Powered-By');
768
+ $response->headers->remove('Server');
769
+
770
+ // Note: X-XSS-Protection was removed from the W3C spec and can introduce bugs
771
+ // in older browsers. The correct XSS mitigation is the CSP above.
772
+
773
+ return $response;
774
+ }
775
+ }
776
+ ```
777
+
778
+ ```php
779
+ // bootstrap/app.php (Laravel 11+)
780
+ ->withMiddleware(function (Middleware $middleware) {
781
+ $middleware->append(SecurityHeaders::class);
782
+ })
783
+ ```
784
+
785
+ ---
786
+
787
+ ## 12. Passwords and Credentials
788
+
789
+ ```php
790
+ // ❌ WRONG — plain text comparison
791
+ if ($request->password === config('app.master_password')) { ... }
792
+
793
+ // ✅ CORRECT — always use Hash::check()
794
+ if (!Hash::check($request->password, $user->password)) {
795
+ abort(403, 'Incorrect password.');
796
+ }
797
+
798
+ // ✅ Generate a secure temporary password (never hardcode)
799
+ $temporaryPassword = Str::password(length: 12, symbols: false);
800
+ // Show ONCE in the admin UI, never save to logs
801
+
802
+ // ❌ NEVER commit to git — .env must be in .gitignore
803
+ // Use environment variables for all credentials
804
+ ```
805
+
806
+ ### APP_DEBUG in production — critical risk
807
+
808
+ `APP_DEBUG=true` in production exposes full stack traces, environment variables, and database credentials directly in HTTP responses. Any error becomes an information leak.
809
+
810
+ ```php
811
+ // .env — required in production
812
+ APP_DEBUG=false
813
+ APP_ENV=production
814
+ ```
815
+
816
+ ### Never expose config() in the API
817
+
818
+ ```php
819
+ // ❌ WRONG — exposes all configs including credentials
820
+ return response()->json(config()->all());
821
+
822
+ // ✅ CORRECT — return only what the client needs
823
+ return response()->json([
824
+ 'app_name' => config('app.name'),
825
+ 'timezone' => config('app.timezone'),
826
+ ]);
827
+ ```
828
+
829
+ ### Logs — never record sensitive data
830
+
831
+ ```php
832
+ // ❌ WRONG — password appears in the log
833
+ Log::info('Login attempt', $request->all());
834
+
835
+ // ✅ CORRECT — exclude sensitive fields
836
+ Log::info('Login attempt', $request->except(['password', 'password_confirmation', 'token']));
837
+ ```
838
+
839
+ ---
840
+
841
+ ## 13. What Never Goes to Git
842
+
843
+ **Rule: If it has an IP, password, server path, or key — it stays out of the repository.**
844
+
845
+ ### Required .gitignore entries
846
+
847
+ ```gitignore
848
+ # Environment variables — NEVER commit
849
+ .env
850
+ .env.*
851
+ !.env.example # only the example without real values goes in git
852
+
853
+ # Deploy — contains server IP and folder path
854
+ deploy.php
855
+ deployer.php
856
+ deploy/
857
+ .deployer/
858
+
859
+ # SSH keys and certificates
860
+ *.pem
861
+ *.key
862
+ *.p12
863
+ *.pfx
864
+ id_rsa
865
+ id_ed25519
866
+
867
+ # Local IDE / editor settings
868
+ .idea/
869
+ .vscode/settings.json
870
+ *.local
871
+
872
+ # Logs — may contain sensitive data
873
+ storage/logs/
874
+ *.log
875
+
876
+ # Dependencies — never commit
877
+ /vendor/
878
+ /node_modules/
879
+ ```
880
+
881
+ ### .env — never commit, always use .env.example
882
+
883
+ ```bash
884
+ # .env.example — goes in git, without real values
885
+ APP_NAME=
886
+ APP_ENV=local
887
+ APP_KEY=
888
+ APP_DEBUG=false
889
+ APP_URL=
890
+
891
+ DB_HOST=
892
+ DB_PORT=3306
893
+ DB_DATABASE=
894
+ DB_USERNAME=
895
+ DB_PASSWORD=
896
+
897
+ MAIL_HOST=
898
+ MAIL_USERNAME=
899
+ MAIL_PASSWORD=
900
+
901
+ AWS_ACCESS_KEY_ID=
902
+ AWS_SECRET_ACCESS_KEY=
903
+ AWS_BUCKET=
904
+ ```
905
+
906
+ ```bash
907
+ # On the production server — generate key without committing
908
+ php artisan key:generate
909
+ ```
910
+
911
+ ### deploy.php — server IP and folder
912
+
913
+ `deploy.php` (Deployer) contains the server IP and deploy directory path. **It must never enter the repository.**
914
+
915
+ ```php
916
+ // deploy.php — OUT OF GIT (.gitignore)
917
+
918
+ host('YOUR_IP_HERE') // ❌ real server IP
919
+ ->set('deploy_path', '/var/www/project') // ❌ real path
920
+ ->set('remote_user', 'deploy');
921
+ ```
922
+
923
+ **Safe alternative:** keep a `deploy.example.php` in the repository with placeholders:
924
+
925
+ ```php
926
+ // deploy.example.php — CAN go in git (no real data)
927
+
928
+ host(env('DEPLOY_HOST', 'YOUR_IP_HERE'))
929
+ ->set('deploy_path', env('DEPLOY_PATH', '/var/www/project'))
930
+ ->set('remote_user', env('DEPLOY_USER', 'deploy'));
931
+ ```
932
+
933
+ ```bash
934
+ # .env.deploy (local, outside git)
935
+ DEPLOY_HOST=192.168.1.100
936
+ DEPLOY_PATH=/var/www/my-project
937
+ DEPLOY_USER=deploy
938
+ ```
939
+
940
+ ### Check if anything sensitive was already pushed
941
+
942
+ ```bash
943
+ # Search git history for possible leaks
944
+ git log --all --full-history -- .env
945
+ git log --all --full-history -- deploy.php
946
+
947
+ # Search for suspicious strings in history (passwords, IPs)
948
+ git grep -i "password\s*=" $(git rev-list --all)
949
+ git grep -i "DB_PASSWORD" $(git rev-list --all)
950
+
951
+ # List all files that ever existed in the repository
952
+ git log --all --name-only --format="" | sort -u | grep -E "\.(env|pem|key)$"
953
+ ```
954
+
955
+ > **If a secret was already committed**, consider it compromised even after removal.
956
+ > Required actions:
957
+ > 1. Revoke/rotate the credential immediately
958
+ > 2. Remove from history with `git filter-repo` (not just `git rm`)
959
+ > 3. Force all collaborators to re-clone the repository
960
+
961
+ ### Automatic verification with git hooks
962
+
963
+ ```bash
964
+ # .git/hooks/pre-commit — block commits of sensitive files
965
+ #!/bin/sh
966
+
967
+ BLOCKED=".env deploy.php *.pem *.key"
968
+
969
+ for pattern in $BLOCKED; do
970
+ if git diff --cached --name-only | grep -qE "$pattern"; then
971
+ echo "❌ BLOCKED: attempt to commit sensitive file ($pattern)"
972
+ echo " Add to .gitignore and run 'git rm --cached <file>'"
973
+ exit 1
974
+ fi
975
+ done
976
+
977
+ # Block hardcoded password/IP strings
978
+ if git diff --cached | grep -iE "(password|secret|api_key)\s*=\s*['\"][^'\"]{4,}"; then
979
+ echo "❌ BLOCKED: possible hardcoded credential detected in diff"
980
+ exit 1
981
+ fi
982
+ ```
983
+
984
+ ```bash
985
+ # Make the hook executable
986
+ chmod +x .git/hooks/pre-commit
987
+ ```
988
+
989
+ ---
990
+
991
+ ## 14. Pre-Deploy Checklist
992
+
993
+ ### Authorization / IDOR
994
+ - [ ] All resource controllers have `$this->authorize()`
995
+ - [ ] Routes grouped with role/permission middleware
996
+ - [ ] Policies registered for every sensitive Model
997
+ - [ ] `role` field never accepted via mass assignment
998
+ - [ ] Sequential IDs in public routes replaced with UUID
999
+
1000
+ ### SQL / Queries
1001
+ - [ ] Zero use of `DB::unprepared()` with user input
1002
+ - [ ] Dynamic `orderBy` and `groupBy` use column whitelists
1003
+ - [ ] CSV/JSON imports use `$fillable` whitelist with size check
1004
+
1005
+ ### Validation
1006
+ - [ ] Every field has `min` and `max` defined
1007
+ - [ ] FormRequests used instead of `$request->all()`
1008
+ - [ ] `email:rfc` (or `email:rfc,dns` if the environment has external DNS)
1009
+ - [ ] `password` limited to `max:72` (real bcrypt limit)
1010
+
1011
+ ### Upload / Images
1012
+ - [ ] `mimetypes:` (not `mimes:`) to validate real file bytes
1013
+ - [ ] `max:` in KB defined for every upload
1014
+ - [ ] Files stored outside `public/` (private disk)
1015
+ - [ ] Filename generated server-side with `Str::uuid()`
1016
+ - [ ] External image URLs validated against domain whitelist
1017
+ - [ ] MIME of downloaded bytes re-validated with `finfo` before saving
1018
+ - [ ] `client_max_body_size` configured in nginx
1019
+
1020
+ ### CSRF / Session
1021
+ - [ ] `VerifyCsrfToken` middleware active (no improper exclusions)
1022
+ - [ ] `SANCTUM_STATEFUL_DOMAINS` configured correctly
1023
+ - [ ] `SESSION_SECURE_COOKIE=true` in production
1024
+ - [ ] `SESSION_SAME_SITE=lax` in production
1025
+ - [ ] `SESSION_ENCRYPT=true` in production
1026
+
1027
+ ### XSS
1028
+ - [ ] No `v-html` with user data in the frontend
1029
+ - [ ] Rich HTML sanitized with Purifier before saving to the database
1030
+
1031
+ ### Rate Limiting
1032
+ - [ ] Rate limit on login by IP and by email
1033
+ - [ ] Rate limit on uploads by user
1034
+ - [ ] Rate limit on critical endpoints (passwords, payments)
1035
+
1036
+ ### Credentials / Config
1037
+ - [ ] `APP_DEBUG=false` in production
1038
+ - [ ] `APP_ENV=production` in production
1039
+ - [ ] `.env` in `.gitignore`
1040
+ - [ ] Passwords compared with `Hash::check()`, never `===`
1041
+ - [ ] No `config()->all()` exposed via API
1042
+ - [ ] Logs do not record sensitive fields (password, token)
1043
+
1044
+ ### Git — Secrets and Sensitive Variables
1045
+ - [ ] `.env` in `.gitignore` (and all `.env.*`)
1046
+ - [ ] `deploy.php` in `.gitignore` (contains server IP and path)
1047
+ - [ ] `.env.example` exists in the repository without real values
1048
+ - [ ] `deploy.example.php` exists with placeholders instead of real data
1049
+ - [ ] No server IP hardcoded in any committed file
1050
+ - [ ] No absolute server path in any committed file (`/var/www/...`)
1051
+ - [ ] SSH keys and certificates (`.pem`, `.key`) in `.gitignore`
1052
+ - [ ] `pre-commit` hook configured to block sensitive files
1053
+ - [ ] Git history verified: `git log --all -- .env` returns empty
1054
+
1055
+ ---
1056
+
1057
+ > **Project-specific security decisions:**
1058
+ >
1059
+ > - Allowed image domains: `[ ]`
1060
+ > - Existing roles: `[ ]`
1061
+ > - Maximum upload size: `[ ]`
1062
+ > - Custom rate limits: `[ ]`
1063
+ > - CSRF-excluded routes: `[ ]` (webhooks with own verification only)
@@ -0,0 +1,26 @@
1
+ #!/bin/sh
2
+ # laravel-security-agent (Capi Guard) — pre-commit hook
3
+
4
+ BLOCKED_FILES=".env deploy.php"
5
+ BLOCKED_PATTERNS="\.pem$|\.key$|\.p12$|id_rsa|id_ed25519"
6
+
7
+ for f in $BLOCKED_FILES; do
8
+ if git diff --cached --name-only | grep -qF "$f"; then
9
+ echo "❌ BLOCKED: attempt to commit '$f' (sensitive file)"
10
+ echo " Add to .gitignore: echo '$f' >> .gitignore"
11
+ exit 1
12
+ fi
13
+ done
14
+
15
+ if git diff --cached --name-only | grep -qE "$BLOCKED_PATTERNS"; then
16
+ echo "❌ BLOCKED: attempt to commit a key or certificate file"
17
+ exit 1
18
+ fi
19
+
20
+ if git diff --cached | grep -iE "(DB_PASSWORD|APP_KEY|password\s*=\s*['\"][^'\"]{4,}|secret\s*=\s*['\"][^'\"]{4,})"; then
21
+ echo "❌ BLOCKED: possible hardcoded credential detected in diff"
22
+ echo " Use environment variables (.env) instead of inline values"
23
+ exit 1
24
+ fi
25
+
26
+ exit 0