start-vibing-stacks 2.16.0 → 2.18.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.
@@ -1,40 +1,207 @@
1
1
  ---
2
2
  name: phpunit-testing
3
- version: 1.0.0
3
+ version: 2.0.0
4
+ description: "PHP testing for 2026 — covers PHPUnit 12 (released 2025, foundation for Pest 4) and Pest 4 (March 2026: 20-30% faster, browser testing via Playwright, visual regression, smoke testing, sharding for CI parallelization, mutation testing). Includes Unit / Feature / Browser / Architecture test layout, AAA pattern, data providers / datasets, mocking, fixtures, dataset sharding, coverage thresholds. Invoke when authoring tests, choosing PHPUnit vs Pest, configuring sharding/parallel, or wiring CI test stages."
4
5
  ---
5
6
 
6
- # PHPUnit Testing Skill
7
+ # PHP Testing — PHPUnit 12 & Pest 4 (2026)
8
+
9
+ **ALWAYS invoke when writing tests, choosing the framework, or wiring CI sharding.**
10
+
11
+ > Pest 4 (Mar 10, 2026) is built on PHPUnit 12. New projects in 2026: prefer **Pest 4** for ergonomics and built-in browser/visual testing; stay on **PHPUnit 12** if your team has muscle memory or you need raw assertion control.
12
+
13
+ ## Framework Choice
14
+
15
+ | Need | Pick |
16
+ |---|---|
17
+ | New Laravel app, want fluent expectation API + browser tests | **Pest 4** |
18
+ | Plain PHP library, prefer xUnit-classic | **PHPUnit 12** |
19
+ | Big legacy PHPUnit suite | **PHPUnit 12** (don't migrate just for migration's sake) |
20
+ | Need visual regression / Playwright in PHP | **Pest 4** (built-in) |
21
+
22
+ Both share the same engine — assertions, mocks, runners are interoperable through Pest's PHPUnit base. Pest is a thin DSL on top.
7
23
 
8
24
  ## Setup
9
25
 
26
+ ### Pest 4 (recommended for new Laravel projects)
27
+
10
28
  ```bash
11
- composer require --dev phpunit/phpunit
29
+ composer require --dev pestphp/pest "^4.0" # PHP 8.3+
30
+ composer require --dev pestphp/pest-plugin-laravel "^4.0"
31
+ ./vendor/bin/pest --init
12
32
  ```
13
33
 
14
- ## Test File Location
34
+ ### PHPUnit 12
35
+
36
+ ```bash
37
+ composer require --dev phpunit/phpunit "^12.0"
38
+ ```
39
+
40
+ ## File Layout
15
41
 
16
42
  ```
17
43
  tests/
44
+ ├── Pest.php # Pest config (test dirs, datasets, helpers)
45
+ ├── TestCase.php # Shared base
18
46
  ├── Unit/
19
- │ ├── Services/
20
- └── UserServiceTest.php
21
- │ └── Helpers/
22
- │ └── StringHelperTest.php
47
+ │ ├── Services/UserServiceTest.php
48
+ │ └── Helpers/StringHelperTest.php
23
49
  ├── Feature/
24
- │ └── Api/
25
- │ └── UserApiTest.php
26
- └── phpunit.xml
50
+ │ └── Api/UserApiTest.php
51
+ ├── Browser/ # Pest 4 — Playwright-backed
52
+ └── LoginFlowTest.php
53
+ └── Arch/ # Pest — architecture rules
54
+ └── LayerBoundariesTest.php
55
+ phpunit.xml # used by both Pest & PHPUnit
56
+ ```
57
+
58
+ ## `phpunit.xml` — production-grade baseline
59
+
60
+ ```xml
61
+ <?xml version="1.0" encoding="UTF-8"?>
62
+ <phpunit
63
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
64
+ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
65
+ bootstrap="vendor/autoload.php"
66
+ colors="true"
67
+ cacheDirectory=".phpunit.cache"
68
+ requireCoverageMetadata="true"
69
+ beStrictAboutCoverageMetadata="true"
70
+ beStrictAboutOutputDuringTests="true"
71
+ failOnRisky="true"
72
+ failOnWarning="true"
73
+ executionOrder="random">
74
+
75
+ <testsuites>
76
+ <testsuite name="Unit">
77
+ <directory>tests/Unit</directory>
78
+ </testsuite>
79
+ <testsuite name="Feature">
80
+ <directory>tests/Feature</directory>
81
+ </testsuite>
82
+ <testsuite name="Browser">
83
+ <directory>tests/Browser</directory>
84
+ </testsuite>
85
+ </testsuites>
86
+
87
+ <source>
88
+ <include>
89
+ <directory>app</directory>
90
+ </include>
91
+ </source>
92
+
93
+ <coverage includeUncoveredFiles="true">
94
+ <report>
95
+ <clover outputFile="coverage.xml"/>
96
+ <text outputFile="php://stdout" showOnlySummary="true"/>
97
+ </report>
98
+ </coverage>
99
+
100
+ <php>
101
+ <env name="APP_ENV" value="testing"/>
102
+ <env name="DB_CONNECTION" value="sqlite"/>
103
+ <env name="DB_DATABASE" value=":memory:"/>
104
+ </php>
105
+ </phpunit>
27
106
  ```
