Function audit

Checks one file or function: names, errors, concurrency, and security at the call level. Use it for this input: file / function. Report each check as PASS, FAIL, or N/A with file:line evidence. A FAIL carries the card's severity. Any block FAIL means request changes. A should-fix or discuss FAIL goes in the review but does not block.

FAIL [block] chg-expand-contract - src/a.py:42 - renames the column and drops the old one in one migration
FAIL [should-fix] chg-one-concern - src/b.py:7 - a logging rewrite rides along with the bug fix
PASS chg-breaking-change-taxonomy - src/c.py:15 - no signature or return shape changes
N/A chg-perf-claim-evidence - the description makes no performance claim

Checklist

What the badges mean

Filters

Concerns
Topic tags
Topics

Cards

fn-aria-role-has-state-and-keys

should-fixstableaccessibility

Applies when: A custom widget sets role or an aria-* attribute because no native element fits.

Rule. When a native element cannot do the job, give the custom widget the state attribute and the keys its role promises.

Why. A role only tells assistive technology what the element is. It adds no keyboard behaviour, and it says nothing about state. A role="switch" with no aria-checked is announced as a switch of unknown state. Wrong ARIA is worse than none, because it gives a screen-reader user a false picture. The widget also needs an accessible name, from its text or a label, or it is announced by role alone.

FAIL
<button type="button" role="switch" onClick={toggle}>Dark mode</button>
// no aria-checked: announced as a switch of unknown state
PASS
<button type="button" role="switch" aria-checked={on} onClick={toggle}>
  Dark mode
</button>
// a native <button> already takes Tab, Enter, and Space

Counterpoint. "ARIA patterns are in the guide, so we copy them." Copy the keyboard table as well as the attributes. For a plain toggle, a button with aria-pressed is enough, and the label should stay the same when the state changes.

Tool gate: ESLint jsx-a11y/role-has-required-aria-props; axe aria-required-attr. Neither checks that the keys work.

Sources: www.w3.org, www.w3.org, developer.mozilla.org, inclusive-components.design, www.w3.org, developer.mozilla.org, dequeuniversity.com, web.dev

fn-colour-not-sole-cue

should-fixstableaccessibilityusability

Applies when: Styles or markup show an error, a required field, a status, or a link using colour.

Rule. Pair every colour that carries meaning with a second cue: text, an icon, a shape, or an underline.

Why. Colour-blind users, glare, greyscale mode, and a screen reader all remove colour. A red border on a bad field, or a link that differs from body text only by hue, tells those users nothing.

