codeintely-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,691 @@
1
+ rules:
2
+ # --- IDOR / BOLA / tenant isolation --------------------------------------
3
+
4
+ - id: django-get-object-or-404-missing-tenant-filter
5
+ languages: [python]
6
+ severity: WARNING
7
+ message: >-
8
+ `get_object_or_404` looks up this model by id/pk alone, with no
9
+ tenant/org/user scoping in the same call. In a multi-tenant app this is
10
+ a direct IDOR: any authenticated user can fetch another tenant's row by
11
+ guessing/incrementing the id. Add the caller's tenant/owner as an extra
12
+ keyword argument, e.g. `get_object_or_404(Invoice, pk=pk,
13
+ organization=request.user.organization)`. (Heuristic rule — expect some
14
+ false positives on genuinely single-tenant/global models.)
15
+ metadata:
16
+ owasp_category: "A01:2021 - Broken Access Control"
17
+ cwe: "CWE-639"
18
+ internal_severity: HIGH
19
+ patterns:
20
+ - pattern-either:
21
+ - pattern: get_object_or_404($MODEL, pk=$PK)
22
+ - pattern: get_object_or_404($MODEL, id=$ID)
23
+
24
+ - id: drf-viewset-queryset-not-scoped-to-user
25
+ languages: [python]
26
+ severity: WARNING
27
+ message: >-
28
+ This DRF ViewSet's `queryset` class attribute is a bare `.all()` with
29
+ no `get_queryset()` override to scope it to the requesting user/tenant.
30
+ DRF's generic list/retrieve/update/destroy actions read straight from
31
+ `self.queryset` — every row in the table is reachable by any
32
+ authenticated caller, not just their own tenant's. Override
33
+ `get_queryset()` to filter by `self.request.user` (or their
34
+ organization) instead of declaring `queryset` directly.
35
+ metadata:
36
+ owasp_category: "A01:2021 - Broken Access Control"
37
+ cwe: "CWE-639"
38
+ internal_severity: HIGH
39
+ patterns:
40
+ - pattern: |
41
+ class $VIEW(...):
42
+ ...
43
+ queryset = $MODEL.objects.all()
44
+ ...
45
+ - pattern-not: |
46
+ class $VIEW(...):
47
+ ...
48
+ def get_queryset(self):
49
+ ...
50
+ - metavariable-regex:
51
+ metavariable: $VIEW
52
+ regex: ".*(ViewSet|View)$"
53
+
54
+ - id: drf-get-queryset-returns-unscoped-all
55
+ languages: [python]
56
+ severity: WARNING
57
+ message: >-
58
+ `get_queryset()` returns `.all()` directly with no visible filter by
59
+ the requesting user/tenant. This is the one place DRF expects
60
+ per-request scoping to happen — returning the whole table here defeats
61
+ that, exposing every tenant's rows to every authenticated caller. Filter
62
+ by `self.request.user` or their organization before returning.
63
+ metadata:
64
+ owasp_category: "A01:2021 - Broken Access Control"
65
+ cwe: "CWE-639"
66
+ internal_severity: HIGH
67
+ patterns:
68
+ - pattern: |
69
+ def get_queryset(self):
70
+ return $MODEL.objects.all()
71
+
72
+ - id: fastapi-sqlalchemy-query-missing-ownership-filter
73
+ languages: [python]
74
+ severity: WARNING
75
+ message: >-
76
+ This SQLAlchemy query filters only by id with no owner/tenant column
77
+ in the same filter chain. A caller who can reach this route with any
78
+ authenticated identity can read/modify another user's or tenant's row
79
+ by supplying a different id. Add an ownership filter in the same call,
80
+ e.g. `.filter($MODEL.id == $ID, $MODEL.user_id == current_user.id)`.
81
+ (Heuristic rule — expect some false positives on genuinely
82
+ global/shared models.)
83
+ metadata:
84
+ owasp_category: "A01:2021 - Broken Access Control"
85
+ cwe: "CWE-639"
86
+ internal_severity: HIGH
87
+ patterns:
88
+ - pattern-either:
89
+ - pattern: $DB.query($MODEL).filter($MODEL.id == $ID).first()
90
+ - pattern: $DB.query($MODEL).filter($MODEL.id == $ID).one()
91
+ - pattern: $DB.query($MODEL).filter($MODEL.id == $ID).one_or_none()
92
+
93
+ # --- Privilege escalation -------------------------------------------------
94
+
95
+ - id: django-serializer-writable-role-field
96
+ languages: [python]
97
+ severity: ERROR
98
+ message: >-
99
+ This serializer's explicit `fields` list includes a privilege/tenancy
100
+ field (role, is_staff, is_superuser, is_admin, tenant_id,
101
+ organization_id, permissions) with no matching entry in
102
+ `read_only_fields`. Any client that can call this serializer's
103
+ create/update can set their own role or move themselves to another
104
+ tenant. Move the field to `read_only_fields`, or drop it from `fields`
105
+ and set it only from trusted server-side code.
106
+ metadata:
107
+ owasp_category: "A01:2021 - Broken Access Control"
108
+ cwe: "CWE-915"
109
+ internal_severity: CRITICAL
110
+ patterns:
111
+ - pattern: fields = [...]
112
+ - pattern-inside: |
113
+ class $META:
114
+ ...
115
+ - metavariable-regex:
116
+ metavariable: $META
117
+ regex: "Meta"
118
+ - pattern-regex: "(role|is_staff|is_superuser|is_admin|tenant_id|organization_id|permissions)"
119
+ - pattern-not-inside: |
120
+ class $META:
121
+ ...
122
+ read_only_fields = ...
123
+ ...
124
+
125
+ - id: django-request-data-role-assignment
126
+ languages: [python]
127
+ severity: ERROR
128
+ message: >-
129
+ A privilege/tenancy attribute is being set directly from
130
+ `request.data`/`request.POST`/`request.GET`. If this code path is
131
+ reachable by a normal (non-admin) authenticated user, they can grant
132
+ themselves elevated privileges or move themselves into another tenant
133
+ by sending that field in the request body. Set this field only from
134
+ trusted server-side logic, gated behind an explicit admin/role check.
135
+ metadata:
136
+ owasp_category: "A01:2021 - Broken Access Control"
137
+ cwe: "CWE-269"
138
+ internal_severity: CRITICAL
139
+ patterns:
140
+ - pattern-either:
141
+ - pattern: $OBJ.role = request.data[...]
142
+ - pattern: $OBJ.role = request.data.get(...)
143
+ - pattern: $OBJ.is_staff = request.data[...]
144
+ - pattern: $OBJ.is_staff = request.data.get(...)
145
+ - pattern: $OBJ.is_superuser = request.data[...]
146
+ - pattern: $OBJ.is_superuser = request.data.get(...)
147
+ - pattern: $OBJ.is_admin = request.data[...]
148
+ - pattern: $OBJ.is_admin = request.data.get(...)
149
+ - pattern: $OBJ.tenant_id = request.data[...]
150
+ - pattern: $OBJ.tenant_id = request.data.get(...)
151
+ - pattern: $OBJ.organization_id = request.data[...]
152
+ - pattern: $OBJ.organization_id = request.data.get(...)
153
+
154
+ # --- RBAC / function-level authorization ----------------------------------
155
+
156
+ - id: django-admin-view-missing-permission-decorator
157
+ languages: [python]
158
+ severity: WARNING
159
+ message: >-
160
+ This function-based view's own name looks admin/internal-only but the
161
+ function has no visible access-control decorator (e.g.
162
+ `@staff_member_required`, `@permission_required`, `@user_passes_test`,
163
+ `@login_required` combined with a role check). Being reachable isn't
164
+ the same as being authorized — verify the caller's role explicitly for
165
+ privileged views. (Heuristic rule based on the view's own name, since a
166
+ Django function-based view's URL path lives in urls.py rather than a
167
+ decorator on the function itself; adjust to your project's naming/
168
+ decorator conventions.)
169
+ metadata:
170
+ owasp_category: "A01:2021 - Broken Access Control"
171
+ cwe: "CWE-862"
172
+ internal_severity: MEDIUM
173
+ patterns:
174
+ - pattern: |
175
+ def $FUNC(request, ...):
176
+ ...
177
+ - metavariable-regex:
178
+ metavariable: $FUNC
179
+ regex: ".*(admin|internal)_.*"
180
+ - metavariable-regex:
181
+ # Separate condition (not merged into the one above): a name
182
+ # starting with `_` is almost always a private helper — quite
183
+ # often the *implementation* of the permission check itself
184
+ # (confirmed live: this rule initially flagged
185
+ # dashboard/views.py::_require_admin_org, which does exactly
186
+ # that) — not a URL-routed view that could be missing one. The
187
+ # `^` anchor excludes it under search-style matching too, not
188
+ # just full-string matching.
189
+ metavariable: $FUNC
190
+ regex: "^[^_]"
191
+ - pattern-not-regex: "(staff_member_required|permission_required|user_passes_test|is_staff|is_superuser|require_role|check_permission|can_[a-z_]*\\()"
192
+
193
+ # --- JWT / token security (P1b phase 1, PyJWT) -----------------------------
194
+ #
195
+ # Grounded in PyJWT specifically (CodeIntely's own real dependency) — not
196
+ # python-jose or Node's jsonwebtoken, which don't get rules until they get
197
+ # their own benchmark coverage. Every rule below targets jwt.decode()/
198
+ # jwt.encode() call sites, matched via pattern + pattern-regex on the call's
199
+ # own source text rather than a rigid AST shape, since PyJWT's kwargs
200
+ # (options=, algorithms=, audience=, issuer=) can appear in any order/
201
+ # combination.
202
+
203
+ - id: jwt-decode-verify-signature-disabled
204
+ languages: [python]
205
+ severity: ERROR
206
+ message: >-
207
+ This `jwt.decode()` call disables signature verification
208
+ (`options={"verify_signature": False}` or the legacy `verify=False`).
209
+ A JWT with a disabled signature check can be forged with any payload —
210
+ an attacker can set themselves as any user/role without knowing the
211
+ signing key. Remove the override; if inspecting an unverified token's
212
+ claims is genuinely needed (e.g. to pick a key before real
213
+ verification), use `jwt.get_unverified_header()`/
214
+ `jwt.decode(..., options={"verify_signature": False})` only on a code
215
+ path that never trusts the resulting claims for authn/authz.
216
+ metadata:
217
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
218
+ cwe: "CWE-347"
219
+ internal_severity: CRITICAL
220
+ patterns:
221
+ - pattern: jwt.decode(...)
222
+ - pattern-regex: "verify_signature['\"]?\\s*:\\s*False|verify\\s*=\\s*False"
223
+
224
+ - id: jwt-decode-algorithm-none-allowed
225
+ languages: [python]
226
+ severity: ERROR
227
+ message: >-
228
+ This `jwt.decode()` call's `algorithms=` allowlist includes `"none"`.
229
+ The "none" algorithm means the token has no signature at all — anyone
230
+ can craft a token with `alg: none` and any claims they like, and it
231
+ will pass verification. Never include "none" in the allowlist; specify
232
+ only the real algorithm(s) your issuer actually signs with (e.g.
233
+ `algorithms=["RS256"]`).
234
+ metadata:
235
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
236
+ cwe: "CWE-347"
237
+ internal_severity: CRITICAL
238
+ patterns:
239
+ - pattern: jwt.decode(...)
240
+ - pattern-regex: "(?i)algorithms\\s*=\\s*\\[[^\\]]*['\"]none['\"]"
241
+
242
+ - id: jwt-decode-missing-algorithms
243
+ languages: [python]
244
+ severity: WARNING
245
+ message: >-
246
+ This `jwt.decode()` call has no `algorithms=` allowlist. Without one,
247
+ PyJWT (depending on version) may trust whatever algorithm the token's
248
+ own header claims — including switching from an asymmetric algorithm
249
+ (RS256) to a symmetric one (HS256) using the public key as the HMAC
250
+ secret, a well-known JWT algorithm-confusion attack. Always pass an
251
+ explicit `algorithms=[...]` naming only the algorithm(s) your issuer
252
+ uses.
253
+ metadata:
254
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
255
+ cwe: "CWE-347"
256
+ internal_severity: HIGH
257
+ patterns:
258
+ - pattern: jwt.decode(...)
259
+ - pattern-not: jwt.decode(..., algorithms=$ALGOS, ...)
260
+ - pattern-not-regex: "verify_signature['\"]?\\s*:\\s*False|verify\\s*=\\s*False"
261
+
262
+ - id: jwt-decode-missing-audience-or-issuer-check
263
+ languages: [python]
264
+ severity: WARNING
265
+ message: >-
266
+ This `jwt.decode()` call verifies the signature (an `algorithms=`
267
+ allowlist is present) but passes no `audience=`/`issuer=` check. A
268
+ correctly-signed token issued for a *different* app or purpose by the
269
+ same identity provider will still pass verification here — the
270
+ signature alone doesn't prove the token was meant for this service.
271
+ Pass `audience=`/`issuer=` matching this service's own expected
272
+ values, or confirm this token's issuer never mints tokens for more
273
+ than one audience. (Heuristic rule — expect some false positives on
274
+ single-audience internal-only issuers.)
275
+ metadata:
276
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
277
+ cwe: "CWE-345"
278
+ internal_severity: MEDIUM
279
+ patterns:
280
+ - pattern: jwt.decode(..., algorithms=$ALGOS, ...)
281
+ - pattern-not: jwt.decode(..., audience=$AUD, ...)
282
+ - pattern-not: jwt.decode(..., issuer=$ISS, ...)
283
+
284
+ - id: jwt-hardcoded-signing-key
285
+ languages: [python]
286
+ severity: ERROR
287
+ message: >-
288
+ This `jwt.encode()`/`jwt.decode()` call's signing/verification key is
289
+ a string literal, not a value loaded from settings/environment/secret
290
+ storage. A key committed to source control is exposed to anyone with
291
+ repo access (including through git history, even if later removed)
292
+ and can't be rotated without a code change. Load it from
293
+ `settings.*`/an environment variable/a secret manager instead.
294
+ metadata:
295
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
296
+ cwe: "CWE-321"
297
+ internal_severity: HIGH
298
+ patterns:
299
+ - pattern-either:
300
+ - pattern: jwt.encode($PAYLOAD, "...", ...)
301
+ - pattern: jwt.encode($PAYLOAD, key="...", ...)
302
+ - pattern: jwt.decode($TOKEN, "...", ...)
303
+ - pattern: jwt.decode($TOKEN, key="...", ...)
304
+
305
+ # --- OAuth/OIDC (P1b phase 2) ----------------------------------------------
306
+ #
307
+ # githubapp/oauth_views.py is this codebase's own real, correct OAuth
308
+ # flow (generates+stores `state` at authorize time, validates it at
309
+ # callback) — used as the template for what "safe" looks like. ID-token
310
+ # audience/issuer validation is NOT a new rule here — it reuses
311
+ # jwt-decode-missing-audience-or-issuer-check from phase 1 directly,
312
+ # since an OIDC ID token is just a JWT.
313
+ #
314
+ # Design note, found the hard way by running these rules against the
315
+ # real oauth_views.py (not just synthetic fixtures): a `pattern:` whose
316
+ # primary/base match is a whole function body via bare `def $FUNC(...):
317
+ # ...` — combined with `pattern-regex`/`pattern-not-regex` checks meant
318
+ # to scan "the whole function" — is unreliable. Semgrep can enumerate
319
+ # multiple overlapping candidate spans for how far a body `...` extends,
320
+ # and a regex is checked independently against each candidate; a
321
+ # candidate that happens to end *before* the line that would have
322
+ # satisfied/excluded the check produces a spurious match even though the
323
+ # complete function does not. Confirmed live: an earlier version of the
324
+ # two rules below flagged this codebase's own real, correct
325
+ # github_login()/github_callback() over a dozen times each, despite both
326
+ # correctly generating/storing/validating state. Fixed by anchoring the
327
+ # base `pattern:` on one exact statement (a dict literal, an
328
+ # assignment) — a single AST node, not an open-ended statement sequence
329
+ # — and using `pattern-inside`/`pattern-not-inside`/`pattern-not` for
330
+ # everything else, all of which are structural (AST-shape) containment
331
+ # checks rather than regex-over-a-range, so this class of bug can't
332
+ # recur here. Re-verified against oauth_views.py directly: zero findings.
333
+
334
+ - id: oauth-authorize-redirect-missing-state
335
+ languages: [python]
336
+ severity: ERROR
337
+ message: >-
338
+ This OAuth/OIDC authorize params dict includes `client_id` (the
339
+ shape of a provider-authorize request) but no `state` key. Without a
340
+ per-request, server-stored `state` value round-tripped through the
341
+ provider and checked at callback, the flow is vulnerable to CSRF —
342
+ an attacker can trick a victim into completing *the attacker's*
343
+ OAuth flow, linking the victim's session to an account the attacker
344
+ controls. Generate a random `state` (`secrets.token_urlsafe(...)`),
345
+ store it (session), include it in the authorize redirect, and
346
+ validate it at the callback. (Heuristic rule — only recognizes
347
+ `state` declared inline in this dict literal, not one added via a
348
+ later `params["state"] = ...` assignment.)
349
+ metadata:
350
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
351
+ cwe: "CWE-352"
352
+ internal_severity: HIGH
353
+ patterns:
354
+ - pattern: '$PARAMS = {"client_id": ..., ...}'
355
+ - pattern-not: '$PARAMS = {..., "state": ..., ...}'
356
+ - pattern-inside: |
357
+ def $FUNC(request):
358
+ ...
359
+ - metavariable-regex:
360
+ metavariable: $FUNC
361
+ regex: "(?i).*(login|authorize|oauth).*"
362
+
363
+ - id: oauth-callback-state-not-validated
364
+ languages: [python]
365
+ severity: ERROR
366
+ message: >-
367
+ This OAuth/OIDC callback view reads `state` from the request but
368
+ this function contains no comparison of it against another value —
369
+ receiving a `state` param isn't the same as validating it came back
370
+ unchanged. Compare the callback's `state` against the value stashed
371
+ in the session at authorize time (e.g. `if state != expected_state:
372
+ reject`), and reject the callback if they don't match (see
373
+ `github_callback()` in this same codebase for a real, correct
374
+ reference implementation).
375
+ metadata:
376
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
377
+ cwe: "CWE-352"
378
+ internal_severity: HIGH
379
+ patterns:
380
+ - pattern: $STATE = request.GET.get("state")
381
+ - pattern-inside: |
382
+ def $FUNC(request):
383
+ ...
384
+ - metavariable-regex:
385
+ metavariable: $FUNC
386
+ regex: "(?i).*callback.*"
387
+ - pattern-not-inside: |
388
+ def $FUNC(request):
389
+ ...
390
+ if <... $STATE != $EXPECTED ...>:
391
+ ...
392
+ - pattern-not-inside: |
393
+ def $FUNC(request):
394
+ ...
395
+ if <... $EXPECTED != $STATE ...>:
396
+ ...
397
+ - pattern-not-inside: |
398
+ def $FUNC(request):
399
+ ...
400
+ if <... $STATE == $EXPECTED ...>:
401
+ ...
402
+ - pattern-not-inside: |
403
+ def $FUNC(request):
404
+ ...
405
+ if <... $EXPECTED == $STATE ...>:
406
+ ...
407
+
408
+ - id: oauth-redirect-uri-from-request-input
409
+ languages: [python]
410
+ severity: ERROR
411
+ message: >-
412
+ `redirect_uri` is being sourced directly from request input rather
413
+ than a fixed, server-side value. If the OAuth provider doesn't
414
+ strictly validate `redirect_uri` against an exact registered allowlist
415
+ (many only check a prefix/domain), an attacker can redirect the
416
+ authorization code or access token to a host they control. Use a
417
+ fixed `redirect_uri` (a constant or `settings.*` value), never one
418
+ built from `request.GET`/`request.POST`/`request.data`.
419
+ metadata:
420
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
421
+ cwe: "CWE-601"
422
+ internal_severity: HIGH
423
+ patterns:
424
+ - pattern-either:
425
+ - pattern: redirect_uri = request.GET.get(...)
426
+ - pattern: redirect_uri = request.GET[...]
427
+ - pattern: redirect_uri = request.POST.get(...)
428
+ - pattern: redirect_uri = request.POST[...]
429
+ - pattern: redirect_uri = request.data.get(...)
430
+ - pattern: redirect_uri = request.data[...]
431
+ - pattern: redirect_uri = request.args.get(...)
432
+
433
+ - id: oauth-authorize-missing-pkce
434
+ languages: [python]
435
+ severity: WARNING
436
+ message: >-
437
+ This view's own name signals a public-client OAuth/OIDC flow (mobile/
438
+ SPA/native), but it has no `code_challenge` (PKCE) in its authorize
439
+ redirect. Public clients have no confidential secret to authenticate
440
+ the token exchange with, which is exactly the case PKCE exists to
441
+ protect: without it, an intercepted authorization code can be
442
+ redeemed by whoever captured it. Add PKCE (`code_challenge`/
443
+ `code_challenge_method` at authorize time, `code_verifier` at token
444
+ exchange). Deliberately name-scoped rather than checking for an
445
+ absent `client_secret` in this same function — a confidential
446
+ client's token exchange commonly lives in a separate callback
447
+ function (this codebase's own `github_login()`/`github_callback()`
448
+ split is a real example), so "no client_secret in this function" is
449
+ not, by itself, a reliable public-vs-confidential signal. (Heuristic
450
+ rule — only fires on views whose own name says public client; expect
451
+ false negatives on public clients that don't name themselves this
452
+ way, not false positives on confidential ones.)
453
+ metadata:
454
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
455
+ cwe: "CWE-287"
456
+ internal_severity: MEDIUM
457
+ patterns:
458
+ - pattern: '$PARAMS = {"client_id": ..., ...}'
459
+ - pattern-not: '$PARAMS = {..., "code_challenge": ..., ...}'
460
+ - pattern-inside: |
461
+ def $FUNC(request):
462
+ ...
463
+ - metavariable-regex:
464
+ metavariable: $FUNC
465
+ regex: "(?i).*(mobile|spa|native|public_client|pkce).*"
466
+
467
+ # --- Session security (P1b phase 3) ----------------------------------------
468
+ #
469
+ # The three cookie-flag rules are deliberately single-line settings
470
+ # matches, same style/reliability as the existing django-debug-true rule
471
+ # — no ellipsis/regex-scoping risk at all. The two code-pattern rules
472
+ # reuse structural idioms already proven reliable this phase: a
473
+ # well-anchored base statement (never bare `def $FUNC(...): ...` alone)
474
+ # plus `pattern-inside`/`pattern-not`/`pattern-not-inside` for
475
+ # containment, verified against this codebase's own real
476
+ # github_login()/github_callback()/github_logout() (oauth_views.py) —
477
+ # which correctly uses login()/logout() and is not flagged.
478
+
479
+ - id: django-session-cookie-not-secure
480
+ languages: [python]
481
+ severity: ERROR
482
+ message: >-
483
+ SESSION_COOKIE_SECURE = False means the session cookie is sent over
484
+ plain HTTP too, not just HTTPS — on any mixed-content page, cached
485
+ redirect, or network position between the user and the server, the
486
+ session cookie can be captured in the clear and replayed. Set
487
+ SESSION_COOKIE_SECURE = True (and terminate the site on HTTPS only).
488
+ metadata:
489
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
490
+ cwe: "CWE-614"
491
+ internal_severity: HIGH
492
+ pattern: SESSION_COOKIE_SECURE = False
493
+
494
+ - id: django-session-cookie-not-httponly
495
+ languages: [python]
496
+ severity: ERROR
497
+ message: >-
498
+ SESSION_COOKIE_HTTPONLY = False makes the session cookie readable
499
+ from JavaScript (`document.cookie`) — any XSS on the site can steal
500
+ the session cookie directly, not just data visible in the DOM. Set
501
+ SESSION_COOKIE_HTTPONLY = True (Django's own default — this is an
502
+ explicit opt-out of it).
503
+ metadata:
504
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
505
+ cwe: "CWE-1004"
506
+ internal_severity: HIGH
507
+ pattern: SESSION_COOKIE_HTTPONLY = False
508
+
509
+ - id: django-session-cookie-samesite-weak
510
+ languages: [python]
511
+ severity: WARNING
512
+ message: >-
513
+ SESSION_COOKIE_SAMESITE is set to a value that drops or weakens the
514
+ SameSite cookie attribute (False/None/"None") — the session cookie
515
+ is then sent on cross-site requests too, removing a real layer of
516
+ CSRF defense-in-depth. Use "Lax" (Django's own default) unless this
517
+ app genuinely needs the session cookie sent cross-site (e.g. an
518
+ embedded iframe use case), in which case "None" requires
519
+ SESSION_COOKIE_SECURE = True as well.
520
+ metadata:
521
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
522
+ cwe: "CWE-352"
523
+ internal_severity: MEDIUM
524
+ patterns:
525
+ - pattern-either:
526
+ - pattern: SESSION_COOKIE_SAMESITE = False
527
+ - pattern: SESSION_COOKIE_SAMESITE = None
528
+ - pattern: SESSION_COOKIE_SAMESITE = "None"
529
+
530
+ - id: django-custom-session-write-missing-rotation
531
+ languages: [python]
532
+ severity: ERROR
533
+ message: >-
534
+ This view writes an authentication-shaped key directly into
535
+ `request.session` (marking someone as logged in) without calling
536
+ Django's `login()` (which auto-rotates the session key) or
537
+ `request.session.cycle_key()` anywhere in the same function. Reusing
538
+ the pre-login session key after authenticating is session fixation —
539
+ an attacker who fixed a victim's session id before login (e.g. via a
540
+ shared/public terminal, or a session id planted through another
541
+ vulnerability) inherits the now-authenticated session. Use Django's
542
+ `login(request, user)` for standard auth, or call
543
+ `request.session.cycle_key()` explicitly right after establishing
544
+ the authenticated identity in a custom flow.
545
+ metadata:
546
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
547
+ cwe: "CWE-384"
548
+ internal_severity: HIGH
549
+ patterns:
550
+ - pattern: request.session[$KEY] = $VAL
551
+ - metavariable-regex:
552
+ metavariable: $KEY
553
+ regex: '(?i).*(user_id|authenticated|logged_in|is_logged_in|\buid\b).*'
554
+ - pattern-inside: |
555
+ def $FUNC(request):
556
+ ...
557
+ - pattern-not-inside: |
558
+ def $FUNC(request):
559
+ ...
560
+ login(request, ...)
561
+ ...
562
+ - pattern-not-inside: |
563
+ def $FUNC(request):
564
+ ...
565
+ $S.cycle_key()
566
+ ...
567
+
568
+ - id: django-logout-view-missing-logout-call
569
+ languages: [python]
570
+ severity: WARNING
571
+ message: >-
572
+ This view's own name signals a logout action, but its body never
573
+ calls Django's `logout()`. Manually clearing one session key (or
574
+ just returning a "logged out" response) without `logout()` leaves
575
+ the rest of the session data and the session's own auth backend
576
+ state intact server-side — the session may still be valid if the
577
+ client re-sends the same session cookie. Call Django's
578
+ `logout(request)`, which flushes the session properly.
579
+ metadata:
580
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
581
+ cwe: "CWE-613"
582
+ internal_severity: MEDIUM
583
+ patterns:
584
+ - pattern: |
585
+ def $FUNC(request):
586
+ ...
587
+ - metavariable-regex:
588
+ metavariable: $FUNC
589
+ regex: "(?i).*logout.*"
590
+ - pattern-not: |
591
+ def $FUNC(request):
592
+ ...
593
+ logout(...)
594
+ ...
595
+
596
+ # --- Authentication, general (P1b phase 4) ---------------------------------
597
+ #
598
+ # Scoped narrowly, per the plan: plaintext password storage, missing
599
+ # rate-limiting on auth endpoints, weak password-reset token generation.
600
+ # Account enumeration (differing error messages) deliberately NOT built
601
+ # — flagged in planning as a likely-noisy candidate, not worth forcing
602
+ # in without evidence it earns its keep. This codebase has no local
603
+ # password auth of its own (GitHub OAuth only) to benchmark against
604
+ # directly, so these fixtures model well-known Django idioms rather
605
+ # than mirroring a real internal reference implementation.
606
+
607
+ - id: django-plaintext-password-assignment
608
+ languages: [python]
609
+ severity: ERROR
610
+ message: >-
611
+ A password is being written directly to the `password` field —
612
+ either via `Model.objects.create(password=...)` or a direct
613
+ `user.password = ...` assignment — bypassing Django's hashing
614
+ entirely. Unlike `create_user()`/`set_password()`, this stores the
615
+ raw value as-is; if it's ever a plaintext password (not an
616
+ already-hashed one from `make_password()`), any database read (a
617
+ backup, a breach, an internal tool) exposes every affected user's
618
+ real password. Use `User.objects.create_user(...)` or call
619
+ `user.set_password(raw_password)` before saving.
620
+ metadata:
621
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
622
+ cwe: "CWE-256"
623
+ internal_severity: CRITICAL
624
+ patterns:
625
+ - pattern-either:
626
+ - pattern: $MODEL.objects.create(..., password=$P, ...)
627
+ - pattern: $USER.password = $P
628
+ - pattern-not: $MODEL.objects.create(..., password=make_password(...), ...)
629
+ - pattern-not: $USER.password = make_password(...)
630
+
631
+ - id: django-auth-view-missing-rate-limit
632
+ languages: [python]
633
+ severity: WARNING
634
+ message: >-
635
+ This login/password-reset view's own name suggests an
636
+ authentication entry point, but it has no visible rate-limiting
637
+ decorator (`@ratelimit`, a DRF throttle class, or similar). This
638
+ flags the code-level *absence of a rate-limiting mechanism* near the
639
+ view — not "this endpoint is being brute-forced," which is a
640
+ runtime signal out of scope for a static scanner. Without any
641
+ throttle, credential-stuffing and password-guessing attempts against
642
+ this endpoint are limited only by network conditions. Add
643
+ `django-ratelimit`'s `@ratelimit(...)`, a DRF `throttle_classes`, or
644
+ equivalent. (Heuristic rule based on the view's own name and nearby
645
+ decorators — expect some false positives where rate-limiting is
646
+ enforced elsewhere, e.g. at a reverse proxy/WAF/API gateway.)
647
+ metadata:
648
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
649
+ cwe: "CWE-307"
650
+ internal_severity: MEDIUM
651
+ patterns:
652
+ - pattern: |
653
+ def $FUNC(request, ...):
654
+ ...
655
+ - metavariable-regex:
656
+ metavariable: $FUNC
657
+ regex: "(?i).*(login|signin|sign_in|password_reset|reset_password|forgot_password).*"
658
+ - metavariable-regex:
659
+ # Same reasoning as django-admin-view-missing-permission-decorator's
660
+ # identical guard: a name starting with `_` is a private helper,
661
+ # not a URL-routed view.
662
+ metavariable: $FUNC
663
+ regex: "^[^_]"
664
+ - pattern-not-regex: "(?i)(ratelimit|throttle)"
665
+
666
+ - id: django-weak-password-reset-token
667
+ languages: [python]
668
+ severity: ERROR
669
+ message: >-
670
+ This password-reset/verification token is generated with `random`
671
+ (not cryptographically secure — seeded, predictable, and
672
+ reproducible by anyone who can infer or brute-force the internal
673
+ state) or a truncated `uuid4()` (deliberately shortening an
674
+ already-random value throws away entropy, making it more
675
+ guessable). A guessable reset token lets an attacker take over any
676
+ account whose email/reset flow they can trigger. Use Django's
677
+ `PasswordResetTokenGenerator` or `secrets.token_urlsafe(...)`
678
+ (cryptographically secure, full entropy, not truncated).
679
+ metadata:
680
+ owasp_category: "A07:2021 - Identification and Authentication Failures"
681
+ cwe: "CWE-330"
682
+ internal_severity: HIGH
683
+ patterns:
684
+ - pattern-either:
685
+ - pattern: $TOKEN = random.randint(...)
686
+ - pattern: $TOKEN = random.choice(...)
687
+ - pattern: $TOKEN = random.random()
688
+ - pattern: $TOKEN = str(uuid.uuid4())[:$N]
689
+ - metavariable-regex:
690
+ metavariable: $TOKEN
691
+ regex: "(?i).*(reset_token|password_token|reset_code|verification_token|reset_key).*"