# timing_prescan — timing-attack / side-channel comparison pre-scan

Deterministic, stdlib-only security pre-scan for the comparison shapes
that leak secret content through response time (CWE-208 / CWE-385 /
CWE-203). An early-exit compare (`==`, `strcmp`, `.equals`, `startswith`)
bails on the first differing byte, so each probe round-trip leaks a
prefix position — remote attackers can recover tokens, signatures, API
keys, and MACs byte-by-byte without ever seeing the value.

**Honest scope:** a pre-scan flags the *shapes* timing findings are
made of for human review. Clean output != constant-time code. It cannot
measure actual runtime, follow dataflow into helpers, or tell whether a
same-named variable holds a real secret.

## Usage

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

Exit 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage. Python 3.8+,
no dependencies, fully offline. Scans .py/.js/.ts/.go/.java/.rb/.php/
.c/.cpp/.cs/.rs/.swift/.kt/.scala/.pl/.ex/.lua/.sh.

## What it flags

| code | sev | signature |
|---|---|---|
| T-SECRET-EQ | HIGH | `==`/`!=`/`===`/`!==`/`.equals()`/`equalsIgnoreCase()`/`strcmp`/`memcmp` between a secret-named operand and a non-literal value — early-exit compare leaks the first differing byte per probe |
| T-LIBCMP-NONCT | MEDIUM | non-constant-time *library* compares on secrets: `bytes.Equal`, `reflect.DeepEqual`, `Arrays.equals`, `Objects.equals`, `SequenceEqual`, `CompareTo`, `strings.Compare` — correct result, wrong timing contract |
| T-PREFIX-EQ | MEDIUM | `startswith`/`startsWith`/`strncmp`/`strings.HasPrefix`/`start_with?` on secret-named data — early-exits at the first mismatch, leaking match length |
| T-MEMBER-EQ | MEDIUM | membership probes: `guess in secret`, `secret.includes(x)`, `strings.Contains`, `strstr`/`strpos` with attacker-named needle — substring oracle |
| T-EARLY-EXIT | MEDIUM | hand-rolled comparator loop: `for`/`while` indexing secret-named buffers with a char compare and an early `return false/0` |
| T-LEN-LEAK | LOW | `len(secret) != len(x)` / `.length` compares — length oracle (emptiness checks `=== 0` are not flagged) |
| T-DB-TOKEN-KEY | LOW | raw token/api-key as a DB lookup key (`WHERE token = ?`, `findOne({token:})`) — pre-hash lookup form; store and look up hashes instead |

## Secret-name model

Two-tier identifier vocabulary keeps precision high on real code:

- **conf tier** (fires unconditionally): `secret`, `hmac`, `digest`,
  `api_key`, `password`/`passwd`/`passphrase`, `otp`/`totp`,
  `credential`, `bearer`, `jwt`, `session_token`/`session_id`,
  `csrf`/`xsrf`, `verifier`, `auth_code`, `recovery`, `pin`, `crypt`,
  `authkey`, `master_key`, `keypair`, `signing_key`, `encryption_key`.
- **noisy tier** (fires only with auth context in the ±2-line window):
  `token`, `signature`/`sig`, `mac`, `cookie`, `hash` — real secret
  names that also collide with lexer/parser/zip-magic/lru-cache
  vocabulary. Context words: verify, valid, auth, expect, provided,
  supplied, stored, mismatch, reject, deny, grant, challenge, compare,
  login, check, failed, approve, forbidden, expired, guard, middleware.

## FP guards

Lines invoking a constant-time primitive are never flagged
(`compare_digest`, `hash_equals`, `timingSafeEqual`,
`subtle.ConstantTime*`, `CRYPTO_memcmp`, `sodium_memcmp`,
`MessageDigest.isEqual`, `secure_compare`, `FixedTimeEquals`,
`password_verify`, `checkpw`, argon2/bcrypt helpers) — that *is* the
fix. Also suppressed: null/empty presence checks (`token == null`),
`typeof x === 'type'` guards, single-char/punct literals in lexers
(`token == '.'`), kind-tag compares (`x.token_type == 'comment'`),
ALLCAPS enum constants (`CHECKED_HASH`, `_CD_SIGNATURE`), signal-number
compares (`sig == signal.SIGINT`), char-index compares (owned by
T-EARLY-EXIT), length expressions (owned by T-LEN-LEAK), hashed DB
fields (`token_hash = $1` — the correct pattern), TS generic type args
(`query<{ token: ... }>`), and `token: null` field literals.

## Validated

41 selftest vectors (every code, tier gating, all FP-guard paths).
Hostile fixture: all 7 finding classes fire (4 HIGH / 5 MEDIUM / 2 LOW).
Hardened fixture (compare_digest + timingSafeEqual + hashed lookups +
type guards): silent.

Live runs:

- Python stdlib, 542 files → **1 HIGH** (`multiprocessing/
  connection.py:740` — `response == digest` on the authkey hmac
  challenge, a genuine early-exit compare) + 2 LOW.
- Production 254-file TS service repo → **1 HIGH** (`client_secret !==`
  hardcoded literal compare) + 1 LOW. Its `where token_hash = $1`
  lookups correctly stay silent — hashed lookup is the fix.

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