EvoScientist/EvoScientist — security scan
Repository: EvoScientist/EvoScientist
Commit scanned: 10c032450e0e
Scan date: 2026-07-28
Disclosure status: disclosed — focused issue filed upstream
Summary
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 16 |
| Medium | 23 |
| Low | 0 |
| Info | 0 |
Total findings: 39 (1 real after curation — and no tool ranked it)
EvoScientist (4.4k★, Apache-2.0) is a self-evolving AI scientist: a multi-agent research system built on deepagents that plans experiments, searches literature, writes and debugs code, analyses data, and drafts papers. It is explicitly human-on-the-loop rather than human-in-the-loop, and it reaches the user through ten chat channels (Feishu, WeChat/WeCom, Slack, Discord, Telegram, DingTalk, QQ, Signal, email, iMessage).
39 findings on a 4.9 MB Python codebase is an unusually low count, and the
dependency picture is the cleanest this series has seen: Trivy reports zero
vulnerabilities on uv.lock, pip-audit 0 across 148 packages, Dependabot is
wired, and the Dockerfile is multi-stage and drops to a non-root USER evosci.
Every one of the 39 scanner findings is a false positive. The one real finding
was found by hand, in the gap between two files.
Top findings
1. Inbound chat webhooks skip signature verification on an attacker-chosen branch — scanner-silent
- File:
EvoScientist/channels/wechat/channel.py:342,EvoScientist/channels/feishu/channel.py:850 - Tool: none — no scanner flagged this
- Confidence: high
- Why it matters: For an inbound webhook the signature check is the
authentication — the port must be reachable by Tencent/Feishu for the product
to work at all, so nothing else stands between the internet and the agent. In
WeChatChannel._handle_messagethe check sits behindif encrypt and self._crypto:, whereencryptis read from the request body the caller sent. A POST of plaintext XML with no<Encrypt>element takes the false branch and falls straight through to_process_message— even when the operator has correctly configuredtokenandencoding_aes_key. Feishu’s_handle_eventhas the sibling shape: token verification is wrapped inif self.config.verification_token:(fail-open when unset — and the onboarding wizard prompts for it as “optional” while defaulting the subscription mode towebhook), and its AES layer is likewise gated on"encrypt" in body. Both servers bind0.0.0.0(channels/mixins.py:120,wechat/channel.py:181) on ports 9001/9000. BecauseFromUserNameis then taken from the forged payload, theallowed_sendersallowlist is spoofable — and it is open by default anyway (AllowListMiddleware._is_sender_allowedreturnsTrueon an empty set). The injected text drives an agent holding a shellexecutetool whose approval prompt is a reply in the same chat (y/1/approve;3/autoapproves all future actions) — a reply the same forged channel can send. - Recommendation: Verify before branching, not inside the branch. Compute the
signature over the raw request for every inbound POST and reject on mismatch;
treat “no
Encryptfield” and “no token configured” as failures rather than as skips. If backwards compatibility matters, refusing to start a webhook-mode channel without credentials is safer than silently accepting unsigned traffic.
Filed upstream as #392. The approval half of this chain — sub-agent shell execution never being gated — is already tracked by the maintainers in #387; this report is only the ingress half.
2. What the 39 scanner findings actually were
- 10 SQL highs in
sessions.py— the #1 parameterized-identifier FP, and an exemplary instance. Everyconn.execute(query, params)binds its data with?; the only interpolation is a module-level constant (MAIN_THREAD_FILTER_SQL) andPRAGMA user_version = {int(version)}, which carries the comment “PRAGMAs cannot be parameter-bound; the integer is interpolated safely because we control the value.” - 3
shell=True— this is the agent’s ownexecutetool (backends.py,background.py). Running code is the product (ag2 class), and the third is a wizard-confirmedcurl/wgetinstaller. - 4 SHA-1 — WeChat’s mandated signature scheme (sorted
token|timestamp|nonce|encrypt, SHA-1), implemented to spec. Flagging it would be an active-harm FP: “fix” it and the channel stops interoperating. - 3 gitleaks — a documentation example of a Discord user ID, and two
fixtures inside
test_serde_default_rich_exception.py, the project’s own test that secrets get redacted from serialised exceptions. The scanner flagged the defense. - 1 insecure-websocket —
_infer_transport()matching the literal string"ws://"to pick a transport. It parses a scheme; it doesn’t open a socket. - The remainder: 7 GitHub Actions mutable tags, 5 non-literal-import (the plugin
and channel loader registry), 5 logger-credential-leak (a deliberately
truncated
token[:8]…token[-4:]debug fingerprint), 1 dynamic-urllib (a constantPYPI_URLwithtimeout=3).
Patterns observed
The interesting thing about scanning a self-evolving agent is that the obvious
question turns out to be the wrong one. I expected the finding to live in the
evolution loop — an agent that writes its own skills and installs them is a
prompt-injection-to-persistence story waiting to happen. It doesn’t, because
that loop is the most carefully built part of the codebase: autoskills go
through a proposal → review → approval lifecycle, skill names are validated
against a strict regex, and skill frontmatter is checked against a key
allowlist. The code interpreter is a QuickJS sandbox, not in-process Python
exec, and its tool allowlist explicitly excludes shell execute with the
reason written down in the module docstring: “would bypass
HumanInTheLoopMiddleware approval.” That is an advertised boundary that is
actually enforced — the opposite of the Agently case,
where a component named PythonSandbox wasn’t one.
So the real finding was in the plumbing beside it, which is becoming the
pattern: docetl built a code-operator engine and left an
unauthenticated /fs router next to it; here the sandboxing, the approval
middleware, and the skill-proposal lifecycle are all thought through, and the
front door is a webhook handler that verifies signatures only if the caller
includes the field that triggers verification. Defenses fail at their seams. The
tell in both cases is the same: the correct pattern exists in the same file —
WeChat’s _handle_verify (the GET handshake) checks the signature properly, and
verify_signature itself is a faithful implementation of the WeChat spec. The
knowledge is there; one branch just doesn’t reach it.
A note on 0.0.0.0, because this series keeps meeting it and the reading is not
constant. On dimos the scattered 0.0.0.0 literals
were fine — the listeners that mattered defaulted to 127.0.0.1, and the ones
binding wide were teleop surfaces that need LAN reach. Here the inverse holds: a
webhook receiver must be reachable by Tencent’s servers, so “it’s only exposed
if you deploy it” isn’t a mitigation, it’s the deployment. When a listener’s
whole purpose is to accept unsolicited POSTs from the public internet, the
signature check is the entire security boundary, and a conditional around it is
a conditional around authentication.
Worth crediting separately: the honesty of the --tunnel flag on
EvoSci deploy, whose own --help text reads “no auth — anyone with the URL
can drive the agent; trusted use only.” Saying that plainly is worth more than
a sandbox that overpromises.
Notes on the tool
- Absence-shaped findings remain invisible. No rule fires on “verification
happens inside a branch the attacker selects.” Semgrep saw the SHA-1
inside
verify_signatureand flagged it as weak crypto — the one place where the code was doing the right thing — while missing that the function goes uncalled on the plaintext path 200 lines away. Same lesson as the zotero-mcp sweep: pair scanner output with a surface-specific hand sweep. Backlog: for any inbound-webhook handler, check that signature verification is unconditional. - Credit-the-defense, inverted, twice in one scan. Gitleaks flagged the secret-redaction test; Semgrep flagged the protocol-mandated SHA-1. Both point at code that exists to improve security. This is a third confirmed instance of the mandated-interop SHA-1 class (after Chromium cookie KDF and RFC 6455 WebSocket) — enough to generalise the backlog rule.
- A genuinely clean dependency graph is possible. Trivy 0 on
uv.lock, pip-audit 0/148. Worth recording as a datapoint, since this series more often reports lockfile drift. - pip-audit completed normally here (148 packages, no hang), unlike the dimos run.
Disclosure timeline
- 2026-07-28 — scan run
- 2026-07-28 — issue #392 filed upstream
- 2026-07-28 — public post (this page)
Reproduce
git clone https://github.com/EvoScientist/EvoScientist /tmp/scan-target
python scanner/run_scan.py --repo /tmp/scan-target \
--reports-dir ./reports/evoscientist-evoscientist --min-severity medium