28
107
 
29
- ## Test Template
108
+ ## Pest 4 — patterns
109
+
110
+ ### Unit + Feature
30
111
 
31
112
  ```php
32
- <?php
113
+ // tests/Unit/Services/UserServiceTest.php
114
+ use App\Services\UserService;
115
+
116
+ beforeEach(fn () => $this->service = new UserService);
117
+
118
+ it('creates a user with valid data', function () {
119
+ $user = $this->service->create(['name' => 'John', 'email' => 'john@test.com']);
120
+
121
+ expect($user)
122
+ ->name->toBe('John')
123
+ ->email->toBe('john@test.com');
124
+ });
125
+
126
+ it('rejects invalid email', function () {
127
+ $this->service->create(['name' => 'John', 'email' => 'invalid']);
128
+ })->throws(InvalidArgumentException::class, 'Invalid email');
129
+
130
+ dataset('invalid_users', [
131
+ 'empty name' => [['name' => '', 'email' => 'a@b.com'], 'Name required'],
132
+ 'empty email' => [['name' => 'John', 'email' => ''], 'Email required'],
133
+ 'no data' => [[], 'Name required'],
134
+ ]);
135
+
136
+ it('rejects invalid data', function (array $data, string $expected) {
137
+ $this->service->create($data);
138
+ })->with('invalid_users')->throws(InvalidArgumentException::class);
139
+ ```
140
+
141
+ ### Browser test (Pest 4 — Playwright-backed)
142
+
143
+ ```php
144
+ // tests/Browser/LoginFlowTest.php
145
+ use function Pest\Browser\visit;
146
+
147
+ it('logs the user in', function () {
148
+ visit('/login')
149
+ ->fill('email', 'user@test.com')
150
+ ->fill('password', 'secret')
151
+ ->press('Sign in')
152
+ ->assertPathIs('/dashboard')
153
+ ->assertSee('Welcome back');
154
+ });
155
+
156
+ // Multi-viewport smoke
157
+ it('login page is responsive', function (string $device) {
158
+ visit('/login')
159
+ ->onDevice($device) // 'iphone-14', 'ipad', 'desktop-1280'
160
+ ->assertNoConsoleErrors()
161
+ ->assertNoBrokenLinks()
162
+ ->screenshot();
163
+ })->with(['iphone-14', 'ipad', 'desktop-1280']);
164
+ ```
165
+
166
+ ### Architecture test (Pest)
33
167
 
