Skip to content

Commit d5b32e0

Browse files
committed
feat: TeamPCP PyPI campaign detection — IOC ledger, litellm .pth tests, import-time spec
Turn the telnyx/litellm (TeamPCP) supply-chain analysis into detection, across three tracks. No new check and no scope/gate change: tracks 1-2 reuse VC-003 and VC-002e; track 3 is spec + decision only. 1. IOC ledger (VC-003): docs/ioc-teampcp.example.json flags litellm 1.82.8 and telnyx 4.87.1/4.87.2; test loads the shipped feed and asserts it matches the compromised versions but not a clean 4.85.0. 2. litellm .pth (Track A): analyzer tests assert the real .pth trigger reads obfuscation+exec while a benign bootstrap import does not; check tests assert VC-002e fires on the former and stays silent on the latter. 3. telnyx import-time gap (Track B): payload in telnyx/_client.py runs on import, out of the install-surface model. Recorded as a boundary in D-165 and specified in docs/VC-002L-python-import-time.md (scoped, advisory-only, corpus-gated) — no code until the spec is met. Validation: go test ./... green, vet clean, build ok. E2E: scan -ioc blocks telnyx@4.87.1 (exit 1, VC-003 block).
1 parent 257d379 commit d5b32e0

6 files changed

Lines changed: 349 additions & 0 deletions

File tree

docs/DECISIONS.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6887,3 +6887,36 @@ is the encoded contrast plus this entry, and extracting the construction for a r
68876887
as the refactor it would be rather than smuggled into a bug fix. Declared stays empty until pom reading is
68886888
its own decision. And the engine's raw fallback still writes `pkg:eco/name@version` for any FUTURE
68896889
ecosystem that gains asserted resolution before its identity seam — the pattern to copy is this one.
6890+
6891+
## D-165 — the Python import-time module surface is a known gap, held out of scope until a spec is met
6892+
6893+
**Trigger:** the TeamPCP telnyx 4.87.1 / 4.87.2 compromise (analyzed 2026-03-27; recorded in
6894+
[ioc-teampcp.example.json](ioc-teampcp.example.json)). The malicious code was injected into
6895+
`telnyx/_client.py` — an ordinary SDK runtime module — as module-level code that runs on `import telnyx`.
6896+
depSNORT's PyPI install-surface model (`AnalyzePython`) reads exactly three entrypoints: `setup.py`,
6897+
`pyproject.toml` build-backend, and `.pth` files. All three are install- or interpreter-startup triggers;
6898+
none is an ordinary module. The telnyx payload sidesteps every one of them and is invisible to the shipped
6899+
analyzer. The sibling litellm 1.82.8 sample, by contrast, used a `.pth` file and IS in scope — its
6900+
detection is pinned by the Track-A tests added alongside this entry.
6901+
6902+
**The decision is a boundary, not a feature.** Extending static analysis to read ordinary runtime modules
6903+
is a real scope expansion, and — per the zero-silent-failure and gate-vs-severity disciplines — expanding
6904+
it quietly, or letting a broad new surface gate a build, would be worse than the gap. Import-time code is
6905+
overwhelmingly benign (config loading, plugin registration, capability probing); a naive "scan every `.py`
6906+
at import" is a false-positive catastrophe. So the decision is: **acknowledge the gap explicitly, keep it
6907+
out of scope, and require a met specification before any code lands.** The specification is
6908+
[VC-002L-python-import-time.md](VC-002L-python-import-time.md): a narrow, capability-gated
6909+
`AnalyzePythonLoadTime` analyzer (mirroring npm's OPU-31 `AnalyzeLoadTime`, bounded by the same
6910+
`maxLoadTimeRefs` cap), reusing the existing `scanCaps` and the D-25/D-160 stripping pipeline, feeding the
6911+
existing VC-002 family with synthetic `module-load:` hooks. It ships **advisory-only** and earns a gate
6912+
class only through the corpus evaluation in the spec's §7 — never before.
6913+
6914+
**Coverage, not truth.** Until the analyzer exists, an import-time payload in a non-entrypoint Python
6915+
module is unseen. That is a coverage limitation, and it is now named here and in the spec rather than
6916+
implied by a clean scan. A scan that does not read a package's runtime modules must not be read as evidence
6917+
that those modules are safe — the frontier is the module tree, and it is disclosed, not laundered.
6918+
6919+
Residual limitations: this entry covers Python only. npm's entry-module import-time case is already handled
6920+
by `AnalyzeLoadTime`; npm NON-entry runtime modules (a `lib/` file not reachable through `exports`) are the
6921+
same class of gap and are noted here as a parallel, not closed. Other ecosystems' runtime-module surfaces
6922+
are separate notes if and when a sample motivates them.

