Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/decision-model-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,35 @@ jobs:

- uses: ./.github/actions/setup-node-pnpm

# PROBE ONLY (do not merge): A/B the master config against #129032's config on the same
# changes, and run both on whole files, printing JSON answers to the job log.
- name: probe
env:
OPENROUTER_API_KEY: ${{ secrets.DECISION_MODEL_LINTER_OPENROUTER_API_KEY }}
run: |
git show HEAD^1:decisionModelLinter.config.ts > decisionModelLinter.old.config.ts
whole="src/sentry/flags/endpoints/logs.py src/sentry/api/serializers/models/exporteddata.py src/sentry/core/endpoints/organization_details.py src/sentry/core/endpoints/organization_environments.py src/sentry/api/endpoints/organization_weekly_report_project_exclusions.py"
probe() {
name=$1; shift
code=0; out=$(pnpm exec decision-model-linter --format json "$@" 2>"$RUNNER_TEMP/$name.err") || code=$?
echo "PROBE_EXIT $name $code"; sed "s/^/PROBE_ERR $name /" "$RUNNER_TEMP/$name.err"
echo "PROBE_JSON $name $(echo "$out" | jq -c .)"
}
DECISION_MODEL_LINT_BASE=HEAD^1 probe new1 --config decisionModelLinter.config.ts --base HEAD^1
DECISION_MODEL_LINT_BASE=HEAD^1 probe new2 --config decisionModelLinter.config.ts --base HEAD^1
probe old --config decisionModelLinter.old.config.ts --base HEAD^1
probe newwhole --config decisionModelLinter.config.ts --suite api -- $whole
probe oldwhole --config decisionModelLinter.old.config.ts --suite api -- $whole
rm decisionModelLinter.old.config.ts

- name: lint
env:
OPENROUTER_API_KEY: ${{ secrets.DECISION_MODEL_LINTER_OPENROUTER_API_KEY }}
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.number }}
LABEL: 'Trigger: Override Decision Model Lint'
# Rules diff each file against the PR's base, it must match --base below
DECISION_MODEL_LINT_BASE: HEAD^1
OVERRIDDEN: "${{ contains(github.event.pull_request.labels.*.name, 'Trigger: Override Decision Model Lint') }}"
run: |
report="$RUNNER_TEMP/decision-model-lint.md"
Expand Down
188 changes: 188 additions & 0 deletions decisionModelLinter.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// Sentry's decision-model lint rules: https://github.com/getsentry/decision-model-linter
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';

import type {Config, Model, Suite} from '@sentry/decision-model-linter';

