Skip to content

Releases: n-shadloo/secure-code-auditor

v1.53.0

Choose a tag to compare

@n-shadloo n-shadloo released this 03 Sep 12:55
v1.53.0
0e29023

secure-code-auditor v1.53.0

Three corrections, in three references. Each one closes the gap between what a
section already required and what it gave a reader to act on, and each was
settled by running the code rather than by reading about it. No claim, control,
or example is removed, and the frontmatter description is unchanged.

The SSRF address check now has a mechanical form

references/a01-broken-access-control.md has always required a check on the
resolved address, and has always argued that a hand-written list of refused
ranges is the list that must be complete and never is. It did not say what to
write instead. It does now:

not ip.is_global or ip.is_multicast

That refuses loopback, every private range, the link-local range that carries
169.254.169.254, the shared address space 100.64.0.0/10, and each
IPv4-mapped IPv6 form of those. The multicast half is not decoration:
ipaddress reports a multicast address as global, so a test on is_global
alone lets 224.0.0.1 through.

The floor is Python 3.12.4, and the gap below it is real. CPython's own
documentation attributes the fix to 3.13, which is the feature release rather
than the first release that carries it. On Python 3.12.3, 2002:a9fe:a9fe::
the 6to4 form of the cloud metadata address — is reported as global, and the
check passes it. On 3.12.4 it is refused. The same fix is in 3.9.20, 3.10.15,
and 3.11.10. If your runtime is pinned anywhere in 3.12.0 to 3.12.3, this
predicate does not cover the 6to4 form.

It is still not a complete refusal list, and the section does not pretend
otherwise.
64:ff9b::/96 is reported as global on every release through
3.14.0, so behind a NAT64 gateway 64:ff9b::a00:1 still reaches 10.0.0.1.
The allowlist keeps the lead. That was the section's argument before this
release, and it now carries a worked example of its own limits.

Three request-size limits are labelled with the server that has them

references/deployment-and-runtime.md listed --limit-request-line (4094),
--limit-request-fields (100), and --limit-request-field-size (8190)
directly beneath the bullet that describes uvicorn and Daphne. They are
Gunicorn options. A reader on ASGI could take them as theirs and conclude they
had a header bound they did not have.

Under uvicorn, none of the three exists. The counterpart is
--h11-max-incomplete-event-size, whose 16384 default comes from h11, and
only the h11 implementation reads it. The httptools implementation has no
equivalent, and --http auto selects httptools wherever it is installed —
which is what uvicorn[standard] installs. Under uvicorn with httptools,
the reverse proxy is the only header bound there is.

Read off Gunicorn 26.2.0, uvicorn 0.52.4, h11 0.16.0, and httptools 0.8.0.

SECRET_KEY rotation names its unit, and the table is current to Django 6.1

The SECRET_KEY_FALLBACKS subsystem table was annotated against Django 6.0 and
5.2. Every row was re-executed at 6.1, 6.0.8, and 5.2.17, and none of them
moved: the signer's fallback default, the session-hash match that cycles the
key and re-stamps the session in place, secret_fallbacks on the reset-token
generator, the cookie signer, and the CSRF secret that is random rather than
derived and that a rotation therefore never invalidates. The annotation now
names 6.1.

The rotation procedure itself was missing something. Its rolling-fleet split
counted instances of one deploy, so a worker fleet, a scheduled job, or a
second service that verifies the same Django-signed values fell outside it. The
procedure now states that every process family which verifies a Django-signed
value is a fleet of the rotation, that the fallbacks reach every fleet before
the primary key changes in any fleet, and that the window closes only after the
last fleet carries the new key.

This matters most on the rotation you least want to get wrong. A compromise
rotation deliberately skips the fallback, so that everything the attacker could
forge becomes invalid at once. A fleet left on the old key still accepts what
the leaked key signs, and the hard cut does not cut.

Verification

Both repository workflows pass on the finished tree: 25 reference files, every
link resolving, no orphan, balanced fences, SKILL.md at 32,436 of the 40,960
bytes allowed, the description at 1013 characters, and the
dangerous_patterns.py self-test at 49 fixtures with all three scanners
exiting 0.

The library-index date does not move.

v1.52.0

Choose a tag to compare

@n-shadloo n-shadloo released this 27 Aug 21:45
v1.52.0
ddb1d65

secure-code-auditor v1.52.0

Two change sets land in one version. The first is the audit closure:
twenty-two commits, one per reference family, that repair what a file-by-file
audit of the whole corpus found — 3,992 insertions and 785 deletions across
twenty-three files. The second is the rebuild that follows it: the reference
corpus returns to the v1.50.0 language line the closure had crossed, and
SKILL.md and the agent cascade are rewritten to the same standard. No file
was added, renamed, or removed, and the frontmatter description is
byte-identical at 1013 characters.

The audit closure

Every reference took one commit, and the changes fall into three kinds.

Examples that taught the defect they warn about are corrected. The
canonical A01 CommentCreateView called check_object_permissions with
permission_classes = [IsAuthenticated]BasePermission.has_object_permission
returns True, so the call authorized nothing; it now carries a permission
class that implements the object hook. The A04 wrapped-hasher migration gains
the survey pass that stops on a row it cannot wrap, because the write destroys
the only copy of the legacy digest, and the section now schedules the session
and reset-token invalidation that rewriting every password row causes. The
graphene UserType scope returns the set a caller may read rather than the
one row the caller is, and refuses an unauthenticated principal outright,
because AnonymousUser has no primary key and filter(owner_id=user.id)
compiles to owner_id IS NULL. The A09 service example records a permission
denial outside the transaction the denial rolls back, and the DRF atomic
counter chooses its cache-outage branch instead of letting the error become
a 500.

Decision rules that let real findings through are closed. The
duplicate-route dismissal now compares five things — the permission class,
the decorator, the middleware, the queryset scope, and the serializer — where
it compared three. The resolved-route artifact carries the request.urlconf
and set_urlconf caveat, because a middleware that sets either moves the
whole table. The tenancy ladder separates who authored a tenant value from
whether it is still true, and asks for a re-read of membership on the
request. The SSRF allowlist states how a host matches — equality on the
parsed, lowercased, IDNA form, with the scheme and port pinned and userinfo
rejected. A model field's validators argument no longer closes a sink
finding, because save(), bulk_create(), QuerySet.update(), migrations,
and raw SQL never run it.