168
+ ```php
169
+ // tests/Arch/LayerBoundariesTest.php
170
+ arch('controllers do not call DB directly')
171
+ ->expect('App\Http\Controllers')
172
+ ->not->toUse(['Illuminate\Support\Facades\DB']);
173
+
174
+ arch('domain layer is framework-free')
175
+ ->expect('App\Domain')
176
+ ->not->toUse([
177
+ 'Illuminate\Database',
178
+ 'Illuminate\Http',
179
+ 'Illuminate\Support\Facades',
180
+ ]);
181
+
182
+ arch('strict types everywhere')
183
+ ->expect('App')
184
+ ->toUseStrictTypes();
185
+ ```
186
+
187
+ ### Mutation testing
188
+
189
+ ```bash
190
+ ./vendor/bin/pest --mutate --covered-only
191
+ ```
192
+
193
+ Pest mutates your code and re-runs tests; surviving mutants reveal weak assertions.
194
+
195
+ ## PHPUnit 12 — same domain, classic style
196
+
197
+ ```php
198
+ <?php
34
199
  declare(strict_types=1);
35
200
 
36
201
  namespace Tests\Unit\Services;
37
202
 
203
+ use PHPUnit\Framework\Attributes\DataProvider;
204
+ use PHPUnit\Framework\Attributes\Test;
38
205
  use PHPUnit\Framework\TestCase;
39
206
  use App\Services\UserService;
40
207
 
@@ -48,80 +215,99 @@ final class UserServiceTest extends TestCase
48
215
  $this->service = new UserService();
49
216
  }
50
217
 
51
- public function testCreateUserWithValidData(): void
218
+ #[Test]
219
+ public function it_creates_a_user_with_valid_data(): void
52
220
  {
53
- // Arrange
54
- $data = ['name' => 'John', 'email' => 'john@test.com'];
55
-
56
- // Act
57
- $user = $this->service->create($data);
58
-
59
- // Assert
60
- $this->assertSame('John', $user->name);
61
- $this->assertSame('john@test.com', $user->email);
221
+ $user = $this->service->create(['name' => 'John', 'email' => 'john@test.com']);
222
+ self::assertSame('John', $user->name);
223
+ self::assertSame('john@test.com', $user->email);
62
224
  }
63
225
 
64
- public function testCreateUserThrowsOnInvalidEmail(): void
226
+ #[Test]
227
+ #[DataProvider('invalidDataProvider')]
228
+ public function it_rejects_invalid_data(array $data, string $expected): void
65
229
  {
66
230
  $this->expectException(\InvalidArgumentException::class);
67
- $this->expectExceptionMessage('Invalid email');
68
-
69
- $this->service->create(['name' => 'John', 'email' => 'invalid']);
70
- }
71
-
72
- /**
73
- * @dataProvider invalidDataProvider
74
- */
75
- public function testCreateUserRejectsInvalidData(array $data, string $expectedError): void
76
- {
77
- $this->expectException(\InvalidArgumentException::class);
78
- $this->expectExceptionMessage($expectedError);
79
-
231
+ $this->expectExceptionMessage($expected);
80
232
  $this->service->create($data);
81
233
  }
82
234
 
83
235
  public static function invalidDataProvider(): array
84
236
  {
85
237
  return [
86
- 'empty name' => [['name' => '', 'email' => 'a@b.com'], 'Name required'],
87
- 'empty email' => [['name' => 'John', 'email' => ''], 'Email required'],
88
- 'null data' => [[], 'Name required'],
238
+ 'empty name' => [['name' => '', 'email' => 'a@b.com'], 'Name required'],
239
+ 'empty email' => [['name' => 'John', 'email' => ''], 'Email required'],
240
+ 'no data' => [[], 'Name required'],
89
241
  ];
90
242
  }
91
243
  }
92
244
  ```
93
245
 
246
+ > PHPUnit 12 deprecated `/** @test */` and `/** @dataProvider */` annotations — use the `#[Test]` and `#[DataProvider]` attributes shown above.
247
+
94
248
  ## Mocking
95
249
 
96
250
  ```php
97
- public function testServiceCallsRepository(): void
98
- {
99
- $repo = $this->createMock(UserRepository::class);
100
- $repo->expects($this->once())
101
- ->method('save')
102
- ->with($this->isInstanceOf(User::class))
103
- ->willReturn(true);
251
+ // PHPUnit
252
+ $repo = $this->createMock(UserRepository::class);
253
+ $repo->expects($this->once())
254
+ ->method('save')
255
+ ->with($this->isInstanceOf(User::class))
256
+ ->willReturn(true);
104
257
 
105
- $service = new UserService($repo);
106
- $result = $service->create(['name' => 'John', 'email' => 'j@t.com']);
258
+ // Pest same engine, fluent
259
+ $repo = mock(UserRepository::class)
260
+ ->shouldReceive('save')->once()->andReturn(true)
261
+ ->getMock();
262
+ ```
107
263
 
