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.
- codeintely_cli/__init__.py +1 -0
- codeintely_cli/binaries.py +178 -0
- codeintely_cli/main.py +76 -0
- codeintely_cli/output.py +63 -0
- codeintely_cli/rules/appsec_breadth.yaml +220 -0
- codeintely_cli/rules/identity_security.yaml +691 -0
- codeintely_cli/rules/jsts_backend.yaml +167 -0
- codeintely_cli/rules/mass_assignment.yaml +37 -0
- codeintely_cli/rules/owasp_generic.yaml +94 -0
- codeintely_cli/rules/tenant_isolation.yaml +100 -0
- codeintely_cli/scanners.py +298 -0
- codeintely_cli-0.1.0.dist-info/METADATA +72 -0
- codeintely_cli-0.1.0.dist-info/RECORD +17 -0
- codeintely_cli-0.1.0.dist-info/WHEEL +5 -0
- codeintely_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codeintely_cli-0.1.0.dist-info/licenses/LICENSE +193 -0
- codeintely_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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).*"
|