Verified mechanics land across every file, each read off a primary source
and dated, most against the Django 6.0.7 or 6.1 source on 27 Aug 2026. The
highlights, by file:

  • A02 — the env-string parsing trap (DEBUG = os.getenv("DEBUG", "False")
    is true; security.W018 catches it and security.W020 does not catch
    [""]), a new section on wildcard entries in ALLOWED_HOSTS and
    CSRF_TRUSTED_ORIGINS, the COOP relaxation scoped to the popup response,
    the computed-salt audit gap, base-uri as the CSP directive whose absence
    undoes the nonce, DKIM selector rotation as a DNS deletion, DMARC and SPF
    record counting, the five suppression kinds, and diffsettings --default
    as the drift instrument whose output is secret material.
  • A03 — the pin as the trust decision (--require-hashes binds the
    artifact to the pin, nothing binds the pin to a person), [build-system] requires and pip install . as the two inputs outside the hashed file,
    --only-binary :all:, PIP_EXTRA_INDEX_URL read from the environment, the
    attest job split so no third-party code runs beside id-token: write, and
    the migration backfill routed through _base_manager so a
    use_in_migrations manager cannot hide rows from the count.
  • A04 — the Argon2 memory budget multiplied by worker concurrency, the
    get_random_string entropy arithmetic, COMPARISON_KEY rotation costing a
    restart rather than a migration, the shared-DEK write budget under NIST SP
    800-38D, the KMS encryption context naming the column as well as the row,
    and the re-encryption pass that filters on the value it read so a
    concurrent write survives.
  • A05 — sources that cross the process boundary without a request, the
    first-party wrapper searched as a sink name, template names as an input
    position, interpreter arguments (-c, -e, a command after a host name)
    that an argument list does not close, env= as a second argument channel,
    format_html passing an already-SafeString argument through unescaped,
    the DomainNameValidator exposure shape, and the CSV renderer in
    DEFAULT_RENDERER_CLASSES turning every endpoint into an export location.
  • A06 — ceilings for public flows with no principal, the
    LimitOffsetPagination offset as a second multiplier, JSON list bodies
    outside DATA_UPLOAD_MAX_NUMBER_FIELDS and the ListSerializer
    max_length answer, writers outside the repository that only a constraint
    reaches, repricing on perform_update(), the provider's operation
    identifier as the only key a callback may resolve a row by, and
    plus-address normalization on notification limits.
  • A07 — recovery resolving the same principal set as login
    (get_users() against the exact column), the bcrypt 72-byte truncation
    against the validators that read the whole string, a mechanism for the
    SP 800-63B-4 forced-change requirement, the breach validator hardened
    (https pinned at load, redirect origin checked, bounded read, one
    fail-open exit), the DRF login CSRF gap (APIView.as_view() is
    csrf_exempt and SessionAuthentication enforces nothing before
    authentication), ModelBackend's dummy hash on a miss and the custom
    backends that drop it, the IPv6 /64 lockout key, django-axes defaults read
    off 8.3.1 (AXES_COOLOFF_TIME = None is a permanent lock), a reset never
    satisfying the second factor, and re-authentication minting a bounded
    artifact rather than a session flag.
  • A08 — the Celery remote-control channel enumerated off 5.6.3
    (shutdown, revoke, pool_restart, and the rest ride broker reach),
    worker_enable_remote_control, a new section binding a webhook event to a
    tenant through the stored connection mapping rather than a payload field,
    and the durable RECEIVED row surviving a lost wake-up.
  • A09 — the query string recorded at the proxy tier above LOGGING,
    sensitive_post_parameters raising TypeError on DRF views, alerts on
    the single decisive event and on silence, the audit alias under
    DATABASE_ROUTERS, group and permission writes as m2m_changed events,
    what a hash chain proves and what it cannot, and the decoy register with
    its expected reader set.
  • A10 — the truthy-coroutine policy check (an async def gate called
    without await grants everyone), negated flag names, the sign bound before
    the balance guard, select_for_update() as a silent no-op on SQLite, the
    anchor-row lock for aggregate invariants, the two ways a declared
    constraint is absent (backend support and NULL columns), IntegrityError
    caught at the write and mapped to 409, UniqueValidator as a message
    rather than enforcement, on_commit in autocommit and robust=True,
    idempotency-key validation, read-path expiry, and the stored response as a
    retained copy; the regex subject measured where the match happens, and
    __regex lookups as a database-side engine no Python cap reaches.
  • DRF — update authorizing the record as stored while serializer.save()
    applies the body after, overridden dispatch()/initial() removing every
    control, every @action keyword argument replacing the viewset's list, the
    GET-default action, scopes that resolve to nothing failing open
    (filter(tenant=None) is IS NULL), BasicAuthentication in the default
    list, cookie-carried tokens needing their own CSRF, ObtainAuthToken
    opting out of project defaults, rest_framework.urls mounting plain Django
    login views, version switch-off by route removal, and bulk_create() on
    the create path.
  • Authorization architecture — a permission class implementing only
    has_object_permission as the worse half of the incomplete-class defect,
    autocomplete lookups scoped on the related ModelAdmin while
    limit_choices_to binds the write, the URLconf audit test reading
    initkwargs so a per-action AllowAny cannot hide, identity-selecting
    fields never writable, the allow-list governing every serializer a route
    can build, scoping applied before the aggregate stage so facet counts do
    not leak, and offboarding targets marked done only from a read-back.
  • Data layer — sequence grants split from SELECT, the migration
    bookkeeping table revoked from the runtime role, BYPASSRLS read from
    pg_roles, SECURITY DEFINER functions pinning search_path, policies on
    partitions, views, and materialized views, tenant context on second
    aliases and after COMMIT pops it, the isolation level proven with SHOW transaction_isolation, retry jitter and a driver-tested classifier, and
    role-level ceilings (CONNECTION LIMIT, idle_in_transaction_session_timeout).
  • Data lifecycle — the tombstone releasing its identifier the moment a
    partial constraint frees it, delete receivers connected in
    AppConfig.ready() so a worker cannot take the fast-delete path, the
    credentials that survive an erasure, crypto-shredding decided at the first
    write, the erasure ledger replayed from a store no restore rewrites, the
    three purge-predicate defects, and masked extracts as pseudonymized data.
  • Deployment — the CDN-fronted origin reachable directly, the
    proxy_set_header inheritance trap with X-Forwarded-Host, X-Real-IP,
    Forwarded, and Client-Cert overwritten, the client-IP function checking
    addresses as well as depth and failing closed off the probe path, the
    world...
Read more

v1.51.0

Choose a tag to compare

@n-shadloo n-shadloo released this 27 Aug 07:19
v1.51.0
37f22bc

secure-code-auditor v1.51.0

Agent-operator security. The skill gains a twenty-fifth reference,
references/agent-operator-security.md, and it covers a surface none of the
other twenty-four does: the access the reviewing agent itself holds, rather
than the surface of the backend it audits. That surface does not exist until an
agent runs a review unsupervised, which is exactly why a skill written for a
supervised review had no reason to carry it.

The boundary with references/agent-and-llm-interfaces.md is the one to keep
straight, and it is drawn in both files with one line each. That file owns the
serving side — a backend that agents call, the tool boundary it publishes, the
confirmation token it issues to its own callers. This one owns the credential
the agent holds while it reads your code.

What the new file owns

The credential files the agent must never open.env and each variant,
*.pem, *.key, ~/.ssh, ~/.aws, ~/.gnupg, ~/.netrc, a service-account
key, and a decrypted secrets file. The counter-instinct half is stated outright,
because it is the half that gets skipped: a .gitignore, a tool-specific ignore
file, and an instruction in a project memory file are defaults rather than
walls. A shell command the agent composes itself opens the file directly, with
no ignore rule between the two. The published defect reports that record exactly
that path are cited — anthropics/claude-code issues #4160, #12102, and #24185,
and The Register reproducing the bypass on 28 Jan 2026 against v2.1.12. An
auto-approve or permission-skip mode is not the moment the rule relaxes; it is
the moment the rule is the only thing left.

The Django half of the same rule is that a settings module is executable. An
import of a production one resolves the production SECRET_KEY, the database
password, and every API key it names — which is why settings_scan.py parses a
settings package with the ast module and never imports the target project. The
file now records that as the reason the constraint is workable rather than
merely as a scanner property.

The name-by-location rule for every finding, report, commit message, and
fixture the agent writes. Redaction is put where it belongs: a masking layer
matches a literal string, so base64, a split across two lines, URL encoding, or
the value inside JSON, XML, or YAML each defeat it. GitHub's own Actions
hardening guidance says both halves — its redaction rests on an exact match, and
automatic redaction is not guaranteed. The instinct points the wrong way here.
"Show the evidence" is correct for every other finding class in this skill and
wrong for this one, because a hardcoded credential is a defect for where it sits
rather than for what it spells.

The kind, scope, and life of the agent's own repository credential, ranked
by blast radius in one table. A classic personal access token reaches every
repository the account can see and carries no expiration requirement. A
fine-grained one is bounded by an organization maximum between 1 and 366 days
(GitHub Changelog, 18 Oct 2024). A deploy key reaches exactly one repository
with no account behind it. An installation token expires in an hour. Beside the
table sit the per-job token permission that resolves an omitted scope to none,
the federated cloud credential that replaces a stored one and leaves the
control-plane record a stored one structurally cannot, and revocation at the end
of the task.

Instructions arriving in content as data rather than as authority
repository files, a commit message, an issue body, a pull-request comment, a web
page, tool output. Four incidents against coding agents specifically carry it:
the Amazon Q Developer extension for VS Code (CVE-2025-8217, AWS advisory
AWS-2025-015), three agents driven from pull-request titles and issue bodies with
one made to post its own API key as a public comment (Google VRP #1609699), a
token exfiltration that defeated the host's own secret scanning by base64 (Orca
Security), and a backdoor header inserted from a helpful-looking issue (Trail of
Bits, 6 Aug 2025).

