Module identity fed into egress-policy module_id/module_id_prefix trust rules, publisher-trust TOFU pinning, and receipt provenance is the module's own self-declared manifest["id"], not the verified install slug — any module can spoof a trusted identity (v1.3.1)
Reproduction steps:
- workbench/primitives/egress_policy.py documents
module_id/module_id_prefix
as first-class atoms an operator can use in WS/egress_policy.json to route
egress decisions by which module is calling — e.g. "trust our own first-party
modules to skip redaction; third-party modules touching PHI must redact":
{"version":1, "default":"would_deny", "rules":[
{"name":"first-party railcall modules skip redaction",
"if":{"module_id_prefix":"railcall/"}, "then":"allow"},
{"name":"third-party modules touching PHI must redact",
"if":{"has_category":"phi"}, "then":"would_redact"}]}
- Trace where the
module_idargument to station_llm.guard_egress_messages()/
station_llm.complete() (which call egress_policy.evaluate_v2(..., module_id=...)
at station_llm.py:248-249 and :349-350) actually comes from for a module-issued
LLM call: workbench/routes/modules.py, at every site that establishes a
module's identity:
line 641: "slug": str(manifest.get("id") or slug), # _module_identity
line 696: module_id=manifest.get("id") or slug, # incremental_runtime.register
line 785: module_id = manifest.get("id") or slug # module listing
line 968: _PT.check_and_pin(WS, str(manifest.get("id") or entry), ...)
line 989: mid_full = manifest.get("id") or entry
line 1173: _mod_slug = str(manifest.get("id") or entry)
line 1188: "id": manifest.get("id") or entry,
slug/entry is the real, install-time-fixed directory name the module was
unpacked into (trustworthy — the loader chose it, e.g. from the marketplace
listing or the operator's sideload path). manifest.get("id") is free text
the module's OWN author writes in module.json, preferred (or) over the real
slug at every one of these sites whenever present.
- Run repro_module_id_spoofing_egress_policy_bypass_v131.py against a clean
v1.3.1 extraction. It drives the REAL, unmodified egress_policy.evaluate_v2()
with the policy above, comparing the SAME PHI-classified content from the
SAME (hypothetically malicious) module under its true slug vs. under a
self-declared "railcall/evil" id:
honest module_id "sami666-evil-scraper" -> decision: would_redact
spoofed module_id "railcall/evil" -> decision: allow
CONFIRMED — same content, same module, different egress decision purely
because the module wrote a different string into its own manifest.
Expected:
A trust decision keyed on "which module is this" must be anchored to something
the platform verifies, not something the module asserts about itself — the
exact principle publisher_trust.py's own docstring states for signatures
("Signature verification alone... proves a bundle came from A specific
publisher — but the loader has no opinion on WHICH publishers are OK to run").
The identity string used to grant relaxed PHI/PII handling, to TOFU-pin a
publisher key, and to stamp "which module ran" into signed receipt provenance
must be the one thing about a module the platform itself assigns (the install
slug), not a field the module's own author writes.
Actual:manifest.get("id") or slug treats the self-declared id as authoritative
whenever present, falling back to the real slug only when the manifest omits
"id" entirely. This one pattern, repeated at every module-identity call site in
routes/modules.py, means a single JSON field an attacker fully controls
determines:
(a) the egress-policy module_id/module_id_prefix decision (proven above) —
PHI/PII redaction, HITL approval, or provider-tier routing rules an
operator writes to trust first-party modules are bypassable by any
self-signed module (trust_mode=any is still the default for pre-existing
installs per publisher_trust.ensure_default_mode; even under
trust_mode=attested, the marketplace attestation and this per-module id
string are independent — attestation proves the PUBLISHER KEY, not that
the module didn't lie about its own "id" field);
(b) publisher_trust.check_and_pin's TOFU pin key (line 968) — the per-module
publisher-key pin that is supposed to "kill the highest-value attack — an
update (or a same-name substitute) signed by a different key" is itself
keyed on the spoofable id. An attacker who installs first (or on any
fresh station, before the operator ever installs the real first-party
module) can declare "id":"railcall/hubspot" and TOFU-pin their OWN key to
that identity; when the operator later installs the genuine, correctly-
signed "railcall/hubspot" module, check_and_pin sees a pin mismatch
(state="mismatch", ok=False) and HARD-REFUSES the legitimate module to
load — a denial-of-service against a specific first-party module's
availability, using only a self-authored manifest field, no key
compromise required;
(c) _module_identity["slug"] (line 641), which the code's own comment says is
"Provenance stamped onto every Integration this module registers...
binding which module VERSION ran against its original signing" — i.e.
the identity that ends up in signed receipts describing which module
performed an action. A malicious module can cause its own actions to be
attributed, in the signed audit trail, to a trusted first-party module's
name.
Note: the ACTION-ID collision guard in the same function (routes/modules.py,if _existing is not None and not _owner.startswith(f"module:{slug}")) DOES
use the real, non-spoofable slug — so that specific check is sound. The bug
is narrowly that every module-IDENTITY use elsewhere in the same file prefers
the manifest's self-declared string instead.
Suggested fix:
Stop treating manifest["id"] as the module's identity for any trust or
provenance decision. Use the real install slug (slug/entry) everywhere
egress_policy.evaluate_v2's module_id, publisher_trust.check_and_pin's
module_id, and _module_identity["slug"]/receipt provenance are populated —
i.e. drop the manifest.get("id") or preference and pass slug directly at
every site listed above. If a human-readable display name from the manifest is
still wanted in the UI, keep it as a SEPARATE field (e.g. "display_name") never
consulted by any policy/pin/provenance decision. If backward compatibility
requires keeping manifest["id"] as a fallback for old bundles, at minimum
refuse to let manifest["id"] collide with a DIFFERENT already-loaded module's
real slug or an existing check_and_pin entry whose install slug doesn't match,
so a self-declared id can never let one module borrow another's trust
standing.