FAIL
.field.error { border-color: red; }
.status-ok   { color: green; }
a { color: #0a58ca; text-decoration: none; }   /* no underline, no hover or focus cue */
PASS
<input aria-invalid="true" aria-describedby="e1" style="border: 2px solid #b42318">
<p id="e1" class="error"><svg aria-hidden="true">…</svg> Error: enter a date such as 27/03/2026</p>
<a href="/terms" style="text-decoration: underline">terms</a>

Counterpoint. "Colour is the quickest cue, and the icon clutters the design." Colour can stay as the fast cue. The rule asks for a second one, not for removing the first. A text prefix such as "Error:" costs a few characters. A link in body text can also pass with 3:1 contrast against the text around it plus a non-colour cue on hover and focus. The underline is the simpler fix.

Tool gate: none; view the page in greyscale, or run a browser colour-vision emulation.

Sources: www.w3.org, www.nngroup.com, developer.apple.com, inclusive-components.design

fn-comments-explain-why

should-fixstablemaintainability

Applies when: A comment sits next to code, or the code needs one to be understood.

Rule. Write comments that give a reason, a constraint, or a trade-off; rename before commenting, and never restate what the next line does.

Why. A restating comment rots silently and adds nothing. A missing reason makes the next person delete the code that mattered.

FAIL
counter += 1  # increment the counter
retry = False  # see the PR, currently disabled
PASS
# Business decision: activation and entitlement update in one transaction,
# so a user never has access without an active subscription.
# TODO: remove when billing emits entitlement events itself.

Counterpoint. "Good names make comments unnecessary." Names carry what. They cannot carry why a branch exists, a unit, or what callers may rely on, and a missing reason costs more than a slightly stale one. Rename first, then comment what the name cannot say. A stale comment is a defect either way. The test is whether the comment adds information that the code cannot carry.

Sources: dev.to, google.github.io, github.com, henrikwarne.com

fn-confine-file-path

blockstablesecurity

Applies when: Code opens, reads, writes or deletes a file whose name or path includes request data.

Rule. Resolve the final path and check that it stays inside the intended base directory before using it.

Why. A name like ../../etc/passwd, or its percent-encoded form, walks out of the upload folder. Stripping ../ fails against ....// and double encoding. The reliable check works on the resolved path. Better still, map a user choice to an ID and build the path in code.

FAIL
return send_file(os.path.join(UPLOADS, request.args["name"]))
PASS
p = (UPLOADS / request.args["name"]).resolve()
if not p.is_relative_to(UPLOADS.resolve()): abort(400)
return send_file(p)

Counterpoint. "We already strip ..." String stripping is a blocklist and gets bypassed. Resolve first, compare second. Resolving catches a symlink that already points outside, but a symlink swapped in between the check and the open still escapes. For trees that untrusted users can write to, use a rooted open such as Go's os.OpenInRoot, which refuses any escape at open time.

Tool gate: CodeQL py/path-injection (CWE-22) or the equivalent query for your language.

Sources: community.owasp.org, cheatsheetseries.owasp.org, go.dev, codeql.github.com

fn-csprng-for-secrets

blockstablesecurity

Applies when: Code generates a token, session ID, password-reset code, API key, nonce or IV.

Rule. Draw every security-relevant random value from the platform CSPRNG.

Why. A general-purpose PRNG is predictable from a few outputs, so an attacker who sees two tokens can guess the next. UUIDv1 is time-based and also not secret. The API call is the whole fix, so it is cheap to demand.

FAIL
token = "".join(random.choices(string.ascii_letters, k=32))
reset = str(int(time.time()))
PASS
token = secrets.token_urlsafe(32)           # os.urandom under the hood
# JavaScript: crypto.getRandomValues(new Uint8Array(32))

Counterpoint. "Math.random is fine for a short-lived code." Short-lived codes still get brute-forced, and predictable ones need no brute force. Use the CSPRNG for anything an attacker gains from guessing.

Tool gate: Bandit B311 (random) for Python; elsewhere a grep for Math.random and rand() in code that builds tokens or keys.

Sources: cheatsheetseries.owasp.org, top10.owasp.org, docs.python.org, developer.mozilla.org, www.rfc-editor.org, pkg.go.dev

fn-destructive-confirm-or-undo

should-fixstableusability

Applies when: A handler deletes user data, spends money, or sends something that cannot be recalled.

Rule. Make a destructive action reversible with undo, or ask the user to confirm it before it runs.

Why. People click the wrong thing. If the action cannot be reversed and nothing stops it, one slip deletes their work. Undo costs a successful user nothing, while a dialog on every click trains them to click through. A dialog works best when its buttons name the outcome, such as "Delete project" and "Cancel", because "OK" and "Are you sure?" say nothing about what happens next.

FAIL
<button onClick={() => api.deleteProject(id)}>Delete</button>
PASS
async function remove() {
  await api.softDelete(id);
  toast("Project deleted", { action: { label: "Undo", onClick: () => api.restore(id) } });
}
// irreversible case: a dialog "Delete project “Q3 plan”?" with buttons "Delete project" and "Cancel"

Counterpoint. "Confirm everything to be safe." Frequent prompts are dismissed without reading, so they protect nothing. Use undo for common actions and reserve the dialog for rare, irreversible ones. Do not make Cancel the default button.

Tool gate: none; a test that triggers the action and asserts a confirm step or an undo path exists.

Sources: www.nngroup.com, www.nngroup.com, developer.apple.com, www.w3.org, www.cs.umd.edu

fn-dialog-focus-managed

blockstableaccessibilityusability

Applies when: A component opens a modal dialog, drawer, or menu that covers the page.

Rule. When a modal opens, move focus into it and keep Tab inside it. Make Escape close it.

Why. Without it, a keyboard user keeps tabbing through the page behind the dialog, or gets stuck inside it with no exit. When it closes, return focus to the control that opened it. Focus that falls to the top of the page makes them start over.

FAIL
{open && <div className="modal"><h2>Delete?</h2><button onClick={del}>Delete</button></div>}
PASS
<button id="opener">Delete project</button>
<dialog id="dlg" aria-labelledby="t"><h2 id="t">Delete project?</h2>
  <button autofocus>Cancel</button> <button>Delete</button></dialog>
<script>
  const opener = document.getElementById("opener"), dlg = document.getElementById("dlg");
  opener.onclick = () => dlg.showModal();   // focus moves in, page behind is inert
  dlg.onclose = () => opener.focus();       // focus returns to the opener
</script>

Counterpoint. "Our library handles it." Check, because a library that traps focus but never restores it is common. Prefer the native <dialog> with showModal(), which makes the page inert and closes on Escape. A dialog built from generic elements must recreate each of those.

Tool gate: none; a component test that opens the dialog, presses Tab and Escape, and asserts document.activeElement.

Sources: www.w3.org, www.w3.org, developer.mozilla.org, webaim.org

fn-error-says-what-and-how

should-fixstableusabilityaccessibility

Applies when: Code builds the message shown when user input fails validation.

Rule. Show each validation error next to its field, in plain words that name what is wrong and how to fix it.

Why. "Invalid input" or an error code leaves the user to guess which field and what to change. A message at the top of a long form, or in a toast that fades, sends a magnifier user searching. Naming the fix is the shortest path to a valid form.

FAIL
<input id="dob" name="dob" class="red">
<div class="toast">Error 422: invalid data</div>
PASS
<label for="dob">Date of birth</label>
<p id="dob-err"><span class="visually-hidden">Error:</span>
   Enter a date of birth in the past, such as 27 3 1986</p>
<input id="dob" name="dob" aria-invalid="true" aria-describedby="dob-err">

Counterpoint. "Detailed messages leak information." For a login, that is true: say "Email or password is wrong". For ordinary fields, the specific message is the fix. Hold back the suggestion only where it would weaken security.

Tool gate: none; a rendered check that submits each invalid field.

Sources: www.nngroup.com, www.nngroup.com, design-system.service.gov.uk, design-system.service.gov.uk, www.w3.org, www.w3.org, designsystem.digital.gov, www.cs.umd.edu

fn-fetch-only-needed-data

should-fixstableperformance

Applies when: A function queries a database or an API and then filters, counts, or sums the result in application code.

Rule. Filter, count, and aggregate in the query, and select only the columns the code uses.

Why. Loading every row to count it, or every column to read one, moves data across the network and through memory for nothing. SELECT * also rules out an index-only scan.

FAIL
total = sum(o.amount for o in Order.objects.all() if o.status == "paid")
n = len(Order.objects.filter(user=u))        # loads every row
emails = [u.email for u in User.objects.all()]   # loads every column
PASS
total = Order.objects.filter(status="paid").aggregate(Sum("amount"))["amount__sum"]
n = Order.objects.filter(user=u).count()
emails = User.objects.values_list("email", flat=True)

Counterpoint. "Pushing logic into the database makes it the bottleneck." It does for formatting and business rules. Filtering and aggregation are what the database is built for, and moving them out usually ships far more data than it saves.

Sources: learn.microsoft.com, docs.djangoproject.com, docs.djangoproject.com, use-the-index-luke.com, docs.sqlalchemy.org, learn.microsoft.com

fn-focus-indicator-visible

blockstableaccessibilityusability

Applies when: Styles or script change outline, :focus, or :focus-visible.

Rule. Keep a visible focus indicator on every focusable element. Never set outline: none without a replacement.

Why. A keyboard user finds their place by the focus ring. Remove it and they navigate blind. A sticky header that covers the focused link has the same effect once they tab down the page. scroll-padding-top on html, as tall as the header, keeps the focused element in view.

FAIL
*:focus { outline: none; }
a:focus { outline: 0; }
PASS
:focus-visible { outline: 3px solid #1a56db; outline-offset: 2px; }

Counterpoint. "Designers dislike the ring on mouse clicks." :focus-visible shows the ring for keyboard focus and hides it for most mouse clicks. Restyle the ring if you must. Do not remove it.

Tool gate: Stylelint declaration-property-value-disallowed-list with outline: ["none", "0"].

Sources: www.w3.org, www.w3.org, web.dev, webaim.org, www.a11yproject.com

fn-form-control-has-label

blockstableaccessibilityusability

Applies when: Markup or a component renders an input, a select, or a textarea.

Rule. Tie every form control to a visible <label>, with for and a matching id or by wrapping it. A placeholder is not a label.

Why. A screen reader announces a bare field as "edit text". Voice-control users cannot say its name. A label also enlarges the click target. A placeholder vanishes when the user types, so it fails every user who looks away and comes back.

FAIL
<input type="email" placeholder="Email">
<div class="label">Name</div>
<input type="text" id="name">
PASS
<label for="email">Email</label>
<input type="email" id="email" autocomplete="email">

<label>Name <input type="text" name="name" autocomplete="name"></label>

Counterpoint. "The design shows a floating placeholder label." A floating label is a real label if it stays visible and is wired to the input. Use aria-label only where the purpose is already visible, such as a search field next to a Search button.

Tool gate: ESLint jsx-a11y/label-has-associated-control and axe label.

Sources: www.w3.org, developer.mozilla.org, dequeuniversity.com, www.a11yproject.com, github.com

fn-heading-structure

should-fixstableaccessibilityusability

Applies when: Markup or a component renders a title or a section heading.

Rule. Mark every heading with a real <h1> to <h6> element, not a styled div, span, or p.

Why. Screen-reader users jump from heading to heading to scan a page. A div styled as a heading is invisible to that. Pick the level from the outline, not from the size you want: a skipped level, such as h2 to h4, makes the outline look as if a section is missing.

FAIL
<div class="title-large">Account</div>
<p><b>Billing</b></p>
<span class="bold">Invoices</span>
PASS
<h1>Account</h1>
<h2>Billing</h2>
<h3>Invoices</h3>
<!-- size comes from CSS: h2 { font-size: 1rem } -->

Counterpoint. "The design needs a small h2." That is a CSS problem. Keep the level that matches the outline and set the size in the stylesheet. Jumping back up a level, such as h4 to h2, is fine when a section ends.

Tool gate: axe p-as-heading (experimental) flags a bold paragraph styled as a heading; axe heading-order flags skipped levels.

Sources: www.w3.org, www.w3.org, developer.mozilla.org, www.a11yproject.com

fn-img-alt-text

should-fixstableaccessibility

Applies when: Markup or a component renders an image, an icon, or a chart.

Rule. Give every <img> an alt attribute: text that serves the image's purpose, or alt="" when the image is decorative.

Why. A screen reader has nothing to say about an image with no alt, and often reads the file name instead. An informative image with an empty alt hides its content. A decorative image with a long alt adds noise to every page view.

FAIL
<img src="/img/IMG_0042.jpg">
<img src="/img/chart.png" alt="chart">
<img src="/img/divider.png" alt="divider line">
<button><img src="/img/trash.svg"></button>
PASS
<img src="/img/q3-revenue.png" alt="Revenue grew from 4.1 to 5.3 million in Q3">
<img src="/img/divider.png" alt="">
<button><img src="/img/trash.svg" alt="Delete draft"></button>

Counterpoint. "A linter already checks alt." It checks that the attribute exists, not that the text fits the image or the context. Review the text itself. For a chart, give a short alt plus the data nearby as text.

Tool gate: ESLint jsx-a11y/alt-text and axe image-alt catch a missing alt. Only a person can judge whether the text is accurate.

Sources: www.w3.org, developer.mozilla.org, dequeuniversity.com, www.a11yproject.com, github.com

fn-independent-work-concurrent

should-fixstableperformancecorrectness

Applies when: A function awaits or calls several I/O operations that do not depend on each other.

Rule. Start independent I/O together and wait for all of it; run calls in sequence only when one needs the result of another.

Why. Sequential awaits add their latencies. Three 200 ms calls take 600 ms in a row and 200 ms together.

FAIL
user = await get_user(uid)
orders = await get_orders(uid)       # does not use `user`
prefs = await get_prefs(uid)
PASS
user, orders, prefs = await asyncio.gather(
    get_user(uid), get_orders(uid), get_prefs(uid))

Counterpoint. "Parallel calls load the dependency harder and complicate errors." Cap fan-out with a semaphore or pool, and use a task group so one failure cancels the rest. A deliberate order, for rate limits or ordering, is a valid reason. Write it down.

Sources: pkg.go.dev, github.com, docs.python.org, developer.mozilla.org

fn-join-dont-concat-in-loop

should-fixstableperformance

Applies when: A function builds a string from pieces in a loop whose length depends on input.

Rule. Build a string from a loop with a builder or a join, never with repeated +=.

Why. Strings are immutable in Python, Go, Java, and .NET, so each += copies everything built so far. Over n pieces the total cost grows with the square of the output length.

FAIL
out = ""
for row in rows:
    out += f"{row.id},{row.name}\n"
PASS
out = "".join(f"{row.id},{row.name}\n" for row in rows)

Counterpoint. "CPython optimises += in place." Sometimes: only in CPython, and only while nothing else holds a reference to the string. Code that leans on it turns quadratic on other runtimes. A small fixed number of pieces is fine with + or an f-string, which read better. The rule covers loops over input-sized data.

Tool gate: Linter rule for string concatenation in a loop (for example pylint consider-using-join).

Sources: docs.python.org, pkg.go.dev, learn.microsoft.com

fn-jwt-verify-pinned

blockstablesecurity

Applies when: Code accepts a JWT as proof of identity or permission.

Rule. Verify the signature against a fixed list of accepted algorithms, and check iss and aud, before reading any claim.

Why. If the verifier takes the algorithm from the token header, an attacker picks none or turns an RSA public key into an HMAC secret. Claims read before verification are attacker text. Pinning the algorithm and binding each key to one algorithm closes both. Also require exp, so a stolen token stops working.

FAIL
claims = jwt.decode(token, options={"verify_signature": False})
claims = jwt.decode(token, key)               # PyJWT 1.x: algorithm taken from the header
PASS
claims = jwt.decode(token, key, algorithms=["RS256"],
                    issuer=ISSUER, audience=AUD,
                    options={"require": ["exp", "iss", "aud"]})

Counterpoint. "Our gateway already verifies the token." Then the service trusts a header that any internal caller can forge. Verify at each trust boundary, or prove that only the gateway can reach the service.

Tool gate: CodeQL js/jwt-missing-verification; a grep for verify_signature": False and for jwt.decode( calls with no algorithms=.

Sources: www.rfc-editor.org, top10.owasp.org, cheatsheetseries.owasp.org, learn.microsoft.com, developers.google.com, codeql.github.com, pyjwt.readthedocs.io

fn-keep-input-on-error

should-fixstableusability

Applies when: A form handler or component re-renders after a failed validation or a failed submit.

Rule. After a failed validation or submit, show the form again with every value the user entered, correct or not.

Why. A form that wipes its fields turns one typo into ten minutes of retyping, and some users give up. The user fixes one field, not the whole page.

FAIL
async function submit(e) {
  e.preventDefault();
  const res = await post("/signup", values);
  if (!res.ok) { setErrors(res.errors); setValues(EMPTY); }
}
PASS
async function submit(e) {
  e.preventDefault();
  const res = await post("/signup", values);
  if (!res.ok) setErrors(res.errors);   // values stay as typed
}

Counterpoint. "Clear a password or card number on error for safety." That is a fair exception for secrets, and the message should say so. Everything else the user typed stays.

Tool gate: none; a test that submits an invalid form and asserts the entered values are still there.

Sources: www.nngroup.com, design-system.service.gov.uk, design-system.service.gov.uk, baymard.com, www.cs.umd.edu

fn-no-blocking-call-in-async-code

blockstableperformancecorrectness

Applies when: Code runs on an event loop or an async runtime (asyncio, Node.js, Tokio, ASP.NET async handlers).

Rule. Never make a blocking I/O call or run long CPU work directly in async code; use the async API or hand the work to a thread or worker.

Why. An event loop runs one callback at a time. A time.sleep, a synchronous HTTP call, or a big hash inside a handler stalls every other request on that loop, not just the slow one.

FAIL
async def handler(req):
    data = requests.get(url).json()          # blocks the loop
    digest = hashlib.pbkdf2_hmac("sha256", pw, salt, 600_000)
PASS
async def handler(req):
    data = (await client.get(url)).json()    # httpx.AsyncClient
    digest = await asyncio.to_thread(hashlib.pbkdf2_hmac, "sha256", pw, salt, 600_000)

Counterpoint. "This call is fast enough." A short read from a local SSD can be quicker synchronously. Measure it with asyncio debug mode or the runtime's equivalent. A call that crosses the network, or that waits on anything outside the process, is never in that class.

Tool gate: Linter rule for blocking calls in async functions (for example ruff ASYNC); asyncio debug mode flags slow callbacks.

Sources: docs.python.org, nodejs.org, docs.rs, learn.microsoft.com

fn-no-mass-assignment

blockstablesecurity

Applies when: A handler copies request fields into a model, ORM entity or settings object.

Rule. Bind request data to a DTO or an explicit field allowlist, never straight onto the model.

Why. When the model has is_admin, balance or owner_id, a client that adds those keys to the JSON body sets them. A blocklist fails the day someone adds a new sensitive column. The allowlist still needs the object-level check from fn-object-level-authz.

FAIL
user = User(**request.json)          # body {"name": "a", "is_admin": true}
user.save()
PASS
class UserIn(BaseModel):            # only editable fields exist here
    name: str
user = User(**UserIn(**request.json).model_dump())

Counterpoint. "A DTO per endpoint is boilerplate." Generate DTOs from the API schema. The boilerplate costs less than one privilege escalation.

Tool gate: A custom Semgrep rule for Model(**request.json); tests that post extra fields such as is_admin and assert they are ignored or rejected.

Sources: cheatsheetseries.owasp.org, api-security.owasp.org, learn.microsoft.com, guides.rubyonrails.org, docs.djangoproject.com

fn-no-native-deserialise-untrusted

blockstablesecurity

Applies when: Code turns bytes or text from outside the process into objects.

Rule. Parse untrusted input with a data-only format, never with a native object deserialiser.

Why. pickle, ObjectInputStream, BinaryFormatter and full-power YAML loaders can run attacker-chosen code while they load, so validating after the call is too late. JSON cannot instantiate arbitrary classes. Validate the parsed data against a schema afterwards, as the PASS does.

FAIL
obj = pickle.loads(request.data)
cfg = yaml.load(body, Loader=yaml.Loader)
PASS
obj = Model.model_validate_json(request.data)
cfg = yaml.safe_load(body)

Counterpoint. "The data comes from our own queue." Anyone who can write to the queue can run code on every consumer, so the queue is a trust boundary. Signing the payload, or a Java serialisation filter that allowlists classes, narrows the risk while you migrate. The call still fails this check until it reads a data-only format.

Tool gate: Bandit B301 (pickle) and B506 (yaml_load), CodeQL py/unsafe-deserialization; a grep for ObjectInputStream, BinaryFormatter and unserialize elsewhere.

Sources: cheatsheetseries.owasp.org, docs.python.org, learn.microsoft.com, docs.oracle.com, codeql.github.com

fn-no-positive-tabindex

should-fixstableaccessibilityusability

Applies when: Markup sets tabindex, or a layout reorders content with CSS.

Rule. Write the DOM in reading order and never set tabindex above 0.

Why. A positive tabindex jumps those elements to the front of the tab order, ahead of everything else on the page. Each new control then needs a new number, and the order stops matching what the user sees. CSS that moves items visually without moving them in the DOM does the same thing.

FAIL
<input name="last" tabindex="2">
<input name="first" tabindex="1">
<button tabindex="3">Save</button>
PASS
<input name="first">
<input name="last">
<button>Save</button>
<!-- tabindex="0" only to add a custom widget to the order; tabindex="-1" to focus it from script -->

Counterpoint. "The visual layout puts the sidebar first, but the main content comes first in the DOM." Reorder the DOM to match, or accept the difference only if the order still makes sense to a keyboard user. Do not patch it with numbers.

Tool gate: ESLint jsx-a11y/tabindex-no-positive; axe tabindex.

Sources: www.w3.org, web.dev, webaim.org, www.a11yproject.com

fn-no-raw-html-sink

blockstablesecurity

Applies when: Code writes a string into a page, a template or a rendered model-output view.

Rule. Write untrusted text through an encoding sink (textContent, auto-escaped template), never through a raw HTML sink.

Why. One innerHTML of a username or of model output runs the attacker's script with the victim's session. Encoding must match the context: body, attribute, script string and URL each differ. Framework escape hatches such as dangerouslySetInnerHTML have the same effect.

FAIL
el.innerHTML = comment.body;
<div dangerouslySetInnerHTML={{ __html: reply }} />
PASS
el.textContent = comment.body;
<div>{reply}</div>                       // escaped by the framework
// user-authored HTML: DOMPurify.sanitize(html), nothing edited afterwards

Counterpoint. "We need to render rich text." Then sanitise with a maintained library such as DOMPurify at render time and add a CSP as a second layer. A CSP alone is not a fix, because it is defence in depth.

Tool gate: ESLint (no-unsanitized/property, no-unsanitized/method, react/no-danger) and template-engine autoescape checks.

Sources: cheatsheetseries.owasp.org, genai.owasp.org, developer.mozilla.org, react.dev, angular.dev, github.com, web.dev, www.cisa.gov

fn-no-shell-with-input

blockstablesecurity

Applies when: Code starts a process and some part of the command comes from a request, a file or model output.

Rule. Start processes with the program and each argument as separate list items, and never through a shell string.

Why. A shell reads ;, | and $() as commands, so a filename of a; rm -rf ~ runs. Argument arrays skip the shell. The values still need validation, since an argument beginning with - becomes an option; end options with -- where the tool allows it.

FAIL
os.system("convert " + upload_name + " out.png")
subprocess.run(f"git log {branch}", shell=True)
PASS
subprocess.run(["convert", upload_path, "out.png"], check=True)   # upload_path is a server-generated name
subprocess.run(["git", "log", branch], check=True)   # branch checked against an allowlist

Counterpoint. "We need shell features like pipes." Chain two subprocess calls in code, or use the library that does the job. If a shell is unavoidable, build the string only from constants.

Tool gate: Bandit B602 (subprocess_popen_with_shell_equals_true) and B605 (start_process_with_a_shell) for Python.

Sources: cheatsheetseries.owasp.org, genai.owasp.org, docs.python.org, pkg.go.dev, www.cisa.gov

fn-object-level-authz

blockstablesecurity

Applies when: A handler loads, changes or deletes a record by an ID that comes from the request.

Rule. Check that the caller may act on the specific record before returning or changing it.

Why. A login proves who the caller is, not which records they own. Without a per-object check, changing id=41 to id=42 reads or edits another user's data (IDOR, also called BOLA). Random IDs only hide the problem.

FAIL
order = db.get(Order, request.args["id"])     # any logged-in user, any order
return order.to_json()
PASS
order = db.get(Order, request.args["id"])
if order is None or order.owner_id != user.id: raise NotFound()   # same error for both
return order.to_json()

Counterpoint. "The IDs are UUIDs, nobody can guess them." A UUID leaks through logs, links and shared screenshots, and it is not an access check. Keep it as a second layer. Scope the query itself (WHERE owner_id = :me) so the check cannot be forgotten on the next endpoint.

Tool gate: Authorization tests that call each endpoint as a second user; no static gate catches a missing check.

Sources: cheatsheetseries.owasp.org, api-security.owasp.org, top10.owasp.org, learn.microsoft.com, developers.cloudflare.com, nvlpubs.nist.gov

fn-password-slow-hash

blockstablesecurity

Applies when: Code stores or verifies a user password.

Rule. Hash passwords with Argon2id (or scrypt, bcrypt, PBKDF2) and a per-password salt from the library.

Why. A fast hash such as SHA-256 lets an attacker with the database try billions of guesses a second. A slow, salted, memory-hard hash makes each guess cost real time. A common baseline is Argon2id with at least 19 MiB of memory and 2 iterations. Check the current numbers when you review.

FAIL
stored = hashlib.sha256(password.encode()).hexdigest()
PASS
ph = argon2.PasswordHasher()            # salt and parameters stored in the hash string
stored = ph.hash(password)
ph.verify(stored, attempt)             # rehash when check_needs_rehash()

Counterpoint. "Argon2 is expensive at login." That cost is the defence; tune it to about a second on production hardware. Where FIPS compliance rules Argon2 out, PBKDF2-HMAC-SHA256 with a high iteration count is the accepted fallback.

Tool gate: Grep or SAST rule for md5, sha1, sha256 near a password identifier.

Sources: cheatsheetseries.owasp.org, top10.owasp.org, www.rfc-editor.org, pages.nist.gov

fn-reduced-motion-respected

should-fixstableaccessibilityusability

Applies when: Styles or script add a transition, an animation, a parallax effect, or autoplay motion.

Rule. Switch off or replace large, repeating, or interaction-triggered motion when prefers-reduced-motion: reduce is set.

Why. Scaling, panning, and parallax can trigger nausea and dizziness for people with vestibular disorders. They have already told their device so. A page that ignores the setting makes them leave.

FAIL
.hero   { animation: zoom-in 1.2s ease-out infinite alternate; }
.card   { transition: transform 0.6s; }
.card:hover { transform: scale(1.3) rotate(4deg); }
PASS
.hero { animation: zoom-in 1.2s ease-out infinite alternate; }
@media (prefers-reduced-motion: reduce) {
  .hero { animation: none; }
  .card { transition: opacity 0.2s; }
  .card:hover { transform: none; opacity: 0.9; }
}

Counterpoint. "Motion carries meaning, such as a loading spinner." Keep what the user needs and make it gentler: swap movement for a fade. The rule is about decorative and large movement, not about every pixel that moves.

Tool gate: Stylelint a11y/media-prefers-reduced-motion from the stylelint-a11y plugin. A browser run with reduced motion emulated.

Sources: developer.mozilla.org, www.w3.org, developer.apple.com, www.a11yproject.com, web.dev

fn-regex-compiled-once

should-fixstableperformance

Applies when: Code builds a regular expression or similar compiled matcher inside a loop or a function that runs per request.

Rule. Compile a regular expression once, at module or startup level, and reuse it.

Why. Compiling costs far more than matching and throws away reusable allocations. Inside a loop or a per-request function, the cost repeats on every call.

FAIL
func valid(s string) bool {
	return regexp.MustCompile(`^[a-z]+$`).MatchString(s)   // compiled per call
}
PASS
var lower = regexp.MustCompile(`^[a-z]+$`)

func valid(s string) bool { return lower.MatchString(s) }

Counterpoint. "The library caches patterns." Python and .NET keep recently used patterns, so a few static calls are fine. A pattern built from input or an unbounded set of patterns defeats the cache.

Tool gate: Linter rule for a regex constructor in a loop, where the language has one.

Sources: docs.rs, pkg.go.dev, docs.python.org, learn.microsoft.com

fn-rerun-safe-after-crash

blockstablereliability

Applies when: A function or job changes state and can be run twice, or can be killed partway.

Rule. Make a state-changing operation give the same result on a second run and after a crash at any step, by deriving each step from current state and making leftovers recognisable as stale.

Why. Jobs get retried and processes get killed. An append, a counter bump, or a lock file with no owner and time leaves the next run unable to tell finished work from half-done work. Record "done" only after the last step succeeds, so a crash leaves the work visibly pending.

FAIL
cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, acct))  # a retry applies it twice
open("out.json", "w").write(data)                                                       # a crash leaves a truncated file
PASS
cur.execute("INSERT INTO applied_ops (op_id) VALUES (%s) ON CONFLICT DO NOTHING", (op_id,))
if cur.rowcount == 1:  # first run of this operation; same transaction as the update
    cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, acct))