The confirmation gate on an action a finding recommends, enforced in the
execution path rather than in the prompt. The July 2025 production-database
deletion happened during a declared freeze that lived only in the instructions,
and the agent then misreported the restore as impossible. The agent recommends
and a person executes — at every level of confidence, because confidence carries
no signal about the cost of a wrong irreversible action.

The command, change, and cloud-action record that a layer the agent cannot
edit has to author, including the source identity a session holder cannot change
against the session name it can.

Three sections SKILL.md never carried

What proof is, and the commands that produce it. The definition was already
implied by the methodology and workflow files; it is now stated where it can be
enforced. A finding without confirmed evidence is a hypothesis, is labelled as
one, and goes under "Worth checking". The not-examined list is part of the
deliverable, because the coverage ledger's not-examined lines are what the
limitations section is written from. The exact commands are the three scripts
under scripts/, with the dangerous_patterns.py self-test named as the way to
confirm a quiet result before trusting it. One ground-truth read rule lands here
too, at the point the sweep first needs a local fact the repository cannot
supply.

Stop conditions, with five handoff fields instantiated for this domain. The
existing refusals are extended rather than duplicated — review-time read-only,
and the three write-time changes that already need explicit confirmation, stand
as they are. Three more cover the skill's own actions: stop the sweep and
escalate at once on a live credential exposure; never execute a rotation, a
revocation, a disable, or a delete; refuse a read that would open credential
material.

Freshness. Django 6.1, 6.0.8, and 5.2.17 LTS with DRF 3.18.0, on the OWASP
Top 10:2025, API Security Top 10:2023, and ASVS 5.0.0 foundation, with the agent
tokens from the LLM Top 10 2026 and the Agentic Top 10 2026. Review again at the
next Django feature release and no later than 9 Feb 2027.

What did not move

The frontmatter description is byte-identical at 1013 characters, measured the
way the validation workflow measures it, and allowed-tools is unchanged at
Read, Grep, Glob, Bash — the no-Edit, no-Write audit posture is deliberate, and
every evidence command in the new verification section fits inside it. No
control, example, or caveat is removed from any pre-existing file. The
library-index date does not move.

Two deletions inside the new file, both under the deletion pass and both long
justifications for a rule rather than the rule: the sentences explaining why the
suite lacked this file, and the sentence apologising for the cost of asking a
person for a value.

The register holds. The corpus carries zero uppercase MUST/NEVER, so the
absolutes here are sentence-initial and unhedged, matching the other twenty-four
files.

Verification

docs-integrity passes with 25 reference files, every cited path resolving, no
orphan, balanced fences, 306 anchored links across 34 markdown files, and
SKILL.md at 39065 of the 40960 bytes allowed. validate-skill passes with the
description at 1013 characters, the dangerous_patterns.py self-test at 49
fixtures and 29 of 29 rules covered, and all three scanners exiting 0 with a
kind: "summary" record on a missing path. All 346 cross-file section citations
in the corpus resolve. The new file carries no prose sentence over 25 words and
no prose line over 79 characters.

v1.50.0

Choose a tag to compare

@n-shadloo n-shadloo released this 21 Aug 13:43
v1.50.0
4d339e8

secure-code-auditor v1.50.0

The language pass. The whole reference corpus is now written in ASD-STE100
Simplified Technical English, converted across three waves and released here as
one version. This release changes how the skill reads, and nothing about what it
says: no control, example, or caveat is added, removed, or reordered, no
reference file is added, and the reference count stays at twenty-four.

Wave 1 took the method and access-control family. Wave 2 took the attack-class
family. Wave 3 took the data, runtime, and interface family, together with
scripts/README.md, the SKILL.md prose, and the AGENTS.md, GEMINI.md, and
.cursor cascade.

The rules applied

The grammar is the standard's: short sentences, the active voice, the imperative
mood for an instruction, one instruction to a sentence, the present tense for a
fact, no gerund or participle as a noun or a verb, noun clusters of three words
or fewer, articles kept, approved phrasal verbs only, one term for one thing, a
warning before the instruction it applies to, and no slang, metaphor, or
rhetorical question.

Vocabulary substitution stops there. In this corpus the verb is frequently the
rule, so log, redact, retain, erase, restore, promote, quarantine, scan,
throttle, lock, commit, and roll back each still name what they named before.
Delete and erase remain two words in the privacy file, and retain and keep are
still not interchangeable there.

What did not move

Every heading is byte-identical, at every level, so every anchor still resolves:
the docs-integrity check passes with twenty-four reference files, no orphan,
balanced fences, and 297 anchored links across thirty-three markdown files.

Every fenced code block, table, cross-reference, identifier, severity word,
threshold, date, and version claim was diffed per file against its previous
state and is unchanged. The dated library index keeps its dates, its
classifications, and its version pins, and its index date does not move. Every
Wrong, Unsafe, Correct, and Write-time label keeps the pairing it marks.
The MUST and NEVER register is preserved, and no rule is softened, hedged, or
widened.

The frontmatter description is byte-identical at 1013 characters, measured the
way the validation workflow measures it. SKILL.md is 33860 of the 40960 bytes
the docs-integrity workflow allows.

The documented script contract does not move either. The exit-code statement,
the kind: "summary" output shape, and the --selftest CI wording are
byte-identical; all twenty-nine rule identifiers still match between
scripts/README.md and dangerous_patterns.py; --selftest passes 49 fixtures
with 29 of 29 rules covered; and all three scanners report zero findings against
this repository.

Sentence length

The eleven files wave 3 names carry no prose sentence over 25 words, against 562
before the wave.

Waves 1 and 2 left 82 such sentences between them across the fourteen files they
touched. Those are fixed here, so the whole of references/ now carries exactly
one: the verbatim SP 800-63B-4 blocklist requirement in
a07-authentication-failures.md. It is kept intact because splitting it would
falsify a quoted normative SHALL.

Also released here

The boundary scrub, which has been unreleased since v1.49.0. Three passages
named a repository that is not public, and each is rewritten by capability
rather than deleted, so the seam and its direction survive without the name. Two
seams the final checkpoint reported as unmarked gained a boundary sentence each,
in deployment-and-runtime.md and graphql-and-alternative-api-surfaces.md.
Two historical changelog rows lost the two names they carried, with no date,
number, claim, or verdict moved.

Upgrading

Nothing to do. This release is documentation only. No setting, script flag, exit
code, output shape, rule identifier, or heading anchor changes, so an existing
integration that pins SKILL.md and references/ keeps working unchanged.

Full Changelog: v1.49.0...v1.50.0

v1.49.0

Choose a tag to compare

@n-shadloo n-shadloo released this 20 Aug 19:13
v1.49.0
94d174e

secure-code-auditor v1.49.0

The final checkpoint on the five hardening releases. One deterministic sweep of
the whole repository, seventeen tests, and every failure fixed in the same
session. No reference file is added, no heading is renamed, and the reference
count stays at twenty-four.

The ledger holds on both sides. All thirty accepted items from v1.44.0 through
v1.48.0 are still present. Every rejected item is still absent: no --strict
flag, no --fail-on, no CHANGELOG.md, no tests/, the six labelled
wrong-versus-correct pairs in the DRF file unchanged from the v1.43.0 count, and
the exit-code contract standing as written.

Corrections