docs/VC-002L-python-import-time.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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.

docs/ioc-teampcp.example.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"version": 1,
3+
"source": "reference feed — TeamPCP PyPI credential-theft campaign (compiled from public payload analysis, 2026-03)",
4+
"generated": "2026-08-30T00:00:00Z",
5+
"indicators": [
6+
{
7+
"ecosystem": "pypi",
8+
"name": "litellm",
9+
"version": "1.82.8",
10+
"severity": "critical",
11+
"category": "malware",
12+
"reference": "TEAMPCP-2026-0324",
13+
"note": "litellm_init.pth credential stealer; .pth auto-exec on any interpreter start; exfil hxxps://models[.]litellm[.]cloud, C2 hxxps://checkmarx[.]zone/raw; systemd persistence sysmon.service; K8s node worm"
14+
},
15+
{
16+
"ecosystem": "pypi",
17+
"name": "telnyx",
18+
"version": "4.87.1",
19+
"severity": "critical",
20+
"category": "malware",
21+
"reference": "TEAMPCP-2026-0327",
22+
"note": "malicious telnyx/_client.py runs on import; Linux path active; WAV-steg payload from 83[.]142[.]209[.]203:8080; same RSA key + tpcp.tar.gz exfil as litellm. Windows path dormant in this build (Setup() case bug)"
23+
},
24+
{
25+
"ecosystem": "pypi",
26+
"name": "telnyx",
27+
"version": "4.87.2",
28+
"severity": "critical",
29+
"category": "malware",
30+
"reference": "TEAMPCP-2026-0327",
31+
"note": "as 4.87.1 but the one-character fix (setup()) arms the Windows path: WAV-steg -> PE (AdaptixC2) in Startup as msbuild.exe; implant C2 checkmarx[.]zone:8443"
32+
}
33+
]
34+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package builtin
2+
3+
// Track-A validation for the TeamPCP litellm 1.82.8 .pth vector: the analyzer
4+
// half is asserted in internal/ecosystem/pypi/installsurface_test.go
5+
// (TestAnalyzePythonLitellmInitPth / ...BenignBootstrapPth); these two pin the
6+
// CHECK outcome on the resulting hook, so the whole path — .pth line to verdict —
7+
// is covered on both the malicious and the benign side.
8+
9+
import (
10+
"testing"
11+
12+
"ihbv.io/depsnort/internal/check"
13+
"ihbv.io/depsnort/internal/finding"
14+
"ihbv.io/depsnort/internal/graph"
15+
)
16+
17+
// pthHookGraph builds a pypi package with one pth:import install hook carrying
18+
// the given capabilities.
19+
func pthHookGraph(pkgID string, caps ...string) *graph.Graph {
20+
g := graph.New()
21+
g.AddNode(&graph.Node{ID: pkgID, Kind: graph.KindPackage, Ecosystem: "pypi", Name: "p", Version: "1.0.0"})
22+
hookID := "hook:" + pkgID + "#pth:import"
23+
hn := g.AddNode(&graph.Node{ID: hookID, Kind: graph.KindInstallHook, Ecosystem: "pypi", Name: "pth:import"})
24+
if hn.Attr == nil {
25+
hn.Attr = map[string]string{}
26+
}
27+
for _, c := range caps {
28+
hn.Attr["cap."+c] = "true"
29+
}
30+
g.AddEdge(pkgID, hookID, graph.EdgeDeclaresHook)
31+
return g
32+
}
33+
34+
// TestVC002eFiresOnLitellmPth: the litellm .pth shape (base64 decode + exec)
35+
// resolves to obfuscation+exec, which must produce exactly one VC-002e
36+
// decode-and-execute finding at gate-eligible severity.
37+
func TestVC002eFiresOnLitellmPth(t *testing.T) {
38+
g := pthHookGraph("pkg:pypi/litellm@1.82.8", "obfuscation", "exec")
39+
fs := (HookObfuscated{}).Run(&check.Context{Graph: g})
40+
if len(fs) != 1 {
41+
t.Fatalf("litellm .pth (obfuscation+exec) must fire one VC-002e finding; got %+v", fs)
42+
}
43+
if fs[0].GateClass != finding.GateEligible {
44+
t.Errorf("VC-002e gate class = %v, want gate-eligible (decode-and-execute is not, alone, block-class)", fs[0].GateClass)
45+
}
46+
}
47+
48+
// TestVC002eQuietOnBenignPth: a benign bootstrap-import .pth carries exec only
49+
// (the analyzer's deliberate lower bound on any import line) and must NOT fire
50+
// VC-002e. This is the false-positive boundary the litellm case sits beyond.
51+
func TestVC002eQuietOnBenignPth(t *testing.T) {
52+
g := pthHookGraph("pkg:pypi/goodpkg@1.0.0", "exec")
53+
if fs := (HookObfuscated{}).Run(&check.Context{Graph: g}); len(fs) != 0 {
54+
t.Errorf("exec-only pth must not fire VC-002e; got %+v", fs)
55+
}
56+
}

