Repository navigation
Expand file tree
/
Copy pathdecisionModelLinter.config.ts
More file actions
718 lines (709 loc) · 39.4 KB
/
Copy pathdecisionModelLinter.config.ts
File metadata and controls
718 lines (709 loc) · 39.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
// Sentry's decision-model lint rules: https://github.com/getsentry/decision-model-linter
import type {Config, Model, Suite} from '@sentry/decision-model-linter';
const jev = {
provider: 'openrouter',
name: 'typesafe/jev-1.13',
apiKey: process.env.OPENROUTER_API_KEY,
maxInputTokens: 32_768,
} satisfies Model;
const api = {
rules: {
addsNewOmittedFields: {
prompt:
'Does the code withhold a field from the public API schema by adding it to omit_from_public_schema in @sentry_schema_serializer(...)?',
values: [
{
value: 'TRUE',
description:
'A field name and its reason are added to an omit_from_public_schema dict, so the field stays in the API but leaves the public schema and every generated SDK.',
severity: 'warn',
report:
'This withholds a field from the public API schema and every generated SDK, though clients can still use it. Withhold a field only if it must never be public, and make its reason say why. To fix a missing description, add help_text to the field instead.',
},
{value: 'FALSE', description: 'No field is added to omit_from_public_schema.'},
],
},
readsRequestBodyOutsideSerializer: {
// Webhook and third-party callback handlers parse external payloads, not our API params.
// readsWebhookPayloadOutsideSerializer covers them instead.
ignores: [
'**/tests/**',
'**/testutils/**',
'**/webhooks/**',
'**/webhook.py',
'**/webhooks.py',
'**/*_webhook.py',
'src/sentry/integrations/slack/requests/**',
'src/sentry/integrations/*/installed.py',
'src/sentry/middleware/integrations/parsers/**',
'src/sentry/flags/endpoints/hooks.py',
],
prompt:
'Does endpoint code read a value from the request body by key, with request.data[...], request.data.get(...), or request.POST, instead of from a serializer\'s validated_data? Checking whether a key is present (for example "confirm" in request.data) without using its value does not count.',
values: [
{
value: 'TRUE',
description:
'Code outside a serializer looks up a key in request.data or request.POST and uses the value, whether or not a serializer also validates the body.',
severity: 'warn',
report:
"Request body values should be read from a serializer's `validated_data`. This file reads a value directly from `request.data` or `request.POST`.",
},
{
value: 'FALSE',
description:
'The body is only passed whole to a serializer, as in Serializer(data=request.data), and values are read from validated_data. Reading self.initial_data or attrs inside a serializer does not count.',
},
],
},
readsWebhookPayloadOutsideSerializer: {
files: [
'src/sentry/**/webhooks/**/*.py',
'src/sentry/**/webhook.py',
'src/sentry/**/webhooks.py',
'src/sentry/**/*_webhook.py',
'src/sentry/integrations/slack/requests/**/*.py',
'src/sentry/integrations/*/installed.py',
'src/sentry/middleware/integrations/parsers/**/*.py',
'src/sentry/flags/endpoints/hooks.py',
],
prompt:
"Does webhook or third-party callback code read a field from the incoming payload by key, with request.data[...], request.data.get(...), request.POST, json.loads(request.body)[...], or an integration's parsed request data such as slack_request.data[...], instead of validating the payload with a serializer or typed schema and reading from the validated result? Only key lookups made in this file count. Decoding the body and passing the whole payload to a helper does not count, even if the helper reads fields, for example data = orjson.loads(self.request.body) followed by get_vsts_external_id(data=data).",
values: [
{
value: 'TRUE',
description:
'The code looks up keys in the raw or parsed incoming payload and uses the values directly, without a serializer or schema validating that payload first.',
severity: 'warn',
report:
'Third-party webhook payloads should be validated with a serializer or typed schema before their fields are read. This file reads fields directly from the incoming payload.',
},
{
value: 'FALSE',
description:
'The payload is validated by a serializer or schema first and values are read from the validated result, or the code only verifies a signature, reads headers, or passes the whole body to a parser without reading fields itself. Passing the whole decoded payload to a helper function without reading its keys here also does not count.',
},
],
},
},
} satisfies Suite;
const audit = {
// Models, services, operator commands, and system tasks change auth state as plumbing; the
// audit belongs at the user-facing caller.
ignores: [
'**/tests/**',
'**/testutils/**',
'**/models/**',
'**/deletions/**',
'**/services/**',
'src/sentry/management/**',
'src/sentry/runner/**',
'src/sentry/tasks/auth/**',
'src/sentry/tasks/commits.py',
],
rules: {
issuesOrRevokesTokenWithoutRecord: {
prompt:
'Outside the OAuth flows for third-party apps (ApiGrant, ApiAuthorization, the authorize, consent, token, and authorizations endpoints, revoking a user\'s ApiAuthorization for an app, and Sentry App token exchange or refresh), does the code create, revoke, deactivate, or change the scopes of an ApiToken, OrgAuthToken, ApiKey, SentryAppInstallationToken, or an agent token grant? Authenticating a request with a token does not count. Helper classes and functions that create tokens for other code to call (for example SentryAppInstallationTokenCreator, or a service function) do not count, because the record belongs at the request handler or task that calls them. If so, does the same file record it with create_audit_entry, create_audit_entry_from_user, create_system_audit_entry, log_service.record_audit_log, capture_security_activity, or a sentry.audit logger (logging.getLogger("sentry.audit..."))?',
values: [
{
value: 'TRUE',
description:
'The code calls create, delete, or update on one of those token models (for example ApiToken.objects.create, token.delete(), update(date_deactivated=...), or update(scope_list=...)), or calls a function whose name says it grants token scopes (for example create_write_grant). Nothing in the file records it.',
severity: 'warn',
report:
'Token changes should be logged with `create_audit_entry` for org tokens or `capture_security_activity` for personal tokens. This file creates, revokes, or rescopes a token, but neither call was found in it. Ignore this if a function the file calls already logs the change.',
},
{
value: 'FALSE',
description:
'No such token is created, revoked, or rescoped. Authenticating with a token, updating last_used or date_last_used, and the OAuth authorize, consent, and token flows for third-party apps do not count. Or the file records it.',
},
],
},
changesPrivilegeWithoutRecord: {
prompt:
'Does the code grant or remove is_superuser, is_staff, or a UserPermission for a user? Organization and team roles are not privileges here. If so, does the same file record it with create_audit_entry, create_audit_entry_from_user, create_system_audit_entry, log_service.record_audit_log, capture_security_activity, or a sentry.audit logger (logging.getLogger("sentry.audit..."))?',
values: [
{
value: 'TRUE',
description:
'The code sets is_superuser or is_staff on a User, or creates or deletes a UserPermission, and no audit_logger.info(...) call on a sentry.audit logger follows it. Judge each separately: if is_superuser or is_staff changes and only the UserPermission change is recorded, answer TRUE. Nothing in the file records it.',
severity: 'warn',
report:
'Superuser, staff, and `UserPermission` changes should be logged to the `sentry.audit.user` logger. This file grants or removes one of them, but no such log call was found in it. Ignore this if a function the file calls already logs the change.',
},
{
value: 'FALSE',
description:
'No privilege flag or UserPermission changes. Starting or ending a superuser or staff session, and checking whether a user has a privilege, do not count. Or the file records it.',
},
],
},
changesAccountIdentityWithoutRecord: {
prompt:
'Does the code change a user\'s email or primary email, unlink or delete an AuthIdentity, or merge or delete user accounts? If so, does the same file record it with create_audit_entry, create_audit_entry_from_user, create_system_audit_entry, log_service.record_audit_log, capture_security_activity, or a sentry.audit logger (logging.getLogger("sentry.audit..."))?',
values: [
{
value: 'TRUE',
description:
'The code updates User.email or a UserEmail of an existing user, deletes an AuthIdentity, or merges or deletes User records. Setting the email of a new account being registered does not count. Nothing in the file records it.',
severity: 'warn',
report:
'Email changes, identity unlinks, and account merges should be logged with `capture_security_activity` so the user is notified. This file makes one of these changes, but no such call was found in it. Ignore this if a function the file calls already logs the change.',
},
{
value: 'FALSE',
description:
'None of those change. Linking an identity through SSO setup, integration Identity links (Slack, MS Teams, Discord), verifying an email, and logging in do not count. Or the file records it.',
},
],
},
},
} satisfies Suite;
const frontend = {
files: [
'static/app/**/*.ts',
'static/app/**/*.tsx',
'static/gsApp/**/*.ts',
'static/gsApp/**/*.tsx',
],
ignores: ['**/*.spec.ts', '**/*.spec.tsx', '**/*.stories.tsx', '**/__stories__/**'],
rules: {
parsesJsonUrlParamWithoutParseAsJson: {
prompt:
'Does the code turn a URL query parameter whose value is JSON into data with JSON.parse instead of nuqs parseAsJson with a Zod schema? A URL query parameter is a value from location.query (including through decodeScalar or decodeList), window.location.search, qs.parse, URLSearchParams, useSearchParams, or a nuqs hook or parser (useQueryState, useQueryStates, createParser, createMultiParser). JSON from localStorage, sessionStorage, an API response, a webhook body, event or span attributes, a file, or the clipboard does not count, and neither do JSON.parse(JSON.stringify(...)) copies or writing JSON into a URL.',
values: [
{
value: 'TRUE',
description:
'JSON.parse turns a JSON-valued URL query parameter into data, whether or not the result is checked afterward.',
severity: 'warn',
report:
'JSON-valued URL query params should be described with a Zod schema and read with nuqs `parseAsJson(schema)`. This file parses one with `JSON.parse`.',
},
{
value: 'FALSE',
description:
'JSON-valued URL query parameters are read with parseAsJson and a Zod schema, or JSON.parse only reads JSON that does not come from the URL.',
},
],
},
usesUrlQueryParamWithoutNuqs: {
// Rule ignores replace the suite's, so the suite's test and story globs are repeated.
ignores: [
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.stories.tsx',
'**/__stories__/**',
'static/app/utils/url/**',
'static/app/utils/queryString.tsx',
'static/app/views/explore/queryParams/parsers.ts',
'static/app/components/pageFilters/**',
'static/app/router/legacy/**',
],
prompt:
"Does the code read a query parameter of the current page's URL, or navigate or link to the current page with a changed query, without nuqs? Reading means taking a value from location.query (directly or through decodeScalar, decodeList, decodeInteger, decodeBoolean, or decodeSorts), from location.search or window.location.search with URLSearchParams or qs.parse, or from useSearchParams. Navigating or linking to the current page means calling navigate, or setting the to of a Link or LinkButton, with the current pathname (...location, location.pathname, a pathname taken from useLocation(), or no pathname) and a query built from location.query, for example navigate({...location, query: {...location.query, cursor}}). Using useQueryState, useQueryStates, createLoader, createSerializer, or Sentry helpers built on them such as loadQueryParamsFromLocation and applyQueryParamsUpdates does not count. Neither do links or navigation to a different pathname, even when they copy location.query to keep page filters. Reading route path parameters from useParams, location.pathname, location.hash, or location.state does not count either, nor do URLs that are not the current page's address, nor passing the whole location, query, or search string along without reading a parameter from it.",
values: [
{
value: 'TRUE',
description:
"The code reads a query parameter of the current page's URL, or navigates or links to the current page with a changed query, without nuqs.",
severity: 'warn',
report:
'URL query params should be read and updated through nuqs, with `useQueryState` or `useQueryStates` in React or `createLoader` elsewhere, and a parser (for repeated-key lists, `parseAsStringArray`). Page filters should come from `usePageFilters`. This file reads or updates a query param through `location.query` or another API.',
},
{
value: 'FALSE',
description:
"The current page's query parameters are only read or updated through nuqs, or the code reads or updates no query parameter of the current page, or only passes the whole location, query, or search string along.",
},
],
},
},
} satisfies Suite;
const security = {
ignores: [
'**/tests/**',
'**/testutils/**',
'**/migrations/**',
'src/sentry/new_migrations/**',
],
rules: {
lookupMayMatchNullRows: {
prompt:
'Does the code put a value that is None for some callers or rows into an exact ORM lookup keyword (in filter(), exclude(), get(), get_or_create(), update_or_create() or Q()) without checking for None first? Examples: user_id=request.user.id, which is None for API keys and anonymous callers; user_id=member.user_id, which is None for a pending invite; application=app.application or user=obj.user when that foreign key is nullable; a parameter typed int | None. It does not count when the code checks the value first (if x is None: return), adds an __isnull=False lookup for the same field, passes a literal None, writes x.id if x else None on purpose to match NULL rows, or uses a value that is always set, such as a primary key or a required field.',
values: [
{
value: 'TRUE',
description:
'A lookup keyword takes request.user.id, a member or object user id, a nullable foreign key, or an optional parameter that can be None, with no None check, so None silently matches rows where the column IS NULL.',
severity: 'warn',
report:
"`filter(field=None)` matches rows where the column is NULL. Decide what None means here: no rows (check for None first, or use `owned_by_acting_user()` for the caller's own rows), no filter (leave the lookup out), or NULL rows (pass None or use `__isnull=True`). See src/AGENTS.md, Request Principals.",
},
{
value: 'FALSE',
description:
'Every lookup value is always set or checked for None first, or matching NULL rows is clearly intended.',
},
],
},
comparesSecretWithEquality: {
// Raised to cut borderline false positives, accepting that some real findings score below it.
threshold: 0.8,
prompt:
"Does the code compare a secret that an untrusted caller supplies (in a request header, query parameter, body field, or webhook payload), such as a signature, HMAC digest, API key, shared or verification token, recovery or verification code, or client secret, against the expected value with == or != instead of constant_time_compare or hmac.compare_digest? Comparing an OAuth state parameter with the value saved in the caller's own session, comparing two values that both come from the database or server-side state, and comparisons in backup, export, or diff tooling do not count.",
values: [
{
value: 'TRUE',
description:
'A caller-supplied secret and the expected secret are compared with == or !=, directly or after hashing one side.',
severity: 'warn',
report:
'Secrets supplied in a request should be compared with `constant_time_compare` or `hmac.compare_digest`. This file compares one with `==` or `!=`.',
},
{
value: 'FALSE',
description:
'No caller-supplied secret is compared with == or !=, or it is compared with constant_time_compare or hmac.compare_digest. OAuth state checks, comparisons between two stored values, and backup or diff tooling do not count.',
},
],
},
allowsWritesWithoutWriteAccess: {
// Raised to cut borderline false positives, accepting that some real findings score below it.
threshold: 0.8,
prompt:
'Does the code define an endpoint where a write method (POST, PUT, PATCH, or DELETE) changes shared organization, project, team, or member data but callers with only read access can use it, or where authentication_classes or permission_classes is empty, with no compensating check? In scope_map, the scopes listed for a method are alternatives: a caller with any one of them is allowed, so a write method whose list includes a read scope (org:read, project:read, team:read, member:read, event:read) accepts read-only callers. For example, "PUT": ["project:read", "project:write"] lets a caller with only project:read use PUT, and "DELETE": ["org:read", "member:admin"] lets a caller with only org:read use DELETE. Compensating checks are: verifying a webhook signature or shared secret; a login, logout, CSRF, recovery, registration, invite-acceptance, or relay registration flow; an action that only changes the caller\'s own data (their own searches, prompts, preferences, visits, or join requests) or records low-risk onboarding or setup progress; an in-method permission check (for example request.access.has_scope, has_project_access, check_object_permissions, or an enforce_*_permission helper); or a comment or docstring that explains why access is intentionally open.',
values: [
{
value: 'TRUE',
description:
'A write method that changes shared organization, project, team, or member data is reachable with only read scopes or without authentication, and the file shows no compensating check.',
severity: 'warn',
report:
"Write methods that change shared data should require write or admin scopes, or have an explicit permission check. This file lets read-only or unauthenticated callers use one. If that's intended, add a comment explaining why.",
},
{
value: 'FALSE',
description:
"Every write method that changes shared data requires write or admin scopes, or the endpoint has one of the compensating checks, or it changes only the caller's own data.",
},
],
},
sendsSecretsToTelemetry: {
// Raised to cut borderline false positives, accepting that some real findings score below it.
threshold: 0.8,
prompt:
"Does the code pass a credential value: an access or refresh token, API key, password, client secret, signing or webhook secret, session key, private key, or a signed JWT, or the incoming request's whole body, headers, or parsed payload (request.data, request.body, request.headers, request.META, or an integration's parsed request data such as slack_request.data or a raw Discord interaction body), to logging (a logger call's message or extra), metrics tags, span attributes, or sentry_sdk scope data (set_tag, set_attribute, set_context, set_extra)? A value named token, key, or signature that is not a credential (a pagination cursor, a per-request nonce, a correlation or session ID, a pipeline signature), a few characters of a token, key names, and redacted or sanitized values do not count.",
values: [
{
value: 'TRUE',
description:
'A credential value, or the whole incoming request body, headers, or parsed payload, reaches one of those sinks. Formatting a dict that contains a token, such as an OAuth token response, counts.',
severity: 'warn',
report:
"Credentials and whole request payloads shouldn't be sent to logs, metrics, spans, or error reports. This file sends one of them. Log an ID or a redacted value instead.",
},
{
value: 'FALSE',
description:
'No credential value or whole incoming request reaches telemetry. Response bodies, outbound messages Sentry sends, internal query data, IDs, and metadata do not count. Handling a secret in the same function without passing it to telemetry does not count.',
},
],
},
fetchesUntrustedUrlWithoutSafeClient: {
prompt:
"Does the code make an outbound HTTP request with requests, urllib, httpx, or urllib3 to a URL or host that comes from a user, a request, an integration's configuration, or other customer-editable data, instead of using sentry.http.safe_urlopen, a session from sentry.http.build_session, or a check with is_safe_hostname? Fixed URLs, URLs built from Sentry settings or options, and well-known third-party API base URLs do not count. Clients that subclass BaseApiClient, ApiClient, or IntegrationProxyClient from sentry.shared_integrations already use a safe session, and so do Sentry App requests sent through send_and_save_sentry_app_request or safe_urlopen, so their requests do not count. Calling the request methods of one of those clients also does not count, even when the client class is defined in another file (for example LegacyWebhookClient, or an integration's get_client()).",
values: [
{
value: 'TRUE',
description:
'A user- or customer-supplied URL or host reaches requests.get, requests.post, a requests session, urllib.request.urlopen, httpx, or urllib3 without safe_urlopen, build_session, or an is_safe_hostname check.',
severity: 'warn',
report:
'Customer-supplied URLs should be fetched with `sentry.http.safe_urlopen` or a session from `build_session`. This file fetches one with a plain HTTP client.',
},
{
value: 'FALSE',
description:
'Outbound requests use safe_urlopen, build_session, or is_safe_hostname, or the URL is fixed, comes from settings or options, or is a known provider API endpoint.',
},
],
},
redirectsToUntrustedUrl: {
prompt:
'Does the code redirect to a URL taken from the request (a next, redirect, redirect_uri, return_to, or callback parameter, the Referer header, or a cookie) with redirect(), HttpResponseRedirect, or a Location header, without first checking it with url_has_allowed_host_and_scheme, is_valid_redirect, get_login_redirect, or an exact allowlist? Redirects to fixed paths, reversed URL names, and URLs the server builds itself do not count.',
values: [
{
value: 'TRUE',
description:
'A redirect target comes from request parameters, headers, or cookies and is not validated against allowed hosts before the redirect.',
severity: 'warn',
report:
'Redirects to URLs taken from the request should be checked with `url_has_allowed_host_and_scheme` or `is_valid_redirect`. This file redirects to one without that check.',
},
{
value: 'FALSE',
description:
'Every redirect goes to a fixed path, a reversed URL, a server-built URL, or a request-supplied URL that is validated first.',
},
],
},
interpolatesIntoRawSql: {
// Raised to cut borderline false positives, accepting that some real findings score below it.
threshold: 0.8,
prompt:
"Does the code build raw SQL with an f-string, % formatting, .format(), or string concatenation and run it with cursor.execute, RawSQL, .raw(), .extra(), or connection.execute, where an interpolated value is not a constant or an allowlisted identifier? Values passed as query parameters (%s placeholders with a separate params list) do not count, and neither do table names from a model's _meta.db_table, identifiers passed through quote_name(), or integer IDs such as project_id or organization_id that internal code such as cleanup or deletion jobs passes in rather than a request.",
values: [
{
value: 'TRUE',
description:
'A value that is not a constant or allowlisted identifier is formatted into the SQL text itself before it runs.',
severity: 'warn',
report:
'Values in raw SQL should be passed as query parameters, and table or column names allowlisted. This file formats a value into the SQL text.',
},
{
value: 'FALSE',
description:
'Raw SQL uses query parameters for values, and only constants or allowlisted identifiers are formatted into the SQL text, or the code runs no raw SQL.',
},
],
},
marksUntrustedHtmlSafe: {
prompt:
'Does the code pass a string containing user-supplied or database values to mark_safe, SafeString, or Markup, build HTML with format_html where the format string itself contains user data, or turn off escaping (|safe, autoescape off) for user-controlled content? Marking fixed HTML or already-escaped output (format_html with arguments, escape()) as safe does not count, and neither do values encoded with json.dumps or json.dumps_htmlsafe and placed in a JavaScript string.',
values: [
{
value: 'TRUE',
description:
'User-supplied or database content reaches mark_safe, SafeString, Markup, a format_html format string, or an unescaped template output without escaping first.',
severity: 'warn',
report:
"User content should be escaped before it's marked safe, or passed as an argument to `format_html`. This file marks user content as safe HTML.",
},
{
value: 'FALSE',
description:
'Only fixed HTML or already-escaped output is marked safe, or the code marks nothing safe.',
},
],
},
usesRandomForSecrets: {
prompt:
'Does the code use the random module (random.choice, random.choices, random.randint, random.random, random.getrandbits, random.sample, but not random.SystemRandom) to generate a security-sensitive value such as a token, password, API key, nonce, verification or recovery code, or session ID? Using random for sampling, jitter, retries, load balancing, test data, or IDs that grant no access does not count.',
values: [
{
value: 'TRUE',
description:
'A value that grants access or proves identity is generated with the random module instead of secrets or os.urandom.',
severity: 'warn',
report:
'Tokens, codes, and passwords should be generated with the `secrets` module. This file generates one with the `random` module.',
},
{
value: 'FALSE',
description:
'The random module is used only for non-security purposes, or security values come from secrets, os.urandom, or random.SystemRandom.',
},
],
},
},
} satisfies Suite;
const brandNames = ['Sentry', 'Seer', 'Autofix', 'Spotlight', 'dotagents'];
const brandCaseExamples = brandNames
.map(
name =>
`"${name === name.toLowerCase() ? name.toUpperCase() : name.toLowerCase()}" should be "${name}"`
)
.join('; ');
const productNames = [
...brandNames,
'Agent Monitoring',
'AI Code Review',
'Application Metrics',
'Build Distribution',
'Code Review',
'Continuous Profiling',
'Cron Monitoring',
'Crons',
'Discover',
'Error Monitoring',
'Explore',
'Insights',
'Logging',
'Logs',
'MCP Monitoring',
'Performance Monitoring',
'Profiling',
'Relay',
'Seer Agent',
'Session Replay',
'Size Analysis',
'Snapshots',
'Structured Logs',
'Trace Explorer',
'Tracing',
'UI Profiling',
'Uptime Monitoring',
'User Feedback',
'User Feedback Widget',
];
const acronyms = [
'2FA',
'AI',
'ANR',
'AOT',
'API',
'ARN',
'ASGI',
'AWS',
'CD',
'CDN',
'CI',
'CIDR',
'CLI',
'CLS',
'CORS',
'CPU',
'CSP',
'CSS',
'CSV',
'DB',
'DEX',
'DNS',
'DOM',
'DSN',
'ELF',
'ESM',
'FCP',
'FID',
'FIFO',
'FPS',
'GC',
'GCP',
'GCS',
'GDPR',
'GPU',
'GraphQL',
'GUID',
'HEIC',
'HTML',
'HTTP',
'HTTPS',
'HMAC',
'IAM',
'ID',
'IdP',
'INP',
'IP',
'JAR',
'JPEG',
'JS',
'JSON',
'JSONL',
'JVM',
'JWT',
'LCP',
'LLM',
'MAUI',
'MCP',
'MIME',
'NDA',
'NDK',
'OAuth',
'OAuth2',
'OIDC',
'OOM',
'OS',
'OTel',
'OTLP',
'OTP',
'PDB',
'PE',
'PEM',
'PHP',
'PII',
'PKCE',
'PNG',
'PR',
'QR',
'RAM',
'REST',
'RSA',
'RPC',
'RSS',
'S3',
'SAML',
'SAML2',
'SCIM',
'SCM',
'SDK',
'SHA',
'SMS',
'SMTP',
'SPM',
'SQL',
'SQS',
'SSL',
'SSO',
'SSR',
'STARTTLS',
'SVG',
'TCP',
'TLS',
'TPM',
'TTFB',
'TTFD',
'TTID',
'TTL',
'U2F',
'UI',
'URI',
'URL',
'UTC',
'UDP',
'UTF-8',
'UUID',
'UWP',
'UX',
'WSGI',
'XSS',
'XML',
'YAML',
'ZIP',
];
const uiCopyScope =
'Inspect only hardcoded English in the FIRST argument of t or tct and the FIRST TWO text arguments of tn imported from sentry/locale, including aliases. Calls to unrelated or locally shadowed helpers do not count. Ignore all other arguments, even if they contain English strings. Ignore unwrapped strings, code, URLs, identifiers, search keywords, and user data. Labels, headings, placeholders, productName fields, and body messages can be UI copy. In tct, [link:text] and [strong:text] display text; [code:text] displays code. In a diff, imports may be omitted; only changed literals count.';
const voice = {
files: ['static/app/**/*.ts*'],
ignores: [
'**/*.tsbuildinfo',
...['spec', 'test', 'stories', 'd', 'figma'].map(kind => `**/*.${kind}.ts*`),
...['tests', 'fixtures', 'stories'].flatMap(directory => [
`**/${directory}/**`,
`**/__${directory}__/**`,
]),
...['testutils', 'dist', 'vendor', '.types', '.artifacts'].map(
directory => `**/${directory}/**`
),
],
threshold: 0.7,
unchecked: 'warn',
rules: {
usesTitleCaseInUiCopy: {
// Distinct contexts evaluate each copy rule independently.
context: {
copyKind: 'heading and label capitalization',
},
prompt: `${uiCopyScope}\n\nDoes a heading, button, or label incorrectly use Title Case after its first word, or ALL CAPS for an ordinary English word? This includes bold section headings, table/chart labels, sidebar items, form labels, and widget titles. Use sentence case: "Last seen", not "Last Seen"; "Settings", not "SETTINGS". Preserve proper names and named products (${productNames.join(', ')}). Preserve recognized acronyms and their plurals, including ${acronyms.join(', ')}. These examples are not exhaustive. Preserve canonical mixed/lowercase names and metric notation. An ordinary English word does not become an acronym just because it is uppercase. Body prose and strings in other function arguments do not count.`,
values: [
{
value: 'TRUE',
description:
'An ordinary word in a translated heading or label uses title case after the first word, or unnecessary ALL CAPS. The offending word is in a translation text argument, not another argument or an unwrapped string.',
severity: 'warn',
report:
'Use sentence case for this title or label. Preserve the canonical spelling of product names, proper nouns, and acronyms.',
},
{
value: 'FALSE',
description:
'Every ordinary word has sentence-case spelling: only the first letter of an ordinary first word is capitalized, and later ordinary words are lowercase. Other capitalization belongs to proper names, acronyms, or technical notation.',
},
],
},
miscapitalizesProductName: {
context: {copyKind: 'product name capitalization'},
threshold: 0.6,
prompt: `${uiCopyScope}\n\nDoes UI text use the wrong capitalization for a named product or feature? Canonical names: ${productNames.join(', ')}. These are proper names: retain ALL their canonical word capitalization even inside a sentence; do not sentence-case a product name. Match names ignoring case, then compare exact spelling. Brand-reference examples: ${brandCaseExamples}. A productName field explicitly identifies a product label. Mentioning a named Sentry product/feature in setup or navigation also counts, including visible link text. For example, "Sentry's continuous profiling product" names a product; "profiling an application" is a generic activity. Generic activities/resources do not count as names.`,
values: [
{
value: 'TRUE',
description:
'A translated UI literal names a product or feature but spells it with different capitalization from the canonical list, including lowercasing ordinary-looking words inside a proper product name.',
severity: 'warn',
report:
'Use the canonical spelling for this named Sentry product or feature. Leave generic descriptions lowercase and preserve package names and identifiers.',
},
{
value: 'FALSE',
description:
'Brand mentions have canonical spelling, and descriptive feature words are either generic activities/resources or correctly spelled named features. Exempt identifiers and keywords do not count.',
},
],
},
directsFrustrationAtUser: {
prompt: `${uiCopyScope}\n\nDoes UI copy direct frustration, ridicule, contempt, or a judgment about competence at the user? Flag insults or scolding, not ordinary second-person instructions. Neutral explanations of permissions, invalid input, configuration mistakes, or responsibility do not count. "You do not have permission" and "Check your connection" are neutral. Empathetic humor about the situation, the product itself, missing pages, or slow loading does not count as user-directed frustration. Do not flag all humor or all uses of "you".`,
values: [
{
value: 'TRUE',
description:
'The wording mocks, belittles, or scolds the user, rather than explaining the problem or joking about the situation.',
severity: 'warn',
report:
'Direct frustration or humor at the situation. Explain what happened and what the user can do next without blaming or belittling them.',
},
{
value: 'FALSE',
description:
'Copy is neutral, helpful, empathetic, or jokes about the situation without attacking the user.',
},
],
},
},
} satisfies Suite;
// Guardrails for the org:ci scope: endpoints reachable by CI org-auth-tokens
// (e.g. sentry-cli) that expose data beyond what CI automation should read.
const orgCiGuardrails = {
ignores: [
'**/tests/**',
'**/testutils/**',
'**/migrations/**',
'src/sentry/new_migrations/**',
],
rules: {
grantsCiScopeToRepositoryContent: {
// Raised to cut borderline false positives, accepting that some real findings score below it.
threshold: 0.8,
prompt:
'Answer in two parts. First: does the code define a read endpoint (a GET handler) that returns source-control or repository content — pull request files, diffs, or patches; file, blob, or tree contents; or commit diffs — and make it reachable by an org:ci token? A caller holds org:ci when the endpoint\'s scope_map lists "org:ci" for GET, or when permission_classes uses a class that grants org:ci on GET: OrganizationIntegrationsLoosePermission, OrganizationCodeMappingsBulkPermission, OrganizationReleasePermission, or ProjectReleasePermission. In a scope_map the scopes listed for a method are alternatives, so including "org:ci" lets any org:ci token call the endpoint. The org:ci scope is for CI automation only — creating releases and uploading suspect-commit data, source maps, and debug files — so it should not read repository contents. Endpoints that only manage releases, deploys, or artifact uploads, and endpoints behind a permission whose GET scope omits org:ci (for example OrganizationIntegrationsPermission or OrganizationPermission), do not count. Second, only if the first part is true: does a comment or docstring on the endpoint explain that org:ci or CI-token access to this data is intentional or expected?',
values: [
{
value: 'EXPECTED',
description:
'The endpoint is a GET that returns repository or source-control content reachable by an org:ci token, AND a comment or docstring explains that org:ci or CI-token access to this data is intentional or expected.',
severity: 'warn',
report:
'An endpoint in this file exposes repository content to an `org:ci` token, which a comment marks as intentional. Logged as an accepted exception — confirm the justification for that endpoint still holds.',
},
{
value: 'TRUE',
description:
'The endpoint is a GET that returns repository or source-control content reachable by an org:ci token (its GET scope_map lists "org:ci", or it uses OrganizationIntegrationsLoosePermission, OrganizationCodeMappingsBulkPermission, OrganizationReleasePermission, or ProjectReleasePermission), and no comment or docstring explains that this org:ci access is intentional.',
severity: 'warn',
report:
'The `org:ci` scope is for CI automation (releases and artifact uploads), not for reading repository contents. An endpoint in this file returns source-control data but is reachable by an `org:ci` token, with no comment marking it as intentional. Find the endpoint whose permission grants `org:ci` and either use a permission class whose GET scope omits `org:ci` (for example `OrganizationIntegrationsPermission`), reject `org:ci` tokens explicitly, or add a comment on it explaining why the access is intended.',
},
{
value: 'FALSE',
description:
'The endpoint does not return repository or source-control content, or it is not reachable by an org:ci token — its GET scope_map omits "org:ci" and it uses no permission class that grants org:ci. Release, deploy, and artifact-upload endpoints do not count.',
},
],
},
},
} satisfies Suite;
export default {
model: jev,
files: [
'src/sentry/**/*.py',
'static/app/**/*.ts',
'static/app/**/*.tsx',
'static/gsApp/**/*.ts',
'static/gsApp/**/*.tsx',
],
ignores: ['**/tests/**', '**/testutils/**'],
suites: {api, audit, frontend, security, voice, orgCiGuardrails},
} satisfies Config;