<?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: Alessio </title>
    <description>The latest articles on DEV Community by Alessio  (@alessio99xx).</description>
    <link>https://dev.to/alessio99xx</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%2F4177251%2F62fb7265-b706-4638-a099-54ace0b63ce9.jpg</url>
      <title>DEV Community: Alessio </title>
      <link>https://dev.to/alessio99xx</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alessio99xx"/>
    <language>en</language>
    <item>
      <title>Auditing MCP Configurations and AI Agent Rules for Security Risks</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Sun, 01 Nov 2026 21:39:14 +0000</pubDate>
      <link>https://dev.to/alessio99xx/auditing-mcp-configurations-and-ai-agent-rules-for-security-risks-nc5</link>
      <guid>https://dev.to/alessio99xx/auditing-mcp-configurations-and-ai-agent-rules-for-security-risks-nc5</guid>
      <description>&lt;p&gt;Modern developer workflows increasingly rely on AI agent configurations and Model Context Protocol (MCP) servers. Tools like Claude Desktop, Cursor, and Windsurf execute local commands, connect to remote services, and read workspace instructions from files like &lt;code&gt;.cursorrules&lt;/code&gt; or &lt;code&gt;claude_desktop_config.json&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;However, these configuration files can introduce serious security vulnerabilities into your environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Plaintext API keys and tokens committed to configuration files or exposed in local environments.&lt;/li&gt;
&lt;li&gt;Unpinned runtime commands (such as executing &lt;code&gt;npx&lt;/code&gt; or &lt;code&gt;uvx&lt;/code&gt; packages without fixed versions), allowing upstream package updates to execute unreviewed code on your machine.&lt;/li&gt;
&lt;li&gt;Overly broad filesystem access, such as giving an MCP server read and write permissions to root (&lt;code&gt;/&lt;/code&gt;), home directories (&lt;code&gt;~&lt;/code&gt;), or entire user profiles.&lt;/li&gt;
&lt;li&gt;Unauthenticated remote endpoints that expose local agent tooling to network manipulation.&lt;/li&gt;
&lt;li&gt;Instruction injection and hidden Unicode characters (such as zero-width spaces or bidirectional overrides) embedded within repository agent instruction files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;agentconfig_audit_mcp_and_rules_scanner.py&lt;/code&gt; is an audit script designed to inspect MCP configuration files and agent instruction rulebooks to flag these specific security issues.&lt;/p&gt;




&lt;h2&gt;
  
  
  Install &amp;amp; Usage
&lt;/h2&gt;

&lt;p&gt;The tool is self-contained and relies entirely on Python's standard library (&lt;code&gt;argparse&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;pathlib&lt;/code&gt;, &lt;code&gt;re&lt;/code&gt;, &lt;code&gt;unicodedata&lt;/code&gt;, &lt;code&gt;dataclasses&lt;/code&gt;, and &lt;code&gt;shlex&lt;/code&gt;). You do not need to run &lt;code&gt;pip install&lt;/code&gt; for any third-party dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;

&lt;p&gt;Download the script file and save it to your workspace as &lt;code&gt;agentconfig_audit_mcp_and_rules_scanner.py&lt;/code&gt;. Ensure you have Python 3.8 or later installed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Basic Usage
&lt;/h3&gt;

&lt;p&gt;You can display the built-in command options by invoking Python directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python agentconfig_audit_mcp_and_rules_scanner.py &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To audit an entire workspace for both MCP configurations and rule files (such as &lt;code&gt;.cursorrules&lt;/code&gt;, &lt;code&gt;agents.md&lt;/code&gt;, and markdown prompts), pass the directory path to the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python agentconfig_audit_mcp_and_rules_scanner.py &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also target specific standard system locations where tools like Claude Desktop or Cursor store their MCP definitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Auditing a specific configuration file&lt;/span&gt;
python agentconfig_audit_mcp_and_rules_scanner.py ~/.cursor/mcp.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The audit tool inspects two main surfaces: structured JSON MCP server configurations and plaintext rule/instruction documents.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Detecting Broad Filesystem Access and Unpinned Packages
&lt;/h3&gt;

&lt;p&gt;When parsing &lt;code&gt;mcpServers&lt;/code&gt; configurations, the scanner evaluates command arguments against dangerous system roots and verifies if package runners include immutable version specifications:&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;# Check Overly Permissive Filesystem Paths
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&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;arg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;expanded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expanduser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arg&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;pat&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BROAD_PATH_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expanded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
                &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="nc"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;rule_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP-BROAD-FILESYSTEM-ROOT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Overly Broad Filesystem Access in &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRITICAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                        &lt;span class="n"&gt;line_number&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Server &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; is assigned an expansive filesystem path &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Agents interacting with this server obtain arbitrary read/write access to user &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;home directories or root.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                        &lt;span class="p"&gt;),&lt;/span&gt;
                        &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arg: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;remediation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scope the server access specifically to a dedicated subproject folder.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script monitors paths matching expressions like &lt;code&gt;^/$&lt;/code&gt;, &lt;code&gt;^/home/?$&lt;/code&gt;, &lt;code&gt;^/Users/?$&lt;/code&gt;, &lt;code&gt;^~$&lt;/code&gt;, or root drives on Windows (&lt;code&gt;^[A-Za-z]:\\?$&lt;/code&gt;). If an MCP server is configured to mount those locations, it raises a &lt;code&gt;CRITICAL&lt;/code&gt; finding.&lt;/p&gt;

&lt;p&gt;Similarly, the scanner evaluates whether &lt;code&gt;npx&lt;/code&gt; or &lt;code&gt;uvx&lt;/code&gt; executions pin their targets. If an argument lacks &lt;code&gt;@&amp;lt;version&amp;gt;&lt;/code&gt; or &lt;code&gt;==&amp;lt;version&amp;gt;&lt;/code&gt;, the tool flags &lt;code&gt;MCP-UNPINNED-SERVER&lt;/code&gt; to prevent automatic downstream execution of unpinned packages.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Identifying Hardcoded Secrets in Environment Blocks
&lt;/h3&gt;

&lt;p&gt;The tool loops through every &lt;code&gt;env&lt;/code&gt; key-value pair in each configured server. If a value contains known credential structures (such as GitHub personal access tokens, AWS keys, Slack tokens, or private key headers), or if keys containing &lt;code&gt;TOKEN&lt;/code&gt;, &lt;code&gt;KEY&lt;/code&gt;, or &lt;code&gt;SECRET&lt;/code&gt; hold hardcoded strings longer than 11 characters, it generates an alert:&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;# Check Plaintext Credentials in 'env'
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&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;env_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env_val&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env_val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# Skip template variables like ${VAR} or $VAR
&lt;/span&gt;        &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env_val&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;env_val&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;env_val&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SECRET_REGEXES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env_val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;env_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&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;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASSWORD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUTH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env_val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
            &lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="nc"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;rule_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP-PLAINTEXT-SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Exposed Secret in Server &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; Env&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRITICAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                        &lt;span class="n"&gt;line_number&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Environment variable &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;env_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; contains a hardcoded plaintext secret (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;).&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                        &lt;span class="p"&gt;),&lt;/span&gt;
                        &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;env_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;env_val&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;env_val&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&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;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env_val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;remediation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Extract credentials to the system shell environment or a secure local keyring and reference via variable expansion.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the value uses variable expansion syntax such as &lt;code&gt;${MY_SECRET}&lt;/code&gt; or &lt;code&gt;$MY_SECRET&lt;/code&gt;, the scanner treats it as externalized and skips it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Scanning Agent Rules for Hidden Unicode and Injection Directives
&lt;/h3&gt;