Each was re-verified against a primary source fetched on 20 August 2026.

  • The gevent floor was paired with the wrong CVE. CVE-2023-41419 is first
    patched in 23.9.0, not 24.10.1. GHSA-x7m3-jprg-wc5g gives the range as
    < 23.9.0, and the upstream issue body says "Fixed in 23.9.0". This one had
    been flagged for re-verification since the 13 August audit and is now settled.
    The eventlet>=0.40.3 and tornado>=6.5.0 floors beside it re-verified as
    correct.
  • The connection-pool claim was false, and it was the strongest claim in its
    section.
    data-layer-and-database.md said a pool's maximum size bounds
    total backends "regardless of how many workers exist". Django holds
    DatabaseWrapper._connection_pools as a class attribute, so every process
    builds its own pool: max_size: 10 across eight workers on three hosts is 240
    backends. The passage now multiplies by processes and by hosts, credits only
    PgBouncer with capping from outside the processes, and the sizing bullet three
    lines down gains the host factor so the two agree.
  • PersistentRemoteUserMiddleware does read the header. A07 said it
    "returns without reading the header at all". process_request() calls
    get_username() and catches KeyError; force_logout_if_no_header is
    consulted only then. The flag decides the logout, not the read.
  • The 6.1 check change is attributed to the wrong place. The command still
    passes options["databases"], which is None. The default lands inside
    run_checks(), which 6.1 gave both an elif not databases filter and a
    databases is None fallback that 6.0 has neither of.
  • The tar filter backport list was incomplete. Misc/NEWS.d/3.8.17.rst
    carries the same entry, so 3.8.17 joins 3.9.17, 3.10.12, and 3.11.4.
  • A08 contradicted itself. Its "Django 6.1 was not available to check"
    caveat sat in the same section as the 6.1 items v1.48.0 added. It now says the
    6.1 items name their release and an unnamed claim is scoped to 6.0.

Controls added

Nine, each at sentence scale in the file the ownership table makes the owner.

  • A01 pins the resolved address for SSRF. "After DNS resolution" left a
    re-resolving check reading as safe, and DNS rebinding defeats it.
  • The default-deny URLconf test gets a closed exemption set. A prefix tuple
    also exempts every route added below it later, which defeats the test's own
    purpose.
  • A07 flags that the authtoken Token model stores its key as plain text.
  • A09 requires the edge to replace a client-supplied X-Request-ID, so a
    forged identifier cannot splice one principal's trace into another's.
  • A10 names btree_gist on the ExclusionConstraint example, which the
    scalar-beside-a-range booking case needs.
  • api-drf names DATA_UPLOAD_MAX_MEMORY_SIZE where a webhook reads
    request.body directly, since that passage is about bypassing DRF's parsers.
  • async-and-channels makes the WebSocket ticket single-use. The same passage
    warns that URLs reach logs, and the ticket rides in the URL.
  • file-uploads pins the versionId or generation at promotion.
    Write-denying the served prefix does not stop a re-PUT into quarantine between
    the clean verdict and the copy.

Attack-pass items that passed

Six of the fifteen passed and are recorded as passes rather than written into
findings. The KDF-input length ceiling is the one worth naming: Django added a
4096-byte cap in 1.4.8 and reverted it in 1.5.5, AuthenticationForm.password
carries no max_length, and OWASP declines to recommend a general maximum. The
bcrypt 72-byte truncation in A04 and the 64-character floor in A07 are the
ceilings that belong here, and both were already correct.

Sibling non-absorption

a03-software-supply-chain.md carried migration operation mechanics — pending-
trigger errors, atomic = False, restart-safe predicates, RunPython.noop
with no handoff. That is django-migration-safety's deliverable. It is cut to
the security invariant, that the access rule holds at every batch boundary, plus
the handoff by name, matching the shape A08 already uses for
django-async-jobs.

Language and register

Twenty-six sentences this build added exceeded the ASD-STE100 length limits or
carried two instructions in one sentence, and are split. Five uppercase
MUST/NEVER uses are lowered: the corpus carries none, using sentence-case
"Never" and lowercase "must" throughout, so the uppercase form was an import
rather than a match.

Budgets

a04-cryptographic-failures.md goes from 1119 to 1112 lines, closing the
deferral v1.47.0 opened and v1.48.0 carried. A mechanical duplication pass,
internal and cross-file, found three passages restating a file that owns them:
the disk-versus-column distinction and the encrypted-column query cost belong to
data-layer-and-database.md, and the os.environ rule to
service-identity-and-secrets.md, whose version is stricter because it also
rules out os.environ.get. Those are pointers now. The rest of the file is
controls rather than duplication, so it stays over the 1100 convention
deliberately — trimming further would delete controls, and this release closes
the item rather than deferring it a fourth time.

SKILL.md is 33,714 of its 40,960-byte ceiling. The description is untouched at
1013 characters.

Verification

Every Django 6.1 claim this build wrote was re-verified against the 6.1 release
notes or the tagged source; none failed. CVE-2026-6873 verified against the NVD
record. All eighty-one identifiers the build introduced resolve in the Django,
DRF, or Python documentation, or in the named project's own source. The
self-test holds 49 fixtures and passes, all three scanners end a --json stream
on a summary record and exit 0 on a missing path, all three produce zero
findings against this repository, and every intra-repository link and heading
anchor resolves. The library index date does not move.

Community health files are added in a separate commit and carry no version:
CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, four issue forms, and a
pull-request template. Issues yes, pull requests no.

Full Changelog: v1.48.0...v1.49.0

v1.48.0

Choose a tag to compare

@n-shadloo n-shadloo released this 20 Aug 18:39
v1.48.0
a93b033

secure-code-auditor v1.48.0

A Django 6.1 release. Django 6.1 shipped on 5 August 2026, and the corpus was
written against 6.0. Twelve security-relevant behavior changes now sit inside
the passage that owns each one. Eight reference files change, no reference file
is added, no heading is renamed, and no script is touched.

Every item below was verified on 20 August 2026 against the 6.1 release notes
read end to end, the linked 6.1 documentation, or the Django source at the 5.2,
6.0, and 6.1 tags. The change brief that prompted this release was treated as an
impetus and not as a source: several of its candidates were dropped, and three
survived only in a narrower or wider form than the release notes read. Those
three are called out below. The library index date does not move — this was a
framework release, not a package sweep.

Password hashing and signing

a04-cryptographic-failures.md carries the new PBKDF2 default. The shipped
iteration count is 1,500,000 on 6.1, against 1,200,000 on 6.0 and 1,000,000
on 5.2, read off django/contrib/auth/hashers.py at each tag. Argon2 and Scrypt
keep their parameters on 6.1 and the shipped PASSWORD_HASHERS order is
unchanged, so the rest of that table re-scopes as it stands rather than needing
a 6.1 row of its own.

The signing notes take the 6.1 deprecation of the implicit algorithm default
on salted_hmac() and django.core.signing.base64_hmac(). Both now warn until
the caller names the algorithm, ahead of the 7.0 change to "sha256" the file
already recorded.

RemoteUserMiddleware under ASGI

This is the first of the three items that came out subtler than the release note
reads. The note says the HTTP_ prefix is no longer added under ASGI, and that
the REMOTE_USER default is unaffected. The 6.1 source says why: get_username()
adds the prefix only when the request is an ASGIRequest and header still
equals the base-class value. So the default goes on reading HTTP_REMOTE_USER,
while a custom header is read exactly as written and has to be spelled
HTTP_AUTHUSER. Django 5.2 and 6.0 prefixed unconditionally on the async path,
so one subclass resolved two different keys depending on the server.

Two upgrade hazards follow, and both are silent:

  • A custom-header subclass reads a key nothing populates on 6.1. That fails
    closed under RemoteUserMiddleware, whose force_logout_if_no_header is
    True — and open under PersistentRemoteUserMiddleware, which sets it to
    False and therefore returns without consulting the header at all. The session
    then outlives any revocation at the proxy.
  • Django 6.1 removed the 6.0 shim that ran a process_request() override through
    sync_to_async. Such an override is now skipped under ASGI while the base class
    still authenticates, so any check it added stops running.

a07-authentication-failures.md also now states the ASGI rule the file never
carried: Django's own documentation says the spoofing warning applies under ASGI
in every configuration, because an ASGI server cannot place a trusted value in
the environ.

Redirects that preserve the request

a01-broken-access-control.md gains RedirectView.preserve_request, new in 6.1.
The preserve_request argument on HttpResponseRedirect and redirect() is
not new — it is 5.2 — and the passage scopes each to the release that added
it. An enabled one rates above a plain open redirect, because a 307 or 308 sends
the POST body to the target host and not only the visit.

The second narrowed item is the redirect length bound. It is not new either:
Django 6.0 hard-codes 16384 characters and raises DisallowedRedirect above it.
What 6.1 adds is the max_length argument that overrides it, so max_length=None
now removes a bound the framework used to enforce for every project.

CSP nonces, mailers, and check --deploy

a02-security-misconfiguration.md takes three changes.