const jev = {
Expand All @@ -8,7 +11,72 @@ const jev = {
maxInputTokens: 32_768,
} satisfies Model;

const diffBase = process.env.DECISION_MODEL_LINT_BASE;
const NO_DIFF = 'No diff available.';
const execFileAsync = promisify(execFile);

// renamed files have the same file path before the rename
let renames: Promise<Map<string, string>> | undefined;

function renamedFrom(base: string): Promise<Map<string, string>> {
if (renames !== undefined) {
return renames;
}

renames = execFileAsync('git', ['diff', '--name-status', '--find-renames', base])
.then(({stdout}) => {
const map = new Map<string, string>();
let line: string;
for (line of stdout.split('\n')) {
const [status, oldPath, newPath] = line.split('\t');
if (status?.startsWith('R') && oldPath && newPath) {
map.set(newPath, oldPath);
}
}
return map;
})
.catch(() => new Map<string, string>());
return renames;
}

// the engine shows a changed file's new lines only, so a rule can't tell new code from code
// that was already there. The diff adds the removed lines, with no context lines so unchanged
// code stays hidden from the other rules in the suite
async function diff({path}: {path: string}): Promise<string> {
if (!diffBase) {
return NO_DIFF;
}
try {
const oldPath = (await renamedFrom(diffBase)).get(path);
let paths: string[];
if (oldPath) {
paths = [oldPath, path];
} else {
paths = [path];
}

const gitArgs = ['diff', '--unified=0', '--find-renames', diffBase, '--'].concat(
paths
);
const result = await execFileAsync('git', gitArgs);
const stdout = result.stdout;

// a new file has no earlier version to compare and its diff would repeat the whole file
if (!stdout || /^new file mode/m.test(stdout)) {
return NO_DIFF;
}
return stdout;
} catch {
return NO_DIFF;
}
}

// added to the prompts of rules that should only report problems the change itself adds
const CAUSED_BY_THIS_CHANGE =
'Use diff to tell whether this change caused it: compare its - (removed) and + (added) lines. A line that only moved or was reformatted appears as both - and +, so it already existed. If diff says No diff available., treat the whole file as under review and answer INTRODUCED for any problem.';

const api = {
context: {diff},
rules: {
addsNewOmittedFields: {
prompt:
Expand Down Expand Up @@ -87,6 +155,105 @@ const api = {
},
],
},
// The four rules below are a temporary, "warn only" safety net for API design rules while
// deterministic checks for them are built. We will remove each one once its check is enforced
returnsUnpaginatedList: {
threshold: 0.8,
prompt:
'Does an endpoint\'s GET method return a list of objects that can keep growing, such as an organization\'s projects, members, issues, releases, or monitors, without paginating it with self.paginate(...) or a paginator class? A list inside a response object (for example {"data": [...]}) counts too. Lists with a small fixed limit, lists of fixed choices or enum values, single objects, and lists that are only built for internal use and never returned do not count. ' +
CAUSED_BY_THIS_CHANGE,
values: [
{
value: 'INTRODUCED',
description:
'This change adds a GET handler, or a newly returned list, whose growing collection nothing paginates or limits, or it removes pagination from a list that had it. When diff says No diff available., this is the answer for any such problem.',
severity: 'warn',
report:
"List endpoints should paginate with `self.paginate(...)` and a `cursor` parameter. This change returns a list that can keep growing without paginating it. If the endpoint already existed, don't add pagination to it: that changes what existing callers get back.",
},
{
value: 'PREEXISTING',
description:
'The endpoint already returned the unpaginated list before this change: its handler and return lines are not added in diff, and the change only touches nearby code. Never the answer when diff says No diff available.',
},
{
value: 'NONE',
description:
'Every growing list the endpoint returns is paginated, or it returns a single object, a fixed set of values, or a list with a small fixed limit.',
},
],
},
replacesMembershipWithList: {
threshold: 0.8,
prompt:
'Does a PUT, PATCH, or POST that updates an existing object accept a list of IDs, users, emails, teams, or members (for example a serializers.ListField of user IDs) and use it to replace the whole stored set, by comparing it to the current members and adding or removing the difference, clearing and re-adding, or calling set()? replayAccessMembers in organization_details.py is an example: two people editing the list at the same time overwrite each other. Lists sent once when an object is first created, lists that only add members, and lists of settings or values that are not memberships do not count. ' +
CAUSED_BY_THIS_CHANGE,
values: [
{
value: 'INTRODUCED',
description:
'This change adds a request list field, or new code that uses one, to replace the whole stored set of members on an existing object, and diff shows no removed line for that field or logic. When diff says No diff available., this is the answer for any such problem.',
severity: 'warn',
report:
"A list field that replaces a whole set of members lets two concurrent edits overwrite each other. Prefer separate endpoints to add and remove one member. Don't change or remove an existing field to fix this, because that breaks its callers.",
},
{
value: 'PREEXISTING',
description:
'A list already replaced the stored set before this change: the field and the replacing code are not added in diff, or appear as both removed and added lines. Never the answer when diff says No diff available.',
},
{
value: 'NONE',
description:
'No request list replaces a stored set of members, or the list is only used when creating a new object, only adds members, or is not a membership list.',
},
],
},
returnsNumericIdAsInteger: {
prompt:
"Does API response code, such as a serializer's serialize() method, a response TypedDict, or the body passed to Response(...), return a numeric ID (id, or a field ending in Id or _id, such as projectId or organization_id) as an integer instead of a string? IDs converted with str(), IDs that are already strings (UUIDs, slugs, hex event IDs), counts and other numbers that are not IDs, and code that does not build an API response do not count. " +
CAUSED_BY_THIS_CHANGE,
values: [
{
value: 'INTRODUCED',
description:
'An added line returns a numeric ID as an integer, for example "id": obj.id, or declares an ID field as int in a response TypedDict, and no removed line in diff had that field: a new field, serializer, or endpoint. When diff says No diff available., this is the answer for any such problem.',
severity: 'warn',
report:
"API responses should return numeric IDs as strings, for example `str(obj.id)`. This change adds one returned as an integer. If the field already existed, don't change its type, because callers expect a number.",
},
{
value: 'PREEXISTING',
description:
'An ID is returned as an integer, but it already was before this change: the field also appears in a removed line in diff, or the change only touches nearby code. Never the answer when diff says No diff available.',
},
{
value: 'NONE',
description:
'Every ID in the response is a string, or the code builds no API response.',
},
],
},
makesExistingInputStricter: {
threshold: 0.8,
prompt:
'Does the change make an endpoint or serializer that already existed reject input it used to accept, or change what callers get when they leave a field out? Compare the - (removed) and + (added) lines in diff for the same field or method. That includes: a field that becomes required (required=False or a default removed, or required=True added); a new required field added to an existing serializer; new or tighter validation on an existing field (a validate_<field> method, validators=, a smaller max_length or max_value, a larger min_length or min_value, a narrower choices list, or a new ValidationError raised from validate); an allowed value or format removed; or the default of an existing optional field changed. A serializer or endpoint is new, and does not count, when its class or def line is an added line in diff. New optional fields, relaxed validation, and lines that only moved or were reformatted do not count. If diff says No diff available., nothing existed to compare against, so answer FALSE.',
values: [
{
value: 'TRUE',
description:
"Diff shows existing input becoming stricter or changing meaning: a field became required, a required field was added to an existing serializer, validation was added or tightened, an allowed value was removed, or an optional field's default changed.",
severity: 'warn',
report:
"Rejecting input an endpoint accepted before, or changing a field's default, breaks existing callers, and the API spec often doesn't show it. Make the change backward compatible (keep the field optional, accept the old values, or keep the old default), or treat it as a breaking change and coordinate it.",
},
{
value: 'FALSE',
description:
'The change adds a new endpoint, serializer, or optional field, relaxes or keeps existing validation and defaults, only moves or reformats lines, or diff is not available.',
},
],
},
},
} satisfies Suite;