&lt;p&gt;Files like &lt;code&gt;.cursorrules&lt;/code&gt;, &lt;code&gt;agents.md&lt;/code&gt;, and &lt;code&gt;prompt.txt&lt;/code&gt; can contain hidden characters that alter model behavior without being visible in standard text editors. The scanner checks each line for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero-width spaces (&lt;code&gt;\u200B&lt;/code&gt;), non-joiners (&lt;code&gt;\u200C&lt;/code&gt;), joiners (&lt;code&gt;\u200D&lt;/code&gt;), and byte order marks (&lt;code&gt;\uFEFF&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Bidirectional overrides (such as &lt;code&gt;\u202E&lt;/code&gt;), which can visually invert displayed text while preserving a different underlying instruction.&lt;/li&gt;
&lt;li&gt;Explicit instruction-override heuristics, such as phrases like &lt;code&gt;ignore all previous instructions&lt;/code&gt;, &lt;code&gt;do not follow safety guidelines&lt;/code&gt;, or injected XML tags like &lt;code&gt;&amp;lt;system-instruction&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;While this tool highlights high-risk patterns, it has specific design limitations based directly on its codebase:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Static Regex Heuristics:&lt;/strong&gt; Secret detection and injection detection rely strictly on static regular expressions. A credential format not listed in &lt;code&gt;SECRET_REGEXES&lt;/code&gt; or an obfuscated prompt injection string that avoids common phrasing will not be caught.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Dynamic Variable Resolution:&lt;/strong&gt; The scanner checks if an environment variable starts with &lt;code&gt;$&lt;/code&gt; or &lt;code&gt;${...}&lt;/code&gt; to avoid false positives. It does not inspect whether the parent shell environment securely populates those variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoded File Target Names:&lt;/strong&gt; During workspace discovery, &lt;code&gt;discover_agent_files()&lt;/code&gt; only checks for a predefined set of filenames (such as &lt;code&gt;.cursorrules&lt;/code&gt;, &lt;code&gt;agents.md&lt;/code&gt;, &lt;code&gt;claude.md&lt;/code&gt;, and &lt;code&gt;.rules&lt;/code&gt; extensions). Rules stored under custom or unexpected file extensions will be skipped unless explicitly passed as target paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-Only MCP Config Support:&lt;/strong&gt; The MCP configuration scanner assumes valid JSON formatting. It does not parse YAML, TOML, or alternative configuration formats used by non-standard orchestrators.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does the scanner transmit any found tokens or file contents to an external server?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
No. The script runs entirely on your local machine using standard Python modules. It performs no network calls, uploads no telemetry, and outputs findings strictly to your local standard output stream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does the script flag my &lt;code&gt;npx&lt;/code&gt; or &lt;code&gt;uvx&lt;/code&gt; command as high severity?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Commands like &lt;code&gt;npx @modelcontextprotocol/server-filesystem&lt;/code&gt; download and run the latest published version by default. If that package is modified or compromised upstream, your agent environment executes that code automatically. The scanner requires pinning a specific version tag (e.g., &lt;code&gt;npx @modelcontextprotocol/server-filesystem@0.1.0&lt;/code&gt;) to pass without a finding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the scanner automatically redact secrets or remove invisible characters from my files?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
No. The scanner is an auditing tool, not an in-place mutation tool. It reports the rule ID, offending file path, line evidence, and suggested remediation, leaving actual file modifications to the developer.&lt;/p&gt;




&lt;p&gt;Full version and the rest of the toolkit: &lt;a href="https://py-forge.com/products/agentconfig-audit-mcp-and-rules-scanner?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=agentconfig-audit-mcp-and-rules-scanner" rel="noopener noreferrer"&gt;AgentConfig Audit MCP and rules scanner on PyForge&lt;/a&gt;. Product page: &lt;a href="https://py-forge.com/products/agentconfig-audit-mcp-and-rules-scanner" rel="noopener noreferrer"&gt;https://py-forge.com/products/agentconfig-audit-mcp-and-rules-scanner&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>cli</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Audit AI Crawler Traffic and Identify Spoofed Bots in Logs</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Sat, 31 Oct 2026 21:34:29 +0000</pubDate>
      <link>https://dev.to/alessio99xx/how-to-audit-ai-crawler-traffic-and-identify-spoofed-bots-in-logs-2iil</link>
      <guid>https://dev.to/alessio99xx/how-to-audit-ai-crawler-traffic-and-identify-spoofed-bots-in-logs-2iil</guid>
      <description>&lt;p&gt;Unmanaged web scraping by artificial intelligence agents can consume server bandwidth, increase egress costs, and skew operational analytics. While legitimate crawlers identify themselves via their request headers, malicious scrapers often spoof those same headers to bypass rules or scrape protected assets under the guise of well-known automated systems.&lt;/p&gt;

&lt;p&gt;Analyzing access logs manually across multiple servers is inefficient. You need a systematic way to parse server logs, match known crawler signatures, estimate hosting expenses tied to crawler bandwidth, and verify whether the IP addresses sending those headers actually belong to the claimed crawler networks.&lt;/p&gt;

&lt;p&gt;The script &lt;code&gt;aibot_ledger_crawler_log_reporter.py&lt;/code&gt; addresses this issue directly. It inspects web server access logs, identifies automated AI crawlers, performs Forward-Confirmed Reverse DNS lookups to catch spoofed clients, checks paths against your robots exclusion file, and generates server-level mitigation templates.&lt;/p&gt;




&lt;h3&gt;
  
  
  Install &amp;amp; Usage
&lt;/h3&gt;

&lt;p&gt;The script relies entirely on standard modules included with Python, such as &lt;code&gt;urllib.robotparser&lt;/code&gt;, &lt;code&gt;socket&lt;/code&gt;, &lt;code&gt;gzip&lt;/code&gt;, and &lt;code&gt;json&lt;/code&gt;. You do not need to install any third-party libraries using pip.&lt;/p&gt;

&lt;p&gt;To get started, save the source code into a file named &lt;code&gt;aibot_ledger_crawler_log_reporter.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can view the command-line help interface by running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python aibot_ledger_crawler_log_reporter.py &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the core processing logic is encapsulated in the &lt;code&gt;AIBotLedger&lt;/code&gt; class, you can also import and execute the ledger directly within your own administrative scripts:&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;aibot_ledger_crawler_log_reporter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AIBotLedger&lt;/span&gt;

&lt;span class="c1"&gt;# Initialize the ledger with custom bandwidth pricing and DNS verification
&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AIBotLedger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost_per_gb&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.09&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verify_dns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Ingest standard or compressed log files
&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ingest_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/log/nginx/access.log&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ingest_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/log/nginx/access.log.1.gz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Print the formatted terminal summary
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_terminal_summary&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  How It Works
&lt;/h3&gt;

&lt;p&gt;The tool is organized around log parsing, pattern matching, DNS validation, and rules checking.&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Dual Log Parsing and Compression Handling
&lt;/h4&gt;

&lt;p&gt;The &lt;code&gt;ingest_file&lt;/code&gt; function checks the file extension. If the target file ends in &lt;code&gt;.gz&lt;/code&gt;, it transparently reads the stream via Python's built-in &lt;code&gt;gzip&lt;/code&gt; module. &lt;/p&gt;

&lt;p&gt;Log entries are processed by &lt;code&gt;parse_log_line&lt;/code&gt;, which detects both standard Combined log entries (typical for Nginx and Apache) and structured JSON log lines (typical for Caddy):&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;def&lt;/span&gt; &lt;span class="nf"&gt;parse_log_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Parses standard Combined format or Caddy JSON logs.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="c1"&gt;# Try Caddy JSON log format
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
            &lt;span class="n"&gt;ua_list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;ua&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ua_list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ua_list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;ua_list&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ua_list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remote_ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;req&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;ip&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="c1"&gt;# Handle host:port
&lt;/span&gt;                &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ip&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ua&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&lt;/span&gt;

    &lt;span class="c1"&gt;# Try Combined / Nginx / Apache format
&lt;/span&gt;    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COMBINED_LOG_REGEX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupdict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;bytes_val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bytes_val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ua&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ua&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  2. Signature Detection
&lt;/h4&gt;

&lt;p&gt;The script maintains an internal collection of bot definitions named &lt;code&gt;BOT_PROFILES&lt;/code&gt;. It matches user-agent values against regular expressions corresponding to entities such as GPTBot, ClaudeBot, Bytespider, CCBot, Google-Extended, PerplexityBot, Cohere-AI, and Diffbot. Each entry maps the signature to a classification category (such as Live Browsing, Dataset Mining, or Aggressive Scraper) and assigns legitimate root domains used for verification.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Spoof Detection with Forward-Confirmed Reverse DNS (FCrDNS)
&lt;/h4&gt;

&lt;p&gt;Anyone can configure an HTTP client to send &lt;code&gt;User-Agent: GPTBot&lt;/code&gt;. To prove authenticity, the script uses Forward-Confirmed Reverse DNS via &lt;code&gt;verify_fcrdns&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_fcrdns&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="n"&gt;expected_domains&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Forward-Confirmed Reverse DNS (FCrDNS) check.

    Returns: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;VERIFIED&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SPOOFED&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;UNVERIFIED&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, or &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SKIPPED&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;expected_domains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SKIPPED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&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;_DNS_CACHE&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;_DNS_CACHE&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="c1"&gt;# Don't resolve private or special addresses
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.16.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.17.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.18.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.19.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.20.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.21.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.22.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.23.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.24.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.25.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.26.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.27.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.28.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.29.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.30.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.31.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SKIPPED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;_DNS_CACHE&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;

    &lt;span class="n"&gt;orig_timeout&lt;/span&gt; &lt;span class="o"&gt;=&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;getdefaulttimeout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&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;setdefaulttimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&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="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&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;gethostbyaddr&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="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;herror&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gaierror&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SPOOFED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="n"&gt;_DNS_CACHE&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;

        &lt;span class="n"&gt;hostname_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hostname&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="n"&gt;domain_matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostname_lower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&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;for&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;expected_domains&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;domain_matched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SPOOFED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="n"&gt;_DNS_CACHE&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;forward_ips&lt;/span&gt; &lt;span class="o"&gt;=&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;gethostbyname_ex&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;if&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;forward_ips&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VERIFIED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SPOOFED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gaierror&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SPOOFED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&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;setdefaulttimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orig_timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;_DNS_CACHE&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This verification process consists of three distinct checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It queries the reverse DNS record for the IP address to obtain a hostname.&lt;/li&gt;
&lt;li&gt;It verifies that the returned hostname ends with one of the legitimate domain suffixes configured for that bot.&lt;/li&gt;
&lt;li&gt;It performs a forward lookup on that hostname to ensure that the originating IP address is present in the resolved address list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any check fails, the record is flagged as &lt;code&gt;SPOOFED&lt;/code&gt; and recorded for auditing.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. Cost Calculations and Defense Export
&lt;/h4&gt;

&lt;p&gt;By accumulating byte counts associated with verified and unverified crawlers, &lt;code&gt;AIBotLedger&lt;/code&gt; multiplies total gigabytes transferred by the &lt;code&gt;cost_per_gb&lt;/code&gt; parameter to provide an estimate of hosting bandwidth charges. &lt;/p&gt;

&lt;p&gt;It also includes standalone helper functions—such as &lt;code&gt;generate_nginx_snippets&lt;/code&gt; and &lt;code&gt;generate_robots_snippet&lt;/code&gt;—to output firewall maps, rate-limiting directives, and exclusion lines targeted at the specific crawler names encountered in your logs.&lt;/p&gt;




&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;p&gt;While practical for audits, the current code has several built-in constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Synchronous DNS Resolution:&lt;/strong&gt; The &lt;code&gt;verify_fcrdns&lt;/code&gt; function runs synchronously using Python's standard &lt;code&gt;socket&lt;/code&gt; module. Although lookups are saved to an in-memory dictionary cache (&lt;code&gt;_DNS_CACHE&lt;/code&gt;), processing very large uncompressed logs containing thousands of unique IP addresses will experience delays due to network timeouts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rigid Log Formats:&lt;/strong&gt; The parser handles standard Apache/Nginx Combined log patterns and Caddy JSON logs. If your server outputs custom access log layouts missing status codes, request sizes, or user-agent fields, lines will fail to match and will be silently ignored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoded Profile List:&lt;/strong&gt; The tool identifies only the twelve specific agents defined inside &lt;code&gt;BOT_PROFILES&lt;/code&gt;. Unlisted crawlers, custom research bots, or newer AI agents will not be cataloged unless you manually add new entries to the profile list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Robots Rule Interpretation:&lt;/strong&gt; Compliance tracking relies on &lt;code&gt;urllib.robotparser&lt;/code&gt;. It does not interpret non-standard directives like &lt;code&gt;Crawl-delay&lt;/code&gt; or vendor-specific path expansions that are outside standard robot exclusions.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Frequently Asked Questions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Does running this script require elevated root privileges?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
No. The script only reads log files from disk and performs standard outbound DNS resolution queries. As long as your operating system user has read permissions for the target log files, root or administrative access is not required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the script process logs stored in &lt;code&gt;.gz&lt;/code&gt; archives alongside plain text logs?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Yes. The &lt;code&gt;ingest_file&lt;/code&gt; method inspects the file name. If it detects a &lt;code&gt;.gz&lt;/code&gt; suffix, it uncompresses and reads the file stream via Python's &lt;code&gt;gzip&lt;/code&gt; module without requiring prior extraction on disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does an IP show up as spoofed even if the user agent looks valid?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
A request is marked as spoofed when the client supplies a known AI crawler user agent, but its IP address either has no reverse DNS entry, resolves to a domain outside the provider's authorized domain list, or fails forward verification when matching the hostname back to the originating IP address.&lt;/p&gt;




&lt;p&gt;Full version and the rest of the toolkit: &lt;a href="https://py-forge.com/products/aibot-ledger-crawler-log-reporter?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=aibot-ledger-crawler-log-reporter" rel="noopener noreferrer"&gt;AIBot Ledger crawler log reporter on PyForge&lt;/a&gt;. Product page: &lt;a href="https://py-forge.com/products/aibot-ledger-crawler-log-reporter" rel="noopener noreferrer"&gt;https://py-forge.com/products/aibot-ledger-crawler-log-reporter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>monitoring</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Guarding CI Pipelines with Dependency Release Age Gates</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Fri, 30 Oct 2026 21:27:33 +0000</pubDate>
      <link>https://dev.to/alessio99xx/guarding-ci-pipelines-with-dependency-release-age-gates-26mn</link>
      <guid>https://dev.to/alessio99xx/guarding-ci-pipelines-with-dependency-release-age-gates-26mn</guid>
      <description>&lt;p&gt;When an attacker compromises a package maintainer's credentials or publishes a typo-squatted package to PyPI or npm, the malicious release is immediately visible to automated tooling. Continuous integration pipelines, automated upgrade bots, and routine builds that pull the newest pinned dependencies will fetch and execute that code right away.&lt;/p&gt;

&lt;p&gt;In most cases, malicious releases are identified and yanked within a few days once security monitors and maintainers notice the compromise. However, if your automated builds pull new releases the moment they appear, your environment acts as the test bed during that dangerous early window.&lt;/p&gt;

&lt;p&gt;A dependency release age gate solves this by enforcing a cooldown period. By auditing dependency manifests and lockfiles to verify that every pinned package has existed in the public registry for a minimum number of days, you introduce a buffer period where bad releases can be caught before your build consumes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Development workflows frequently update lockfiles through automated bots or manual upgrades. When dependencies update immediately to a release published only hours earlier, the build takes on unvetted supply chain exposure.&lt;/p&gt;

&lt;p&gt;Common threats during this early window include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compromised developer accounts publishing malicious minor or patch updates.&lt;/li&gt;
&lt;li&gt;Fast package takeovers where existing package names are hijacked.&lt;/li&gt;
&lt;li&gt;Accidental breakages or incomplete releases that get replaced shortly after publishing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To mitigate this without completely halting updates, pipelines need an automated verification step: checking the upload timestamp of pinned dependencies against a cooldown policy. If an update is too young, the pipeline should flag it and recommend an older, verified version that meets the cooldown requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install &amp;amp; Usage
&lt;/h2&gt;

&lt;p&gt;The tool is implemented as a self-contained Python script. It relies exclusively on the Python standard library, requiring no external packages from package managers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;

&lt;p&gt;Download the script file and place it in your project repository under the name &lt;code&gt;depcooldown_dependency_release_age_gate.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The script runs on standard Python. If you are running Python 3.11 or later, it uses the built-in &lt;code&gt;tomllib&lt;/code&gt; module to read TOML files. If you run an earlier Python release, it uses an internal fallback parser to process package definitions from lockfiles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Command-Line Usage
&lt;/h3&gt;

&lt;p&gt;To inspect the supported command-line options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python depcooldown_dependency_release_age_gate.py &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To run an audit against a Python &lt;code&gt;requirements.txt&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python depcooldown_dependency_release_age_gate.py requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can audit an npm lockfile by passing the path to your &lt;code&gt;package-lock.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python depcooldown_dependency_release_age_gate.py package-lock.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script can also inspect modern Python lockfiles and manifests, such as &lt;code&gt;poetry.lock&lt;/code&gt;, &lt;code&gt;uv.lock&lt;/code&gt;, and &lt;code&gt;pyproject.toml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The tool works through three main stages: parsing manifest files, fetching upload timestamps from registries, and evaluating ages against a cutoff window.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Extracting Pinned Dependencies
&lt;/h3&gt;

&lt;p&gt;The tool inspects the structure of the input file to detect whether it belongs to the Python or npm ecosystem. It extracts package names and strictly pinned versions.&lt;/p&gt;

&lt;p&gt;Here is the implementation from &lt;code&gt;parse_requirements_txt&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_requirements_txt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Extract strictly pinned dependencies (name==version) from requirements.txt.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&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;raw_line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw_line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# Strip trailing comments and environment markers
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; #&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# Match name[extras]==version
&lt;/span&gt;        &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&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;^([A-Za-z0-9_.\-]+)(?:\[[^\]]*\])?\s*==\s*([A-Za-z0-9_.\-+]+)$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;pkg_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pkg_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pinned&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For npm, it reads &lt;code&gt;package-lock.json&lt;/code&gt; and extracts versions from the &lt;code&gt;packages&lt;/code&gt; dictionary (used in modern lockfiles) or the legacy &lt;code&gt;dependencies&lt;/code&gt; block. For Python project manifests, it reads dependencies from &lt;code&gt;pyproject.toml&lt;/code&gt;, &lt;code&gt;poetry.lock&lt;/code&gt;, or &lt;code&gt;uv.lock&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Fetching Release Timestamps
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;RegistryClient&lt;/code&gt; queries public registry metadata endpoints using standard library &lt;code&gt;urllib.request&lt;/code&gt;. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For npm packages, it inspects the registry JSON metadata and reads the &lt;code&gt;time&lt;/code&gt; object, which maps each released version to an ISO-8601 upload timestamp.&lt;/li&gt;
&lt;li&gt;For PyPI packages, it reads the &lt;code&gt;releases&lt;/code&gt; object and extracts the &lt;code&gt;upload_time_iso_8601&lt;/code&gt; or &lt;code&gt;upload_time&lt;/code&gt; entry associated with the release files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The client stores fetched metadata in an in-memory dictionary and can serialize it to a local JSON cache file to prevent redundant network lookups across runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Evaluating Cooldown Violations
&lt;/h3&gt;

&lt;p&gt;The audit core compares the release timestamp against the required age window:&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;def&lt;/span&gt; &lt;span class="nf"&gt;audit_dependency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ecosystem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;package&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RegistryClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cooldown_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;reference_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AuditResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Audit a single pinned package version against the cooldown window.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_release_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ecosystem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;package&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reference_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cooldown_days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AuditResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;package&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;package&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ecosystem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ecosystem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;current_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;upload_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;age_days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;is_violation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;suggested_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unverified (version metadata not found in registry)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;upload_dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_iso_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;upload_dt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;86400.0&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;upload_dt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Pinned version is in the cooldown window! Find newest safe release
&lt;/span&gt;        &lt;span class="n"&gt;eligible_versions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&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;ver_str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_str&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;candidate_dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_iso_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts_str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;candidate_dt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;parsed_v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_semver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ver_str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="n"&gt;eligible_versions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;parsed_v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ver_str&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a package version was uploaded after the cutoff date, &lt;code&gt;audit_dependency&lt;/code&gt; marks it as a violation. It then loops through all historical releases in the registry metadata, identifies those uploaded prior to the cutoff, sorts them using &lt;code&gt;parse_semver&lt;/code&gt;, and returns the newest safe version as a recommendation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;Before deploying the script into your automation, keep the following structural limitations in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strict Pinning Requirement&lt;/strong&gt;: The parser only evaluates exact equality pins (&lt;code&gt;==&lt;/code&gt;). Flexible ranges such as &lt;code&gt;&amp;gt;=1.2.0&lt;/code&gt;, &lt;code&gt;~=2.1&lt;/code&gt;, or caret dependencies in manifests are ignored unless resolved into a concrete lockfile like &lt;code&gt;poetry.lock&lt;/code&gt; or &lt;code&gt;package-lock.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplified Version Parsing&lt;/strong&gt;: Version comparison relies on &lt;code&gt;parse_semver&lt;/code&gt;, which splits versions into major, minor, patch, and prerelease components. Complex PEP 440 specifications (such as post-releases, epoch numbers, or local versions) are parsed as zero-fallbacks or plain integers rather than strictly compliant PEP 440 objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal TOML Fallback&lt;/strong&gt;: When running on Python versions older than 3.11 without &lt;code&gt;tomllib&lt;/code&gt;, the script falls back to a basic line-by-line parser. While this fallback handles standard &lt;code&gt;[[package]]&lt;/code&gt; entries in Poetry and uv lockfiles, complex or nested TOML structures may fail to parse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registry Unavailability&lt;/strong&gt;: If the registry metadata endpoint is unreachable, timed out, or blocked by a proxy, the tool returns an unverified status for that dependency rather than aborting.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Can this tool be run in an environment without internet access?
&lt;/h4&gt;

