start-vibing-stacks 2.1.1 → 2.2.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/dist/ui.js +1 -1
- package/package.json +1 -1
- package/stacks/php/skills/api-security/SKILL.md +431 -0
package/dist/ui.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Start Vibing Stacks — Terminal UI
|
|
3
3
|
*/
|
|
4
4
|
import chalk from 'chalk';
|
|
5
|
-
const VERSION = '2.
|
|
5
|
+
const VERSION = '2.2.0';
|
|
6
6
|
const gradient = (text) => {
|
|
7
7
|
const colors = [chalk.hex('#FF6B6B'), chalk.hex('#FF8E53'), chalk.hex('#FFBD2E'), chalk.hex('#48BB78'), chalk.hex('#4299E1'), chalk.hex('#9F7AEA')];
|
|
8
8
|
return text.split('').map((c, i) => colors[i % colors.length](c)).join('');
|
package/package.json
CHANGED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# API Security — NSA-Level Hardening for Laravel + Octane
|
|
2
|
+
|
|
3
|
+
**ALWAYS invoke when building APIs, auth endpoints, or handling user input.**
|
|
4
|
+
|
|
5
|
+
## Security Layers
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Internet → Cloudflare WAF → Rate Limiting → CORS → Auth Middleware
|
|
9
|
+
→ Input Validation → Business Logic → Output Sanitization → Response
|
|
10
|
+
|
|
11
|
+
Every layer is a wall. Assume the previous one failed.
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 1. Authentication (Sanctum + Octane)
|
|
15
|
+
|
|
16
|
+
### Token-Based (API)
|
|
17
|
+
|
|
18
|
+
```php
|
|
19
|
+
// config/sanctum.php
|
|
20
|
+
'expiration' => 60 * 24, // 24h token expiry (MANDATORY)
|
|
21
|
+
'token_prefix' => 'flk_', // Prefix for easy log scanning
|
|
22
|
+
|
|
23
|
+
// Issue token with abilities (least privilege)
|
|
24
|
+
$token = $user->createToken('api-client', [
|
|
25
|
+
'read:leads',
|
|
26
|
+
'write:leads',
|
|
27
|
+
// NOT '*' — never wildcard abilities
|
|
28
|
+
])->plainTextToken;
|
|
29
|
+
|
|
30
|
+
// Middleware: check specific ability
|
|
31
|
+
Route::middleware(['auth:sanctum', 'ability:read:leads'])->group(function () {
|
|
32
|
+
Route::get('/api/v1/leads', [LeadController::class, 'index']);
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Token Rotation
|
|
37
|
+
|
|
38
|
+
```php
|
|
39
|
+
// Rotate on every sensitive action
|
|
40
|
+
public function changePassword(Request $request): JsonResponse
|
|
41
|
+
{
|
|
42
|
+
// ... change password logic
|
|
43
|
+
|
|
44
|
+
// Revoke ALL tokens (force re-login everywhere)
|
|
45
|
+
$request->user()->tokens()->delete();
|
|
46
|
+
|
|
47
|
+
// Issue fresh token
|
|
48
|
+
$newToken = $request->user()->createToken('session', ['*'])->plainTextToken;
|
|
49
|
+
|
|
50
|
+
return $this->success(['token' => $newToken]);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Octane Session Safety
|
|
55
|
+
|
|
56
|
+
```php
|
|
57
|
+
// app/Providers/AppServiceProvider.php
|
|
58
|
+
use Laravel\Octane\Facades\Octane;
|
|
59
|
+
|
|
60
|
+
public function boot(): void
|
|
61
|
+
{
|
|
62
|
+
// MANDATORY: flush auth state between requests
|
|
63
|
+
Octane::prepare(function ($sandbox) {
|
|
64
|
+
$sandbox->forgetScopedInstances();
|
|
65
|
+
$sandbox->flushDatabaseConnections();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 2. Input Validation (Trust NOTHING)
|
|
71
|
+
|
|
72
|
+
### FormRequest (Always)
|
|
73
|
+
|
|
74
|
+
```php
|
|
75
|
+
// app/Http/Requests/StoreLeadRequest.php
|
|
76
|
+
class StoreLeadRequest extends FormRequest
|
|
77
|
+
{
|
|
78
|
+
public function authorize(): bool
|
|
79
|
+
{
|
|
80
|
+
return $this->user()->tokenCan('write:leads');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public function rules(): array
|
|
84
|
+
{
|
|
85
|
+
return [
|
|
86
|
+
'name' => ['required', 'string', 'min:2', 'max:255'],
|
|
87
|
+
'email' => ['required', 'email:rfc,dns', 'max:320'], // RFC + DNS check
|
|
88
|
+
'phone' => ['nullable', 'string', 'regex:/^\+?[1-9]\d{1,14}$/'], // E.164
|
|
89
|
+
'domain_id' => ['required', 'uuid', 'exists:domains,id'],
|
|
90
|
+
'metadata' => ['nullable', 'json', 'max:10000'], // Size limit on JSON
|
|
91
|
+
'tags' => ['nullable', 'array', 'max:10'],
|
|
92
|
+
'tags.*' => ['string', 'max:50', 'alpha_dash'],
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Sanitize AFTER validation
|
|
97
|
+
public function validated($key = null, $default = null): array
|
|
98
|
+
{
|
|
99
|
+
$data = parent::validated($key, $default);
|
|
100
|
+
$data['email'] = strtolower(trim($data['email']));
|
|
101
|
+
$data['name'] = strip_tags($data['name']);
|
|
102
|
+
return $data;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### SQL Injection Prevention
|
|
108
|
+
|
|
109
|
+
```php
|
|
110
|
+
// ✅ ALWAYS Eloquent or parameterized
|
|
111
|
+
Lead::where('email', $request->validated('email'))->first();
|
|
112
|
+
|
|
113
|
+
// ✅ Raw with bindings
|
|
114
|
+
DB::select('SELECT * FROM leads WHERE email = ?', [$email]);
|
|
115
|
+
|
|
116
|
+
// ❌ NEVER interpolate user input
|
|
117
|
+
DB::select("SELECT * FROM leads WHERE email = '{$email}'"); // ❌ SQL INJECTION
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Mass Assignment Protection
|
|
121
|
+
|
|
122
|
+
```php
|
|
123
|
+
class Lead extends Model
|
|
124
|
+
{
|
|
125
|
+
// Explicit fillable (whitelist approach)
|
|
126
|
+
protected $fillable = [
|
|
127
|
+
'name', 'email', 'phone', 'domain_id', 'metadata',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
// NEVER: protected $guarded = []; ← allows EVERYTHING
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## 3. Rate Limiting (Multi-Layer)
|
|
135
|
+
|
|
136
|
+
```php
|
|
137
|
+
// app/Providers/AppServiceProvider.php
|
|
138
|
+
use Illuminate\Cache\RateLimiting\Limit;
|
|
139
|
+
use Illuminate\Support\Facades\RateLimiter;
|
|
140
|
+
|
|
141
|
+
public function boot(): void
|
|
142
|
+
{
|
|
143
|
+
// Global API limit
|
|
144
|
+
RateLimiter::for('api', function ($request) {
|
|
145
|
+
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Strict limit on auth endpoints
|
|
149
|
+
RateLimiter::for('auth', function ($request) {
|
|
150
|
+
return [
|
|
151
|
+
Limit::perMinute(5)->by($request->ip()), // 5/min per IP
|
|
152
|
+
Limit::perHour(20)->by($request->ip()), // 20/hour per IP
|
|
153
|
+
Limit::perDay(50)->by($request->ip()), // 50/day per IP
|
|
154
|
+
];
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Strict limit on sensitive actions
|
|
158
|
+
RateLimiter::for('sensitive', function ($request) {
|
|
159
|
+
return Limit::perMinute(3)->by($request->user()->id); // 3/min per user
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Webhook endpoints
|
|
163
|
+
RateLimiter::for('webhooks', function ($request) {
|
|
164
|
+
return Limit::perMinute(100)->by($request->ip());
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Routes
|
|
169
|
+
Route::middleware('throttle:auth')->group(function () {
|
|
170
|
+
Route::post('/login', [AuthController::class, 'login']);
|
|
171
|
+
Route::post('/register', [AuthController::class, 'register']);
|
|
172
|
+
Route::post('/forgot-password', [AuthController::class, 'forgotPassword']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
Route::middleware('throttle:sensitive')->group(function () {
|
|
176
|
+
Route::post('/change-password', [AuthController::class, 'changePassword']);
|
|
177
|
+
Route::post('/change-email', [AuthController::class, 'changeEmail']);
|
|
178
|
+
Route::delete('/account', [AuthController::class, 'deleteAccount']);
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## 4. CORS (Restrictive)
|
|
183
|
+
|
|
184
|
+
```php
|
|
185
|
+
// config/cors.php
|
|
186
|
+
return [
|
|
187
|
+
'paths' => ['api/*'],
|
|
188
|
+
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
|
189
|
+
'allowed_origins' => [
|
|
190
|
+
env('APP_URL'), // Only your domain
|
|
191
|
+
// NOT '*' — NEVER wildcard in production
|
|
192
|
+
],
|
|
193
|
+
'allowed_headers' => [
|
|
194
|
+
'Content-Type', 'Authorization', 'X-Requested-With',
|
|
195
|
+
'X-Timezone', 'Accept', 'X-CSRF-TOKEN',
|
|
196
|
+
],
|
|
197
|
+
'exposed_headers' => ['X-Request-ID', 'Retry-After'],
|
|
198
|
+
'max_age' => 86400, // 24h preflight cache
|
|
199
|
+
'supports_credentials' => true,
|
|
200
|
+
];
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## 5. Security Headers Middleware
|
|
204
|
+
|
|
205
|
+
```php
|
|
206
|
+
// app/Http/Middleware/SecurityHeaders.php
|
|
207
|
+
class SecurityHeaders
|
|
208
|
+
{
|
|
209
|
+
public function handle($request, Closure $next)
|
|
210
|
+
{
|
|
211
|
+
$response = $next($request);
|
|
212
|
+
|
|
213
|
+
return $response
|
|
214
|
+
->header('X-Content-Type-Options', 'nosniff')
|
|
215
|
+
->header('X-Frame-Options', 'DENY')
|
|
216
|
+
->header('X-XSS-Protection', '0') // Modern browsers use CSP instead
|
|
217
|
+
->header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
218
|
+
->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
|
219
|
+
->header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload')
|
|
220
|
+
->header('X-Request-ID', $request->attributes->get('request_id', (string) Str::uuid()))
|
|
221
|
+
->header('Content-Security-Policy', implode('; ', [
|
|
222
|
+
"default-src 'self'",
|
|
223
|
+
"script-src 'self' 'unsafe-inline' https://js.stripe.com",
|
|
224
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
225
|
+
"img-src 'self' data: https:",
|
|
226
|
+
"font-src 'self' https://fonts.gstatic.com",
|
|
227
|
+
"connect-src 'self' https://api.stripe.com",
|
|
228
|
+
"frame-src https://js.stripe.com",
|
|
229
|
+
"object-src 'none'",
|
|
230
|
+
"base-uri 'self'",
|
|
231
|
+
]));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## 6. Output Sanitization
|
|
237
|
+
|
|
238
|
+
```php
|
|
239
|
+
// app/Http/Resources/LeadResource.php
|
|
240
|
+
class LeadResource extends JsonResource
|
|
241
|
+
{
|
|
242
|
+
use FormatsDatesForApi;
|
|
243
|
+
|
|
244
|
+
public function toArray(Request $request): array
|
|
245
|
+
{
|
|
246
|
+
return [
|
|
247
|
+
'id' => $this->id,
|
|
248
|
+
'name' => e($this->name), // HTML entity encode
|
|
249
|
+
'email' => $this->email,
|
|
250
|
+
'status' => $this->status->value,
|
|
251
|
+
'created_at' => $this->formatDateTime($this->created_at, $request),
|
|
252
|
+
// NEVER expose:
|
|
253
|
+
// 'password' → NEVER
|
|
254
|
+
// 'api_token' → NEVER
|
|
255
|
+
// 'ip_address' → only if admin
|
|
256
|
+
// 'remember_token' → NEVER
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Model hidden attributes (defense in depth)
|
|
262
|
+
class User extends Authenticatable
|
|
263
|
+
{
|
|
264
|
+
protected $hidden = [
|
|
265
|
+
'password',
|
|
266
|
+
'remember_token',
|
|
267
|
+
'two_factor_secret',
|
|
268
|
+
'two_factor_recovery_codes',
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## 7. Encryption at Rest
|
|
274
|
+
|
|
275
|
+
```php
|
|
276
|
+
// Encrypt sensitive data in database
|
|
277
|
+
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
278
|
+
|
|
279
|
+
class ApiCredential extends Model
|
|
280
|
+
{
|
|
281
|
+
// Laravel encrypted cast (AES-256-CBC)
|
|
282
|
+
protected $casts = [
|
|
283
|
+
'api_key' => 'encrypted',
|
|
284
|
+
'api_secret' => 'encrypted',
|
|
285
|
+
'webhook_secret' => 'encrypted',
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// For searchable encrypted fields
|
|
290
|
+
use Illuminate\Support\Facades\Crypt;
|
|
291
|
+
|
|
292
|
+
class Lead extends Model
|
|
293
|
+
{
|
|
294
|
+
// Store hash for lookup, encrypted value for display
|
|
295
|
+
protected static function booted(): void
|
|
296
|
+
{
|
|
297
|
+
static::creating(function ($lead) {
|
|
298
|
+
$lead->email_hash = hash('sha256', strtolower($lead->email));
|
|
299
|
+
$lead->email_encrypted = Crypt::encryptString($lead->email);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## 8. Audit Logging
|
|
306
|
+
|
|
307
|
+
```php
|
|
308
|
+
// app/Traits/Auditable.php
|
|
309
|
+
trait Auditable
|
|
310
|
+
{
|
|
311
|
+
protected static function bootAuditable(): void
|
|
312
|
+
{
|
|
313
|
+
foreach (['created', 'updated', 'deleted'] as $event) {
|
|
314
|
+
static::$event(function ($model) use ($event) {
|
|
315
|
+
AuditLog::create([
|
|
316
|
+
'auditable_type' => $model->getMorphClass(),
|
|
317
|
+
'auditable_id' => $model->getKey(),
|
|
318
|
+
'event' => $event,
|
|
319
|
+
'old_values' => $event === 'updated' ? $model->getOriginal() : null,
|
|
320
|
+
'new_values' => $event !== 'deleted' ? $model->getAttributes() : null,
|
|
321
|
+
'user_id' => auth()->id(),
|
|
322
|
+
'ip_address' => request()->ip(),
|
|
323
|
+
'user_agent' => request()->userAgent(),
|
|
324
|
+
]);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Usage on sensitive models
|
|
331
|
+
class Lead extends Model
|
|
332
|
+
{
|
|
333
|
+
use HasUuids, Auditable;
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## 9. Brute Force Protection
|
|
338
|
+
|
|
339
|
+
```php
|
|
340
|
+
// app/Http/Controllers/Auth/LoginController.php
|
|
341
|
+
public function login(LoginRequest $request): JsonResponse
|
|
342
|
+
{
|
|
343
|
+
$key = 'login_attempts:' . $request->ip() . ':' . Str::lower($request->email);
|
|
344
|
+
|
|
345
|
+
// Check lockout (progressive delay)
|
|
346
|
+
$attempts = (int) Cache::get($key, 0);
|
|
347
|
+
if ($attempts >= 5) {
|
|
348
|
+
$lockoutMinutes = min(pow(2, $attempts - 5), 60); // 1, 2, 4, 8, 16, 32, 60 min
|
|
349
|
+
$ttl = Cache::get("{$key}:lockout");
|
|
350
|
+
if ($ttl && now()->lt($ttl)) {
|
|
351
|
+
return $this->error('Too many attempts. Try again later.', 429);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!Auth::attempt($request->validated())) {
|
|
356
|
+
// Increment with exponential TTL
|
|
357
|
+
Cache::put($key, $attempts + 1, now()->addHours(1));
|
|
358
|
+
if ($attempts + 1 >= 5) {
|
|
359
|
+
$lockoutMinutes = min(pow(2, $attempts - 4), 60);
|
|
360
|
+
Cache::put("{$key}:lockout", now()->addMinutes($lockoutMinutes), now()->addMinutes($lockoutMinutes));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Generic message (don't reveal if user exists)
|
|
364
|
+
return $this->error('Invalid credentials', 401);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Reset on success
|
|
368
|
+
Cache::forget($key);
|
|
369
|
+
Cache::forget("{$key}:lockout");
|
|
370
|
+
|
|
371
|
+
$token = $request->user()->createToken('session')->plainTextToken;
|
|
372
|
+
return $this->success(['token' => $token]);
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## 10. Request ID Tracking
|
|
377
|
+
|
|
378
|
+
```php
|
|
379
|
+
// app/Http/Middleware/RequestId.php
|
|
380
|
+
class RequestId
|
|
381
|
+
{
|
|
382
|
+
public function handle($request, Closure $next)
|
|
383
|
+
{
|
|
384
|
+
$requestId = $request->header('X-Request-ID', (string) Str::uuid());
|
|
385
|
+
$request->attributes->set('request_id', $requestId);
|
|
386
|
+
|
|
387
|
+
// Add to all log entries in this request
|
|
388
|
+
Log::shareContext(['request_id' => $requestId]);
|
|
389
|
+
|
|
390
|
+
$response = $next($request);
|
|
391
|
+
$response->header('X-Request-ID', $requestId);
|
|
392
|
+
|
|
393
|
+
return $response;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
## Security Checklist — Before Deploy
|
|
399
|
+
|
|
400
|
+
- [ ] All endpoints have auth middleware
|
|
401
|
+
- [ ] All input validated via FormRequest
|
|
402
|
+
- [ ] Rate limiting on auth + sensitive endpoints
|
|
403
|
+
- [ ] CORS restricted to your domain only
|
|
404
|
+
- [ ] Security headers middleware active
|
|
405
|
+
- [ ] Sensitive fields `$hidden` on models
|
|
406
|
+
- [ ] API keys encrypted at rest
|
|
407
|
+
- [ ] Audit logging on sensitive models
|
|
408
|
+
- [ ] Brute force protection on login
|
|
409
|
+
- [ ] Request ID tracking in all logs
|
|
410
|
+
- [ ] No `env()` in code (only `config()`)
|
|
411
|
+
- [ ] No `*` in token abilities
|
|
412
|
+
- [ ] No `$guarded = []` on models
|
|
413
|
+
- [ ] CSP headers configured
|
|
414
|
+
- [ ] HSTS enabled
|
|
415
|
+
- [ ] Webhook signatures verified
|
|
416
|
+
- [ ] Error responses don't expose internals
|
|
417
|
+
|
|
418
|
+
## FORBIDDEN
|
|
419
|
+
|
|
420
|
+
| ❌ Don't | ✅ Do |
|
|
421
|
+
|---|---|
|
|
422
|
+
| `$guarded = []` | Explicit `$fillable` |
|
|
423
|
+
| `'allowed_origins' => ['*']` | Your domain only |
|
|
424
|
+
| `createToken('x', ['*'])` | Specific abilities |
|
|
425
|
+
| `"WHERE email = '$email'"` | Parameterized queries |
|
|
426
|
+
| `dd($user)` in production | `Log::info()` structured |
|
|
427
|
+
| Generic error messages with stack traces | Clean error + request_id for debugging |
|
|
428
|
+
| Store API keys in plaintext | `'encrypted'` cast |
|
|
429
|
+
| Same rate limit for all endpoints | Progressive: auth(5/min) < api(60/min) |
|
|
430
|
+
| Trust `X-Forwarded-For` directly | Use trusted proxies config |
|
|
431
|
+
| No token expiry | 24h max, rotate on sensitive actions |
|