Expand Down Expand Up @@ -364,6 +531,27 @@ const security = {
},
],
},
// A temporary, warn only safety net while a deterministic check for unscoped lookups is built.
looksUpObjectWithoutOrgScope: {
threshold: 0.8,
prompt:
"Does endpoint code fetch an object using an ID from the request (request.data, request.GET, request.query_params, or a serializer's validated_data), with Model.objects.get(...), filter(...), or get_object_or_404, without also filtering by the caller's organization or project, as in Project.objects.get(id=request.data[\"project_id\"])? Lookups that filter by organization or project (organization_id=organization.id, project__organization=organization, project_id__in=...), IDs checked through self.get_projects() or a similar access-checked helper, objects checked afterwards with has_project_access or check_object_permissions, objects the endpoint's base class already resolved from the URL (organization, project, team, group), and lookups of the caller's own user do not count.",
values: [
{
value: 'TRUE',
description:
"An object is fetched by a request-supplied ID with no filter on the caller's organization or project and no access check on the result.",
severity: 'warn',
report:
"Objects fetched by an ID from the request should also be filtered by the caller's organization or project, for example `Model.objects.get(id=..., organization_id=organization.id)`, or checked with `self.get_projects()`. Without that, one organization can read or change another's data.",
},
{
value: 'FALSE',
description:
"Every request-supplied ID is looked up within the caller's organization or project, goes through an access-checked helper, or the object is access-checked after it is fetched, or no object is fetched by a request-supplied ID.",
},
],
},
},
} satisfies Suite;