&lt;p&gt;Yes. The &lt;code&gt;RegistryClient&lt;/code&gt; supports an offline mode and local JSON cache files. If metadata was previously fetched and cached to disk, the script evaluates dependencies directly against the stored JSON cache without performing network calls.&lt;/p&gt;

&lt;h4&gt;
  
  
  How does the script handle npm scoped packages?
&lt;/h4&gt;

&lt;p&gt;The script handles scoped npm packages (for example, &lt;code&gt;@scope/package&lt;/code&gt;) by preserving the &lt;code&gt;@&lt;/code&gt; character while percent-encoding the path separator when querying the npm registry endpoint.&lt;/p&gt;

&lt;h4&gt;
  
  
  What happens if a brand-new package has no versions meeting the cooldown threshold?
&lt;/h4&gt;

&lt;p&gt;If a package is entirely new and none of its released versions were published before the cutoff threshold, the tool flags the pinned version as a cooldown violation and leaves the suggested version empty, indicating that no safe historical release exists in the registry.&lt;/p&gt;




&lt;p&gt;Full version and the rest of the toolkit: &lt;a href="https://py-forge.com/products/depcooldown-dependency-release-age-gate?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=depcooldown-dependency-release-age-gate" rel="noopener noreferrer"&gt;DepCooldown dependency release age gate on PyForge&lt;/a&gt;. Product page: &lt;a href="https://py-forge.com/products/depcooldown-dependency-release-age-gate" rel="noopener noreferrer"&gt;https://py-forge.com/products/depcooldown-dependency-release-age-gate&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>tooling</category>
      <category>webdev</category>
      <category>cli</category>
    </item>
    <item>
      <title>How to Scrub API Keys from Local AI Coding Agent Transcripts</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:08:37 +0000</pubDate>
      <link>https://dev.to/alessio99xx/how-to-scrub-api-keys-from-local-ai-coding-agent-transcripts-4f22</link>
      <guid>https://dev.to/alessio99xx/how-to-scrub-api-keys-from-local-ai-coding-agent-transcripts-4f22</guid>
      <description>&lt;p&gt;Autonomous AI coding agents and terminal assistants have transformed everyday software engineering workflows. Tools like Claude Code, Cursor, Aider, Open Interpreter, and Gemini CLI read workspaces, execute commands, and help debug issues directly in development environments. &lt;/p&gt;

&lt;p&gt;However, during active development sessions, developers routinely paste credentials, pass environment variables, or allow agents to inspect configuration files containing secrets. Even when those commands finish, the underlying transcripts and session logs are retained indefinitely on your local machine. &lt;/p&gt;

&lt;p&gt;If your workstation is accessed, or if agent history folders are synced, backed up, or shared during collaboration, sensitive authentication material can be exposed. The &lt;code&gt;AgentTrail Scrub&lt;/code&gt; tool inspects local AI agent session logs, identifies credentials using regular expressions and Shannon entropy, and helps with redacting them while keeping file backups.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Problem: Persistent Credentials in Local AI History
&lt;/h3&gt;

&lt;p&gt;Most developers do not realize how much sensitive state AI developer tools store on disk. When you run an agent or use an editor plugin, the tool writes comprehensive logs to record the conversation, tool execution calls, and terminal responses.&lt;/p&gt;

&lt;p&gt;Common locations where local history accumulates include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.claude/projects&lt;/code&gt; and &lt;code&gt;~/.claude/history&lt;/code&gt; for Claude Code sessions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;workspaceStorage&lt;/code&gt; folders under user configuration paths for Cursor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.aider&lt;/code&gt; session logs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.gemini&lt;/code&gt; logs for terminal-based Gemini tools.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.openinterpreter&lt;/code&gt; and &lt;code&gt;~/.codex&lt;/code&gt; local caches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When an AI tool prints the output of an environment dump or inspects an uncommitted file, raw strings such as Anthropic keys, OpenAI tokens, AWS secret keys, GitHub personal access tokens, and JSON Web Tokens (JWTs) are embedded directly into session records on disk. &lt;/p&gt;

&lt;p&gt;Simply clearing your shell history or adding &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; does not touch these assistant logs. Cleaning these records manually across deeply nested directories is tedious and error-prone.&lt;/p&gt;




&lt;h3&gt;
  
  
  Install &amp;amp; Usage
&lt;/h3&gt;

&lt;p&gt;The tool is self-contained and implemented entirely in Python with zero third-party library dependencies. It uses standard library modules such as &lt;code&gt;argparse&lt;/code&gt;, &lt;code&gt;pathlib&lt;/code&gt;, &lt;code&gt;re&lt;/code&gt;, &lt;code&gt;shutil&lt;/code&gt;, &lt;code&gt;math&lt;/code&gt;, and &lt;code&gt;collections&lt;/code&gt;. You do not need to install additional packages through package managers.&lt;/p&gt;