tmp = path.with_suffix(".tmp")
tmp.write_text(data)
os.replace(tmp, path)  # the reader sees the old file or the new one, never half of one

Counterpoint. "Retries are rare." Crashes are not. For API calls that cross a network, svc-idempotency-key covers the key-based form of this rule.

Sources: github.com, www.uber.com, stripe.dev, dropbox.tech, netflixtechblog.com, github.blog

fn-response-fields-explicit

should-fixstablesecuritycompatibility

Applies when: A handler returns a stored object or entity to the client.

Rule. Return a response type that lists each field, never the stored model or a generic dump of it.

Why. A model gains password_hash, internal_notes or email and every endpoint that serialises it leaks the new field silently. Callers also start to depend on whatever is sent, so removing a field later is a breaking change. An explicit response type makes each exposed field a decision someone reviewed. The write side is fn-no-mass-assignment.

FAIL
return jsonify(user.__dict__)
return user_model.to_dict()          # all columns
PASS
class UserOut(BaseModel): id: int; display_name: str
return UserOut.model_validate(user)

Counterpoint. "A response type per endpoint duplicates the model." Derive it from the model with a field list. The duplication is one line per field and shows up in the diff when a field is added.

Tool gate: Response-schema tests that fail on unlisted fields.

Sources: api-security.owasp.org, learn.microsoft.com, www.django-rest-framework.org, nvlpubs.nist.gov