108
- $this->assertTrue($result);
109
- }
264
+ For HTTP: `Http::fake()` (Laravel) or Saloon's `MockClient` — see `external-api-patterns`.
265
+
266
+ ## Sharding for CI *(Pest 4 / PHPUnit 12)*
267
+
268
+ ```bash
269
+ # Split a slow suite across 4 runners
270
+ ./vendor/bin/pest --parallel --shard=1/4
271
+ ./vendor/bin/pest --parallel --shard=2/4
272
+ ./vendor/bin/pest --parallel --shard=3/4
273
+ ./vendor/bin/pest --parallel --shard=4/4
274
+ ```
275
+
276
+ ```yaml
277
+ # .github/workflows/ci.yml
278
+ strategy:
279
+ matrix:
280
+ shardIndex: [1, 2, 3, 4]
281
+ shardTotal: [4]
282
+ steps:
283
+ - run: ./vendor/bin/pest --parallel --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
110
284
  ```
111
285
 
112
- ## Running
286
+ ## Coverage Thresholds
113
287
 
114
288
  ```bash
115
- vendor/bin/phpunit # All tests
116
- vendor/bin/phpunit tests/Unit/ # Unit only
117
- vendor/bin/phpunit --filter testCreateUser # Specific test
118
- vendor/bin/phpunit --coverage-text # With coverage
289
+ # Fail under 70%
290
+ ./vendor/bin/pest --coverage --min=70
291
+ ./vendor/bin/phpunit --coverage-text --min=70
119
292
  ```
120
293
 
294
+ Realistic 2026 baseline: **70% line coverage minimum**, 100% on Services / Domain layer, lower for Controllers (covered by feature tests anyway).
295
+
121
296
  ## Rules
122
297
 
123
- 1. **One assertion concept per test**
124
- 2. **Descriptive method names**: `testMethodName_Scenario_ExpectedResult`
125
- 3. **Use data providers** for multiple inputs
126
- 4. **setUp/tearDown** for shared state
127
- 5. **Never skip tests** in commits
298
+ 1. **One concept per test** — don't `@dataProvider` a test that mixes unrelated assertions
299
+ 2. **Descriptive names** `it_rejects_invalid_email`, not `testCreateUser2`
300
+ 3. **Datasets / data providers** for input matrices (DRY assertions, named cases)
301
+ 4. **`#[Test]` attribute, not `@test` PHPDoc** (PHPUnit 12 deprecated the latter)
302
+ 5. **`setUp` for shared fresh state, not shared mutable state**
303
+ 6. **Architecture tests on every Laravel project** — enforce layer rules in code, not docs
304
+ 7. **Mutation testing on critical Services** quarterly — catches "fake green" suites
305
+ 8. **Never `->skip()` / `->only()` in committed code**
306
+ 9. **Browser tests use real browsers (Pest 4 Playwright)** — no jsdom hacks
307
+
308
+ ## See Also
309
+
310
+ - `playwright-automation` (`_shared`) — same Playwright engine Pest 4 uses
311
+ - `quality-gate` — test step ordering
312
+ - `external-api-patterns` — `Http::fake()` / Saloon mocking patterns
313
+ - `phpstan-analysis` — typing that makes tests cheaper
@@ -1,9 +1,12 @@
1
1
  ---
2
2
  name: security-scan-php
3
- version: 1.0.0
3
+ version: 2.0.0
4
+ description: "Laravel-specific security overlay on top of `security-baseline` (OWASP 2021 + 2025 deltas). Mass assignment, Sanctum/policies, Blade XSS, env() pitfalls in Octane, file upload validation, signed URLs, password hashing (Argon2id default since L10), CSRF on web routes, role-aware scoping, supply-chain hardening (composer audit + Roave Security Advisories + gitleaks 3-layer). Cross-references stay aligned with `security-baseline` §A01–§A10 anchors so the security-auditor agent can match. Invoke when reviewing any Laravel feature touching user data, auth, persistence, file IO, or external calls."
4
5
  ---
5
6
 
