autogen — unsafe auto-generated ID parsing¶
ID: autogen
Since: 0.7.0
Order: 44 (between forms_meta and urls)
Needs DB: no
Severity: ERROR
What it catches¶
The class of bug that bit altiusone on hikimatech.altiusone.ch on
2026-04-20: Mandat.save() auto-generated the next numero via
A single existing row whose numero had a non-numeric suffix
(MAN-2026-CIB, imported from another system) crashed with
ValueError: invalid literal for int() with base 10: 'CIB'. HTTP 500 on
every subsequent POST /mandats/nouveau/ until the pattern was
patched.
The check AST-scans every first-party model's save() / clean() /
full_clean() method for calls shaped like:
int(<x>.split(<sep>)[<i>])int(<x>.rsplit(<sep>)[<i>])
and flags each one whose surrounding code does not catch one of:
ValueError,TypeError,IndexError,AttributeError- or a bare
except:/except Exception:/except BaseException:
Example finding¶
autogen — 1 finding(s)
┌──────┬───────────────────────┬─────────────────────────────────────┬─────────────────────────────────────────────────────┐
│ Sev │ Rule │ Location │ Message │
├──────┼───────────────────────┼─────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ ERROR│ autogen.unsafe_int_split│ core.models.Mandat.save:2632 │ int("last.numero.split('-')[-1]") parses an │
│ │ │ │ unvalidated string without try/except. A single row │
│ │ │ │ with a non-numeric suffix crashes with ValueError → │
│ │ │ │ HTTP 500 on every new save. │
│ │ │ │ → Either (a) filter the query with │
│ │ │ │ <field>__regex=r'^...-\d+$' so only conforming │
│ │ │ │ rows are considered, or (b) wrap the parse in │
│ │ │ │ try/except (ValueError, IndexError): continue, │
│ │ │ │ or both. │
└──────┴───────────────────────┴─────────────────────────────────────┴─────────────────────────────────────────────────────┘
Two recommended fixes¶
def save(self, *args, **kwargs):
if not self.numero:
year = self.date_debut.year
siblings = Mandat.objects.filter(
numero__regex=rf'^MAN-{year}-\d+$'
).values_list('numero', flat=True)
max_num = 0
for numero in siblings:
try:
max_num = max(max_num, int(numero.split('-')[-1]))
except (ValueError, IndexError):
continue
self.numero = f'MAN-{year}-{max_num + 1:03d}'
super().save(*args, **kwargs)
Both belt and suspenders: the regex keeps legacy rows out of the candidate set, and the try/except is there as a second line of defense if a bad row ever slips past the regex.
def save(self, *args, **kwargs):
if not self.numero:
last = Mandat.objects.filter(
numero__startswith=f'MAN-{year}'
).order_by('numero').last()
try:
last_num = int(last.numero.split('-')[-1]) if last else 0
except (ValueError, IndexError):
last_num = 0
self.numero = f'MAN-{year}-{last_num + 1:03d}'
super().save(*args, **kwargs)
Cheaper, but still vulnerable to numeric collisions — if last is a
row named MAN-2026-CIB and you fall back to 0, the next auto-gen
produces MAN-2026-001, which may already exist. Prefer the first
variant for anything constrained by a unique index.
Configuration¶
[tool.django-doctor.autogen]
# Apps to skip entirely (in addition to the third-party heuristic).
skip_apps = []
# Method names to scan. Defaults cover the places auto-gen usually lives;
# extend if your codebase has custom hooks like `_generate_numero`.
methods = ["save", "clean", "full_clean"]
Scope¶
- Only first-party models (anything outside
site-packages/dist-packages). - Only scans
save/clean/full_cleanby default — override viamethodsif you use a different convention. - Skips abstract / proxy models.
- Static analysis only: no DB access, runs in
--quickmode.