fn-status-announced-live-region

should-fixstableaccessibilityusability

Applies when: Script shows a message after an async action without moving focus, such as "Saved", a result count, or an error.

Rule. Announce an async status message through a live region (role="status" or role="alert") that is already in the markup before its text changes.

Why. A sighted user sees "Saved" appear. A screen-reader user hears nothing, because focus did not move. Most readers announce a change only in a region that existed first, so a region created together with its message is often skipped.

FAIL
const note = document.createElement("div");
note.textContent = "Changes saved";
document.body.append(note);
PASS
<div id="status" role="status" aria-live="polite"></div>
<script>
  document.getElementById("status").textContent = "Changes saved";
</script>

Counterpoint. "Move focus to the message instead." Use that for a blocking error after submit, where the user must act. For a passive result, moving focus steals the user's place. Keep assertive and role="alert" for errors, because they interrupt speech.

Tool gate: none; a screen-reader pass, or a test that the region exists with a role before the text changes.

Sources: www.w3.org, developer.mozilla.org, inclusive-components.design, www.w3.org, www.w3.org

fn-target-size

should-fixstableaccessibilityusability

Applies when: Styles size a button, icon button, checkbox, or link that a pointer or finger operates.

Rule. Make each pointer target at least 24 by 24 CSS pixels, or space it so that a 24 px circle centred on it touches no other target and no other undersized target's circle.