6
- # Laravel Security Scan
7
+ # Laravel Security Scan (overlay on `security-baseline`)
8
+
9
+ > **This skill is a Laravel overlay.** The universal OWASP rules (with 2025 deltas — Software Supply Chain Failures, Mishandling Exceptional Conditions) live in `_shared/skills/security-baseline`. The §A01–§A10 anchors below match those (2021 numbering kept for security-auditor cross-references).
7
10
 
8
11
  ## OWASP Top 10 for Laravel
9
12
 
@@ -189,15 +192,105 @@ $user = $request->user();
189
192
  - [ ] Sensitive headers set (X-Content-Type-Options, X-Frame-Options, CSP)
190
193
  - [ ] Rate limiting on authentication endpoints
191
194
 
195
+ ## Supply-Chain Hardening (OWASP 2025-A03)
196
+
197
+ Laravel apps inherit every CVE in the Composer tree. The build server is a target.
198
+
199
+ ```bash
200
+ # Production install — never run third-party scripts on the build server
201
+ composer install --no-dev --no-scripts --optimize-autoloader --classmap-authoritative
202
+
203
+ # Audit (Composer 2.8+) — fail the PR on vulns OR abandoned packages
204
+ composer audit --abandoned=fail --ignore-severity=low
205
+ ```
206
+
207
+ ```json
208
+ // composer.json — refuse to install any version with a known CVE
209
+ "require-dev": {
210
+ "roave/security-advisories": "dev-latest"
211
+ },
212
+ "config": {
213
+ "allow-plugins": {
214
+ "pestphp/pest-plugin": true
215
+ // Add new plugins explicitly. Default-deny.
216
+ }
217
+ }
218
+ ```
219
+
220
+ Layered secret detection (mirrors `secrets-management` skill):
221
+
222
+ ```yaml
223
+ # .github/workflows/ci.yml
224
+ - uses: gitleaks/gitleaks-action@v2 # Layer 2 — CI scan
225
+ - run: composer audit --abandoned=fail # Composer-side audit
226
+ ```
227
+
228
+ Layer 1 = pre-commit gitleaks hook locally. Layer 3 = GitHub Push Protection at the org/repo. See `secrets-management` skill for full setup.
229
+
230
+ ---
231
+
232
+ ## Mishandling Exceptional Conditions (OWASP 2025-A10) — Laravel-specific
233
+
234
+ The new 2025 category. Common Laravel mistakes:
235
+
236
+ | Anti-pattern | Why dangerous | Fix |
237
+ |---|---|---|
238
+ | `try { Gate::authorize(...) } catch { /* ignore */ }` | Authz fails open — caller proceeds as if allowed | Let it bubble; Laravel maps `AuthorizationException` to 403 |
239
+ | `Render::abort` returning a 500 with full stack trace | Leaks file paths, ORM internals, env hints | Set `APP_DEBUG=false` in prod; custom `app/Exceptions/Handler.php` returns generic problem+json |
240
+ | Webhook handler returns 500 on parse error | Provider retries forever, floods the queue | Verify signature first; on parse error log + return 200 (idempotently dropped) |
241
+ | Partial writes when transaction rolls back partially | Money charged but order not created | `DB::transaction()` closure (auto-rollback) — see `mariadb-octane` |
242
+ | `if (!config('feature.requireMfa')) return $user;` | Missing config = MFA bypassed | Default-deny: `if (config('feature.requireMfa', true) === false && app()->runningInConsole())` — and even then, gate behind explicit env |
243
+ | Octane uncaught exception leaves DB in TX | Next request inherits open TX → cascading failures | `try/finally { DB::rollBack() }` or `DB::transaction()` closure (see `mariadb-octane`) |
244
+
245
+ ```php
246
+ // Correct webhook reception — fail closed on signature, fail open on parse
247
+ public function handle(Request $request): JsonResponse
248
+ {
249
+ try {
250
+ $event = \Stripe\Webhook::constructEvent(
251
+ $request->getContent(),
252
+ $request->header('Stripe-Signature'),
253
+ config('services.stripe.webhook_secret'),
254
+ );
255
+ } catch (\UnexpectedValueException | \Stripe\Exception\SignatureVerificationException $e) {
256
+ Log::warning('[stripe] invalid signature', ['err' => $e->getMessage()]);
257
+ return response()->json(['error' => 'invalid signature'], 400); // 4xx → provider stops retrying
258
+ }
259
+
260
+ try {
261
+ ProcessStripeEvent::dispatch($event->type, $event->data->object);
262
+ } catch (\Throwable $e) {
263
+ Log::error('[stripe] dispatch failed', ['event' => $event->id, 'err' => $e->getMessage()]);
264
+ // Acknowledge anyway — we'll reconcile from the webhook log
265
+ }
266
+ return response()->json(['status' => 'received']);
267
+ }
268
+ ```
269
+
270
+ ---
271
+
192
272
  ## Sensitive Patterns (FORBIDDEN)
