Skip to content

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

last_num = int(last.numero.split('-')[-1])
self.numero = f'MAN-{year}-{last_num + 1:03d}'

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.                                          │
└──────┴───────────────────────┴─────────────────────────────────────┴─────────────────────────────────────────────────────┘
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_clean by default — override via methods if you use a different convention.
  • Skips abstract / proxy models.
  • Static analysis only: no DB access, runs in --quick mode.