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 +76 -0
- package/bin/index.js +100 -0
- package/package.json +40 -0
- package/public/readme.png +0 -0
- package/src/copy-security.js +19 -0
- package/src/install-hook.js +21 -0
- package/src/update-gitignore.js +44 -0
- package/templates/SECURITY.md +1063 -0
- package/templates/pre-commit +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# 🐾 Capi Guard — Laravel Security Agent
|
|
2
|
+
|
|
3
|
+

|
|
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
|