&lt;p&gt;To install the tool, download the source file directly and save it to your machine as &lt;code&gt;agenttrail_scrub_ai_transcript.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can view all available command-line arguments and configuration options by running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python agenttrail_scrub_ai_transcript.py &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To try it safely on synthetic transcripts first, run the built-in demo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python agenttrail_scrub_ai_transcript.py &lt;span class="nt"&gt;--demo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical flow is scan first, then redact (backups go to &lt;code&gt;~/.agenttrail/backups&lt;/code&gt; unless you pass &lt;code&gt;--backup-dir&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python agenttrail_scrub_ai_transcript.py &lt;span class="nt"&gt;--scan&lt;/span&gt;
python agenttrail_scrub_ai_transcript.py &lt;span class="nt"&gt;--redact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retention cleanup is a dry run by default; old transcripts are only deleted when you add &lt;code&gt;--apply-retention&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python agenttrail_scrub_ai_transcript.py &lt;span class="nt"&gt;--retention-days&lt;/span&gt; 30
python agenttrail_scrub_ai_transcript.py &lt;span class="nt"&gt;--retention-days&lt;/span&gt; 30 &lt;span class="nt"&gt;--apply-retention&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the script runs in user space using your standard Python interpreter, you can run it on macOS, Linux, or Windows environments where your AI coding assistants reside.&lt;/p&gt;




&lt;h3&gt;
  
  
  How It Works
&lt;/h3&gt;

&lt;p&gt;The cleaner combines agent directory discovery, targeted pattern matching, entropy filtering, safe redaction, and optional retention cleanup.&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Target Directory Discovery
&lt;/h4&gt;

&lt;p&gt;Rather than forcing you to look up where each individual tool stores session state, the script inspects well-known workspace and history paths:&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;def&lt;/span&gt; &lt;span class="nf"&gt;discover_agent_transcript_dirs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Locate common transcript, history, and workspace caches of AI coding agents.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;home&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;app_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APPDATA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AppData&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Roaming&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Claude Code Projects&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.claude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Claude Code History&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.claude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;history&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Claude Code Root&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.claude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cursor Workspaces (Linux/macOS)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspaceStorage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cursor Workspaces (macOS AppSupport)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Library&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Application Support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspaceStorage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cursor Workspaces (Windows)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_data&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspaceStorage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cursor Global Storage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Gemini CLI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.gemini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Gemini Config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Aider History&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.aider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OpenAI Codex / CLI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.codex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Open Interpreter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.openinterpreter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="c1"&gt;# ... further candidate paths trimmed for brevity
&lt;/span&gt;    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;discovered&lt;/span&gt; &lt;span class="o"&gt;=&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;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="n"&gt;discovered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&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="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;discovered&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool identifies which directories exist on your machine, then gathers files ending in formats commonly used for logs and chats, such as &lt;code&gt;.json&lt;/code&gt;, &lt;code&gt;.jsonl&lt;/code&gt;, &lt;code&gt;.log&lt;/code&gt;, &lt;code&gt;.txt&lt;/code&gt;, and &lt;code&gt;.md&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Pattern Matching with Shannon Entropy Filtering
&lt;/h4&gt;

&lt;p&gt;Simple regular expressions frequently trigger false alarms when matching generic token assignments. To distinguish real cryptographic keys from low-randomness dummy values (such as &lt;code&gt;test_token_12345&lt;/code&gt;), the tool calculates the Shannon entropy of candidate strings.&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;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_shannon_entropy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Compute the Shannon entropy of a string.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;entropy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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;count&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;p_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p_x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;entropy&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;p_x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_x&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;entropy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;High-confidence signatures (like AWS access key IDs or structured GitHub tokens) have explicit prefix formats and can match without an entropy check. More generic strings, such as &lt;code&gt;.env&lt;/code&gt; variable assignments, must exceed configured entropy thresholds before they are treated as valid secret hits.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Overlap Suppression and Safe In-Place Redaction
&lt;/h4&gt;

&lt;p&gt;When an API key matches multiple detection patterns, the scanner suppresses overlapping spans to avoid duplicate notifications. When modifying a file, it preserves a backup before overwriting contents:&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;def&lt;/span&gt; &lt;span class="nf"&gt;redact_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SecretHit&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;backup_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Create a backup and rewrite the file with redacted tokens.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;safe_rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&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="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;target_backup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;backup_dir&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;safe_rel&lt;/span&gt;
        &lt;span class="n"&gt;target_backup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_backup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;unique_secrets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_secret&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;secret&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique_secrets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;replacement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[AGENTTRAIL_REDACTED_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;mask_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replacement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_backup&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The redaction logic sorts detected secrets by length in descending order before performing replacements. This prevents shorter substrings of a key from being partially replaced before a complete key string is processed.&lt;/p&gt;




&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;p&gt;Before incorporating this script into automated cleaning routines, keep these limitations in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Large File Skip Limit&lt;/strong&gt;: The &lt;code&gt;scan_file&lt;/code&gt; function skips any file exceeding 100 MB. Very large log files or aggregated session archives above this threshold will not be processed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line-by-Line Text Handling&lt;/strong&gt;: The script scans files using line-based string reading (&lt;code&gt;open(..., "r", encoding="utf-8", errors="replace")&lt;/code&gt;). While &lt;code&gt;.sqlite&lt;/code&gt; and &lt;code&gt;.db&lt;/code&gt; file extensions are collected, treating database files as plain text can miss secrets split across database blocks, and modifying them via direct string replacement can damage database file formatting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specific Provider Coverage&lt;/strong&gt;: Secret detection relies on the patterns defined in &lt;code&gt;SECRET_PATTERNS&lt;/code&gt;. Keys from unlisted services or custom corporate authentication tokens will not be recognized unless they conform to generic key-value assignments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overlapping Match Suppression&lt;/strong&gt;: When a regex matches an offset range already claimed by another pattern, subsequent matches within that character span are skipped.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Frequently Asked Questions
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Does the script upload transcripts or telemetry to remote servers?
&lt;/h4&gt;

&lt;p&gt;No. The script uses only local Python standard libraries and performs all directory traversal, scanning, and file rewrites locally on your machine.&lt;/p&gt;

&lt;h4&gt;
  
  
  What happens to my original files when redaction runs?
&lt;/h4&gt;

&lt;p&gt;Before modifying any file containing a secret, &lt;code&gt;redact_file&lt;/code&gt; copies the original file into a specified backup directory, mirroring the source path so you can review or restore the original content if needed.&lt;/p&gt;

&lt;h4&gt;
  
  
  How does the tool avoid flagging placeholder tokens in configuration files?
&lt;/h4&gt;

&lt;p&gt;The detection engine evaluates minimum Shannon entropy thresholds on generic token matches. Repetitive strings, common words, and short sequences are discarded because their calculated randomness falls below the required threshold for cryptographic secrets.&lt;/p&gt;




&lt;p&gt;AgentTrail Scrub is a single Python file that helps with finding and redacting secrets in local AI agent transcripts: &lt;a href="https://py-forge.com/products/agenttrail-scrub-ai-transcript-secret-cleaner?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=agenttrail-scrub-ai-transcript-secret-cleaner" rel="noopener noreferrer"&gt;AgentTrail Scrub on PyForge&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to Audit Python Environments for Malicious .pth Files</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Tue, 27 Oct 2026 10:08:09 +0000</pubDate>
      <link>https://dev.to/alessio99xx/how-to-audit-python-environments-for-malicious-pth-files-9cg</link>
      <guid>https://dev.to/alessio99xx/how-to-audit-python-environments-for-malicious-pth-files-9cg</guid>
      <description>&lt;p&gt;Python startup mechanisms offer convenience for package setup, but they also present subtle opportunities for unwanted persistence. When Python boots, it inspects directory trees for path configuration files (&lt;code&gt;.pth&lt;/code&gt;), as well as user and site customizer scripts. If an untrusted dependency or a compromised package drops files into these locations, code can execute every time the interpreter launches.&lt;/p&gt;

&lt;p&gt;Auditing an environment requires inspecting these startup hooks safely. If you attempt to inspect a compromised environment by running Python scripts that import modules from it, you risk executing the very payloads you are attempting to analyze.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;PthGuard&lt;/code&gt; tool address this problem. It is a standalone, dependency-free scanner designed to perform static read and checksum verification against Python environments without importing or executing target files.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Stealth Startup Hooks in Python
&lt;/h2&gt;

&lt;p&gt;Python path configuration files typically contain directory paths added to &lt;code&gt;sys.path&lt;/code&gt;. However, the Python interpreter supports a feature where lines beginning with &lt;code&gt;import&lt;/code&gt; are evaluated directly during interpreter startup.&lt;/p&gt;

&lt;p&gt;Attackers and compromised packages can exploit this behavior by embedding inline Python commands inside &lt;code&gt;.pth&lt;/code&gt; files. For instance, a single &lt;code&gt;.pth&lt;/code&gt; file can decode strings, spawn subprocesses, or establish network connections before your application code runs. Similar issues affect &lt;code&gt;sitecustomize.py&lt;/code&gt; and &lt;code&gt;usercustomize.py&lt;/code&gt;, which execute automatically whenever Python initialises if they reside within the module search path.&lt;/p&gt;

&lt;p&gt;Additionally, standard package managers track installed files and checksums inside a &lt;code&gt;.dist-info/RECORD&lt;/code&gt; file. Tampered packages might alter existing modules or add rogue unrecorded scripts directly to the package directory. Detecting these modifications requires verifying on-disk files against the signed or recorded standard hashes without running code from the packages themselves.&lt;/p&gt;




&lt;h2&gt;
  
  
  Install &amp;amp; Usage
&lt;/h2&gt;

&lt;p&gt;The scanner relies exclusively on standard library modules (&lt;code&gt;argparse&lt;/code&gt;, &lt;code&gt;base64&lt;/code&gt;, &lt;code&gt;csv&lt;/code&gt;, &lt;code&gt;hashlib&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;os&lt;/code&gt;, &lt;code&gt;pathlib&lt;/code&gt;, &lt;code&gt;re&lt;/code&gt;, &lt;code&gt;sys&lt;/code&gt;, and &lt;code&gt;tempfile&lt;/code&gt;). It does not require any third-party libraries or installation frameworks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Save the File
&lt;/h3&gt;

&lt;p&gt;Save the scanner code locally as &lt;code&gt;pthguard_python_backdoor_scanner.py&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Run the Scanner Under Safe Interpreter Flags
&lt;/h3&gt;

&lt;p&gt;When running any security audit against an existing installation, you should isolate your running Python interpreter. Use the &lt;code&gt;-I&lt;/code&gt; (isolated mode) and &lt;code&gt;-S&lt;/code&gt; (skip site initialization) flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; pthguard_python_backdoor_scanner.py /path/to/target/site-packages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-I&lt;/code&gt; flag tells Python to ignore environment variables such as &lt;code&gt;PYTHONPATH&lt;/code&gt; and prevent the user site-packages directory from being loaded into memory. The &lt;code&gt;-S&lt;/code&gt; flag disables the automatic import of the &lt;code&gt;site&lt;/code&gt; module, preventing the interpreter from parsing rogue &lt;code&gt;.pth&lt;/code&gt; files in your own scanning environment.&lt;/p&gt;

&lt;p&gt;You can inspect specific directories, isolated virtual environments, or individual candidate files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; pthguard_python_backdoor_scanner.py ~/.local/lib/python3.11/site-packages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The scanner evaluates targets through purely static checks and checksum calculations, avoiding dynamic loading.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Pattern Matching on &lt;code&gt;.pth&lt;/code&gt; Files
&lt;/h3&gt;

&lt;p&gt;The scanner reads path files line by line using standard text parsing. It rejects execution and scans each line against common execution indicators:&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="n"&gt;PTH_DANGEROUS_PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&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;^\s*import\s+[a-zA-Z0-9_]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MULTILINE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Direct code execution via &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;import&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; statement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&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;exec\s*\(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Executable &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;exec()&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; call found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&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;eval\s*\(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dynamic &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;eval()&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; call found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&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;base64\.(?:b64decode|decode)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Base64 payload decoding detected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&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;subprocess\.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Subprocess / command execution attempt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&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;os\.system\s*\(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Direct OS shell execution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&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;__import__\s*\(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Obfuscated __import__() call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&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;urllib\.request|urllib2|requests|socket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Network / egress socket invocation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any &lt;code&gt;.pth&lt;/code&gt; file containing executable expressions or raw &lt;code&gt;import&lt;/code&gt; directives triggers a critical alert with the exact line number and character sample.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Checking Customizer Scripts
&lt;/h3&gt;

&lt;p&gt;The scanner checks for &lt;code&gt;sitecustomize.py&lt;/code&gt; and &lt;code&gt;usercustomize.py&lt;/code&gt; files in the target path. These files are matched against expressions associated with credential access (such as references to environment variables or key files), base64 decoding routines, socket creation, and process execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. RECORD Integrity and Tamper Checks
&lt;/h3&gt;

&lt;p&gt;Python wheels follow the PEP 376 format, which stores relative file paths, cryptographic hashes, and byte counts inside &lt;code&gt;dist-info/RECORD&lt;/code&gt;. The scanner reads these records via the standard &lt;code&gt;csv&lt;/code&gt; reader and computes SHA-256 hashes matching the standard format:&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;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_file_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Calculates sha256 in url-safe base64 unpadded (PEP 376 RECORD standard) and byte size.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;hasher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;hasher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;b64_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hasher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ascii&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;b64_hash&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ERR:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script performs three checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Path Traversal Protection:&lt;/strong&gt; Ensures that the relative paths inside &lt;code&gt;RECORD&lt;/code&gt; do not resolve outside the target &lt;code&gt;site-packages&lt;/code&gt; directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash and Size Validation:&lt;/strong&gt; Confirms that on-disk files match the unpadded base64 SHA-256 hash listed in &lt;code&gt;RECORD&lt;/code&gt;. Mismatches indicate altered code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unrecorded File Detection:&lt;/strong&gt; Scans the &lt;code&gt;.dist-info&lt;/code&gt; directory for unexpected files that are not referenced in the manifest.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Persistence File Locations
&lt;/h3&gt;

&lt;p&gt;The scanner evaluates known operating system scratchpads and persistence paths (such as &lt;code&gt;/tmp/.pth&lt;/code&gt;, &lt;code&gt;/var/tmp/.pth&lt;/code&gt;, and autostart directory hooks) to determine if staging artifacts exist outside normal module trees.&lt;/p&gt;




&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;Understanding what static tools cannot do is essential for a thorough audit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Regular Expression Evasion:&lt;/strong&gt; The scanner uses pattern matches to detect suspicious operations. Custom variable indirection, character concatenation, or atypical encodings that do not match the compiled expressions will not be flagged by the pattern rules alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untrusted RECORD Manifests:&lt;/strong&gt; Hash validation verifies that files on disk match the &lt;code&gt;RECORD&lt;/code&gt; file. If a completely malicious package was constructed from the start with matching hashes in its own &lt;code&gt;RECORD&lt;/code&gt;, the hashes will validate cleanly. Hash checking only flags modifications made after initial package installation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Dynamic Execution Analysis:&lt;/strong&gt; The tool does not execute files in an instrumented sandbox. Code paths, dynamically constructed calls, and network communication that occur during regular program flow are outside the scope of static source inspection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope of Unrecorded Files:&lt;/strong&gt; The tool checks for untracked files located directly inside the &lt;code&gt;.dist-info&lt;/code&gt; directories. It does not perform a recursive inventory of every neighboring package subfolder to discover untracked helper scripts unless they are specifically mentioned in a &lt;code&gt;RECORD&lt;/code&gt; manifest.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why run the scanner with &lt;code&gt;python -I -S&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;-I&lt;/code&gt; and &lt;code&gt;-S&lt;/code&gt; options isolate the interpreter. If the Python environment you are using to launch the scanner shares paths with an infected directory, running without these flags could cause Python to load malicious &lt;code&gt;.pth&lt;/code&gt; or &lt;code&gt;sitecustomize.py&lt;/code&gt; files before the scanner even starts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the script automatically delete infected files?
&lt;/h3&gt;

&lt;p&gt;No. The script explicitly avoids mutating or deleting files on disk. Instead, it aggregates findings into structured reports and produces quarantine instructions, leaving administrative review and remediation decisions entirely to the user.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run the scanner offline?
&lt;/h3&gt;

&lt;p&gt;Yes. The scanner operates completely offline. It relies exclusively on local file access, internal regular expressions, local checksum operations, and a bundled offline list of known bad releases. It does not query remote package indexes or network services.&lt;/p&gt;




&lt;p&gt;Full version and the rest of the toolkit: &lt;a href="https://py-forge.com/products/pthguard-python-backdoor-scanner?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=pthguard-python-backdoor-scanner" rel="noopener noreferrer"&gt;PthGuard Python backdoor scanner on PyForge&lt;/a&gt;. Product page: &lt;a href="https://py-forge.com/products/pthguard-python-backdoor-scanner" rel="noopener noreferrer"&gt;https://py-forge.com/products/pthguard-python-backdoor-scanner&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>tooling</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Find the slow span in your traces with a 600-line Python script</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Wed, 21 Oct 2026 00:35:34 +0000</pubDate>
      <link>https://dev.to/alessio99xx/how-to-sift-distributed-traces-and-detect-spans-outliers-in-python-36ma</link>
      <guid>https://dev.to/alessio99xx/how-to-sift-distributed-traces-and-detect-spans-outliers-in-python-36ma</guid>
      <description>&lt;p&gt;You have a pile of exported spans, a p99 that jumped, and no time to click through a tracing UI one trace at a time. What you want is a quick answer to two questions: &lt;strong&gt;which operations are slower than their own normal, and which span in each bad trace is to blame?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;TraceSift is a single Python file (standard library only) that answers that from a JSON file of spans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it without your own data
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate 50 mock traces (about 200 spans) and analyze them&lt;/span&gt;
python3 tracesift.py &lt;span class="nt"&gt;--generate-mock&lt;/span&gt; &lt;span class="nt"&gt;--mock-count&lt;/span&gt; 50 &lt;span class="nt"&gt;-o&lt;/span&gt; spans.json
python3 tracesift.py &lt;span class="nt"&gt;-i&lt;/span&gt; spans.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real output from that run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total Ingested Spans: 206 across 50 traces
Total Anomalies Detected: 13
Root Causes Isolated: 9
------------------------------------------------------------------------------
TOP ROOT CAUSES IDENTIFIED:
  [ROOT_ERROR] Trace: trc-228bb000f -&amp;gt; Culprit: database-cluster::UPDATE inventory_qty (20.1ms, depth=2)
  [LATENCY_BOTTLENECK] Trace: trc-266da0018 -&amp;gt; Culprit: edge-gateway::GET /api/v1/checkout (808.3ms, depth=0)
------------------------------------------------------------------------------
TOP COST OPTIMIZATION OPPORTUNITIES:
  edge-gateway -&amp;gt; GET /healthz: vol=5.34% noise=95% rec_sample=1% | Saving: $0.79/mo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;--demo&lt;/code&gt; runs a built-in self-test and demonstration instead.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What the input looks like
&lt;/h2&gt;

&lt;p&gt;A flat JSON list of spans (or an object with a &lt;code&gt;spans&lt;/code&gt; list). Field names are matched loosely, so &lt;code&gt;trace_id&lt;/code&gt;/&lt;code&gt;traceId&lt;/code&gt;, &lt;code&gt;span_id&lt;/code&gt;/&lt;code&gt;spanId&lt;/code&gt;, &lt;code&gt;parent_span_id&lt;/code&gt;/&lt;code&gt;parentId&lt;/code&gt;, &lt;code&gt;service&lt;/code&gt;/&lt;code&gt;serviceName&lt;/code&gt;, &lt;code&gt;operation&lt;/code&gt;/&lt;code&gt;name&lt;/code&gt; and &lt;code&gt;duration_ms&lt;/code&gt; (or &lt;code&gt;duration_us&lt;/code&gt;, &lt;code&gt;duration_ns&lt;/code&gt;) all work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"t1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"span_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parent_span_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edge-gateway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"operation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GET /api/v1/checkout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;127.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"t1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"span_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parent_span_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"database-cluster"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"operation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UPDATE inventory_qty"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;20.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; read Jaeger, Zipkin or OTLP export files natively. If your backend exports nested formats, flatten them to one object per span first (a few lines of &lt;code&gt;jq&lt;/code&gt; or Python).&lt;/p&gt;

&lt;h2&gt;
  
  
  How it decides
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Anomalies:&lt;/strong&gt; spans are grouped by &lt;code&gt;(service, operation)&lt;/code&gt;. For each group it computes a robust mean and standard deviation, and a span is flagged when it is above that group's p95 &lt;strong&gt;and&lt;/strong&gt; its z-score is at or above the threshold (default 2.5, change it with &lt;code&gt;-z&lt;/code&gt;). Comparing each operation with itself avoids "the DB call is slower than the cache call" noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root cause per bad trace:&lt;/strong&gt; if the trace has failed spans, the culprit is the &lt;strong&gt;deepest&lt;/strong&gt; failed span in the call tree (where the error started, not the gateway that reported it). Otherwise it is the longest span in the trace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sampling suggestions:&lt;/strong&gt; health checks are suggested at 1%, stable error-free operations at 10%, and operations with more than 5% errors are never downsampled.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Reports for CI or a PR
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 tracesift.py &lt;span class="nt"&gt;-i&lt;/span&gt; spans.json &lt;span class="nt"&gt;-z&lt;/span&gt; 3 &lt;span class="nt"&gt;--format&lt;/span&gt; markdown &lt;span class="nt"&gt;-o&lt;/span&gt; trace-report.md
python3 tracesift.py &lt;span class="nt"&gt;-i&lt;/span&gt; spans.json &lt;span class="nt"&gt;--format&lt;/span&gt; json &lt;span class="nt"&gt;-o&lt;/span&gt; trace-report.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Limitations (read these)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The dollar "savings" are a rough estimate, not a bill: it assumes about 600 bytes per span, scales your sample to a monthly volume with a fixed multiplier, and uses &lt;code&gt;--cost-per-gb&lt;/code&gt; (default $0.65). Use it to rank operations, not to promise numbers.&lt;/li&gt;
&lt;li&gt;It works on a file you give it; it is not a live collector or a tracing UI.&lt;/li&gt;
&lt;li&gt;Small samples make z-scores unreliable: with only a handful of spans per operation, expect few or no anomalies.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;TraceSift is on PyForge: &lt;a href="https://py-forge.com/products/tracesift?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tracesift" rel="noopener noreferrer"&gt;get TraceSift&lt;/a&gt; for $49, or get it together with every other PyForge tool, including future ones, with the &lt;a href="https://py-forge.com/pass?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tracesift" rel="noopener noreferrer"&gt;All-Access Pass&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>observability</category>
      <category>devops</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to Find What's Bloating Your Deploy Zip (and Strip It)</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Mon, 19 Oct 2026 23:33:52 +0000</pubDate>
      <link>https://dev.to/alessio99xx/packslim-the-autonomous-engine-for-extreme-bundle-and-container-optimization-in-production-cicd-5f0m</link>
      <guid>https://dev.to/alessio99xx/packslim-the-autonomous-engine-for-extreme-bundle-and-container-optimization-in-production-cicd-5f0m</guid>
      <description>&lt;p&gt;You zip a project folder to upload it to a server or a serverless function, and the archive is a lot bigger than the code you wrote. Usually the extra weight comes from files that never run in production: &lt;code&gt;__pycache__&lt;/code&gt; folders, &lt;code&gt;.map&lt;/code&gt; source maps, old &lt;code&gt;.log&lt;/code&gt; files, &lt;code&gt;.git&lt;/code&gt; leftovers, and the same asset copied twice.&lt;/p&gt;

&lt;p&gt;PackSlim is a small Python script that finds those files, shows how much space each kind takes up, and writes a cleaned &lt;code&gt;zip&lt;/code&gt;, &lt;code&gt;tar.gz&lt;/code&gt; or folder with only the remaining files. This tutorial shows what it really does, what its output looks like on a sample project, and where it can catch you out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install: one file, standard library only
&lt;/h2&gt;

&lt;p&gt;PackSlim is a single &lt;code&gt;.py&lt;/code&gt; file. It imports only the Python standard library (&lt;code&gt;argparse&lt;/code&gt;, &lt;code&gt;fnmatch&lt;/code&gt;, &lt;code&gt;hashlib&lt;/code&gt;, &lt;code&gt;zipfile&lt;/code&gt;, &lt;code&gt;tarfile&lt;/code&gt; and a few others), so you don't need &lt;code&gt;pip install&lt;/code&gt;. It uses the walrus operator, so you need Python 3.8 or newer.&lt;/p&gt;

&lt;p&gt;Save the script as &lt;code&gt;packslim.py&lt;/code&gt; and check the options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python packslim.py &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;usage: PackSlim [-h] [-o OUTPUT] [-f {zip,tar.gz,dir}]
                [-p {standard,aggressive,python,node}] [-x EXCLUDE]
                [--no-dedup] [--dry-run] [--downloads DOWNLOADS]
                [--egress-cost EGRESS_COST] [--json] [--demo]
                [source]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to try it before pointing it at real code, &lt;code&gt;python packslim.py --demo&lt;/code&gt; creates a &lt;code&gt;packslim_demo_sandbox/&lt;/code&gt; folder with fake files and packs it into &lt;code&gt;packslim_demo_output.zip&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: dry run on your project
&lt;/h2&gt;

&lt;p&gt;I made a small sample project: a Flask-style app with a &lt;code&gt;__pycache__&lt;/code&gt; file, a 586 KB source map, a server log, a duplicated minified JS file, a &lt;code&gt;.github/&lt;/code&gt; workflow, a &lt;code&gt;.pytest_cache/&lt;/code&gt; and a &lt;code&gt;.env&lt;/code&gt;. Then I ran an analysis only, with &lt;code&gt;--dry-run&lt;/code&gt; so nothing gets written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python packslim.py myapp &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;".env*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the real output (banner removed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[*] Target Directory  : /tmp/ps/myapp
[*] Active Preset     : STANDARD
[*] Duplicate Purging : ENABLED (SHA-256)
[*] Scanning file hierarchies...

================================================================================
METRIC                                        | OPTIMIZATION VALUE
--------------------------------------------------------------------------------
Source Artifacts Scanned                      | 16 files
Original Footprint                            | 1.06 MB
Production Retained Footprint                 | 195.46 KB (8 files)
Total Dead Bloat Eliminated                   | 888.70 KB (8 files)
Redundant Duplicate Copies Pruned             | 1 duplicates (195.31 KB)
Overall Bundle Reduction Ratio                | 81.97% payload reduction
--------------------------------------------------------------------------------

=== BLOAT CATEGORY BREAKDOWN ===
Category                            | Count      | Payload Eliminated
------------------------------------------------------------------------
Build Logs &amp;amp; Dumps                  | 1          | 78.12 KB
VCS Artifacts                       | 2          | 15.00 B
Custom Excludes                     | 1          | 9.00 B
Caches &amp;amp; Bytecode                   | 2          | 29.30 KB
Source Maps &amp;amp; Debug Symbols         | 1          | 585.94 KB
Exact Duplicate                     | 1          | 195.31 KB
------------------------------------------------------------------------
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The breakdown answers the question "what is bloating my bundle?" by category. In this sample, the source map and the duplicate JS file account for most of the extra weight.&lt;/p&gt;

&lt;p&gt;Below that, PackSlim prints a "financial ROI estimate". Treat it as a rough calculation, not a measurement (see the "Limitations" section).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: write the slim archive
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;-o&lt;/code&gt; to write the output. The format is &lt;code&gt;zip&lt;/code&gt; by default; &lt;code&gt;-f tar.gz&lt;/code&gt; and &lt;code&gt;-f dir&lt;/code&gt; are the other choices.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python packslim.py myapp &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;".env*"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; build/myapp-slim.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[OK] Optimized Package Ready: /tmp/ps/build/myapp-slim.zip
[OK] Final Output Footprint: 196.33 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The zip that came out contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;main.py
requirements.txt
LICENSE
README.md
app/core.py
app/utils.py
static/app.min.js
tests/test_core.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your code, the minified JS and the metadata files are kept. The cache, the log, the source map, the &lt;code&gt;.git&lt;/code&gt;/&lt;code&gt;.github&lt;/code&gt; leftovers, the &lt;code&gt;.env&lt;/code&gt; (excluded with &lt;code&gt;-x&lt;/code&gt;) and the second copy of &lt;code&gt;app.min.js&lt;/code&gt; are left out.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The scan is a plain &lt;code&gt;os.walk&lt;/code&gt;. For each file, the name and every part of its relative path are checked against glob groups, and the first matching group decides the category:&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;for&lt;/span&gt; &lt;span class="n"&gt;grp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patterns&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pattern_groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_matches_any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rel_p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patterns&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;is_bloat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;grp&lt;/span&gt;
        &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Matched filter group &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;grp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Matching runs against each path part, so a directory pattern like &lt;code&gt;__pycache__*&lt;/code&gt; or &lt;code&gt;.git*&lt;/code&gt; catches every file below that directory.&lt;/p&gt;

&lt;p&gt;Files that don't match any pattern are hashed with SHA-256. The first copy of a file is kept, and any later file with the same content is marked "Exact Duplicate". &lt;code&gt;--no-dedup&lt;/code&gt; turns this off.&lt;/p&gt;

&lt;p&gt;Packaging just copies the files that aren't flagged:&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="n"&gt;keep_files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_bloat&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;zip&lt;/code&gt; and &lt;code&gt;tar.gz&lt;/code&gt; outputs use compression level 9.&lt;/p&gt;

&lt;h3&gt;
  
  
  Presets
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;standard&lt;/code&gt;: VCS folders (&lt;code&gt;.git*&lt;/code&gt;, &lt;code&gt;.svn*&lt;/code&gt;, &lt;code&gt;.hg*&lt;/code&gt;), Python and tool caches, &lt;code&gt;*.log&lt;/code&gt;/&lt;code&gt;*.dump&lt;/code&gt;, source maps and &lt;code&gt;.pdb&lt;/code&gt;, swap and PID files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aggressive&lt;/code&gt;: everything in &lt;code&gt;standard&lt;/code&gt;, plus tests and fixtures, docs and &lt;strong&gt;all&lt;/strong&gt; &lt;code&gt;*.md&lt;/code&gt;, &lt;code&gt;*.txt&lt;/code&gt; and &lt;code&gt;LICENSE*&lt;/code&gt; files, and design files (&lt;code&gt;.psd&lt;/code&gt;, &lt;code&gt;.fig&lt;/code&gt;, &lt;code&gt;.sketch&lt;/code&gt;, &lt;code&gt;.ai&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python&lt;/code&gt;: Python caches, &lt;code&gt;*.egg-info&lt;/code&gt;, &lt;code&gt;venv&lt;/code&gt;/&lt;code&gt;.venv&lt;/code&gt;, &lt;code&gt;.env*&lt;/code&gt;, test caches, &lt;code&gt;.vscode&lt;/code&gt;/&lt;code&gt;.idea&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node&lt;/code&gt;: npm/yarn caches, &lt;code&gt;*.tsbuildinfo&lt;/code&gt;, &lt;code&gt;*.map&lt;/code&gt;, docs, test files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Presets &lt;strong&gt;don't stack&lt;/strong&gt;. &lt;code&gt;-p python&lt;/code&gt; doesn't remove &lt;code&gt;.log&lt;/code&gt;, &lt;code&gt;.map&lt;/code&gt; or &lt;code&gt;.git&lt;/code&gt; files, and &lt;code&gt;standard&lt;/code&gt; doesn't remove &lt;code&gt;.env&lt;/code&gt;. To fill the gaps, use &lt;code&gt;-x&lt;/code&gt; (you can repeat it), as in &lt;code&gt;-x ".env*"&lt;/code&gt; above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it in CI
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--json&lt;/code&gt; prints the same statistics as structured data. The exit code is 0 whether or not bloat was found, so if you want a build to fail, check the report yourself. For example, to fail when the source tree contains a source map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python packslim.py myapp &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;".env*"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; packslim-report.json
python - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import json, sys
report = json.load(open("packslim-report.json"))
breakdown = report["statistics"]["bloat_breakdown"]
maps = breakdown.get("Source Maps &amp;amp; Debug Symbols", {}).get("count", 0)
if maps:
    sys.exit(f"{maps} source map(s) found in the source tree")
print("bundle clean")
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the sample project, this printed &lt;code&gt;1 source map(s) found in the source tree&lt;/code&gt; and exited with status 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations (read before shipping)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The output must be outside the source folder.&lt;/strong&gt; PackSlim refuses (exit code 2) if &lt;code&gt;-o&lt;/code&gt; is the source folder, sits inside it, or is a parent of it. With &lt;code&gt;-f dir&lt;/code&gt;, an existing output folder is replaced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No per-file report and no "keep" list.&lt;/strong&gt; The dashboard and the JSON only show counts by category. To see exactly what was removed, write with &lt;code&gt;-f dir&lt;/code&gt; to a new folder and diff it against the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aggressive&lt;/code&gt; removes files you may need&lt;/strong&gt;: &lt;code&gt;requirements.txt&lt;/code&gt; (every &lt;code&gt;*.txt&lt;/code&gt;), &lt;code&gt;LICENSE&lt;/code&gt;, &lt;code&gt;README.md&lt;/code&gt;, and any path part starting with &lt;code&gt;test&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates are removed, not linked.&lt;/strong&gt; If your code loads both copies by path, the second path will be missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ROI numbers are an estimate.&lt;/strong&gt; The egress figure is removed bytes × &lt;code&gt;--downloads&lt;/code&gt; × &lt;code&gt;--egress-cost&lt;/code&gt;. CI build count (1200/month) and network speed (120 Mbps) are fixed in the code. Set the two CLI values to your own numbers, or ignore that section.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.gitignore&lt;/code&gt; and &lt;code&gt;.github/&lt;/code&gt; count as "VCS Artifacts", which is fine for a deploy bundle but surprising the first time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does it change my project folder?&lt;/strong&gt;&lt;br&gt;
No. The source is only read. The output has to be a path outside the source folder; with &lt;code&gt;-f dir&lt;/code&gt;, an existing output folder is replaced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it shrink the code itself, like minifying?&lt;/strong&gt;&lt;br&gt;
No. It works on whole files: each one is either kept or left out. It doesn't edit file contents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use it on a JavaScript project?&lt;/strong&gt;&lt;br&gt;
Yes. Matching is by file name, so &lt;code&gt;-p node&lt;/code&gt; (or &lt;code&gt;standard&lt;/code&gt; for &lt;code&gt;.map&lt;/code&gt; and log files) works on any folder. It doesn't read &lt;code&gt;package.json&lt;/code&gt; or resolve imports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does &lt;code&gt;--demo&lt;/code&gt; do?&lt;/strong&gt;&lt;br&gt;
It creates &lt;code&gt;./packslim_demo_sandbox&lt;/code&gt; with sample files and writes &lt;code&gt;./packslim_demo_output.zip&lt;/code&gt; in the current folder.&lt;/p&gt;




&lt;p&gt;Full version and the rest of the toolkit: &lt;a href="https://py-forge.com/products/packslim?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=packslim" rel="noopener noreferrer"&gt;PackSlim on PyForge&lt;/a&gt;. Product page: &lt;a href="https://py-forge.com/products/packslim" rel="noopener noreferrer"&gt;https://py-forge.com/products/packslim&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Or get all 23 tools with the &lt;a href="https://py-forge.com/pass?utm_source=devto&amp;amp;utm_medium=organic&amp;amp;utm_campaign=packslim" rel="noopener noreferrer"&gt;All-Access Pass for $99&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>devops</category>
      <category>cli</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Detect Breaking OpenAPI Changes in CI with a Zero-Dependency Python Diff</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Mon, 19 Oct 2026 16:53:17 +0000</pubDate>
      <link>https://dev.to/alessio99xx/specdiff-next-generation-semantic-api-contract-diffing-and-breaking-change-prevention-for-modern-j0a</link>
      <guid>https://dev.to/alessio99xx/specdiff-next-generation-semantic-api-contract-diffing-and-breaking-change-prevention-for-modern-j0a</guid>
      <description>&lt;p&gt;Breaking an API usually doesn't happen on purpose. Someone renames a field in a shared &lt;code&gt;User&lt;/code&gt; schema, makes an optional request field required, or drops an old endpoint. The OpenAPI file changes by a few lines, the PR looks harmless, and a mobile client that's three versions behind starts failing. A plain text diff of &lt;code&gt;openapi.json&lt;/code&gt; can't tell you which changes break clients. A semantic diff can.&lt;/p&gt;

&lt;p&gt;SpecDiff is a single-file Python tool (standard library only) that compares two OpenAPI JSON documents and sorts every difference into &lt;strong&gt;BREAKING&lt;/strong&gt;, &lt;strong&gt;WARNING&lt;/strong&gt; or &lt;strong&gt;INFO&lt;/strong&gt;. It knows direction: removing a field from a &lt;em&gt;response&lt;/em&gt; breaks clients, while making a &lt;em&gt;request&lt;/em&gt; field required breaks callers. With &lt;code&gt;--fail-on-breaking&lt;/code&gt;, it exits with code 2 so CI can block the merge. Everything below was run locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two versions of a spec
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;openapi.main.json&lt;/code&gt; is a small Users API with &lt;code&gt;GET /users/{id}&lt;/code&gt; (returns &lt;code&gt;#/components/schemas/User&lt;/code&gt;), &lt;code&gt;POST /users&lt;/code&gt; (accepts &lt;code&gt;NewUser&lt;/code&gt;) and &lt;code&gt;DELETE /users/{id}/avatar&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The PR version, &lt;code&gt;openapi.pr.json&lt;/code&gt;, does four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;removes &lt;code&gt;email&lt;/code&gt; from the &lt;code&gt;User&lt;/code&gt; component schema;&lt;/li&gt;
&lt;li&gt;makes &lt;code&gt;name&lt;/code&gt; required in &lt;code&gt;NewUser&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;marks &lt;code&gt;DELETE /users/{id}/avatar&lt;/code&gt; as deprecated;&lt;/li&gt;
&lt;li&gt;adds &lt;code&gt;GET /users&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 specdiff.py &lt;span class="nt"&gt;-b&lt;/span&gt; openapi.main.json &lt;span class="nt"&gt;-t&lt;/span&gt; openapi.pr.json &lt;span class="nt"&gt;--no-color&lt;/span&gt; &lt;span class="nt"&gt;--fail-on-breaking&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[SpecDiff Contract Verification Report]

Summary: 2 BREAKING | 1 WARNINGS | 1 INFO

[BREAKING] Property -&amp;gt; paths./users/{id}.GET.responses.200.application/json.schema.properties.email
         Property 'email' removed from response payload
[WARNING]  Deprecation -&amp;gt; paths./users/{id}/avatar.DELETE
         Operation DELETE /users/{id}/avatar has been marked as deprecated
[INFO]     Operation -&amp;gt; paths./users.GET
         HTTP Method GET added to /users
[BREAKING] Constraint -&amp;gt; paths./users.POST.requestBody.content.application/json.schema.required[name]
         Existing property 'name' marked as REQUIRED
exit 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both breaking changes were made inside &lt;code&gt;components/schemas&lt;/code&gt;, not in the path definitions. SpecDiff follows the local &lt;code&gt;$ref&lt;/code&gt; pointers (&lt;code&gt;#/components/...&lt;/code&gt;) and reports each finding at the endpoint where clients will notice it, which is the useful place for a reviewer.&lt;/p&gt;

&lt;p&gt;A purely additive change (a new optional &lt;code&gt;avatar_url&lt;/code&gt; field and a new &lt;code&gt;GET /users&lt;/code&gt;) passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Summary: 0 BREAKING | 0 WARNINGS | 2 INFO

[INFO]     Operation -&amp;gt; paths./users.GET
         HTTP Method GET added to /users
[INFO]     Property -&amp;gt; paths./users/{id}.GET.responses.200.application/json.schema.properties.avatar_url
         Property 'avatar_url' added (optional)
exit 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it checks
&lt;/h2&gt;

&lt;p&gt;From the rules in the source:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;BREAKING&lt;/th&gt;
&lt;th&gt;Non-breaking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Endpoints / methods&lt;/td&gt;
&lt;td&gt;endpoint or method removed&lt;/td&gt;
&lt;td&gt;endpoint or method added (INFO), operation deprecated (WARNING)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parameters&lt;/td&gt;
&lt;td&gt;parameter removed, new required parameter, optional → required&lt;/td&gt;
&lt;td&gt;new optional parameter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request body&lt;/td&gt;
&lt;td&gt;body now required, media type removed, request property made required&lt;/td&gt;
&lt;td&gt;media type added&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Responses&lt;/td&gt;
&lt;td&gt;status code removed, response media type removed, response property removed&lt;/td&gt;
&lt;td&gt;new status code or media type, new optional property&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schemas&lt;/td&gt;
&lt;td&gt;data type changed (&lt;code&gt;string&lt;/code&gt; → &lt;code&gt;integer&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Reports for PRs and tooling
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;-f markdown&lt;/code&gt; writes a report you can post as a PR comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**Status**&lt;/span&gt;: ❌ CONTRACT BREACH DETECTED

| Metric | Count |
| :--- | :--- |
| &lt;span class="gs"&gt;**Breaking Changes**&lt;/span&gt; | 2 |
| &lt;span class="gs"&gt;**Warnings / Deprecations**&lt;/span&gt; | 1 |
| &lt;span class="gs"&gt;**Informational**&lt;/span&gt; | 1 |

&lt;span class="gu"&gt;### 🚨 Breaking Changes&lt;/span&gt;
| Category | Target Path | Issue Description |
| :--- | :--- | :--- |
| &lt;span class="sb"&gt;`Constraint`&lt;/span&gt; | &lt;span class="sb"&gt;`paths./users.POST.requestBody...required[name]`&lt;/span&gt; | Existing property 'name' marked as REQUIRED |
| &lt;span class="sb"&gt;`Property`&lt;/span&gt; | &lt;span class="sb"&gt;`paths./users/{id}.GET.responses.200...properties.email`&lt;/span&gt; | Property 'email' removed from response payload |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-f json&lt;/code&gt; gives a summary plus one object per finding, with the old and new value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"total_diffs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"breaking"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"warning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"divergences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BREAKING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Property"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paths./users/{id}.GET.responses.200.application/json.schema.properties.email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Property 'email' removed from response payload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"old_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"new_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  In CI
&lt;/h2&gt;

&lt;p&gt;Compare the spec on the PR branch with the one on &lt;code&gt;main&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;API contract check&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;git fetch origin main --depth=1&lt;/span&gt;
    &lt;span class="s"&gt;git show origin/main:openapi.json &amp;gt; /tmp/openapi.main.json&lt;/span&gt;
    &lt;span class="s"&gt;python3 tools/specdiff.py -b /tmp/openapi.main.json -t openapi.json \&lt;/span&gt;
      &lt;span class="s"&gt;--fail-on-breaking --no-color&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there's a breaking change the step fails with exit 2. Without &lt;code&gt;--fail-on-breaking&lt;/code&gt; the report is informational and the exit code is 0. When a breaking change is intentional (a new major version), skip the check for that PR or compare against the new version's baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it without your own spec
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 specdiff.py &lt;span class="nt"&gt;--demo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo runs the engine against two built-in sample specs, so you can see the output format first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON only.&lt;/strong&gt; For YAML specs, convert first, e.g. &lt;code&gt;yq -o=json openapi.yaml &amp;gt; openapi.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Only &lt;strong&gt;local&lt;/strong&gt; references (&lt;code&gt;#/components/...&lt;/code&gt;) are resolved. Specs split over several files need bundling into one first (most OpenAPI tooling has a "bundle" command).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;oneOf&lt;/code&gt; / &lt;code&gt;anyOf&lt;/code&gt; / &lt;code&gt;allOf&lt;/code&gt; compositions and enum value changes aren't analysed.&lt;/li&gt;
&lt;li&gt;Constraint changes like &lt;code&gt;maxLength&lt;/code&gt;, &lt;code&gt;pattern&lt;/code&gt; or &lt;code&gt;minimum&lt;/code&gt; aren't classified.&lt;/li&gt;
&lt;li&gt;It reports changes. It doesn't check that your implementation matches the spec.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;OpenAPI 3.0 or 3.1?&lt;/strong&gt; It reads the standard &lt;code&gt;paths&lt;/code&gt; / &lt;code&gt;components&lt;/code&gt; structure that both share. Swagger 2.0 (&lt;code&gt;definitions&lt;/code&gt;) isn't the target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependencies?&lt;/strong&gt; None. Python 3 and the standard library.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I hide colours in logs?&lt;/strong&gt; Yes, &lt;code&gt;--no-color&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;SpecDiff is a one-time purchase that includes the source and README: &lt;a href="https://py-forge.com/products/specdiff?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=specdiff" rel="noopener noreferrer"&gt;get SpecDiff ($19)&lt;/a&gt;. More details are on the &lt;a href="https://py-forge.com/products/specdiff" rel="noopener noreferrer"&gt;SpecDiff product page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>openapi</category>
      <category>apidesign</category>
      <category>cicd</category>
      <category>python</category>
    </item>
    <item>
      <title>Find Your API's p99 Latency Spikes with a Zero-Dependency Python Benchmark</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:41:52 +0000</pubDate>
      <link>https://dev.to/alessio99xx/apibench-the-enterprise-standard-for-high-throughput-api-benchmarking-and-agentic-reliability-212m</link>
      <guid>https://dev.to/alessio99xx/apibench-the-enterprise-standard-for-high-throughput-api-benchmarking-and-agentic-reliability-212m</guid>
      <description>&lt;p&gt;Your API's median latency looks fine, but users keep reporting the occasional request that hangs for a second. Averages hide that. Percentiles show it, and you don't need k6, Locust or a JVM to measure them. ApiBench is one Python file, standard library only, that fires concurrent requests at an endpoint and reports p50/p90/p95/p99. It exits non-zero when your p99 or error-rate budget is blown, so it can gate a CI job.&lt;/p&gt;

&lt;p&gt;All the numbers in this post come from runs on my machine against a small local test server. They show how the tool behaves; they aren't benchmarks of anything you run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and first run
&lt;/h2&gt;

&lt;p&gt;Unzip the download and check it runs (any recent Python 3, nothing to &lt;code&gt;pip install&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 apibench.py &lt;span class="nt"&gt;--demo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 100 &lt;span class="nt"&gt;-c&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--demo&lt;/code&gt; starts a built-in mock server on &lt;code&gt;127.0.0.1:8765&lt;/code&gt; that sleeps 15 ms per request, and benchmarks it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Completed Reqs:    100 in 0.332s
 SLA Gatekeeper:    [PASS]
 Throughput:        301.55 req/sec (14.36 KB/s)
 Success Rate:      100/100 (100.00%)
 Median (p50):         16.17 ms
 p99:                  18.06 ms  (Target SLA: &amp;lt; 250.0 ms)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler sleeps 15 ms, the median is 16 ms, and the p99 sits right next to it: about a millisecond of client and loopback overhead on top of the work. That's what a healthy tail looks like. The rest of this post is about the unhealthy kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real investigation: where does a 1-second p99 come from?
&lt;/h2&gt;

&lt;p&gt;I wrote a minimal JSON endpoint with Python's &lt;code&gt;ThreadingHTTPServer&lt;/code&gt; and hit it with 500 requests from 20 workers, with a 50 ms p99 budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 apibench.py http://127.0.0.1:9000/health &lt;span class="nt"&gt;-n&lt;/span&gt; 500 &lt;span class="nt"&gt;-c&lt;/span&gt; 20 &lt;span class="nt"&gt;--sla-p99&lt;/span&gt; 50 &lt;span class="nt"&gt;--json&lt;/span&gt; r.json &lt;span class="nt"&gt;--markdown&lt;/span&gt; r.md
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Completed Reqs:    500 in 1.236s
 SLA Gatekeeper:    [FAIL]
 Throughput:        404.58 req/sec (4.74 KB/s)
 Success Rate:      500/500 (100.00%)

--- Latency Profile (Wall Time) ---
 Min:                   0.90 ms
 Mean:                 28.71 ms
 Median (p50):          1.72 ms
 p90:                   2.98 ms
 p95:                   4.86 ms
 p99:                1023.13 ms  (Target SLA: &amp;lt; 50.0 ms)
 Max:                1212.12 ms
 StdDev:              164.09 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code: &lt;code&gt;2&lt;/code&gt;. Every request succeeded, the median is under 2 ms, and yet the slowest 1% take about a second. A mean of 28 ms describes no request that actually happened.&lt;/p&gt;

&lt;p&gt;A delay of about one second with no errors is the signature of TCP SYN retransmission. When a server's listen backlog is full, the kernel drops the new connection attempt, and the client retries it after the initial retransmission timeout of one second. &lt;code&gt;socketserver&lt;/code&gt; defaults to &lt;code&gt;request_queue_size = 5&lt;/code&gt;, and ApiBench opens a fresh connection for each request, so 20 workers overflow it. To test that theory, I changed one line in the server:&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;class&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ThreadingHTTPServer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;request_queue_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same command, same machine:&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;backlog 5 (default)&lt;/th&gt;
&lt;th&gt;backlog 128&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;390.41 req/s&lt;/td&gt;
&lt;td&gt;3696.94 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;1.82 ms&lt;/td&gt;
&lt;td&gt;4.91 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;6.61 ms&lt;/td&gt;
&lt;td&gt;5.92 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;1029.44 ms&lt;/td&gt;
&lt;td&gt;8.87 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max&lt;/td&gt;
&lt;td&gt;1237.26 ms&lt;/td&gt;
&lt;td&gt;13.77 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exit code&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(This table comes from a second pair of runs, so the backlog-5 column differs slightly from the output above.)&lt;/p&gt;

&lt;p&gt;The median went &lt;em&gt;up&lt;/em&gt; a little, because the server now accepts more connections at once and they compete for the CPU. The tail collapsed, though, and throughput went up almost tenfold because clients stopped sleeping in retransmission. That's why you gate on p99 and not on the average.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gating CI on latency and errors
&lt;/h2&gt;

&lt;p&gt;The exit code is the whole integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt;: p99 ≤ &lt;code&gt;--sla-p99&lt;/code&gt; (default 250 ms) &lt;strong&gt;and&lt;/strong&gt; error rate ≤ 1%&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt;: either budget was exceeded&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt;: bad invocation, such as a missing URL
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 apibench.py &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAGING_URL&lt;/span&gt;&lt;span class="s2"&gt;/health"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 300 &lt;span class="nt"&gt;-c&lt;/span&gt; 10 &lt;span class="nt"&gt;--sla-p99&lt;/span&gt; 200 &lt;span class="nt"&gt;--markdown&lt;/span&gt; apibench.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any status outside 200–399 counts as an error. I ran it against an endpoint that returns a random 503 about 10% of the time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Success Rate:      269/300 (89.67%)
 Error Rate:        10.33%
 p99:                  15.59 ms  (Target SLA: &amp;lt; 250.0 ms)

--- Status Code Breakdown ---
 Status 200  -&amp;gt;    269 ( 89.7%)
 Status 503  -&amp;gt;     31 ( 10.3%)

--- Failure Diagnostics ---
  - [31x] HTTP Status 503
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Latency passed, errors didn't: exit &lt;code&gt;2&lt;/code&gt;. A slow endpoint fails the other way. Against a handler that sleeps 300 ms, p99 came out at 304.99 ms against a 250 ms budget, and the exit code was again &lt;code&gt;2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--json&lt;/code&gt; writes every metric (&lt;code&gt;latency_p99_ms&lt;/code&gt;, &lt;code&gt;error_rate_pct&lt;/code&gt;, &lt;code&gt;status_codes&lt;/code&gt;, &lt;code&gt;overall_sla_passed&lt;/code&gt; and so on), which is useful for tracking trends between builds. &lt;code&gt;--markdown&lt;/code&gt; writes a table you can post as a PR comment.&lt;/p&gt;

&lt;p&gt;Other options: &lt;code&gt;-m POST -d '{"q":1}'&lt;/code&gt; for a request body, &lt;code&gt;-H 'Authorization: Bearer …'&lt;/code&gt; (repeatable) for headers, and &lt;code&gt;-t&lt;/code&gt; for the per-request timeout. HTTPS works with the default certificate verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Each request is timed around a raw &lt;code&gt;http.client&lt;/code&gt; call, so the measurement covers connect, send, the server's work and reading the full body:&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="n"&gt;start_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HTTPConnection&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="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getresponse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;latency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1000.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; with &lt;code&gt;-c&lt;/code&gt; workers runs &lt;code&gt;-n&lt;/code&gt; of these. Percentiles use linear interpolation between the closest ranks rather than picking the nearest sample:&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="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sorted_values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;percentile&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;c&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;sorted_values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&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;sorted_values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sorted_values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gate is just &lt;code&gt;p99 &amp;lt;= sla_p99_ms and error_rate &amp;lt;= sla_max_error_rate&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new connection for every request.&lt;/strong&gt; There's no keep-alive, so the numbers include the TCP handshake (and the TLS handshake on HTTPS). That's realistic for clients that don't pool connections, but pessimistic compared with a browser or a pooled HTTP client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threads, not an event loop.&lt;/strong&gt; It's fine for tens or low hundreds of concurrent workers. If you need tens of thousands of virtual users, use a distributed load tester.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed request count, not a duration or a ramp.&lt;/strong&gt; Each run sends &lt;code&gt;-n&lt;/code&gt; identical requests, with no scenario scripting.&lt;/li&gt;
&lt;li&gt;The "Efficiency Score" and cost projection in the report are simple formulas based on your SLA and the &lt;code&gt;--cost-per-million&lt;/code&gt; you pass in. Use the percentiles and the exit code to make decisions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I benchmark production with it?&lt;/strong&gt; Technically yes, but every request is real traffic. Point it at staging or a health endpoint, and keep &lt;code&gt;-n&lt;/code&gt; and &lt;code&gt;-c&lt;/code&gt; modest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why do my numbers differ from wrk or k6?&lt;/strong&gt; Those tools reuse connections. ApiBench doesn't, so its latencies include the connection setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it need anything installed?&lt;/strong&gt; No, only the Python standard library.&lt;/p&gt;




&lt;p&gt;ApiBench is a one-time purchase that includes the single-file source and README: &lt;a href="https://py-forge.com/products/apibench?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=apibench" rel="noopener noreferrer"&gt;get ApiBench ($29)&lt;/a&gt;. Details are on the &lt;a href="https://py-forge.com/products/apibench" rel="noopener noreferrer"&gt;ApiBench product page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>api</category>
      <category>testing</category>
      <category>cli</category>
    </item>
    <item>
      <title>Mask PII in Logs, CSV and JSON with Python (and Keep the Data Joinable)</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:35:08 +0000</pubDate>
      <link>https://dev.to/alessio99xx/datamask-high-performance-data-masking-and-real-time-pii-anonymization-for-modern-engineering-teams-10ij</link>
      <guid>https://dev.to/alessio99xx/datamask-high-performance-data-masking-and-real-time-pii-anonymization-for-modern-engineering-teams-10ij</guid>
      <description>&lt;p&gt;Somebody needs a copy of production data to debug an issue, or a support ticket export has to go to a contractor, or your application logs are about to be shipped to a third-party service. Each time, emails, card numbers and phone numbers go along unless you strip them first. DataMask is a single Python file, standard library only, that masks that kind of PII in plain text, CSV and JSON. With a secret key it can replace values with stable tokens, so the masked data can still be joined and counted.&lt;/p&gt;

&lt;p&gt;Everything below was run locally with the shipped &lt;code&gt;datamask.py&lt;/code&gt;. The outputs are copied as-is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it detects
&lt;/h2&gt;

&lt;p&gt;The built-in rules are regular expressions for:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Example input&lt;/th&gt;
&lt;th&gt;Default output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jane.doe@example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;j******e@example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Card (16 digits, with or without separators)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4111 1111 1111 1111&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;****-****-****-1111&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;US SSN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;123-45-6789&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[SSN_REDACTED]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;North American phone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;415-555-0132&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(415) ***-0132&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPv4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.10.24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.0.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;key=value&lt;/code&gt; secrets (&lt;code&gt;api_key&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;) with a value of 16+ chars&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api_key=sk_test_51Hx…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[GENERIC_SECRET_REDACTED]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Masking free text (logs, tickets, notes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'Contact jane.doe@example.com or call +1 (415) 555-0132.\nCard 4111 1111 1111 1111, SSN 123-45-6789, IP 192.168.10.24\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.txt
python3 datamask.py &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.txt &lt;span class="nt"&gt;-k&lt;/span&gt; demo-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Contact j******e@example.com or call +(415) ***-0132.
Card ****-****-****-1111, SSN [SSN_REDACTED], IP 192.168.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The defaults keep enough to stay useful: the email domain, the last four digits of the card, the phone's area code and the /16 of the IP. To mask everything with one strategy, pass &lt;code&gt;--strategy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--strategy REDACT
Contact [EMAIL_REDACTED] or call +[PHONE_REDACTED].
Card [CREDIT_CARD_REDACTED], SSN [SSN_REDACTED], IP [IPV4_REDACTED]

--strategy TOKENIZE
Contact TOK_16978AC475DBF78A or call +TOK_407080C9ACCD2486.
Card TOK_7B8FCD949B6A4A3A, SSN TOK_39D12AE6849AA1EB, IP TOK_5052DA002CECEAC6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also reads stdin and writes stdout, so it fits in a pipe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; app.log | python3 datamask.py &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATAMASK_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; app.masked.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Tokens you can still join on
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TOKENIZE&lt;/code&gt; isn't random. It's an HMAC-SHA256 of the value under your key, truncated to 16 hex characters:&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;def&lt;/span&gt; &lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secret_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TOK_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same input with the same key always gives the same token, whether you pass the key with &lt;code&gt;-k&lt;/code&gt; or through the &lt;code&gt;DATAMASK_KEY&lt;/code&gt; environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ada.park@example.com"&lt;/span&gt; | python3 datamask.py &lt;span class="nt"&gt;-k&lt;/span&gt; team-secret &lt;span class="nt"&gt;--strategy&lt;/span&gt; TOKENIZE
TOK_58D0726A2F40DC76
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ada.park@example.com"&lt;/span&gt; | &lt;span class="nv"&gt;DATAMASK_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;team-secret python3 datamask.py &lt;span class="nt"&gt;--strategy&lt;/span&gt; TOKENIZE
TOK_58D0726A2F40DC76
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So an analyst can still count orders per customer, or join the masked users table to the masked orders table, without ever seeing an address. Without a key, DataMask generates a random one for each run, so tokens won't match across runs. Keep the key in your secrets manager, not next to the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSV with per-column rules
&lt;/h2&gt;

&lt;p&gt;For structured files you choose a strategy per column in a small JSON file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TOKENIZE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REDACT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"card"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PARTIAL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PARTIAL"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;users.csv
&lt;span class="nb"&gt;id&lt;/span&gt;,name,email,phone,card,notes
1,Ada Park,ada.park@example.com,415-555-0101,4242 4242 4242 4242,VIP since 2021
2,Luis Ortega,luis@example.org,212-555-0199,5555-5555-5555-4444,called from 10.0.4.17

python3 datamask.py &lt;span class="nt"&gt;-f&lt;/span&gt; csv &lt;span class="nt"&gt;-i&lt;/span&gt; users.csv &lt;span class="nt"&gt;--rules&lt;/span&gt; rules.json &lt;span class="nt"&gt;-k&lt;/span&gt; team-secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;id,name,email,phone,card,notes
1,[CONFIDENTIAL],TOK_58D0726A2F40DC76,(415) ***-0101,****-****-****-4242,VIP since 2021
2,[CONFIDENTIAL],TOK_9E2DBA2E53D1D11F,(212) ***-0199,****-****-****-4444,called from 10.0.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Columns without a rule still go through the text scanner, which is why the IP buried in &lt;code&gt;notes&lt;/code&gt; came out as &lt;code&gt;10.0.0.0&lt;/code&gt;. The CSV is processed row by row with &lt;code&gt;csv.DictReader&lt;/code&gt;, so large files don't need to fit in memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON, at any depth
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"user":{"id":7,"email":"ada.park@example.com","note":"reach me at 415-555-0101"},"orders":[{"card":"4242424242424242"}]}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ev.json
python3 datamask.py &lt;span class="nt"&gt;-f&lt;/span&gt; json &lt;span class="nt"&gt;-i&lt;/span&gt; ev.json &lt;span class="nt"&gt;-k&lt;/span&gt; team-secret &lt;span class="nt"&gt;--rules&lt;/span&gt; rules.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TOK_58D0726A2F40DC76"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reach me at (415) ***-0101"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"card"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"****-****-****-4242"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules match on key names at any depth, and every other string value is scanned. Numbers and booleans are left alone. Notice that the email token is identical to the one in the CSV: same key, same value, same token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it from Python
&lt;/h2&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;datamask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DataMaskEngine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MaskingStrategy&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DataMaskEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmac_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;team-secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mask_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refund to jane.doe@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# refund to j******e@example.com
&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mask_structure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MaskingStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TOKENIZE&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes it easy to wire into a &lt;code&gt;logging.Filter&lt;/code&gt; or a serializer before data leaves the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Each rule is a compiled regex, a default strategy and an optional format-preserving replacer. &lt;code&gt;mask_text&lt;/code&gt; applies them in order:&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;for&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strategy_override&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_strategy&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;MaskingStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PARTIAL&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;custom_replacer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sanitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;custom_replacer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sanitized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;MaskingStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TOKENIZE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sanitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;sanitized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mask_structure&lt;/code&gt; walks dicts and lists recursively. Keys with a rule get that strategy; other strings go through &lt;code&gt;mask_text&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It matches patterns, it doesn't understand language.&lt;/strong&gt; Names and street addresses in free text aren't detected. "Ada Park" stays as it is unless it sits in a column or key you've given a rule. Use field rules for structured data.&lt;/li&gt;
&lt;li&gt;The phone and SSN rules are North American formats. Other countries' numbers and 15-digit Amex cards aren't covered by the default rules.&lt;/li&gt;
&lt;li&gt;The secret rule looks for &lt;code&gt;api_key=&lt;/code&gt;, &lt;code&gt;secret:&lt;/code&gt; and &lt;code&gt;token=&lt;/code&gt; style pairs and &lt;code&gt;Bearer &amp;lt;token&amp;gt;&lt;/code&gt; values (for example &lt;code&gt;Authorization: Bearer …&lt;/code&gt; headers), with tokens of 16+ characters. Other credential formats need a custom rule.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HASH&lt;/code&gt; is a plain SHA-256 with no key. For low-entropy values such as phone numbers, use &lt;code&gt;TOKENIZE&lt;/code&gt; (keyed) instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is this GDPR/HIPAA compliance?&lt;/strong&gt; No tool makes you compliant by itself. DataMask helps with data minimisation and pseudonymisation; whether that's sufficient depends on your data and your process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it need dependencies?&lt;/strong&gt; No, it's standard library only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I add my own patterns?&lt;/strong&gt; Yes. The rules are a list of &lt;code&gt;PIIRule(name, sensitivity, pattern, default_strategy, custom_replacer)&lt;/code&gt; objects in the source, so you can append your own (an internal customer-ID format, for example).&lt;/p&gt;




&lt;p&gt;DataMask is a one-time purchase that includes the source and README: &lt;a href="https://py-forge.com/products/datamask?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=datamask" rel="noopener noreferrer"&gt;get DataMask ($19)&lt;/a&gt;. More details are on the &lt;a href="https://py-forge.com/products/datamask" rel="noopener noreferrer"&gt;DataMask product page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>security</category>
      <category>logging</category>
      <category>python</category>
    </item>
    <item>
      <title>Test Retries and Timeouts for Real: an HTTP Fault-Injection Proxy in Python</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Sun, 18 Oct 2026 04:26:57 +0000</pubDate>
      <link>https://dev.to/alessio99xx/chaosproxy-bulletproof-your-microservices-with-automated-fault-injection-and-network-chaos-testing-ini</link>
      <guid>https://dev.to/alessio99xx/chaosproxy-bulletproof-your-microservices-with-automated-fault-injection-and-network-chaos-testing-ini</guid>
      <description>&lt;p&gt;Your retry logic, timeouts and circuit breakers only get tested when a dependency actually misbehaves, and that usually happens in production. A fault-injection proxy lets you cause that on purpose. Put it between your service and the dependency, then tell it to add 1.5 s of latency, return a 503 on 30% of requests, or hang up mid-request.&lt;/p&gt;

&lt;p&gt;ChaosProxy is a small reverse proxy written in Python on aiohttp that does this. Rules are managed at runtime over a JSON API, so you can turn faults on and off from a test script without restarting anything. Everything below was run locally.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;aiohttp multidict
python3 chaosproxy.py &lt;span class="nt"&gt;--upstream&lt;/span&gt; http://127.0.0.1:5700 &lt;span class="nt"&gt;--port&lt;/span&gt; 8780 &lt;span class="nt"&gt;--host&lt;/span&gt; 127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point your client at &lt;code&gt;http://localhost:8780&lt;/code&gt; instead of the real service. With no rules, requests pass straight through, including method, query string, body and headers (hop-by-hop headers are filtered out). For this post the upstream is a tiny API that returns an order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8780/orders/1001
&lt;span class="c"&gt;# {"id": "1001", "status": "paid"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fault 1: latency
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8780/__chaos/rules &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "rule_id": "slow-orders",
  "path_pattern": "^/orders/",
  "fault_type": "latency",
  "params": {"delay_seconds": 1.5, "jitter_seconds": 0.3}
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"rule_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"slow-orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path_pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^/orders/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fault_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"latency"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"delay_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jitter_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} in %{time_total}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; localhost:8780/orders/1001
&lt;span class="c"&gt;# 200 in 1.634716s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request still reaches the upstream and returns 200, just late. That's how you find out whether your client's 1 s timeout really fires, and what the user sees when it does.&lt;/p&gt;

&lt;p&gt;Remove the rule when you're done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; DELETE localhost:8780/__chaos/rules/slow-orders
&lt;span class="c"&gt;# {"status": "deleted", "rule_id": "slow-orders"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fault 2: intermittent errors
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;probability&lt;/code&gt; makes a rule fire on a fraction of matching requests, and &lt;code&gt;method&lt;/code&gt; limits it to one HTTP verb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8780/__chaos/rules &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "rule_id": "flaky", "path_pattern": "^/orders/", "method": "GET",
  "probability": 0.3, "fault_type": "http_error",
  "params": {"status_code": 503, "body": {"error": "upstream unavailable"}}
}'&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 20&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} "&lt;/span&gt; localhost:8780/orders/&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;503 200 200 200 200 200 200 503 200 200 200 200 200 200 503 200 503 200 503 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5 of 20, close to the 30% asked for. This is the realistic case for testing retries with backoff. Failed requests return your JSON body with the status you chose, and they never reach the upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fault 3: dropped connections and truncated bodies
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8780/__chaos/rules &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"rule_id":"drop","path_pattern":"^/orders/9$","fault_type":"connection_drop"}'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8780/orders/9&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"curl exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# curl exit 52   (empty reply from server)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;payload_corrupt&lt;/code&gt; fetches the real response and then truncates it (&lt;code&gt;"strategy": "truncate"&lt;/code&gt;) or flips random bytes (any other strategy value):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8780/__chaos/rules &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"rule_id":"trunc","path_pattern":"^/orders/7$","fault_type":"payload_corrupt","params":{"strategy":"truncate"}}'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8780/orders/7
&lt;span class="c"&gt;# {"id": "7", "s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's exactly what a JSON parser hates. Good for checking that your client reports a clear error instead of crashing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing what happened
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8780/__chaos/rules     &lt;span class="c"&gt;# active rules&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8780/__chaos/stats
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"requests_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"requests_proxied"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"faults_injected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"fault_breakdown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"latency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"http_error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"payload_corrupt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"connection_drop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every injected fault is also logged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[WARNING] [ChaosProxy] INJECTING FAULT [HTTP_ERROR] via rule [flaky] on GET /orders/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Using it in an automated test
&lt;/h2&gt;

&lt;p&gt;Because rules are just HTTP calls, a test can switch a fault on, assert on behaviour, and clean up:&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;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;CHAOS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8780/__chaos/rules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_checkout_survives_slow_payments&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CHAOS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rule_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slow-pay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path_pattern&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^/payments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                               &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fault_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;params&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delay_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}})&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&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;post&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://localhost:3000/checkout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;          &lt;span class="c1"&gt;# we expect async fallback, not a 500
&lt;/span&gt;    &lt;span class="k"&gt;finally&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;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CHAOS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/slow-pay&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;h2&gt;
  
  
  How matching works
&lt;/h2&gt;

&lt;p&gt;Each rule has a regex &lt;code&gt;path_pattern&lt;/code&gt; matched against the path plus query string, a &lt;code&gt;method&lt;/code&gt; (&lt;code&gt;*&lt;/code&gt; by default), a &lt;code&gt;probability&lt;/code&gt; between 0 and 1, and an &lt;code&gt;enabled&lt;/code&gt; flag. Rules are checked in the order they were created, and the first match wins. Paths under &lt;code&gt;/__chaos&lt;/code&gt; are reserved for the control API and never forwarded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The control API has no authentication.&lt;/strong&gt; Anyone who can reach the port can add rules. The default bind address is &lt;code&gt;0.0.0.0&lt;/code&gt;, so pass &lt;code&gt;--host 127.0.0.1&lt;/code&gt; (as above) or keep it on an isolated test network. Don't expose it publicly.&lt;/li&gt;
&lt;li&gt;Rules live in memory. Restarting the proxy clears them, so re-create them from your test setup.&lt;/li&gt;
&lt;li&gt;One upstream per proxy instance. Run several instances on different ports for several dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;payload_corrupt&lt;/code&gt; reads the whole upstream response into memory before corrupting it, so it isn't meant for very large downloads. Normal forwarding streams.&lt;/li&gt;
&lt;li&gt;HTTP only. There's no TCP-level chaos (packet loss, bandwidth limits) and no TLS termination. Put it on the plain-HTTP side of your stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does it change responses when no rule matches?&lt;/strong&gt; No. Status, headers and body are relayed as received, including compressed bodies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the default upstream?&lt;/strong&gt; &lt;code&gt;https://httpbin.org&lt;/code&gt;. Always pass &lt;code&gt;--upstream&lt;/code&gt; for real use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python version?&lt;/strong&gt; Python 3 with &lt;code&gt;aiohttp&lt;/code&gt; and &lt;code&gt;multidict&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;ChaosProxy is a one-time purchase that includes the source and README: &lt;a href="https://py-forge.com/products/chaosproxy?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=chaosproxy" rel="noopener noreferrer"&gt;get ChaosProxy ($29)&lt;/a&gt;. More details are on the &lt;a href="https://py-forge.com/products/chaosproxy" rel="noopener noreferrer"&gt;ChaosProxy product page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>chaosengineering</category>
      <category>testing</category>
      <category>microservices</category>
      <category>resilience</category>
    </item>
    <item>
      <title>A Tamper-Evident Audit Log in Python and SQLite: Hash Chains, Verification and Exports</title>
      <dc:creator>Alessio </dc:creator>
      <pubDate>Sat, 17 Oct 2026 16:19:41 +0000</pubDate>
      <link>https://dev.to/alessio99xx/auditlog-designing-production-grade-tamper-evident-audit-trails-for-modern-cloud-architectures-4fck</link>
      <guid>https://dev.to/alessio99xx/auditlog-designing-production-grade-tamper-evident-audit-trails-for-modern-cloud-architectures-4fck</guid>
      <description>&lt;p&gt;Sooner or later someone asks "who changed this, and can you prove the log wasn't edited?" If your audit trail is a normal table, the honest answer is no. Anyone with write access to the database can &lt;code&gt;UPDATE&lt;/code&gt; a row, and nothing will look different afterwards.&lt;/p&gt;

&lt;p&gt;A hash chain is the usual way to make edits detectable. Each record stores the SHA-256 of its own contents plus the hash of the record before it. Change any field in any row and its hash no longer matches. Delete a row and the next record points at a hash that isn't there anymore.&lt;/p&gt;

&lt;p&gt;This post walks through AuditLog, a single-file Python tool (standard library only) that keeps that kind of chain in SQLite. Everything below was run locally against the shipped file. Only the IDs and timestamps will differ on your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recording events
&lt;/h2&gt;

&lt;p&gt;Each event has an actor, an action, a resource, a status and arbitrary JSON metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 auditlog.py &lt;span class="nt"&gt;--db&lt;/span&gt; ledger.db record &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--actor&lt;/span&gt; user_42 &lt;span class="nt"&gt;--action&lt;/span&gt; document.sign &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource&lt;/span&gt; doc_981 &lt;span class="nt"&gt;--resource-type&lt;/span&gt; contract &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metadata&lt;/span&gt; &lt;span class="s1"&gt;'{"ip": "10.0.0.7"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9577713c-7243-4e0f-835c-b787e096d48c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-20T00:21:20.788253+00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actor_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actor_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document.sign"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resource_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"doc_981"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resource_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"contract"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SUCCESS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.0.0.7"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previous_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0000000000000000000000000000000000000000000000000000000000000000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aa10ac7caf7b5bd18f5af000f4189d899a2d636e8f56672c0042b3d76ff93150"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first record links to an all-zero genesis hash. I added two more: a service creating an invoice, and a denied privilege escalation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 auditlog.py &lt;span class="nt"&gt;--db&lt;/span&gt; ledger.db record &lt;span class="nt"&gt;--actor&lt;/span&gt; svc_billing &lt;span class="nt"&gt;--actor-type&lt;/span&gt; service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; invoice.create &lt;span class="nt"&gt;--resource&lt;/span&gt; inv_2026_114
python3 auditlog.py &lt;span class="nt"&gt;--db&lt;/span&gt; ledger.db record &lt;span class="nt"&gt;--actor&lt;/span&gt; user_7 &lt;span class="nt"&gt;--action&lt;/span&gt; role.grant &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource&lt;/span&gt; rbac:admin &lt;span class="nt"&gt;--status&lt;/span&gt; DENIED &lt;span class="nt"&gt;--metadata&lt;/span&gt; &lt;span class="s1"&gt;'{"reason": "not_in_group"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--status&lt;/code&gt; accepts &lt;code&gt;SUCCESS&lt;/code&gt;, &lt;code&gt;FAILURE&lt;/code&gt; or &lt;code&gt;DENIED&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Listing and filtering
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 auditlog.py &lt;span class="nt"&gt;--db&lt;/span&gt; ledger.db list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Retrieved 3 events:
[2026-10-20T00:21:20.788253+00:00] user_42 -&amp;gt; document.sign on doc_981 [SUCCESS] (Hash: aa10ac7caf7b...)
[2026-10-20T00:21:20.843074+00:00] svc_billing -&amp;gt; invoice.create on inv_2026_114 [SUCCESS] (Hash: cb076a346079...)
[2026-10-20T00:21:21.189529+00:00] user_7 -&amp;gt; role.grant on rbac:admin [DENIED] (Hash: bf448e23db4f...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--actor&lt;/code&gt;, &lt;code&gt;--action&lt;/code&gt; and &lt;code&gt;--limit&lt;/code&gt; narrow it down. For example, &lt;code&gt;list --actor user_7&lt;/code&gt; returns only the denied grant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the chain and catching an edit
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 auditlog.py &lt;span class="nt"&gt;--db&lt;/span&gt; ledger.db verify&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[PASS] Cryptographic audit chain verified. Zero tampering detected.
exit 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now play the insider and make the denied request look approved, straight in SQLite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;audit_trail&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'SUCCESS'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;actor_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'user_7'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[FAIL] Audit chain verification failed! Tampering detected:
  - Tampered Payload at sequence 2 (Event ID: 7039abbf-...). Stored: bf448e23db4f886c..., Computed: 72319ad9d48b1a54...
exit 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code is the useful part. &lt;code&gt;0&lt;/code&gt; means intact and &lt;code&gt;2&lt;/code&gt; means tampered, so &lt;code&gt;verify&lt;/code&gt; can run as a nightly cron or CI job that alerts on anything non-zero. Deleting a row from the middle or the start is caught too, and reported as a &lt;code&gt;Broken Link&lt;/code&gt; because the next record's &lt;code&gt;previous_hash&lt;/code&gt; no longer matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the hash is built
&lt;/h2&gt;

&lt;p&gt;The hash covers a canonical JSON serialisation of every field, including the previous hash:&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;def&lt;/span&gt; &lt;span class="nf"&gt;compute_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actor_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actor_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actor_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resource_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resource_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;previous_hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;previous_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# serialised with sorted keys, then SHA-256
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sorted keys keep the bytes stable, so the same event always gives the same hash, on any machine.&lt;/p&gt;

&lt;p&gt;Appending is done in one SQLite write transaction (&lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt;). The tool reads the current tip and inserts the new record before another writer can get in. I ran 8 processes writing 40 events each into the same file at the same time. The result was 320 rows, and &lt;code&gt;verify&lt;/code&gt; passed. On this box, 500 sequential writes from Python took about 0.3 s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it from Python
&lt;/h2&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;auditlog&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AuditLogger&lt;/span&gt;

&lt;span class="n"&gt;audit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AuditLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ledger.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inv_2026_114&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUCCESS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4900&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;problems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify_integrity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Exporting for a review
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 auditlog.py &lt;span class="nt"&gt;--db&lt;/span&gt; ledger.db &lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nt"&gt;--out&lt;/span&gt; audit.csv &lt;span class="nt"&gt;--format&lt;/span&gt; csv
python3 auditlog.py &lt;span class="nt"&gt;--db&lt;/span&gt; ledger.db &lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nt"&gt;--out&lt;/span&gt; audit.jsonl &lt;span class="nt"&gt;--format&lt;/span&gt; jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both include &lt;code&gt;previous_hash&lt;/code&gt; and &lt;code&gt;event_hash&lt;/code&gt;, so a reviewer can re-check the chain without your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tamper-evident, not tamper-proof.&lt;/strong&gt; The hashes are plain SHA-256 with no secret key. Someone with write access to the file and this source code could rewrite a row &lt;em&gt;and&lt;/em&gt; recompute every hash after it. The standard defence is anchoring: copy the latest &lt;code&gt;event_hash&lt;/code&gt; somewhere the database admin can't edit, like a daily line in a separate log, a ticket or an object-lock bucket. Then compare it during review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting the newest records isn't detectable from the chain alone,&lt;/strong&gt; because nothing points forward to them. Anchoring the tip solves this as well.&lt;/li&gt;
&lt;li&gt;It's SQLite, so all writers must share one database file on a local disk. It isn't a multi-node log service.&lt;/li&gt;
&lt;li&gt;There's no built-in date-range filter on &lt;code&gt;list&lt;/code&gt;. Use the export, or query the &lt;code&gt;audit_trail&lt;/code&gt; table directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this make me SOC 2 / ISO 27001 compliant?&lt;/strong&gt; No tool does that by itself. It gives you an append-only audit trail with integrity checks and exports, which is evidence auditors commonly ask for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I add my own fields?&lt;/strong&gt; Put them in &lt;code&gt;metadata&lt;/code&gt;. It's part of the hash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Python version?&lt;/strong&gt; Python 3.9+ and the standard library. There's nothing to &lt;code&gt;pip install&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;AuditLog is a one-time purchase that includes the source and README: &lt;a href="https://py-forge.com/products/auditlog?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=auditlog" rel="noopener noreferrer"&gt;get AuditLog ($29)&lt;/a&gt;. More details are on the &lt;a href="https://py-forge.com/products/auditlog" rel="noopener noreferrer"&gt;AuditLog product page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>security</category>
      <category>compliance</category>
      <category>logging</category>
    </item>
  </channel>
</rss>