Why. Small, tight controls cause mis-taps for anyone with a tremor, a large thumb, or a bumpy train. A mis-tap on "Delete" next to "Edit" costs more than a mis-tap on a menu item.

FAIL
.icon-btn { width: 16px; height: 16px; margin: 0; }
<button class="icon-btn">✎</button><button class="icon-btn">🗑</button>
PASS
.icon-btn { min-width: 24px; min-height: 24px; padding: 4px; }

Counterpoint. "Inline links and dense tables are exempt." Targets inside a sentence are, and so are controls with an equivalent control that is big enough. A row of toolbar icons is neither, and nor are the action buttons on a table row. Touch platforms often ask for 44 px or more, so treat 24 px as the floor, not the goal.

Tool gate: axe target-size (WCAG 2.2); a rendered check for the spacing exception.

Sources: www.w3.org, developer.apple.com, www.a11yproject.com, dequeuniversity.com, developer.mozilla.org

fn-tls-verify-on

blockstablesecurity

Applies when: Code makes an outbound HTTPS, database or message-broker connection.

Rule. Leave certificate and hostname verification on in every environment that ships.

Why. With verification off, any on-path attacker presents a certificate and reads or changes the traffic. The flag is usually added to silence a dev-box error and then ships. Fix the trust store or pin a private CA instead.

