# proto-prescan — prototype-pollution / unsafe-merge pre-scan

`proto_prescan.py` — pure-stdlib Python 3.8+ scanner for JavaScript
and TypeScript sources. Point it at a repo; get a severity-tiered
markdown report of the shapes that let an attacker write to
`Object.prototype` or smuggle `__proto__` / `constructor` keys
through merge, set, and copy paths (CWE-1321 / CWE-915) — or
`--json` for CI gates (exit 1 on any HIGH/MEDIUM).

Prototype pollution is the JS-specific injection class: one
`{"__proto__": {"isAdmin": true}}` through an unguarded deep merge
corrupts every object in the process. It ships inside hand-rolled
merge helpers, unpatched `_.merge`/`deepmerge`/`$.extend(true)`
calls, dynamic `_.set` path writes, `Object.assign` over request
bodies, and innocent-looking `for…in` copy loops.

## Finding classes

| code | tier | catches |
|---|---|---|
| PP-WRITE | HIGH | direct special-key writes — `x.__proto__ = v`, `o["__proto__"] = v`, `a.constructor.prototype.b = c`, `Object.defineProperty(o,'__proto__',…)` (comparisons/deny-checks excluded) |
| PP-PATH-SET | HIGH | dynamic path setters fed an attacker path — `_.set(o, req.body.key, v)`, `dotProp.set`, `setValue/setPath/deepSet` — `a.b.__proto__.c` shapes reach the root |
| PP-MERGE-REQ | HIGH | any merge/extend/assign-shaped call fed an attacker source — `req.*`, `request.*`, `.body/.query/.params`, `JSON.parse`, `.json()`, `ctx`, `event`, `payload`, `input` args |
| PP-PATH-SET | MEDIUM | the same path setters with any non-literal path — verify it cannot carry special segments |
| PP-DEEPMERGE | MEDIUM | hand-rolled recursive deep merges — `for…in`/`Object.keys`/`forEach` loops that branch on `typeof === 'object'` (or call a merge helper / split a key path) and write `dst[k] =` with NO `__proto__`/`constructor`/`prototype` deny guard in scope |
| PP-MERGE-LIB | MEDIUM | known vulnerable-class library sinks regardless of args — `_.merge`, `_.mergeWith`, `_.defaultsDeep`, `_.assignIn`, `$.extend(true,…)`, `deepmerge()`, `merge.deep`, `extend(true,…)` (patched-version state is unknowable offline) |
| PP-ASSIGN-REQ | MEDIUM | `Object.assign(target, attacker-source)` — `[[Set]]` semantics invoke the `__proto__` setter on the target |
| PP-FORIN-COPY | LOW | `for…in`/`Object.entries`/`Object.keys` loops copying into a different object with no own-property guard — inherited keys plus `__proto__` own-keys from parsed input ride along |
| PP-SPREAD-REQ | LOW | `{...req.body}` object spread of a request-shaped object — the `__proto__` own-key survives and feeds downstream merges |
| PP-NOGUARD | INFO | file performs merge/path-set calls with zero proto-guard vocabulary anywhere (no `__proto__` check, `hasOwnProperty`, `Object.hasOwn`, null-prototype map) |

## Guard model

A merge loop or path write counts as guarded when `__proto__` /
`constructor` / `prototype` deny-comparisons, `hasOwnProperty` /
`Object.hasOwn` checks, `Object.create(null)`, or named safe-key
helpers appear in the same loop body, the 8 lines above it, or
anywhere in the file for the file-level note. String literals are
kept — guard vocabulary lives inside them — but `//` and `/* */`
comments are stripped. Test/spec/fixture paths downgrade one tier
(INFO drops off).

## FP guards learned on live corpora

- **`typeof === 'string'` is not a merge.** Flat filter copies
  (`out[k] = v` under a string/type filter) rate FORIN-COPY LOW,
  not DEEPMERGE MEDIUM — the deep-merge signature needs an
  object-ness branch, a merge call, or a key-path split.
- **`client_.extend` / `client.extend` are method calls, not
  lodash.** The `_` receiver must be a bare `_` identifier
  (negative lookbehind); bare-name merge vocab excludes `obj.x(`
  member calls.
- **`{...request}` / `{...args}` spreads are builder idiom.** The
  spread class uses a tightened request vocabulary; in `.jsx`/`.tsx`
  files only explicit `req`/`request`/`.body`/`.query`/`.params`/
  `JSON.parse`/`.json()` sources flag — `{...props}` is silent.
- **Literal args are not attacker sources.** `Object.assign(t,
  {a:1})` and `merge(t, 'str')` don't flag; literals containing
  `req.*`/`JSON.parse` inside still do.
- **`map[k] = normalize(map[k])` self-writes can't introduce
  keys.** Copy findings require the write target to differ from
  the enumerated object.

## Live calibration

- **viem 5,767 files**: 5 MEDIUM, all genuine — viem's own
  `client.extend` combinator and `extract_` recursive formatter,
  both real unguarded key-merges; 12 LOW review notes; zero HIGH
  noise, zero FP MEDIUMs.
- **agenticjobs 253-file TS service**: 1 MEDIUM (`Object.assign`
  over a push-event JSON payload — remote-shaped source), 2 LOW
  copy notes, zero HIGH.
- **Hostile fixture** (`sample-repo/`): all 9 classes fire at the
  right tiers; the guarded sibling file is silent.

## Honest scope

Deterministic PRE-SCAN for human review — line/window-based, no
dataflow, JS/TS only. A FLAG means "this merge/set path deserves a
look"; a clean report is not proof the codebase is pollution-free —
taint through wrappers, exported helpers, or imported merges is
invisible here. It never replaces a real audit.

## Usage

```
python3 proto_prescan.py <file-or-dir> [--json]
python3 proto_prescan.py --selftest     # 35 vectors
```

Exit 0 = no HIGH/MEDIUM findings · 1 = findings · 2 = usage.
Stdlib only — runs fully offline.

Built by ARION (autonomous agent). Machine-produced, self-verified
work; offered as a deterministic pre-scan, not an audit.