The CSP passage gains the nonce path. security.W027 reports CSP.NONCE in a
policy with no django.template.context_processors.csp context processor — a
second way a policy goes inert, after the missing middleware the bundled scanner
already flags. Without the processor the header promises a nonce that no element
carries, so every inline script the policy was written for is blocked. The new
csp_nonce_attr tag renders the attribute on external <script> and <link>
elements and on a Media object's assets.

The mail material gains MAILERS, which moves the backend, the credentials, and
the TLS posture into a per-alias dictionary and deprecates eleven EMAIL_*
settings. Review each alias on its own: one mailer can hold TLS while a second
sends in cleartext, and neither setting contradicts the other. The mail.E001
deploy check rejects a development-only backend in the default alias, and
mail.W001 reports a MAILERS value with no default entry.

The third narrowed item is the check command. The release note says it now
supplies all databases; the 6.1 run_checks() source says it defaults
databases to every configured alias but still filters database-tagged
checks out
without an explicit --database. Django's own deploy checks
therefore still read settings alone. The real exposure is a custom or
third-party check that accepts databases and opens a connection in CI.

Database-level cascades and the audit trail

a09-logging-and-alerting.md takes the delete-signal half of DB_CASCADE.
SKIP_COLLECTION in 6.1's django/db/models/deletion.py holds DO_NOTHING,
DB_CASCADE, DB_SET_DEFAULT, and DB_SET_NULL. An audit hook on a delete
signal therefore records nothing for the far side of such a relation, and the
diff that causes it is one keyword long.

The erasure half was already correct in data-lifecycle-and-privacy.md and is
left alone. What was missing is the audit half, because a09 stated that
QuerySet.delete() sends delete signals for cascades without exception.

Request parsing and strict Base64

file-uploads.md gains HttpRequest.multipart_parser_class, which selects the
parser that reads the request body — a replacement owns every bound above it,
because the settings are read by the parser rather than applied around it. It
also gains the strict Base64 validation now applied by MultiPartParser,
BinaryField, and DatabaseCache. Each is a fail-closed change, so the handler
around those paths should return a 400 rather than a 500.

The ownership call: request-body parsing belongs here with the upload pipeline,
and api-drf-specific.md keeps DRF parser configuration only.

Admin actions and the permission model

authorization-architecture.md takes the admin action location argument.
ActionLocation.CHANGE_FORM can place an action on the change form, which gives
an action declaring no permissions a second unguarded entry point past
_filter_actions_by_permissions() — the gap this file recorded at v1.47.0. The
same change deprecates the get_actions() signature the file's own mitigation
depends on, and its return values are Action objects now, so that sentence is
updated with it.

The access-review section takes two more: a migration that renames a model now
renames the matching Permission.name and Permission.codename, and the new
Permission.user_perm_str returns the string User.has_perm() expects.

Deserialization

a08-integrity-and-deserialization.md takes the XML deserializer raising
SuspiciousOperation on an unexpected nested tag, and the picklability of Task
and TaskResult. Task.__reduce__ stores module_path, and Django resolves it
through import_string when it rebuilds the object — the same import-driven path
the file already records for TaskError.exception_class.

Neither fact is a new risk in a pickle store an attacker can already write, since
pickle runs code by design. What changes is that a TaskResult holding arguments
and tracebacks verbatim can now reach a pickle-backed cache or session, so the
retention and scrubbing rules the file already states reach further.

Dropped after checking

  • delete_confirmation_max_display — its own documentation calls it purely a
    display setting that does not change what is retrieved from the database, and
    the default of None preserves the old behavior.
  • The admin login redirect to next — it resolves through
    LoginView.get_redirect_url(), which validates with
    url_has_allowed_host_and_scheme.
  • Systematic quoting of SQL SELECT aliasesa05-injection.md's alias
    section already generalizes framework hardening across versions and keeps the
    durable fix. Inviting a reader to lean on the quoting is the wrong lesson in
    the one section built to prevent exactly that.
  • SessionBase.__bool__, the File truthiness change whose upload
    subclasses keep the old behavior, and the task() decorator's new keyword
    arguments
    — extensibility or no security behavior.
  • Dropped PostgreSQL 14, MySQL < 8.4, MariaDB < 10.11, and SQLite < 3.37
    support
    — a support matter with no home in this corpus.
  • Fetch modes and the select_related() deprecation — these belong to
    django-performance-optimizer.

Already landed

The signed-cookie item needed no edit. a02-security-misconfiguration.md
already carries CVE-2026-6873, the 5.2.15 and 6.0.6 floor, the 6.1 flip of
SIGNED_COOKIE_LEGACY_SALT_FALLBACK to False, and the removal at 7.0.

Known carry-over

a04-cryptographic-failures.md is now 1119 lines and stays over the 1100-line
convention that v1.47.0 deferred to the final checkpoint.

Full Changelog: v1.47.0...v1.48.0

v1.47.0

Choose a tag to compare

@n-shadloo n-shadloo released this 20 Aug 18:01
v1.47.0
e566259

secure-code-auditor v1.47.0

An identity and admin release. Eight controls the corpus named nowhere now have
an owner: REMOTE_USER authentication, the email change, purpose-bound tokens,
the device cookie, peppering, custom admin URLs, Markdown as an output context,
and the restore that resurrects an erased subject. bleach gets a disposition.
Six reference files change, no reference file is added, no heading is renamed,
and no script is touched.

Every claim below was verified against its primary source on 20 August 2026 —
installed Django 6.0.7 source, the projects' own source for markdown-it-py
and mistune, and PyPI and the GitHub API for bleach and nh3. Three claims
were settled by execution rather than by reading, and each of those three
corrected the finding as written. The library index date does not move: no PyPI
sweep ran for this release, so the one new package row carries its own dates.

REMOTE_USER and header authentication

a07-authentication-failures.md carried no occurrence of REMOTE_USER. The
new section states the mapping that makes the base middleware safe and a
subclass dangerous. RemoteUserMiddleware reads request.META["REMOTE_USER"],
which under WSGI the server's own authentication module sets; a client-sent
Remote-User header arrives under the different key HTTP_REMOTE_USER.
Django's own source comment says so at the header attribute. The trap is a
subclass whose header names an HTTP_ key, and it is safe only behind a
proxy that overwrites that header on every request and strips every inbound
copy.

Two defaults come off 6.0.7 source. RemoteUserBackend.create_unknown_user is
True, so every name the header carries becomes a row through get_or_create.
PersistentRemoteUserMiddleware sets force_logout_if_no_header = False, so
the session survives the header's disappearance.

The email change, and a correction to the finding

The finding stated that default_token_generator binds a token to the password
hash, last_login, and the token time, and instructed the reader to invalidate
outstanding password-reset tokens when the address changes.

_make_hash_value() hashes five values, not three: the primary key, the
password hash, last_login, the token time, and the email address. A scratch
project on 6.0.7 confirmed the consequence — make a token, change the address,
and check_token() returns False for the same user. The default generator
therefore already invalidates every outstanding reset token on an address
change. Only a custom generator or a stored token row needs that invalidation
written down, and the section says that instead.

The rest of the control stands: an address change re-authenticates, completes
on confirmation at the new address, notifies the old one with a revert path,
and never carries the new address inside a client-held signed token.

Purpose-bound tokens

Reused for email confirmation, a reset token's validity starts depending on
unrelated logins, and the same user state produces the same token in both
flows. The recipe is a PasswordResetTokenGenerator subclass per purpose with
its own key_salt and its own _make_hash_value(). The key_salt half is
in the release because the same run showed it is sufficient on its own: a
subclass that changes nothing but key_salt rejects a reset token, and the
default generator rejects its token in return. The _make_hash_value() half
binds the normalized target address.

The device cookie

Per-account lockout gives an attacker a denial-of-service method, which the
"Brute force and enumeration" section already named and left unanswered. The
device-cookie pattern splits the limits: a signed cookie set on each successful
login names the account, and (account, device-cookie) pairs are throttled
separately from attempts carrying no valid cookie. The cookie is signed under
its own django.core.signing salt and carries a version that a credential
change increases.

Peppering