FAIL
requests.get(url, verify=False)                  # Python
https.request({ rejectUnauthorized: false })     // Node.js
PASS
requests.get(url, verify="/etc/ssl/company-ca.pem")   # private CA added, check stays on

Counterpoint. "It is an internal network." Internal links get sniffed after one compromised host. A private CA gives you the same convenience with the check intact.

Tool gate: Bandit B501 (request_with_no_cert_validation), CodeQL py/request-without-cert-validation; a grep for rejectUnauthorized: false and InsecureSkipVerify: true.

Sources: top10.owasp.org, cheatsheetseries.owasp.org, www.rfc-editor.org, pkg.go.dev, codeql.github.com, docs.python.org

Draft cards

23 draft cards

A draft card is not vetted. Read it for ideas, but do not use it for a verdict.

fn-bool-arg-at-callsite

discussdraftmaintainability

Applies when: A call passes a literal True or False for a parameter.

Rule. Replace a boolean flag argument with an enum, keyword-only argument or two functions.

Why. render(doc, True, False) says nothing. A boolean also hides that a third state is coming, and when it does, every caller changes.

FAIL
export(report, true, false)
PASS
export(report, fmt=Format.CSV, header=Header.OMIT)

Counterpoint. "A keyword flag is fine and shorter." A named keyword-only flag is acceptable when it is truly binary and will stay so. The FAIL is the positional literal, which is why the severity is discuss.

Tool gate: ruff FBT

fn-comments-survive-context

should-fixdraftmaintainability

Applies when: A comment or TODO refers to a ticket, a PR, a sprint or the present moment.

Rule. Write comments that remain true without outside context: no "see the PR", "currently", "not yet", ticket ids or codenames.

Why. The PR vanishes from memory and the "currently" never gets updated. A comment whose context has expired is a lie by the next release.

FAIL
# TODO(JIRA-4412): handle the legacy case for now
PASS
# TODO: drop this branch once every client sends v2 payloads

Counterpoint. "Ticket links help traceability." Traceability belongs in the commit message and the PR, which are tied to the change. A removal condition in the TODO is what a later reader can act on.

Sources: dev.to

fn-distinct-types-for-ids

should-fixdraftcorrectness

Applies when: A function takes two or more arguments of one primitive type that mean different things (ids, amounts, units).

Rule. Give arguments with different meanings distinct types, so a call that swaps them fails to type-check.

Why. Two str ids compile in either order. The swap passes review and fails in production, often as a "not found" that looks like missing data.

FAIL
def refund(user_id: str, order_id: str) -> None: ...
refund(order.id, user.id)   # accepted
PASS
UserId = NewType("UserId", str)
OrderId = NewType("OrderId", str)
def refund(user_id: UserId, order_id: OrderId) -> None: ...

Counterpoint. "Wrapper types add ceremony." A newtype costs one line and no runtime. Where the types differ only by argument position and the function has two arguments with obvious names, keyword-only parameters can be enough.

Tool gate: Type checker with a newtype or branded-type pattern.

Sources: github.com

fn-empty-collection-not-none

should-fixdraftcorrectness

Applies when: A function returns a list, set or map.

Rule. Return an empty collection for "nothing found"; return None only when absent differs from empty.

Why. Every caller must remember the None check, and the one who forgets crashes on iteration, in production, on the rare empty case.

FAIL
def find_orders(user) -> list[Order] | None:
    return rows or None
PASS
def find_orders(user) -> list[Order]:
    return rows

Counterpoint. "None tells me the query failed." Then raise, or return a distinct result type. Using None for both "failed" and "empty" is the bug.

Tool gate: type checker

fn-english-identifiers

discussdraftmaintainability

Applies when: The project has a written language policy, or its code may be read by people outside one language group.

Rule. Write identifiers and comments in English, unless the project policy names another language.

Why. Mixed languages split search, break naming conventions, and exclude readers. The tools, libraries, and error messages the code touches are already in English.

FAIL
def calcular_precio_total(carrito): ...   # next to total_price() and cart.items
PASS
def calculate_total_price(cart): ...

Counterpoint. "Domain terms have no English word." Keep the local term, once, in a glossary, and comment it (mod-one-name-per-concept). Python itself allows non-ASCII identifiers so that teams can name things in their own language (PEP 3131). If the code will never leave one language group, a team may choose that language. Write the choice down so the check can pass.

Tool gate: Spell-check or non-ASCII identifier lint.

Sources: peps.python.org, peps.python.org, llvm.org

fn-known-keys-get-a-type

should-fixdraftcorrectness

Applies when: A function takes or returns a dictionary whose keys are fixed and known.

Rule. Give a fixed key set a record type (model, dataclass, or typed dict), and use a generic map only for open keys.

Why. A bag of keys hides the contract. A mistyped key is a runtime KeyError or, worse, a silently absent value.

FAIL
def summary(o) -> dict[str, Any]:
    return {"id": o.id, "totl": o.total}
PASS
@dataclass(frozen=True)
class Summary:
    id: int
    total: Decimal

Counterpoint. "A dict is easier to serialise and extend." Records serialise as well (asdict, model dump). Extension is exactly what the type should make deliberate.

Tool gate: mypy --strict (partial)

fn-name-intermediate-conditions

discussdraftmaintainability

Applies when: An if or while joins three or more terms in one expression.

Rule. Name each compound condition by assigning its sub-conditions to booleans that say what they mean.

Why. Dense conditions force the reader to simulate them. A name states the intent and gives a place to put the next clause.

FAIL
if u.active and not u.banned and (u.plan == "pro" or u.trial_days > 0) and not u.locked:
PASS
can_login = u.active and not u.banned and not u.locked
has_access = u.plan == Plan.PRO or u.trial_days > 0
if can_login and has_access:

Counterpoint. "More variables, more state to track." These are immutable one-use names, and they replace a comment you would otherwise write. If the names turn out vague (flag1), the condition probably mixes two concepts. Fix that instead.

Sources: dev.to

fn-no-anonymous-tuples

should-fixdraftmaintainability

Applies when: A function returns or passes a tuple whose positions mean different things.

Rule. Return a small named record, not a positional tuple, once the fields differ in meaning.

Why. code, message = parse_error(e) works until someone swaps the order. Nothing fails, and the wrong value lands in the wrong place.

