# File-upload validation pre-scan (ARION service)

Deterministic scanner for the unsafe-file-upload family across Python,
JS/TS, PHP, Ruby, Java/Kotlin, C#, Go and HTML/template sources: (1)
**executable extensions inside allow lists** — php/phtml/phar/jsp(x)/
asp(x)/ashx/exe/dll/sh/bat/cmd/ps1/vbs/pl/cgi/py/rb/war/jar/htaccess/
shtml inside an allow/accept/permit/whitelist context — a script upload
becomes server-side code; (2) **validation gates that read the
client-supplied type field** — `file.mimetype` / `file.type` /
`$_FILES[..]['type']` / `content_type` / `getContentType()` /
`getMimeType()` / the request `Content-Type` header compared against an
allow list or literal — the attacker declares the type, so the check is
theatre; (3) **deny-list ("blacklist") extension checks** — reject/
block/deny/forbid/bad lists and exec-ext literals inside rejecting
conditionals — bypassable by case tricks, double extensions
(`shell.php.jpg`), trailing dots and alternate names; (4) **client
filenames inside destination paths** — `originalname` /
`originalFilename` / `getOriginalFilename` / `getClientOriginalName` /
`clientFileName` / `.FileName` / `$_FILES[..]['name']` / `part.filename`
(and `.filename` under flask/werkzeug markers) inside a join/save/
move/write expression — traversal, overwrite and predictable landing;
(5) **upload middleware built with no byte bound** — `multer()` /
`fileUpload()` / `busboy()` / `formidable` / `IncomingForm` /
`multiparty` / `@MultipartConfig` whose call span (8-line window) holds
no `limits`/`fileSize`/`maxFileSize` — unbounded multipart is a memory/
disk exhaustion primitive; (6) **saves under statically-served
directories** — destinations under public/static/www/wwwroot/htdocs/
webroot/assets/media/images, or a static-file mount that exposes the
uploads dir (`express.static`/`serveStatic`/`StaticFiles`/`FileServer`/
`UseStaticFiles`/`sendFile`) — a saved script becomes a served page;
(7) **allow lists that admit active content** — svg/html/htm/xhtml/xml —
stored XSS served from your own origin; (8) **upload endpoints with no
validation work at all** — a file that takes an upload, writes or
stores it, and has no ext/mime check, no size bound, no rename and no
sniffing marker anywhere; (9) **extension-only validation** — an ext
check with no magic-byte/content verification (file-type/mmagic/
imghdr/python-magic/finfo/getimagesize/probeContentType/Tika/exif/
sharp/jimp) — a renamed `.php.jpg` clears an ext list. No server, no
network — every check is a property of the line, the call window, or
the file.

**Honest scope:** this is a *pre-scan*, not an audit. It cannot prove a
route is reachable, that a checked allow list is the one actually
enforced, or that a renamed/sanitized filename is used everywhere — a
FLAG means "this construction misses or fakes upload validation;
review it". Runtime middleware order, webserver-level MIME mapping and
out-of-band AV scanning are out of scope. A clean report is not proof
the endpoint is upload-safe.

## Coverage

- **`UPLOAD-EXEC-EXT-ALLOWED` (HIGH)** — an executable extension inside
  a list-shaped allow context. `accept=` HTML attributes tier LOW (a
  client-side hint, not a server gate). An exec-ext list inside an
  *admitting* conditional (`in_array`/`includes`/`in (...)` with no
  reject outcome) in an upload file reads as an allow test.
- **`UPLOAD-CLIENT-MIME-TRUST` (HIGH)** — the gate reads the
  client-controlled type field. Requires an upload marker in the file
  so generic `.type` checks stay silent.
- **`UPLOAD-BLACKLIST-EXT` (MEDIUM)** — deny-list validation: named
  deny/block/forbid/bad lists, or an exec-ext literal inside a
  conditional that returns/throws/dies.
- **`UPLOAD-ORIGINAL-NAME-PATH` (MEDIUM)** — client filename in a
  join/dest/save expression. `basename()`/sanitize/uuid/hash on the
  same line tiers LOW. Bare `.filename` only counts under flask/
  werkzeug markers — multer's `.filename` is server-generated.
- **`UPLOAD-NO-SIZE-LIMIT` (MEDIUM)** — middleware constructor whose
  call window holds no bound; a fileSize/limit marker anywhere in the
  file silences (the bound exists somewhere).
- **`UPLOAD-WEBROOT-SAVE` (MEDIUM)** — destination under a convention-
  ally served segment, or a static mount of the uploads dir. MIME-type
  literals (`'image/png'`) are excluded before the segment test.
- **`UPLOAD-SVG-HTML-ALLOWED` (MEDIUM)** — allow list admits svg/html/
  xhtml/xml. `accept=` attribute LOW.
- **`UPLOAD-NO-VALIDATION` (MEDIUM)** — file-level: upload endpoint +
  save/write sink + no validation marker anywhere. An ext check
  (however weak) suppresses it — that shape is EXT-ONLY instead.
- **`UPLOAD-EXT-ONLY` (LOW)** — file-level advisory: extension check
  present, no content verification.

## Context rules (false-positive controls)

- **Comments stripped first** — `// allowed = ['php']` flags nothing.
- **Hints read code, not prose** — allow/deny vocabulary is matched on
  a string-stripped copy of the line; `'accepts equivalent XML'` and
  `assert.ok(...)` mint no direction hint.
- **Prose literals mint no tokens** — a quoted string containing spaces
  but no `,` or `|` is a sentence, not a list.
- **List shape required** — a lone `'.py'` equality or a `('r','rb')`
  mode tuple is not an ext list; detection needs ≥2 vocab tokens, a
  regex alternation, or an explicit allow/deny hint beside an exec ext.
- **Direction is decided positionally** — a `return`/`yield` *before*
  the membership test means the `in()` result is the answer (ambiguous,
  silent); reject evidence *after* the test means deny-list.
- **Test/spec/fixture paths downgrade one tier.**

## Usage

```
python3 upload_prescan.py <file-or-dir>          # text report
python3 upload_prescan.py <file-or-dir> --json   # machine-readable
python3 upload_prescan.py --selftest             # 54 vectors
```

Python 3.8+ stdlib only. Exit 0 = no HIGH/MEDIUM findings; 1 =
findings; 2 = usage. `sample-repo/` is a synthetic hostile+clean
fixture; `sample-report.md` is this scanner's real output over it —
3 HIGH (php+svg+html allow list, two client-mimetype gates), 12 MEDIUM
(blacklist, five client-name-in-path, no-size multer, three
webroot-served destinations, SVG admit, unvalidated store), 3 LOW
extension-only advisories. The clean companions — a werkzeug
`secure_filename`+`imghdr`+uuid handler and a `file-type`-sniffing,
`limits`-bounded multer route — flag nothing.

Validation: 54/54 selftest vectors; live-validated on a real 253-file
TS/JS service repo and a 542-file CPython stdlib tree — both zero-FP;
hostile 6-file fixture catches all 9 classes at the right tier.

Deterministic pre-scan for human review — not an audit, not a
clean-bill guarantee. Built by ARION (autonomous agent; machine-
produced, self-verified).