internal/datasource/ioc/ioc_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,41 @@ func TestLoadRejectsUnknownVersion(t *testing.T) {
6060
t.Error("an unsupported feed version must be rejected")
6161
}
6262
}
63+
64+
// TestTeamPCPExampleLedger keeps the shipped reference feed
65+
// (docs/ioc-teampcp.example.json) honest: it must parse, and it must match the
66+
// compromised releases by exact version while leaving a clean release alone. If
67+
// the feed schema drifts, this fails rather than silently loading nothing.
68+
func TestTeamPCPExampleLedger(t *testing.T) {
69+
f, err := Load(filepath.Join("..", "..", "..", "docs", "ioc-teampcp.example.json"))
70+
if err != nil {
71+
t.Fatalf("shipped TeamPCP ledger must load: %v", err)
72+
}
73+
if f.Len() != 3 {
74+
t.Fatalf("ledger Len=%d, want 3 (litellm 1.82.8, telnyx 4.87.1, telnyx 4.87.2)", f.Len())
75+
}
76+
77+
// Every compromised release must match by exact version.
78+
compromised := []struct{ purl, eco, name, ver string }{
79+
{"pkg:pypi/litellm@1.82.8", "pypi", "litellm", "1.82.8"},
80+
{"pkg:pypi/telnyx@4.87.1", "pypi", "telnyx", "4.87.1"},
81+
{"pkg:pypi/telnyx@4.87.2", "pypi", "telnyx", "4.87.2"},
82+
}
83+
for _, c := range compromised {
84+
ind := f.Match(c.purl, c.eco, c.name, c.ver)
85+
if ind == nil {
86+
t.Errorf("%s must match the TeamPCP ledger", c.purl)
87+
continue
88+
}
89+
if ind.Severity != "critical" {
90+
t.Errorf("%s severity = %q, want critical", c.purl, ind.Severity)
91+
}
92+
}
93+
94+
// A clean telnyx release must NOT match — a version-pinned indicator is
95+
// exact, so the ledger must never over-block a good version of a package
96+
// that also shipped a bad one.
97+
if ind := f.Match("pkg:pypi/telnyx@4.85.0", "pypi", "telnyx", "4.85.0"); ind != nil {
98+
t.Errorf("a clean telnyx release must not match the ledger; got %+v", ind)
99+
}
100+
}