FAIL
def parse_error(exc) -> tuple[str, str]: ...
code, message = parse_error(exc)
PASS
@dataclass(frozen=True)
class ParsedError:
    code: str
    message: str

Counterpoint. "A pair is obviously (a, b)." That holds only when both elements are the same kind (min and max of one quantity, a coordinate). When two strings play different roles, the position is the only documentation, and it is wrong the day someone adds a third field.

fn-no-check-then-act-across-await

blockdraftcorrectness

Applies when: Code reads shared state, yields (await, I/O, a lock release), then writes based on the earlier read.

Rule. Make check-and-write one atomic operation where the state lives, and never split it across a suspension point.

Why. Another task runs at every yield. Two withdrawals both read 100, both pass the check, and the balance goes negative.

FAIL
balance = await repo.get_balance(acct)
if balance >= amount:
    await repo.set_balance(acct, balance - amount)
PASS
# UPDATE accounts SET balance = balance - :amt WHERE id = :id AND balance >= :amt
if not await repo.debit_if_sufficient(acct, amount):
    raise InsufficientFunds(acct)

Counterpoint. "A lock fixes it." A process-local lock covers one process. With two workers or two hosts, you need a transaction, an atomic statement, an idempotency key, or optimistic concurrency. Name the other readers and writers of that state.

fn-no-eval-untrusted

blockdraftsecurity

Applies when: Code runs eval, exec, new Function, an expression language, or compiles a template, and some of the text comes from a request, a file, a database row, or model output.

Rule. Never pass untrusted text to eval, exec, new Function, an expression language, or a template compiler. Pass it to a fixed template or a parser as data.

Why. Each of these turns a string into code that runs with the server's rights, so one field becomes remote code execution. A template engine counts: render_template_string(request.args["msg"]) lets {{ config }} or worse run on the server. Parse the data format instead (json.loads, ast.literal_eval), and keep templates in files the author wrote, with user values passed as variables.

FAIL
total = eval(request.args["expr"])
html = render_template_string("Hello " + request.args["name"])
PASS
data = json.loads(request.get_data())
html = render_template("hello.html", name=request.args["name"])

Counterpoint. "Users need formulas, so we need eval." Write or adopt a small expression parser that knows only numbers and the operators you allow. A sandbox around eval loses to the next escape someone publishes.

Tool gate: Bandit B307 (eval) and B102 (exec_used); CodeQL py/code-injection; ESLint no-eval and no-implied-eval.

Sources: raw.githubusercontent.com, cwe.mitre.org, developer.mozilla.org, portswigger.net

fn-no-getattr-for-known-names

discussdraftmaintainability

Applies when: Code reads or writes an attribute or field by a dynamic key when the name is known at write time.

Rule. Use plain field access, and reserve dynamic access for genuinely dynamic names.

Why. Dynamic access defeats rename tools, type checking, and grep. A rename misses it, and it fails at runtime.

FAIL
value = getattr(order, "total")
PASS
value = order.total

Counterpoint. "I loop over field names." That is fine when the set of names is the data (a mapping from column names, a serializer). If the names are fixed, unroll the loop.

Tool gate: ruff B009 / B010

fn-no-magic-values

should-fixdraftmaintainability

Applies when: A literal string or number carries meaning the reader must guess (status codes, mode names, limits).

Rule. Give every meaningful literal a name: an enum member or a constant.

Why. Literals drift. "canceled" in one file and "cancelled" in another is a silent bug, and nobody can grep for what 3 means.

FAIL
if resp.status == 404 or retries > 3:
    mode = "fast"
PASS
if resp.status == HTTPStatus.NOT_FOUND or retries > MAX_RETRIES:
    mode = Mode.FAST

Counterpoint. "A constant used once is noise." True for self-evident values (0, 1, "", an index into a pair). The rule is for literals whose meaning is not visible at the use site, or that appear twice. The failure is two spellings of one concept, not the literal itself.

Tool gate: none (partial: ruff PLR2004)

Sources: google.github.io

fn-no-pass-through-wrapper

should-fixdraftmaintainability

Applies when: A method's whole body calls a collaborator's method of the same name with the same arguments.

Rule. Delete a method that only forwards, and keep wrappers that validate, adapt, or narrow.

Why. A pass-through adds an interface to learn and a hop to step over, and gives no abstraction. Changing the signature now means editing two places.

FAIL
class UserService:
    def get(self, id): return self.repo.get(id)
PASS
# callers use repo.get(id) directly,
# or the service adds something: check access, map to a domain error

Counterpoint. "The service layer should expose everything for uniformity." Uniformity does not make an empty layer useful. If a layer boundary is the goal, say what the layer adds. The Clean Code side of the Ousterhout debate favours small forwarding methods more readily, so treat this as a design call, not a law.

Sources: dev.to, github.com

fn-no-prototype-pollution

blockdraftsecurity

Applies when: JavaScript or TypeScript code merges, clones, or assigns properties whose key names come from JSON, a query string, or other untrusted input.

Rule. Never write an untrusted key into a plain object. Use a Map, an object from Object.create(null), or an allowlist of keys, and block __proto__, constructor, and prototype in any deep merge.

Why. obj[key] = value with key set to __proto__ writes to Object.prototype, so every object in the process gains that property. An attacker who sets isAdmin or a template option there can skip checks or reach code execution. The bug sits in generic helpers such as deep merge and path setters, so one bad helper exposes every caller.

FAIL
function merge(dst, src) {
  for (const k in src) {
    if (typeof src[k] === "object") merge(dst[k] ??= {}, src[k]);
    else dst[k] = src[k];
  }
}
merge(settings, JSON.parse(body));   // body: {"__proto__": {"isAdmin": true}}
PASS
const ALLOWED = new Set(["theme", "lang"]);
const prefs = new Map();
for (const [k, v] of Object.entries(JSON.parse(body))) {
  if (ALLOWED.has(k)) prefs.set(k, v);
}

Counterpoint. "Just freeze Object.prototype at startup." That helps, and the sources list it, but it can break libraries that patch prototypes, and it leaves constructor.prototype paths in class instances open. Fix the merge, and add the freeze on top if your dependencies allow it.

Tool gate: CodeQL js/prototype-pollution-utility and js/prototype-polluting-assignment.

Sources: raw.githubusercontent.com, cheatsheetseries.owasp.org, cwe.mitre.org, portswigger.net, codeql.github.com, learn.snyk.io

fn-no-stringly-typed-returns

should-fixdraftcorrectnesscompatibility

Applies when: A function returns a status, kind or outcome, and callers branch on it.

Rule. Return an enum or a tagged type, never a bare string that the caller must compare.

Why. Callers compare against strings the compiler cannot check. A typo in the producer or the consumer compiles, runs, and takes the wrong branch.

FAIL
def check(order) -> str:
    return "missing_items" if not order.lines else "ok"
PASS
class OrderCheck(Enum):
    OK = "ok"
    MISSING_ITEMS = "missing_items"

def check(order) -> OrderCheck: ...

Counterpoint. "Strings serialise for free." The enum serialises too (by value), and the wire format is unchanged. You gain a closed set that the type checker can exhaust.

Tool gate: type checker with exhaustive match

fn-propagate-dont-swallow