193
273
 
194
274
  | Pattern | Risk |
195
275
  |---------|------|
196
276
  | `DB::raw()` with user input | SQL Injection |
197
277
  | `{!! $userInput !!}` | XSS |
198
- | `md5()` / `sha1()` for passwords | Weak hashing |
278
+ | `md5()` / `sha1()` for passwords | Weak hashing (Argon2id is the Laravel 10+ default) |
199
279
  | Dynamic code execution | RCE |
200
280
  | `unserialize()` on user data | Object injection |
201
281
  | `$request->all()` without `$fillable` | Mass assignment |
202
282
  | `env()` in runtime code | Null after config cache |
203
283
  | Static user state in services | Data leaks in Octane |
284
+ | `composer install` with `--ignore-platform-reqs` in prod | Hides incompatible PHP/ext versions |
285
+ | `composer install` in prod **without** `--no-scripts` | Supply-chain RCE on package post-install hooks |
286
+ | Missing `roave/security-advisories` dev dep | Known CVE installs silently |
287
+ | `try { ... } catch { /* nothing */ }` around authz | 2025-A10 fail-open |
288
+
289
+ ## See Also
290
+
291
+ - `_shared/skills/security-baseline` — universal §A01–§A10 (OWASP 2021 + 2025 deltas)
292
+ - `_shared/skills/secrets-management` — gitleaks 3-layer + OIDC + Roave
293
+ - `_shared/skills/observability` — log redaction
294
+ - `composer-workflow` — `audit` + `roave/security-advisories`
295
+ - `mariadb-octane` — transaction safety in long-lived workers
296
+ - `api-security` — Sanctum cookie SPA + CORS hardening
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: api-security-python
3
- version: 1.0.0
4
- description: Production-grade API hardening for Python (FastAPI, Django, Flask). Rate limit, CORS, JWT, secure cookies, CSRF, OAuth2.
3
+ version: 2.0.0
4
+ description: "Python (FastAPI / Django / Flask) overlay on top of _shared/security-baseline v2 (OWASP Top 10:2025). Production-grade hardening: security headers via Starlette middleware and SECURE_* settings, strict CORS allowlist, rate limiting (slowapi / django-ratelimit), HttpOnly+Secure+SameSite cookies, JWT with algorithms=[ALG] pinning + jti for revocation using PyJWT (python-jose is unmaintained as of 2025), CSRF built-in for Django and double-submit cookie for FastAPI, Pydantic V2 extra='forbid' against mass-assignment, file-upload magic-byte sniffing, Argon2id passwords, parameterised SQL/ORM. Cross-references 2025-A03 (Software Supply Chain Failures) and 2025-A10 (Mishandling Exceptional Conditions)."
5
5
  ---
6
6
 
7
- # API Security — Python
7
+ # API Security — Python (FastAPI / Django / Flask)
8
8
 
9
9
  **ALWAYS invoke when building API endpoints, auth flows, or admin actions.**
10
10
 
11
- > Pair this with `security-baseline` for OWASP Top 10. This skill is stack-specific hardening.
11
+ > Stack-specific overlay on top of `_shared/skills/security-baseline` v2 (OWASP Top 10:2025). The shared skill defines §A01–§A10 anchors; this one wires the Python equivalents.
12
12
 
13
13
  ## Layered Defense
14
14
 