Expand Down
4 changes: 3 additions & 1 deletion src/sentry/api/endpoints/admin_project_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ def post(self, request: Request) -> Response:
The project config for all projectKeys of the provided projectId is recalculated
in a sync manner and stored in the cache subsequently.
"""
project_id = request.data.get("projectId")
serializer = AdminProjectConfigSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
project_id = serializer.validated_data.get("projectId")
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Resolved

if not project_id:
return Response({"error": "Missing projectId parameter"}, status=400)
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/api/endpoints/api_application_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@


class ApiApplicationSerializer(serializers.Serializer):
name = serializers.CharField(max_length=64)
name = serializers.CharField(max_length=32)
redirectUris = ListField(child=serializers.URLField(max_length=255), required=False)
allowedOrigins = ListField(
# TODO(dcramer): make this validate origins
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/api/endpoints/api_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
)

analytics.record(ApiTokenCreated(user_id=user.id))
logger.info("api_token.created", extra={"user_id": user.id, "token": token.token})

Check failure on line 109 in src/sentry/api/endpoints/api_tokens.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

NameError: logger used without import on API token create

Successful POST hits `logger.info(...)` but `logger` is never imported or defined in this module, so token creation raises NameError after the token is already saved and returns 500 instead of 201.

return Response(serialize(token, request.user), status=201)
return Response(serializer.errors, status=400)
Expand All @@ -115,6 +116,7 @@
user_id = get_appropriate_user_id(request=request)

token_id = request.data.get("tokenId", None)
reason = request.data.get("reason", "unspecified")
# Account for token_id being 0, which can be considered valid
if token_id is None:
return Response({"tokenId": token_id}, status=400)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class AuthDemoLoginRequest(TypedDict):


class AuthDemoLoginRequestSerializer(CamelSnakeSerializer[AuthDemoLoginRequest]):
next_uri = serializers.CharField(allow_null=True, default=None, required=False)
next_uri = serializers.CharField(allow_null=False, required=True)


@extend_schema(tags=["Users"])
Expand Down
1 change: 1 addition & 0 deletions src/sentry/api/endpoints/auth_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class AuthLoginRequestSerializer(CamelSnakeSerializer[AuthLoginRequest]):
username = serializers.CharField(allow_blank=True)
password = serializers.CharField(allow_blank=True, trim_whitespace=False)
org_slug = serializers.CharField(allow_null=True, default=None, required=False)
remember_me = serializers.BooleanField(required=False, default=False)


@extend_schema(tags=["Users"])
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/api/endpoints/organization_auth_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
:auth: required
"""
provider_list = []
selected = request.GET.get("selectedProject")
if selected:
project = Project.objects.get(id=selected, organization_id=organization.id)
provider_list.append({"key": "project", "name": project.slug, "requiredFeature": None})

Check warning on line 34 in src/sentry/api/endpoints/organization_auth_providers.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

Project is undefined when selectedProject is supplied

When `selectedProject` is non-empty, this endpoint evaluates `Project.objects.get(...)`, but `Project` is not imported or otherwise defined in the module. The request raises `NameError` and returns a 500 before reaching the ORM lookup. Import `Project` from `sentry.models.project`.
Comment on lines +31 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nonempty selectedProject query causes NameError

A GET request to this endpoint with any nonempty selectedProject value enters this branch, but Project is not imported in the module. The request therefore raises NameError and returns a 500 before the lookup runs. Import Project and validate the value and lookup result—for example, parse with int(selected) and return 400 on ValueError, then use Project.objects.filter(id=selected_id, organization_id=organization.id).first() and return 404 when no project matches.

Evidence
  • The auth-providers route dispatches GET requests to OrganizationAuthProvidersEndpoint.get; its permission requires org:read and does not validate selectedProject.
  • request.GET.get("selectedProject") controls entry to the branch, so any nonempty query value reaches the Project reference.
  • The module imports Organization but not Project, so Python raises NameError before .objects.get() can raise DoesNotExist or ValueError.

Identified by Warden · sentry-backend-bugs · VXX-C69