blockdraftcorrectness

Applies when: A handler catches an error and either drops it or raises a different one.

Rule. Chain the original cause when you translate an exception.

Why. Dropping the cause removes the only trace of why the call failed. A translated error without the original traceback sends the on-call person to the wrong place. Never swallow an error silently.

FAIL
try: gateway.charge(...)
except GatewayError:
    raise PaymentDeclined(order.id)   # cause lost
PASS
try: gateway.charge(...)
except GatewayError as err:
    raise PaymentDeclined(order.id) from err

Counterpoint. "Chaining leaks internals to the caller." Chaining stays in logs and tracebacks. What the client sees is a separate mapping (see sys-secrets-and-errors).

Tool gate: ruff B904 (Python)

fn-redirect-target-allowlist

should-fixdraftsecurity

Applies when: A handler redirects to a URL taken from a query parameter, form field, header, or stored value, such as ?next= after login.

Rule. Redirect only to a local path or to a host on a server-side allowlist, never to a URL taken from the request as is.

Why. A link such as https://bank.example/login?next=https://evil.example carries your domain, so users and mail filters trust it, and after login the user lands on a phishing page. When page script sets location from the same value, a javascript: URL runs as script. Parse the URL and compare its scheme and host. A startswith("/") check alone passes //evil.example, and browsers read /\evil.example the same way. Better still, accept a short name and map it to the URL on the server.

FAIL
return redirect(request.args["next"])
PASS
target = urlparse(request.args.get("next", "/"))
if target.scheme or target.netloc or not target.path.startswith("/") or "\\" in target.path:
    target = urlparse("/")
return redirect(target.path)

Counterpoint. "The redirect only matters after a successful login." That is when it hurts most: the user has just typed a password and trusts the next page. A phishing copy of the login form on that page collects the password a second time.

Tool gate: CodeQL py/url-redirection; ASP.NET Core LocalRedirect or Url.IsLocalUrl.

Sources: cheatsheetseries.owasp.org, cwe.mitre.org, learn.microsoft.com, portswigger.net, codeql.github.com

fn-resource-lifetime

blockdraftcorrectness

Applies when: Code starts a task, thread, goroutine, or subprocess, or opens a handle (file, socket, connection).

Rule. Give every task and handle an owner that waits for it or closes it, on the error path too.

Why. A task started and forgotten outlives its caller. Its errors vanish, nobody cancels it, and its resources are never released, which shows up later as a leak or a stuck shutdown.

FAIL
asyncio.create_task(send_metrics())     # nobody awaits it, nobody cancels it
f = open(path); data = f.read()         # leaked if read() raises
PASS
async with asyncio.TaskGroup() as tg:   # children finish inside this scope
    tg.create_task(send_metrics())
with open(path) as f:
    data = f.read()

Counterpoint. "Some work is fire-and-forget by design." Then a long-lived owner (a supervisor or worker pool) holds it, logs its failures, and stops it at shutdown. py-task-reference covers Python task references.

Tool gate: Leak detectors in tests (unclosed-resource warnings, goroutine leak checks).

Sources: vorpus.org

fn-specific-exceptions-only

blockdraftcorrectness

Applies when: Code catches exceptions in business logic.

Rule. Catch only the specific exception you can handle in business code.

Why. A blanket catch treats a mistyped attribute the same as a network blip. Real bugs get swallowed, logged as a warning, and retried. py-ruff-gate enforces this.

FAIL
try:
    charge(order)
except Exception:
    return None
PASS
try:
    charge(order)
except CardDeclined as err:
    raise PaymentDeclined(order.id) from err

Counterpoint. "A worker loop must not die on one bad message." That is the one legitimate blanket catch: once, at the process boundary, with the traceback logged, playing the role that a web framework's error middleware plays for routes. Everywhere else, catch specific exceptions. The other exception is a security boundary: catch broadly there, log redacted, and deny (fail closed). See sys-secrets-and-errors.

Tool gate: ruff BLE001 / E722 (Python)

Sources: go.dev, dev.to

fn-test-oracle-independent

should-fixdraftcorrectnesstesting

Applies when: A test asserts on a computed value or asserts that something was not called.

Rule. Take a test's expected value from the requirement, not from the code under test, and pair every "not called" assertion with a case where the call happens.

Why. An expected value computed by the code under test passes when the code is wrong. A "not called" assertion passes when the spy is wired to the wrong target, so it proves nothing until a case shows it can see the call.

FAIL
assert total(items) == sum(i.price for i in items)   # re-implements the code
mailer.send.assert_not_called()                      # no case where it is called
PASS
assert total([Item(price=300), Item(price=450)]) == 750
assert_not_called_when(free_plan); assert_called_when(paid_plan)

Counterpoint. "Recomputing keeps the test short." It also repeats the bug. Use a literal from the spec or a worked example. See chg-agent-mock-asserting-tests for tests that only check their own mocks.

Tool gate: Mutation testing.

Sources: github.com

fn-variants-as-tagged-union

should-fixdraftmaintainability

Applies when: A type or function models a value that is one of several variants or states.

Rule. Model variants as a tagged union, and make every switch over it fail the build when a variant is unhandled.

Why. A bag of optional fields or booleans that must stay in sync allows states that mean nothing, and each reader has to know which combinations are legal. A new variant then slips past every switch that lacks an exhaustiveness check.

FAIL
type Job = { running: boolean; failed: boolean; error?: string; result?: Result }
PASS
type Job =
  | { kind: "running" }
  | { kind: "failed"; error: string }
  | { kind: "done"; result: Result }

default: { const _: never = job; throw new Error("unhandled") }

Counterpoint. "Two booleans are simpler." They are until a third state appears or the booleans disagree. For a true on/off flag with no related fields, a boolean is fine.

Tool gate: A type checker with exhaustiveness checking (never in TypeScript, assert_never in Python).

Sources: github.com, github.com

fn-xml-no-external-entities

blockdraftsecurity

Applies when: Code parses XML, SVG, SOAP, SAML, or an office document that a user or another system can supply.

Rule. Parse untrusted XML with DTDs, external entities, and network access turned off, or with a hardened wrapper such as defusedxml.

Why. A DTD can declare an entity whose value is file:///etc/passwd or an internal URL, and a parser that resolves it pastes that content into the document or makes the request for the attacker. Nested internal entities can also expand a small file into gigabytes. Each parser has its own switches, so name them in the code instead of trusting the library default.

FAIL
parser = lxml.etree.XMLParser(resolve_entities=True, no_network=False)
doc = lxml.etree.fromstring(request.get_data(), parser)
PASS
import defusedxml.ElementTree as ET
doc = ET.fromstring(request.get_data())   # rejects DTD entities by default

Counterpoint. "Recent Python XML modules already refuse external entities." Some do, and CodeQL says so. The FAIL above opts back in, Java and .NET parsers have their own defaults, and a later change to the parser config reopens the hole. An explicit hardened parser makes the setting visible in review.

Tool gate: CodeQL py/xxe; Bandit B313 to B319 (stdlib XML parse calls) and B405 to B410 (XML imports).

Sources: cheatsheetseries.owasp.org, cwe.mitre.org, portswigger.net, codeql.github.com