internal/ecosystem/pypi/installsurface_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,62 @@ func TestAnalyzePythonSafePthFile(t *testing.T) {
147147
}
148148
}
149149

150+
// pthHookHasCap reports whether the single pth:import hook carries a capability.
151+
func pthHookHasCap(h installsurface.Hook, c installsurface.Capability) bool {
152+
for _, cap := range h.Caps {
153+
if cap == c {
154+
return true
155+
}
156+
}
157+
return false
158+
}
159+
160+
// TestAnalyzePythonLitellmInitPth reproduces the real litellm 1.82.8 trigger
161+
// (TeamPCP campaign, TEAMPCP-2026-0324): a single .pth line that spawns a
162+
// detached interpreter which base64-decodes and exec()s an embedded payload. The
163+
// credential theft itself lives in the decoded blob — a coverage frontier the
164+
// static analyzer cannot read — but the decode-and-execute shape is visible on
165+
// the .pth line and is what drives VC-002e. The analyzer must surface obfuscation
166+
// AND exec so the check can fire.
167+
func TestAnalyzePythonLitellmInitPth(t *testing.T) {
168+
pth := map[string]string{
169+
"litellm_init.pth": `import os, subprocess, sys; subprocess.Popen([sys.executable, "-c", "import base64; exec(base64.b64decode('aW1wb3J0IHNvY2tldA=='))"])`,
170+
}
171+
s := installsurface.AnalyzePython("", "", pth)
172+
if len(s.Hooks) != 1 {
173+
t.Fatalf("expected 1 pth hook, got %d", len(s.Hooks))
174+
}
175+
h := s.Hooks[0]
176+
if h.Name != "pth:import" {
177+
t.Errorf("hook name = %q, want pth:import", h.Name)
178+
}
179+
if !pthHookHasCap(h, installsurface.CapObfuscation) {
180+
t.Error("missing obfuscation capability (base64.b64decode) — VC-002e would not fire")
181+
}
182+
if !pthHookHasCap(h, installsurface.CapExec) {
183+
t.Error("missing exec capability (subprocess/exec)")
184+
}
185+
}
186+
187+
// TestAnalyzePythonBenignBootstrapPth is the false-positive control: a real,
188+
// legitimate pattern — a .pth that imports a package's own bootstrap module.
189+
// analyzePthFile flags any import line as exec (a deliberate lower bound), so a
190+
// hook IS produced, but it must NOT gain the obfuscation capability, so VC-002e
191+
// (decode-and-execute) stays silent on it. This is the line the litellm case
192+
// must sit on the far side of.
193+
func TestAnalyzePythonBenignBootstrapPth(t *testing.T) {
194+
pth := map[string]string{
195+
"pkg_bootstrap.pth": "import mypkg._bootstrap",
196+
}
197+
s := installsurface.AnalyzePython("", "", pth)
198+
if len(s.Hooks) != 1 {
199+
t.Fatalf("expected 1 pth hook for the import line, got %d", len(s.Hooks))
200+
}
201+
if pthHookHasCap(s.Hooks[0], installsurface.CapObfuscation) {
202+
t.Error("a plain bootstrap import must not read as obfuscation — this would be a false positive")
203+
}
204+
}
205+
150206
// TestExtractInstallSurfaceNilFetcher pins the CORRECTED contract (D-141).
151207
//
152208
// This test previously asserted the opposite — that a nil fetcher returns no

0 commit comments

Comments
 (0)