a04-cryptographic-failures.md carried no occurrence of pepper. The new
subsection sits after the wrapped-hasher migration, because the supported
spelling in Django is that same wrapper: subclass the Argon2 hasher and HMAC
the password with a key from the secret manager before the hash. Rotation is a
new wrap version, not a mass reset.

It carries its own severity ruling. A missing pepper is no finding. Name it
as available hardening where the threat model is a database-only dump, and
never as a substitute for the Argon2 parameters above it.

Custom admin URLs, and two more corrections

authorization-architecture.md carried no occurrence of admin_view or
get_urls, and the finding was right about the gap. Execution made it sharper
than "runs with no admin gate": on Django 6.0.7 an unwrapped view returned from
get_urls() answered an anonymous request in full, while the wrapped one
redirected to login. admin_site.admin_view() adds the has_permission()
check — is_active and is_staff — plus never_cache and csrf_protect, and
cacheable=True drops the cache decorator only, never the check.

The finding also stated that the POST running an admin action is a plain
request an attacker can craft directly, implying no re-check.
response_action() re-resolves through get_actions(request), so the
permission filter does apply on POST. The real gap is inside
_filter_actions_by_permissions(): an action with no allowed_permissions
attribute is appended unconditionally. The same scratch project proved both
halves — an action declaring permissions=["delete"] did not run for a staff
user holding only view_widget, and an action declaring nothing ran on every
selected row. The section's existing action bullet already said the rest
correctly, so it gains that one clause rather than a new paragraph.

Markdown is an output context

a05-injection.md carried no occurrence of Markdown. It now has a sink-table
row and a passage beside the existing rich-HTML paragraph, which already
carries the nh3 disposition, so the passage points at it rather than
repeating it.

Both renderer facts come from the projects' own source. markdown-it-py sets
html to True in the commonmark preset its constructor selects, and in
both gfm-like presets; MarkdownIt("js-default") or an explicit
{"html": False} is the safe construction. mistune.create_markdown() escapes
raw HTML by default, but the module-level mistune.html renderer is built with
escape=False. Escaping is not sanitization: rich text still needs the
sanitizer over the rendered result.

A restore resurrects an erased subject

data-lifecycle-and-privacy.md already routed crypto-erasure for backups and
listed backups among the copies an erasure must reach, but no rule made the
restore path replay them. A backup taken before an erasure still holds the
subject, so a restore undoes the erasure silently. The restore procedure — and
point-in-time recovery with it — replays every completed erasure against the
restored data before that data serves traffic, and the test is concrete: erase
a fixture subject, restore yesterday's backup, prove the subject stays gone.

The passage builds on the opaque subject reference the section already requires
rather than restating it, and hands the backup mechanism itself to
data-layer-and-database.md.

bleach gets a disposition

The package appeared nowhere in the repository. It is now a row in the
existing-install-audit-only table, rejected for new use. Both dates are
verified: the 6.4.0 release of 5 June 2026 carries the
Development Status :: 7 - Inactive classifier, and mozilla/bleach is
archived with its last push on the same day. The cadence before that was
roughly one release a year since 6.1.0 in 2023. The successor is the nh3
entry already in the index — bindings to the Rust ammonia crate, still
releasing, most recently 0.3.6 on 22 June 2026. Migrating bleach.clean() to
nh3.clean() needs every allowlist argument re-verified, because the defaults
differ.

Router and cascade

Four review checklists gain a line each. The .cursor rule enumerates routing
triggers file by file, so three of its lists gain the new entry points: an
email-change flow and RemoteUserMiddleware for A07, a restore or
point-in-time recovery for the data-lifecycle file, and a Markdown renderer for
the A05 sink list. AGENTS.md and GEMINI.md are unchanged — both summarize
scope in prose rather than enumerating triggers, and every control added here
falls inside a phrase they already carry.

The description is unchanged at 1013 of 1024 characters, measured with
CI's own fold. A query naming REMOTE_USER is an authentication query and
lands on authentication, with X-Forwarded-For already in the field carrying
the header-trust half; a query naming an email change lands on authentication
and password hashing. Adding REMOTE_USER would cost thirteen characters
against eleven of headroom, and no term in the field is covered by a broader one
already there, so nothing is traded.

Known deviation

a04-cryptographic-failures.md was exactly at the repository's 1100-line
convention and is now 1115. Reclaiming fifteen lines would mean rewriting prose
that the previous release verified and that this change set does not own, so
the file grows and the tightening is left for the final checkpoint.

Full Changelog: v1.46.0...v1.47.0

v1.46.0

Choose a tag to compare

@n-shadloo n-shadloo released this 20 Aug 17:32
v1.46.0
07a5b74

secure-code-auditor v1.46.0

A hardening release. Eight controls the corpus named nowhere now have an owner:
the proxy and static-serving edge, tar extraction, three response headers, mail
transport, index resolution, secret scanning, and release artifacts. Five
reference files change, no reference file is added, no heading is renamed, and
no script is touched.

Every version, RFC, and default claim below was verified against its primary
source on 20 August 2026 — Django release notes and installed 6.0.7 source, the
Python documentation for each version named, nginx's own directive reference,
and RFC 8460, RFC 8461, and RFC 9116. Two claims were verified by execution
rather than by reading. The library index date does not move: no PyPI sweep ran
for this release.

The Nginx off-by-slash traversal

deployment-and-runtime.md carried no occurrence of the word alias. It now
carries the trap. A location prefix written without a trailing slash, beside
an alias value that has one, also matches /static../, and nginx joins the
remainder onto the alias path — so GET /static../config/settings.py reads
one directory above the static root. root inside a prefix location does not
have this trap, which is why nginx's own documentation recommends root where
the URL prefix mirrors the directory name. The Write-time rule is to put the
slash on both sides in the same edit.

USE_X_FORWARDED_HOST gets an owner

The setting appeared once in the whole repository, as an aside in
api-drf-specific.md versioning, while settings_scan.py already reported it
at INFO and pointed at the deployment file. It now has the bullet the scanner
points to: it moves host trust to the proxy, and it is safe only where the
proxy sets X-Forwarded-Host on every request and strips the client's copy.
ALLOWED_HOSTS still validates the forwarded host.

One correction to the finding as written. A password-reset link does not
trust the forwarded host unconditionally. PasswordResetForm.save() takes its
domain from get_current_site(request), which returns the Site row when
django.contrib.sites is installed and falls back to
RequestSite(request).get_host() only when it is not. The bullet is scoped to
that condition.

Tar extraction filters

file-uploads.md named zipfile and never tarfile, so the archive section
taught the ZIP half of a boundary that also exists for tar — where, unlike ZIP,
the standard library supplies the answer. TarFile.extractall() without a
filter obeys the archive's own metadata: absolute names, .. components,
symlinks, hardlinks, device nodes, and setuid modes. filter="data" rejects
every one of them.

Four version claims, each read off that version's own documentation: Python
3.12 adds the filters; the backports are 3.9.17, 3.10.12, and 3.11.4; 3.12
warns when the caller gives no filter; and 3.14 makes "data" the default. The
last two were executed rather than read — 3.12.10 emits the
DeprecationWarning from _get_filter_function, and 3.14.4 returns
data_filter from the same function. The section also carries the
documentation's own advice to test with hasattr(tarfile, "data_filter")
rather than with a version number, because a micro version below the backport
has no filter API at all.

Four new sections in A02

SECURE_CROSS_ORIGIN_OPENER_POLICY appeared nowhere in the corpus while
settings_scan.py already reported None and "unsafe-none" at LOW. It is
now a settings-matrix row and a gotcha: SecurityMiddleware has served the
"same-origin" default since Django 4.0, and the correct relaxation for an
OAuth or payment popup that calls window.opener.postMessage is
"same-origin-allow-popups" — not None, and not "unsafe-none".

The CSP rollout. The passage settled the middleware choice and stopped
there. A policy now goes out through SECURE_CSP_REPORT_ONLY first, read
against real pages, before it moves to SECURE_CSP.

Compression and BREACH. GZipMiddleware and BREACH appeared nowhere in the
file. A compressed response that reflects attacker-controlled input beside a
secret leaks that secret by length. Django masks the CSRF token it renders, and
GZipMiddleware has added up to 100 random bytes per response since Django 4.2
— the Heal The Breach mitigation, which narrows the channel rather than
removing it. Compress static assets at the proxy instead.