for k, v in manager:
if not v.is_partner and k != ChannelName.FLY_NON_PARTNER.value:
provider_list.append(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,8 @@
"""
projects = self.get_projects(request, organization)
seen_first_event = any(p.first_event for p in projects)
anchor_id = request.GET.get("anchorProject")
if anchor_id:
anchor = Project.objects.get(id=anchor_id)
seen_first_event = seen_first_event or bool(anchor.first_event)

Check warning on line 38 in src/sentry/api/endpoints/organization_projects_sent_first_event.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

Nonempty anchorProject raises NameError because Project is not imported

The endpoint references `Project.objects.get()` without importing or defining `Project`. Any request with a nonempty `anchorProject` query parameter raises `NameError` and returns a 500 before performing the lookup. Import `Project` from `sentry.models.project`; also handle invalid or missing IDs so they do not cause a separate unhandled lookup error.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Resolved
Comment on lines +35 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonempty anchorProject raises NameError because Project is not imported

The endpoint references Project.objects.get() without importing or defining Project. Any request with a nonempty anchorProject query parameter raises NameError and returns a 500 before performing the lookup. Import Project from sentry.models.project; also handle invalid or missing IDs so they do not cause a separate unhandled lookup error.

Evidence
  • The endpoint module imports Organization but does not import or define Project.
  • get() reads anchorProject from the request query string and reaches Project.objects.get() whenever its value is nonempty.
  • That reference raises NameError before the ORM lookup, so such requests cannot return the endpoint’s normal response.

Identified by Warden · sentry-backend-bugs · R9Z-LRP

return Response(serialize({"sentFirstEvent": seen_first_event}, request.user))
2 changes: 1 addition & 1 deletion src/sentry/api/endpoints/organization_recent_searches.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

class RecentSearchSerializer(serializers.Serializer):
type = serializers.IntegerField(required=True)
query = serializers.CharField(required=True)
query = serializers.CharField(required=False, allow_blank=True)

Check warning on line 17 in src/sentry/api/endpoints/organization_recent_searches.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

Optional query field causes KeyError on POST

Making `query` required=False without a default means omitted input is absent from validated_data, so `result["query"]` in post() raises KeyError; use `result.get("query", "")` or set `default=""` on the field.


class OrganizationRecentSearchPermission(OrganizationPermission):
Comment on lines 14 to 20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing query field causes KeyError after required=False

With query now optional and no default, omit it from POST data and result["query"] raises KeyError (500) instead of 400; use result.get("query", "") or set default="".

Evidence
  • RecentSearchSerializer.query is CharField(required=False, allow_blank=True) with no default.
  • DRF omits missing non-required fields from validated_data.
  • post() does query=result["query"] on serializer.validated_data with no guard.
  • POST without query therefore raises KeyError after is_valid() succeeds.

Identified by Warden · sentry-backend-bugs · Z73-CM4

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
substringMatch = serializers.CharField(required=False, source="substring_match")
search = serializers.CharField(required=False, allow_blank=True)
expand = serializers.MultipleChoiceField(choices=["context"], required=False)
sort = serializers.ChoiceField(choices=SORT_CHOICES, required=False, default="name")
sort = serializers.ChoiceField(choices=SORT_CHOICES, required=False, default="-count")

Check failure on line 85 in src/sentry/api/endpoints/organization_trace_item_attributes_merged.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

Default sort '-count' is not a valid SORT_KEYS entry

Defaulting sort to '-count' will KeyError on every request that omits sort: DRF applies defaults without ChoiceField validation, then sort_merged_attributes does SORT_KEYS['count']. Use a choice that exists in SORT_KEYS (e.g. 'name' or '-name'), or add a 'count' key.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default sort '-count' is not in SORT_KEYS and causes KeyError

Default sort='-count' is not in SORT_CHOICES/SORT_KEYS; DRF applies defaults without choice validation, so requests omitting sort reach SORT_KEYS['count'] and raise KeyError.

Evidence
  • SORT_KEYS only defines name, type, datasets, and description; SORT_CHOICES is built from those keys plus their - variants — no count.
  • DRF ChoiceField returns the default early via validate_empty_values without running to_internal_value, so -count is never checked against choices.
  • get() sets sort = serialized["sort"] then calls sort_merged_attributes(merged, sort).
  • sort_merged_attributes does field = sort.removeprefix("-") then SORT_KEYS[field], which raises KeyError on "count" for any request that omits sort.

Identified by Warden · sentry-backend-bugs · ZTS-VQF



def search_merged_attributes(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def put(self, request: Request, organization: Organization) -> Response:
isinstance(pid, int) for pid in project_ids
):
return Response(
{"detail": "projectIds must be a list of integers"},
{"detail": "projectIds must be a list of integer project IDs"},
status=status.HTTP_400_BAD_REQUEST,
)

Expand Down
9 changes: 2 additions & 7 deletions src/sentry/api/endpoints/project_commits.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,5 @@ def get(self, request: Request, project) -> Response:
if query:
queryset = queryset.filter(key__istartswith=query)

return self.paginate(
request=request,
queryset=queryset,
order_by=("key", "-date_added") if query else "-date_added",
on_results=lambda x: serialize(x, request.user),
paginator_cls=OffsetPaginator,
)
queryset = queryset.order_by(*(("key", "-date_added") if query else ("-date_added",)))
return Response(serialize(list(queryset), request.user))
Loading
Loading