|
| 1 | +# VC-002L — Python import-time module surface (design note) |
| 2 | + |
| 3 | +Status: **proposal**. Not yet implemented. If adopted it lands as a new analyzer |
| 4 | +`AnalyzePythonLoadTime` in `internal/installsurface/`, wired through the pypi |
| 5 | +adapter, feeding the existing VC-002 family — and a decision record. The |
| 6 | +scope-boundary decision to keep this OUT of scope until the spec is met is |
| 7 | +already recorded as [D-165](DECISIONS.md). |
| 8 | + |
| 9 | +The trigger is a real sample: the **telnyx 4.87.1 / 4.87.2** compromise (TeamPCP |
| 10 | +campaign). See [`ioc-teampcp.example.json`](ioc-teampcp.example.json). |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## 1. One line |
| 15 | + |
| 16 | +Malicious code injected into an **ordinary runtime module** (e.g. an SDK's own |
| 17 | +`telnyx/_client.py`) that executes on `import` is a real activation surface the |
| 18 | +tool does not currently assess. Assess it the way we already assess install |
| 19 | +hooks — walk import-reachable modules, run `scanCaps`, emit synthetic hooks — but |
| 20 | +**advisory-only** and behind a hard false-positive budget, because import-time |
| 21 | +code is overwhelmingly benign. |
| 22 | + |
| 23 | +## 2. The gap this closes |
| 24 | + |
| 25 | +depSNORT's PyPI install-surface model reads exactly three entrypoints |
| 26 | +(`AnalyzePython` in `internal/installsurface/analyze.go`): `setup.py`, |
| 27 | +`pyproject.toml` build-backend, and `.pth` files. All three are **install- or |
| 28 | +interpreter-startup** triggers. None of them is an ordinary module. |
| 29 | + |
| 30 | +The telnyx attack sidesteps every one of them: the payload lives in |
| 31 | +`telnyx/_client.py`, a normal SDK module, as module-level code that runs on |
| 32 | +**`import telnyx`** — a *runtime* event, not an install event. depSNORT never |
| 33 | +reads that file, so the compromise is invisible to static analysis today. This is |
| 34 | +confirmed by the subsystem map: PyPI has no import-time module analyzer at all, |
| 35 | +and npm's `AnalyzeLoadTime` (the OPU-31 RedC2 fix) only covers *declared entry |
| 36 | +modules*, not arbitrary runtime modules. |
| 37 | + |
| 38 | +This note does NOT propose reading every `.py` file in a package — that is a |
| 39 | +false-positive catastrophe (import-time code is where packages legitimately load |
| 40 | +config, register plugins, and run version checks). It proposes a **narrow, |
| 41 | +capability-gated** analyzer. |
| 42 | + |
| 43 | +## 3. Attack hypothesis |
| 44 | + |
| 45 | +> An attacker who can publish a package version injects credential-harvesting or |
| 46 | +> loader code into a module that the SDK's public API imports, so it runs on |
| 47 | +> first `import` in any consuming application — no lifecycle hook, no `.pth`, no |
| 48 | +> `setup.py` change that a hook scanner would see. |
| 49 | +
|
| 50 | +## 4. Detection design |
| 51 | + |
| 52 | +New analyzer `AnalyzePythonLoadTime(entryModules, read)` mirroring npm's |
| 53 | +`AnalyzeLoadTime`: |
| 54 | + |
| 55 | +1. **Entry set, bounded.** Start from the package's declared public surface — |
| 56 | + the top-level package `__init__.py` and the modules it imports — not the whole |
| 57 | + tree. Follow intra-package imports to a bounded depth (reuse the |
| 58 | + `maxLoadTimeRefs = 16` cap), disclosing the bound via `Surface.Truncated` → |
| 59 | + `GapTruncated` exactly as the npm analyzer does. Never follow into |
| 60 | + dependencies. |
| 61 | +2. **Module-level only.** Scan code that runs at import: top-level statements and |
| 62 | + the bodies of module-level calls — not function/method bodies that only a |
| 63 | + later call would reach. |
| 64 | +3. **Reuse the capability model unchanged.** Run the existing `scanCaps`. Emit a |
| 65 | + synthetic `module-load:<relpath>` hook when the scanned code carries an |
| 66 | + **escalating** capability, never on bare presence of code. |
| 67 | + |
| 68 | +The analyzer only produces facts; the existing VC-002 family judges. No new |
| 69 | +check logic is required — a `module-load:` hook with `obfuscation+exec` fires |
| 70 | +VC-002e, with `network+credentials` fires VC-002d, etc. |
| 71 | + |
| 72 | +## 5. Evidence / confidence / gate |
| 73 | + |
| 74 | +| Field | Value | |
| 75 | +|---|---| |
| 76 | +| Required evidence | module-level code reachable from the package's import surface with an escalating capability (`network`, `credentials`, `exec`, `obfuscation`, `cradle`) | |
| 77 | +| Optional reinforcing | co-located with a version delta that ADDED the capability (VC-010), first-seen publisher (VC-011) | |
| 78 | +| Excluded conditions | capability basis is a display-sink string, a docstring/comment, or a URL literal (reuse the D-25/D-160 stripping pipeline before scanning) | |
| 79 | +| Severity ceiling | **advisory** on first ship, regardless of capability — see D-165 | |
| 80 | +| Gate eligibility | **none initially.** Promotion to gate-eligible requires the corpus evaluation in §7 | |
| 81 | +| Coverage prerequisite | the module tree was actually read; a truncated/unread walk is disclosed as a gap, never as "clean" | |
| 82 | + |
| 83 | +Rationale for the advisory ceiling: import-time execution is a vastly broader and |
| 84 | +more benign surface than install hooks. Blocking on it before a measured |
| 85 | +false-positive rate would violate the gate-vs-severity discipline (§22 of the |
| 86 | +contract). It ships in shadow, earns its gate class with evidence, or never gets |
| 87 | +one. |
| 88 | + |
| 89 | +## 6. Known false positives (the make-or-break set) |
| 90 | + |
| 91 | +Legitimate packages routinely run capability-adjacent code at import: |
| 92 | + |
| 93 | +- lazy config load that reads environment variables (`env`, never alone an |
| 94 | + escalation — `credentials` requires a NAMED secret); |
| 95 | +- plugin registries that `importlib.import_module` by name (`exec`-adjacent); |
| 96 | +- packages that shell out at import for capability detection (`subprocess` + |
| 97 | + version parse); |
| 98 | +- vendored code that base64-decodes embedded data (fonts, certs) without |
| 99 | + executing it (`obfuscation` requires decode PAIRED with an exec sink). |
| 100 | + |
| 101 | +The benign corpus MUST include these, harvested from real scans the way |
| 102 | +`legitimate.go` was built. The FP rate on this set is the gating metric. |
| 103 | + |
| 104 | +## 7. Promotion gate (corpus evaluation) |
| 105 | + |
| 106 | +Before this analyzer feeds anything above advisory: |
| 107 | + |
| 108 | +1. malicious corpus: the telnyx `_client.py` shape + synthetic variants |
| 109 | + (split strings, renamed funcs, indirection) — all detected; |
| 110 | +2. benign corpus (§6): measured false-positive rate at or below the documented |
| 111 | + budget; |
| 112 | +3. adversarial: added to `internal/ecosystem/conformance/`; |
| 113 | +4. shadow run over a representative real project set, FP rate reported. |
| 114 | + |
| 115 | +## 8. Test vectors |
| 116 | + |
| 117 | +- **Positive:** module-level `os.environ['AWS_SECRET_ACCESS_KEY']` + |
| 118 | + `urllib.request.urlopen(...)` in a non-entry module → `module-load:` hook with |
| 119 | + `credentials+network` → VC-002d. |
| 120 | +- **Positive:** the telnyx shape — base64 decode + `subprocess.Popen` at module |
| 121 | + level → `obfuscation+exec` → VC-002e. |
| 122 | +- **Negative (FP control):** module-level `os.environ.get('MYAPP_DEBUG')` + a |
| 123 | + plugin-registry loop → no escalating capability → no hook. |
| 124 | +- **Negative (FP control):** `base64.b64decode(EMBEDDED_FONT)` with no exec sink |
| 125 | + → no `obfuscation` → no hook. |
| 126 | + |
| 127 | +## 9. What is explicitly NOT in scope |
| 128 | + |
| 129 | +- Reading dependency modules (only the scanned package's own tree). |
| 130 | +- Non-Python ecosystems (npm's entry-module case is already handled; other |
| 131 | + ecosystems are separate notes). |
| 132 | +- Any gate-class outcome before §7 is satisfied. |
0 commit comments