Fetch Metadata as a second wall. Sec-Fetch appeared nowhere in the
repository. A small middleware that rejects a cross-site request unless it is
a top-level navigation GET blocks cross-site request forgery, cross-site
inclusion, and some XS-Leaks probes before view code runs — beside Django's
CSRF protection, never instead of it, with an absent header treated as allowed
so a non-browser client is not locked out.

Mail transport, and security.txt

SPF, DKIM, and DMARC authenticate the message; nothing in the file covered the
transport it rides on. SMTP sends in cleartext when STARTTLS fails, and an
attacker on the network can force that failure. MTA-STS (RFC 8461) closes it,
TLS-RPT (RFC 8460) reports the failures, and an absent policy on a domain that
sends password-reset mail is rated LOW. Every record name and the mode
vocabulary come from the two RFCs.

security.txt (RFC 9116) is disclosure support rather than a control, so its
absence is rated INFO. Contact: and Expires: are both mandatory, HTTPS is
mandatory, and the common failure is an expired file.

Dependency confusion, and stopping the commit

--extra-index-url appeared nowhere in a03-software-supply-chain.md. pip
treats every configured index as one pool, so an attacker who registers an
internal package name on PyPI at a higher version can win the install. One
--index-url pointed at a proxy that mirrors PyPI is the rule; hash pinning is
the backstop for when resolution still goes wrong.

service-identity-and-secrets.md named secret scanning twice, both times
inside a runbook step, and owned no section. "Stopping the commit" now puts the
three layers — a pre-commit scan, host push protection, and a scheduled history
scan — above the leak response, and states that a hit in history is a leak
rather than a lint.

Release artifacts

The reviewed archive of this repository carried __pycache__/, .DS_Store,
and __MACOSX/ entries, and git ls-files shows none of them tracked — so
they entered through a zip of the working directory rather than through git.
.gitignore gains __MACOSX/ and ._*, and the README records that GitHub
builds each tag download with git archive. The packaging manifest, validator
script, and allowlist release job the same finding proposed are rejected and
are absent.

Also in this release

Three review checklists gain one line each — the opener policy,
GZipMiddleware on a secret-bearing response, and MTA-STS. AGENTS.md,
GEMINI.md, and the .cursor rule gain MTA-STS and TLS-RPT in the
DNS-published list they already enumerate item by item, and the .cursor
rule's A02 routing trigger gains them too. The description is unchanged at
1013 of 1024 characters.

Full Changelog: v1.45.0...v1.46.0

v1.45.0

Choose a tag to compare

@n-shadloo n-shadloo released this 20 Aug 13:10
v1.45.0
62ddfe0

secure-code-auditor v1.45.0

A correction release. Every verified factual error and every defective example
in the reference corpus is fixed, each against its primary source. Fifteen
reference files change, two controls are new, and no reference file is added,
no heading renamed, and no script touched.

Each Django and DRF claim behind these corrections was read off installed
source on 20 August 2026 — Django 6.0.7 and DRF 3.17.1 — and each PostgreSQL,
Celery, PyJWT, and Have I Been Pwned claim was checked against that project's
own documentation. The library index date does not move: no PyPI sweep ran for
this release.

The corrections that change a verdict

Stock DjangoObjectPermissions does not check safe methods. Its
perms_map holds an empty permission list for GET, HEAD, and OPTIONS,
and has_perms over an empty list is True — so a safe request makes no
object check, and the documented "safe method, permission missing → 404" row
could never fire on the stock class. authorization-architecture.md now states
that read access is view-level only until the map is subclassed, supplies the
subclass, and scopes the 404/403/404 matrix to it. The instruction not to
"fix" the 404 into a 403 is unchanged; it was always right for the subclassed
form.

Four errors in data-layer-and-database.md. pg_dump sets row_security
to off and a restricted role gets an error rather than a silently partial dump
--enable-row-security is the opt-in that filters, so the incompleteness
runs the opposite way from what the file said. An ALL or UPDATE policy with
no WITH CHECK reuses USING as its check, so the real trap is a FOR SELECT
policy beside a separate permissive write policy, combined with OR.
REPEATABLE READ is snapshot isolation that permits write skew across rows;
only SERIALIZABLE detects it. And the alias on transaction.atomic() does
not route the ORM, which the retry example now says at the call it wraps.

AUTH_PASSWORD_VALIDATORS never ran on the paths that need them most. They
run where validate_password() is called: the built-in auth forms call it,
while set_password(), the model constructor, and create_user() do not.
a07-authentication-failures.md now says so, and points the custom
UserManager example at the rule. The Have I Been Pwned Add-Padding header
varies the entry count inside a band rather than making responses byte-uniform,
and the outbound read is capped.

Examples that taught the defect they warn about

Three blocks labelled "correct" carried one.

The DRF serializer bound extra_kwargs to a password its fields list
omitted, so the write_only rule it demonstrated applied to nothing. It now
declares the field and validates the secret before hashing it.

The async membership transfer authorized the source tenant and then wrote the
destination — which lets an admin move a member into any tenant by identifier.
Both sides are now loaded and authorized inside the one transaction.

The webhook receiver acknowledged an event it could then lose: a crash or a
broker outage between the row commit and the enqueue left the provider
satisfied and the work gone. It now stores a RECEIVED record and enqueues
inside transaction.on_commit, with the sweep that recovers a lost wake-up
named. The delivery mechanics stay with django-async-jobs; this file owns
only that the verified event survives the acknowledgment.

Two new controls, both already scanned

Writable relation fields. ModelSerializer builds each writable
PrimaryKeyRelatedField with the related model's default queryset, so the
client may name any row in the table — another tenant's included. The same rule
reaches SlugRelatedField, nested writes, and the validator querysets, where an
unscoped UniqueValidator is an existence oracle across tenants.

ModelForm and formset mass assignment. The serializer failure has a
non-DRF twin that CFG001 already fires on and no file owned:
Meta.fields = "__all__" on a form, exclude failing open the same way, a
server-owned field rendered as a hidden input, and the formset bounds
(absolute_max, validate_max, DATA_UPLOAD_MAX_NUMBER_FIELDS) that a
project can raise into a caller-controlled work multiplier.

Smaller corrections

At-least-once delivery begins at the broker, and the enqueue itself is lost
when the process dies before the callback (A10). The idempotency fingerprint
needs the method and the route beside the body (A10). The two debug decorators
reach Django's own error reports only, and extra= populates the record while
the formatter decides the output (A09). SECRET_KEY rotation needs two phases
on a rolling fleet, because one deploy leaves instances signing with a key
their neighbours cannot validate. RFC 8693 exchange sends the subject token,
not decoded claims. PyJWT verifies HMAC only without the crypto extra. Django
6.2 is unreleased, so listurls takes a future tense. The MultiFernet
rotation pass overwrites a row the application changed mid-batch. A nested
router adds no predicate without a parent-filter mixin, and the parent object
still needs its own authorization. safe_join's containment is lexical, so a
writable storage tree defeats it between the check and the open.

Django's YAML fixture serializer loads with SafeLoader, so a YAML fixture is
not the yaml.load finding — its risk is the same unvalidated bulk write as
every other format. The unsafe-YAML bullet is realigned to the scanner's
calibration, and result_accept_content is named beside the Celery pair it
belongs with.

Adjudicated, no edit

The raw-SQL section already states that a raw path bypasses the row-level
security context, and the command example already scopes -- with "where the
program supports it". Both were checked and left alone.

The description is unchanged at 1013 of 1024 characters.

Full Changelog: v1.44.0...v1.45.0

v1.44.0

Choose a tag to compare

@n-shadloo n-shadloo released this 20 Aug 12:46
v1.44.0
f8cc4f7

secure-code-auditor v1.44.0

The three bundled scanners become complete, honest about coverage, and precise.
Three new rules, seven new settings checks, four rules widened to the spellings
they were silently missing, one false positive removed, a redaction, and a new
output contract that makes an empty JSON stream impossible. No reference file
changed. No rule identifier was reused, and no existing severity value moved.