@@ -136,13 +136,16 @@ response.set_cookie(
136
136
 
137
137
  ## 5. JWT / OAuth2 — FastAPI
138
138
 
139
+ > **Library choice (2026):** Use **PyJWT** (`pyjwt[crypto]`) or **authlib** for OAuth2/OIDC flows. `python-jose` has been effectively unmaintained since 2024 and should be removed from dependencies. For the resource-server side that just verifies tokens, PyJWT is enough.
140
+
139
141
  ```python
140
142
  from datetime import datetime, timedelta, timezone
141
- from jose import jwt, JWTError
142
143
  import os, uuid
144
+ import jwt
145
+ from jwt import InvalidTokenError
143
146
 
144
147
  SECRET = os.environ["JWT_SECRET"]
145
- ALG = "HS256"
148
+ ALG = "HS256" # use RS256/EdDSA when verifying tokens minted elsewhere
146
149
 
147
150
  def issue_access_token(user_id: str, role: str) -> str:
148
151
  now = datetime.now(timezone.utc)
@@ -151,8 +154,11 @@ def issue_access_token(user_id: str, role: str) -> str:
151
154
  "sub": user_id,
152
155
  "role": role,
153
156
  "iat": now,
157
+ "nbf": now,
154
158
  "exp": now + timedelta(minutes=15),
155
159
  "jti": str(uuid.uuid4()),
160
+ "iss": os.environ["JWT_ISSUER"],
161
+ "aud": os.environ["JWT_AUDIENCE"],
156
162
  },
157
163
  SECRET,
158
164
  algorithm=ALG,
@@ -160,9 +166,18 @@ def issue_access_token(user_id: str, role: str) -> str:
160
166
 
161
167
  async def current_user(token: str = Depends(oauth2_scheme)) -> User:
162
168
  try:
163
- payload = jwt.decode(token, SECRET, algorithms=[ALG]) # pin algorithm
164
- except JWTError:
169
+ payload = jwt.decode(
170
+ token,
171
+ SECRET,
172
+ algorithms=[ALG], # pin — never accept "alg: none"
173
+ audience=os.environ["JWT_AUDIENCE"],
174
+ issuer=os.environ["JWT_ISSUER"],
175
+ options={"require": ["exp", "iat", "sub", "jti"]},
176
+ )
177
+ except InvalidTokenError:
165
178
  raise HTTPException(401, "Invalid token")
179
+ if await is_revoked(payload["jti"]): # check revocation list (Redis set)
180
+ raise HTTPException(401, "Token revoked")
166
181
  user = await User.get_or_none(id=payload["sub"])
167
182
  if not user:
168
183
  raise HTTPException(401, "User not found")
@@ -170,9 +185,11 @@ async def current_user(token: str = Depends(oauth2_scheme)) -> User:
170
185
  ```
171
186
 
172
187
  Rules:
173
- - Access tokens: ≤ 15 min. Refresh tokens: rotate on use, store hash in DB, revocable.
174
- - Pin `algorithms=[ALG]`. Never accept `alg: none`.
175
- - Include `jti` for revocation lists.
188
+ - Access tokens ≤ 15 min. Refresh tokens: rotate on use, store **hash** in DB, revocable.
189
+ - Pin `algorithms=[ALG]`. Never accept `alg: none` or omit the kwarg (confusion attack).
190
+ - Always validate `aud` and `iss` when present.
191
+ - Include `jti` and check a revocation set for sensitive scopes.
192
+ - Store JWTs in **HttpOnly+Secure+SameSite cookies**, not `localStorage` (XSS exfiltration).
176
193
 
177
194
  ---
178
195
 
@@ -280,6 +297,91 @@ user = await User.get(id=user_id) # Tortoise / SQLAlchemy / Dj
280
297
 
281
298
  ---
282
299
 
300
+ ## 11. Outbound calls — SSRF guard (OWASP 2025 still relevant)
301
+
302
+ SSRF was demoted from a top-level OWASP category in the 2025 list but remains in scope. Any time you fetch a URL the user controls (preview cards, webhooks, image proxies), validate the destination:
303
+
304
+ ```python
305
+ import ipaddress
306
+ import socket
307
+ from urllib.parse import urlparse
308
+ import httpx
309
+
310
+ PRIVATE = ipaddress.collapse_addresses([
311
+ ipaddress.ip_network("10.0.0.0/8"),
312
+ ipaddress.ip_network("172.16.0.0/12"),
313
+ ipaddress.ip_network("192.168.0.0/16"),
314
+ ipaddress.ip_network("169.254.0.0/16"), # link-local + AWS metadata
315
+ ipaddress.ip_network("127.0.0.0/8"),
316
+ ipaddress.ip_network("::1/128"),
317
+ ])
318
+
319
+ def safe_url(url: str) -> str:
320
+ p = urlparse(url)
321
+ if p.scheme not in {"http", "https"}:
322
+ raise ValueError("scheme")
323
+ host = p.hostname
324
+ if not host:
325
+ raise ValueError("host")
326
+ for fam, _, _, _, sockaddr in socket.getaddrinfo(host, None):
327
+ ip = ipaddress.ip_address(sockaddr[0])
328
+ if any(ip in net for net in PRIVATE):
329
+ raise ValueError("private")
330
+ return url
331
+
332
+ async def fetch_user_url(url: str):
333
+ safe_url(url)
334
+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as c:
335
+ return await c.get(url)
336
+ ```
337
+
338
+ Disable redirect-following or re-validate every hop — otherwise an attacker can redirect from a public IP to `169.254.169.254` (cloud metadata).
339
+
340
+ ## 12. OWASP 2025 deltas — Python specifics
341
+
342
+ ### §A03 — Software Supply Chain Failures (NEW in 2025)
343
+
344
+ ```toml
345
+ # pyproject.toml — pin everything; uv produces a deterministic lockfile
346
+ [project]
347
+ dependencies = ["fastapi>=0.115,<0.116", "pydantic>=2.6,<3"]
348
+
349
+ [tool.uv]
350
+ dev-dependencies = ["pip-audit>=2.7", "ruff>=0.5"]
351
+ ```
352
+
353
+ ```bash
354
+ # Lock + verify in CI
355
+ uv lock --check # fails if lockfile drifted
356
+ uv export --format requirements-txt --no-dev | pip-audit -r /dev/stdin --strict
357
+ ```
358
+
359
+ - Use `uv` (or Poetry) to produce a deterministic lockfile; never `pip install` without one in production.
360
+ - Run `pip-audit` (PyPA) **and** subscribe to GHSA advisories for your deps.
361
+ - Pin GitHub Actions by SHA, not tag — see `_shared/skills/secrets-management`.
362
+
363
+ ### §A10 — Mishandling Exceptional Conditions (NEW in 2025)
364
+
365
+ ```python
366
+ # WRONG — silently swallows everything, including programming bugs
367
+ try:
368
+ user = await get_user(id)
369
+ except Exception:
370
+ user = None
371
+
372
+ # CORRECT — narrow except + log + propagate or translate
373
+ try:
374
+ user = await get_user(id)
375
+ except UserNotFoundError:
376
+ raise HTTPException(404, "user not found")
377
+ except DatabaseUnavailableError as e:
378
+ logger.exception("db-down", extra={"user_id": id})
379
+ raise HTTPException(503, "service unavailable") from e
380
+ # Programming errors (KeyError, TypeError) are NOT caught — let the global handler 500 + log
381
+ ```
382
+
383
+ Bare `except Exception:` (or worse, `except:`) is now an explicit OWASP pattern to flag. Use `logger.exception()` so the stack trace lands in logs, return a **generic** message to the client, never the original exception text.
384
+
283
385
  ## Endpoint Checklist
284
386
 
285
387
  - [ ] `Depends(current_user)` for protected routes
@@ -306,7 +408,8 @@ user = await User.get(id=user_id) # Tortoise / SQLAlchemy / Dj
306
408
 
307
409
  ## See Also
308
410
 
309
- - `security-baseline` — OWASP Top 10
310
- - `secrets-management` — env vars, rotation
311
- - `pydantic-validation` — schema patterns
312
- - `observability` — structured logs without PII
411
+ - `_shared/skills/security-baseline` v2 — OWASP Top 10:2025 (§A01–§A10 anchors)
412
+ - `_shared/skills/secrets-management` v2 OIDC federation, gitleaks 3-layer, SOPS+age
413
+ - `_shared/skills/observability` v2 structured logs without PII, GenAI semconv 1.41+
414
+ - `pydantic-validation` v2 `extra="forbid"` against mass-assignment
415
+ - `fastapi-patterns` v2 — lifespan, DI, exception handlers