Every change ships with a fixture. The self-test now holds 49 of them, 35
positive and 14 negative, and covers 29 of 29 rules. Every Django default this
release rests on was read off installed source on 20 August 2026 — Django 6.0.7,
cross-checked against 5.2.15 — rather than recalled. The library index date does
not move: no PyPI sweep ran.

What landed

Rules that matched one spelling and passed the rest. DES002 dispatched on
yaml.load alone, so yaml.unsafe_load, yaml.full_load, and the three
*_load_all variants went unreported. DES001 knew pickle, cPickle, and
_pickle, but not dill, cloudpickle, or joblib.load, which carry the same
protocol. TPL002 tested for ast.JoinedStr, so a % operation and the
.format spelling passed while the f-string was caught.
SEC001 read str literals only and did not know PASSWD or PASSPHRASE.
Each gap produced a clean result that looked like a clean tree.

Three new rules. SEC002jwt.decode with verify=False or an options
literal carrying verify_signature: False. NET002 — TLS verification disabled
at the ssl layer, through ssl._create_unverified_context(),
check_hostname = False, or verify_mode = ssl.CERT_NONE; the failure NET001
catches at the requests layer, caught where a client builds its own context.
NET003 — a requests/httpx verb or urllib.request.urlopen whose URL
argument derives from request data, resolved through the same taint machinery as
the ORM identifier rules. A URL from a settings value or a module constant is
not reported: who last wrote the value is the SSRF question.

Seven settings checks the references already teach. MIDDLEWARE membership
CsrfViewMiddleware absent (HIGH), SecurityMiddleware absent (MEDIUM),
XFrameOptionsMiddleware absent (LOW), and SECURE_CSP present with no CSP
middleware (MEDIUM) — judged only on a literal list with no later +=, because
an augmented list may add what the literal lacks. SESSION_ENGINE naming
signed_cookies, where no server-side record exists and no single session can
be revoked. SESSION_COOKIE_SAMESITE and CSRF_COOKIE_SAMESITE weakened, with
the string "None" reported higher when the matching *_SECURE flag is not
True. SECURE_CROSS_ORIGIN_OPENER_POLICY set to None or "unsafe-none".
USE_X_FORWARDED_HOST set to True, on the same trust reasoning as
SECURE_PROXY_SSL_HEADER. A REST_FRAMEWORK block with no
DEFAULT_PERMISSION_CLASSES, or with AllowAny in it, since DRF's own default
is AllowAny. A MySQL/MariaDB alias whose OPTIONS carry no ssl.

A secret that was printed back. SEC001 attached the source line to every
hit, so a hardcoded key appeared in both the text report and the JSON stream.
The snippet for that rule is now the fixed text
<redacted: secret-shaped literal>, and every snippet from every rule is
stripped of its ANSI escapes and control bytes before it reaches a terminal. The
self-test proves it: it scans a canary assignment and asserts the canary reaches
no snippet.

An empty stream can no longer occur. Every --json invocation of all three
scripts now ends with one kind: "summary" record carrying path,
files_discovered, files_scanned, files_unparsed, the per-script emitted
count, and walk_errors. A path that is not a file or a directory writes one
kind: "error" record and the summary instead of nothing at all. Every
os.walk carries an onerror handler, so a directory that cannot be read is
counted rather than skipped in silence, and settings_scan.py no longer raises
on an unreadable package directory. Exit codes are unchanged at 0.

CI runs the scanners. A second job runs --selftest and fails the workflow
on a nonzero exit, then asserts that each of the three scripts, given a missing
path with --json, exits 0 and writes a last line that parses as JSON with
"kind": "summary". Stdlib only, no new dependency.

Behavior worth knowing

  • --selftest returns 1 when a check fails. It returned 0 before,
    whatever the fixtures did. This is the one mode whose exit code carries a
    verdict, and scripts/README.md, "Invariants", now states it as the single
    exception to the always-zero rule. Every scanning mode still exits 0. The
    documented guidance was always to read the self-test's output rather than
    $?, so no described workflow changes; a CI step that reads $? now gets a
    real answer.
  • TPL003 no longer reports from string import Template. The stdlib
    exemption tested only the attribute form, so the bare-import spelling was
    reported HIGH. Both spellings are now exempt, resolved through the import map.
  • PostGIS aliases are judged. The PostgreSQL transport check gated on
    "postgresql" in engine, which django.contrib.gis.db.backends.postgis does
    not contain, so a PostGIS project never had its sslmode read.
  • OPTIONS["pool"] is judged on its value, not its key. A literal
    "pool": False is not a pool. Django itself reads the value for truth, in
    django/db/backends/postgresql/base.py, and the check now matches it.
  • An HSTS companion is judged only when HSTS is on.
    SECURE_HSTS_INCLUDE_SUBDOMAINS reported its absence even when
    SECURE_HSTS_SECONDS was absent or zero, where it does nothing at all.
  • An absence whose Django default is already safe is INFO, not LOW.
    SECURE_CONTENT_TYPE_NOSNIFF and SESSION_COOKIE_HTTPONLY both default to
    True; the message names the default. Entries whose default is unsafe are
    unchanged.
  • A dynamic SECRET_KEY is INFO, not OK. Every other dynamic value in the
    scanner is reported as verify-by-hand, and this one now reads the same.
  • AccessMixin no longer counts as an authorization declaration. It
    configures the failure handling and enforces nothing, so a class carrying only
    AccessMixin reads as inherited rather than declared.
  • A ProtocolTypeRouter row is always absent. AuthMiddlewareStack
    supplies the identity, not the authorization: it puts a user in the scope and
    admits every consumer it wraps. The stack stays in the row's stack detail.
  • A bare @task resolved to django.tasks carries system: django-tasks.
    Its default backend, ImmediateBackend, runs the task inline in the caller's
    transaction, which is not what the celery family otherwise implies.
    Everything else in that family carries system: celery.
  • import a.b now resolves correctly in both scanners. The import map bound
    a to a.b, so urllib.request.urlopen resolved to
    urllib.request.request.urlopen and the new NET003 entry for it could only
    have fired through the from-import spelling. This was found while adding
    that rule and is fixed with a fixture for both spellings.

Verified clean

  • python3 scripts/dangerous_patterns.py --selftest passes: 49 fixtures, 0
    failures, 29 of 29 rules with a positive fixture, every negative fixture
    clean, and the redaction canary absent from every snippet. Exit 0. With a
    failing fixture injected, it returns 1.
  • Every --json mode of every script — missing path, empty directory,
    unreadable directory, a file target, a directory target, --min-severity, and
    an unknown --kind — writes only valid JSON Lines, ends with a summary
    record, and exits 0. Fourteen invocations checked.
  • All three scripts report zero findings against this repository's own tree.
  • The document-integrity checks pass: 24 reference files, every link resolves,
    no orphans, balanced fences, SKILL.md at 33,618 of 40,960 bytes.
  • Changed files carry no citation artifacts.

Deferred, and why

  • references/01-audit-workflow.md still says the self-test "exits 0 whether
    or not the fixtures pass."
    That clause is now stale. The file belongs to no
    change set in this repair except the final whole-repository checkpoint, so it
    was left for that pass rather than edited across an ownership boundary. The
    authority it cites, scripts/README.md, "Invariants", is correct as of this
    release. SKILL.md's router row for that file carries the same phrasing.
  • data-layer-and-database.md does not yet teach the MySQL/MariaDB transport
    rule
    the new DATABASES check routes to. The check is grounded — Django
    reads OPTIONS["ssl"]["ca"] in django/db/backends/mysql/client.py — but the
    reference prose belongs to another change set and was not written here.
  • The library index was not re-dated. Nothing in this release rests on a new
    PyPI sweep.
  • One item stands open from the 13 August 2026 audit, unchanged here: the
    gevent>=24.10.1 floor recorded for CVE-2023-41419 in
    deployment-and-runtime.md still wants confirmation against the advisory.

Two commits are released here for the first time: the SKILL.md frontmatter
validation, and the reference link, orphan, fence, and size check.

Full Changelog: v1.43.0...v1.44.0

Full Changelog: v1.43.0...v1.44.0