<?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: Victor Gutierrez Areyzaga</title>
    <description>The latest articles on DEV Community by Victor Gutierrez Areyzaga (@victor_areyzaga).</description>
    <link>https://dev.to/victor_areyzaga</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%2F3990954%2F920d1a7c-47b3-4559-ba8d-809182c3b43e.png</url>
      <title>DEV Community: Victor Gutierrez Areyzaga</title>
      <link>https://dev.to/victor_areyzaga</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/victor_areyzaga"/>
    <language>en</language>
    <item>
      <title>The Missing Check After Your Database Query</title>
      <dc:creator>Victor Gutierrez Areyzaga</dc:creator>
      <pubDate>Thu, 25 Jun 2026 15:37:37 +0000</pubDate>
      <link>https://dev.to/victor_areyzaga/the-missing-check-after-your-database-query-4b49</link>
      <guid>https://dev.to/victor_areyzaga/the-missing-check-after-your-database-query-4b49</guid>
      <description>&lt;p&gt;We have tools for checking whether a query is injectable. We have linters, scanners, ORMs, parameterized queries, and database policies. But after the database returns rows, most applications simply trust that the result set matches the operation that asked for it.&lt;/p&gt;

&lt;p&gt;queryguard starts there.&lt;/p&gt;




&lt;h2&gt;
  
  
  The query may be safe. The result may still be wrong.
&lt;/h2&gt;

&lt;p&gt;SQL injection taught us to distrust query construction. Parameterized queries answered the question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Did the user control the query structure?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That question is well understood. The tooling is mature. But it is a different question from the one queryguard asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Did this operation receive only the rows and fields it was allowed to receive?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those two questions are not the same. A perfectly safe parameterized query can still return the wrong row — because a predicate was dropped, a join widened the result, a developer selected a column they shouldn't have, or a query was rewritten without updating its scope contract.&lt;/p&gt;

&lt;p&gt;queryguard is not a database firewall. It is not a SQL injection scanner. It is not an ORM plugin. It is a contract check for observed result sets.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where it sits
&lt;/h2&gt;

&lt;p&gt;The hook position is the core design decision. queryguard sits immediately after cursor execution — before any result shaping, filtering, serialization, or response mapping.&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;cursor&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;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bindings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&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="n"&gt;queryguard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contract&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;contract_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;user_profile_lookup&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;contract_version&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;0.1.0&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;user_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;user_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;session&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;tenant_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;tenant_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;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;PASS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;QueryguardViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evidence&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;rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not at the HTTP layer. Not inside the ORM. Not at the API gateway. Immediately after the cursor returns rows — while the result is still raw, before anything shapes or discards it.&lt;/p&gt;

&lt;p&gt;This is intentional. If rows are shaped before queryguard sees them, queryguard cannot detect violations in the discarded or modified data. The adapter-risk demo in the live lab shows this explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The contract
&lt;/h2&gt;

&lt;p&gt;Each named operation has a contract that declares what its result set is allowed to look like.&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="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user_profile_lookup&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
&lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fetch one user profile by id&lt;/span&gt;

&lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cardinality&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;max_rows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;name&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;email&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;avatar_url&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;created_at&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;name&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;email&lt;/span&gt;
    &lt;span class="na"&gt;forbidden&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;password_hash&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;reset_token&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mfa_secret&lt;/span&gt;

  &lt;span class="na"&gt;row_constraints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
        &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;equals&lt;/span&gt;
        &lt;span class="na"&gt;value_from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;params.user_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract declares intent, not SQL. queryguard never sees the query that ran. It only sees the contract and the result, and it asks: does the result match the declared scope?&lt;/p&gt;

&lt;p&gt;The checks cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cardinality&lt;/strong&gt; — did the operation return the expected number of rows?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field allowlist&lt;/strong&gt; — does every row contain only declared fields?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Required fields&lt;/strong&gt; — is every required field present?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forbidden fields&lt;/strong&gt; — did any sensitive field leak into the result?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Row constraints&lt;/strong&gt; — does every row satisfy the declared predicates?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each executed check produces a finding. Gateway checks can stop evaluation early when later checks would not be meaningful, but once the input envelope, contract, and result shape are valid, row and field checks run completely so multiple violations can be reported together. The evidence includes a &lt;code&gt;contract_hash&lt;/code&gt; — a SHA-256 of the full contract body — so you can prove not just which contract identity was claimed, but which exact policy was applied. The &lt;code&gt;evidence_hash&lt;/code&gt; then covers the contract body hash, the contract identity, the params, the session, and the result — so the record ties the observed rows to the exact policy used and changes when the verification inputs change.&lt;/p&gt;




&lt;h2&gt;
  
  
  The live DB proof
&lt;/h2&gt;

&lt;p&gt;The real test is against real SQL. The live lab creates an in-memory SQLite database, seeds it with users, orders, and invoices, executes actual queries, and passes the raw cursor results to queryguard.&lt;/p&gt;

&lt;p&gt;Nine cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;SQL&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Login clean&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE email = ?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login tautology&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE email = ? OR 1=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FAIL — cardinality, row_constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile clean&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE id = ?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile wrong row&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WHERE id = 2&lt;/code&gt; with &lt;code&gt;params.user_id = 1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;FAIL — row_constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile forbidden column&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SELECT ... password_hash ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FAIL — field_allowlist, forbidden_fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orders clean&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE customer_id = ?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orders missing predicate&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SELECT id, customer_id, status, total, created_at, updated_at FROM orders&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FAIL — row_constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoice clean&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE id = ? AND tenant_id = ?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoice missing tenant predicate&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WHERE id = ?&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;FAIL — row_constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Case 2 is the one worth pausing on. The query &lt;code&gt;WHERE email = ? OR 1=1&lt;/code&gt; returns every user in the database. The login contract says &lt;code&gt;max_rows: 1&lt;/code&gt; and &lt;code&gt;email must equal params.email&lt;/code&gt;. queryguard catches the widened result on cardinality and row_constraints — before the application ever sees it.&lt;/p&gt;

&lt;p&gt;Case 4 shows something different: the query is safe, the parameterization is correct, but the hardcoded predicate &lt;code&gt;WHERE id = 2&lt;/code&gt; returns the wrong row for &lt;code&gt;params.user_id = 1&lt;/code&gt;. No injection. Clean SQL. Wrong result. queryguard catches it.&lt;/p&gt;

&lt;p&gt;The adapter-risk demo shows the inverse: if SQL returns &lt;code&gt;password_hash&lt;/code&gt; but an adapter strips it before queryguard sees the rows, queryguard returns PASS. The violation was hidden upstream. The rule is absolute — queryguard must sit before any shaping.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;p&gt;queryguard v0.1 is narrow by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adapter shaping can hide violations.&lt;/strong&gt; queryguard only verifies the rows it receives. If an adapter filters fields or rows before verification, queryguard cannot detect what was removed. The checker must run immediately after query execution and before any result shaping, filtering, serialization, or response mapping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row uniqueness is not enforced.&lt;/strong&gt; Three identical authorized rows produce PASS, because every row satisfies the declared constraints. Detection of duplicate rows requires either a &lt;code&gt;max_rows&lt;/code&gt; cardinality constraint or a future &lt;code&gt;unique_by&lt;/code&gt; primitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nested result rows are not verified.&lt;/strong&gt; A row containing a nested object produces &lt;code&gt;UNKNOWN&lt;/code&gt;. Flat rows only in v0.1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write operations are out of scope.&lt;/strong&gt; No INSERT, UPDATE, DELETE. No result set, nothing to verify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregates are out of scope.&lt;/strong&gt; COUNT, SUM, GROUP BY — row-level predicate enforcement requires individual rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row order affects the evidence hash.&lt;/strong&gt; The same logical result in a different order produces a different &lt;code&gt;evidence_hash&lt;/code&gt;. Use a deterministic &lt;code&gt;ORDER BY&lt;/code&gt; when reproducible hashes matter.&lt;/p&gt;

&lt;p&gt;These are not oversights. They are the boundary of the one claim.&lt;/p&gt;




&lt;h2&gt;
  
  
  The test suite
&lt;/h2&gt;

&lt;p&gt;The embedded suite has 59 cases across five batches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Documented&lt;/strong&gt; — clean reads, tautology injection, cross-user rows, forbidden columns, nested objects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adversarial batches 1–4&lt;/strong&gt; — malformed contracts, hostile envelopes, null/bool coercion bypass, contract body substitution, cyclic structures, depth-exceeded values&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta-tests&lt;/strong&gt; — determinism, hash sensitivity to result changes, hash sensitivity to contract body changes, row-order behavior, duplicate-row known limitation, shared Python reference detection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The adversarial suite exists because a checker that catches bad results but crashes on bad inputs is not a reliable checker. The bounded JSON-domain walk handles depth &amp;gt; 64, node count &amp;gt; 10,000, and cyclic Python structures without crashing. Every failure returns structured evidence.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters — and where it should go next
&lt;/h2&gt;

&lt;p&gt;SQL injection gave us decades of tooling for one side of the database boundary: the query going in. queryguard is an attempt at the other side: the result coming out.&lt;/p&gt;

&lt;p&gt;It turns "I think this query returns the right data" into a testable, evidence-producing assertion. But v0.1 is narrow by design, and there are open questions I haven't fully answered yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does the contract lifecycle look like at scale?&lt;/strong&gt; Right now contracts are simple versioned operation definitions, treated as immutable once published. That works for a lab. It gets harder when a schema changes and fifty contracts need updating. How do teams manage that without the manifest going stale?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where should this live in a real pipeline?&lt;/strong&gt; I've placed it at the cursor boundary, immediately after rows are returned. In real applications, the nearest practical hook might be a repository layer, database access wrapper, or ORM-adjacent interception point — but the rule stays the same: verify before shaping, filtering, serialization, or response mapping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the one-thing discipline the right call for v0.2?&lt;/strong&gt; Row uniqueness, nested rows, and write authorization are explicitly out of scope. Each could become its own tool in the same family. But maybe the right move is a more complete result verifier rather than three separate tools. I genuinely don't know yet.&lt;/p&gt;

&lt;p&gt;If you've hit similar gaps — results you couldn't trust, predicates that got dropped, columns that leaked into responses — I'd like to hear how you've handled it. And if you run the suite or the live lab and find something that should fail but doesn't, open an issue.&lt;/p&gt;

&lt;p&gt;The scope is narrow. The claim is specific. That's where v0.1 has to start.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;queryguard v0.1&lt;/strong&gt; — &lt;a href="https://github.com/rodrigo-areyzaga/queryguard" rel="noopener noreferrer"&gt;github.com/rodrigo-areyzaga/queryguard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Single file. Zero dependencies. &lt;code&gt;python queryguard.py&lt;/code&gt; to run the suite. &lt;code&gt;python live_db_lab.py&lt;/code&gt; for the SQLite validation.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The Tool Found Corridor Nodes — But the Bigger Finding Was Where It Found None</title>
      <dc:creator>Victor Gutierrez Areyzaga</dc:creator>
      <pubDate>Tue, 23 Jun 2026 15:36:00 +0000</pubDate>
      <link>https://dev.to/victor_areyzaga/the-tool-found-corridor-nodes-but-the-bigger-finding-was-where-it-found-none-2m33</link>
      <guid>https://dev.to/victor_areyzaga/the-tool-found-corridor-nodes-but-the-bigger-finding-was-where-it-found-none-2m33</guid>
      <description>&lt;p&gt;A few weeks ago I published &lt;a href="https://dev.to/victor_areyzaga/the-service-that-stored-nothing-sensitive-but-still-became-high-priority-40c4"&gt;corridor-lab&lt;/a&gt; — a Docker lab that proved a triage mismatch: a service that stores nothing sensitive can become high-priority because of where it sits in the path to a sensitive downstream system.&lt;/p&gt;

&lt;p&gt;The lab proved the premise. The next question was whether a tool could identify those nodes automatically — without manual path declaration, without value labels, from graph position alone.&lt;/p&gt;

&lt;p&gt;So I built corridor-id. You point it at a Docker Compose file. It discovers the topology, computes depth from exposed surfaces, and identifies which nodes expand forward reach into deeper parts of the environment. No asset-value labels. No sensitivity ratings. No human classification. Reach and graph position only.&lt;/p&gt;

&lt;p&gt;Then I pointed it at four architecturally different Docker environments.&lt;/p&gt;

&lt;p&gt;Two had corridor nodes. Two had none.&lt;/p&gt;

&lt;p&gt;Both answers were useful. But the zero-corridor results taught me more than the positive ones.&lt;/p&gt;




&lt;h2&gt;
  
  
  What corridor-id does
&lt;/h2&gt;

&lt;p&gt;The tool reads a Docker Compose file and builds a reachability graph from service definitions, network memberships, and port mappings. It then orients that graph from exposed surfaces using BFS and identifies nodes that provide forward reach — access to strictly deeper nodes that the exposed surface cannot reach directly.&lt;/p&gt;

&lt;p&gt;The output is a ranked list with two metrics: exposure distance (how close to the surface) and forward reach gain (how many deeper nodes become reachable through this node).&lt;/p&gt;

&lt;p&gt;One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python corridor-id.py docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No manual path declaration. No value labels. No configuration. From graph position alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  The four tests
&lt;/h2&gt;

&lt;h3&gt;
  
  
  corridor-lab — segmented, depth 3
&lt;/h3&gt;

&lt;p&gt;My own lab, five services across five segmented networks. The tool independently identified &lt;code&gt;status-api&lt;/code&gt; as a corridor node — the same finding the lab was built to prove.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Corridor nodes found: 3

  → status-api
    Exposure distance: 1
    Forward reach gain: 1

  → log-monitor
    Exposure distance: 1
    Forward reach gain: 1

  → internal-admin-api
    Exposure distance: 2
    Forward reach gain: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was validation that the tool's graph logic matched the lab's manual analysis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sock Shop — segmented test variant, depth 3
&lt;/h3&gt;

&lt;p&gt;Weaveworks' microservices demo with production-reasonable network segmentation applied for analysis. The segmentation is not how the original Compose file ships. The point was not to claim Sock Shop is segmented by default — the point was to test whether corridor-id could identify corridor nodes when meaningful network depth exists. Fifteen services, seven networks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Corridor nodes found: 6

  → front-end
    Exposure distance: 1
    Forward reach gain: 6

  → orders
    Exposure distance: 2
    Forward reach gain: 3

  → shipping
    Exposure distance: 2
    Forward reach gain: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;front-end&lt;/code&gt; ranked first because it bridges the edge tier into six application services. &lt;code&gt;orders&lt;/code&gt; ranked second because it bridges the app tier into both a data tier and a queue tier — disproportionate reach for a single service. The tool surfaced these findings without knowing what any service does.&lt;/p&gt;

&lt;h3&gt;
  
  
  OWASP crAPI — flat, no segmentation
&lt;/h3&gt;

&lt;p&gt;Ten services. One default network. Every service can reach every other service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Corridor nodes found: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Docker Example Voting App — segmented-looking but flat in practice
&lt;/h3&gt;

&lt;p&gt;Five services. Two networks. But both exposed services sit on both networks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Corridor nodes found: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What the zero-corridor results actually mean
&lt;/h2&gt;

&lt;p&gt;My first instinct when crAPI returned zero corridor nodes was that the tool had nothing to say about it. No finding. Nothing to report.&lt;/p&gt;

&lt;p&gt;That instinct was wrong.&lt;/p&gt;

&lt;p&gt;crAPI returned zero corridor nodes because it has no network segmentation. All ten services share one default network. There are no corridors because there are no walls. Every service is already reachable from every other service.&lt;/p&gt;

&lt;p&gt;The voting app was subtler. It has two named networks — &lt;code&gt;front-tier&lt;/code&gt; and &lt;code&gt;back-tier&lt;/code&gt; — which looks like segmentation. But both exposed services (&lt;code&gt;vote&lt;/code&gt; and &lt;code&gt;result&lt;/code&gt;) sit on both networks. So the back-tier services are all directly reachable from the exposed surface at depth 1. The segmentation exists in the Compose file but doesn't create depth.&lt;/p&gt;

&lt;p&gt;The tool correctly identified both cases. It didn't just fail to find corridor nodes — it surfaced that the architecture lacks the conditions for corridor nodes to exist.&lt;/p&gt;

&lt;p&gt;In a segmented topology, corridor-id finds corridor nodes.&lt;/p&gt;

&lt;p&gt;In a flat topology, corridor-id finds something different: there are no corridors because there are no meaningful zones. That is not a clean bill of health. A zero-corridor result has two meanings. In a well-segmented topology, it may mean no node expands reach — genuinely low corridor exposure. In a flat topology, it means the corridor model collapsed because everything is already in the same reachability zone.&lt;/p&gt;




&lt;h2&gt;
  
  
  The distinction that matters
&lt;/h2&gt;

&lt;p&gt;Corridor topology: some nodes control movement between zones. Those are corridor nodes and they deserve monitoring proportional to their reach.&lt;/p&gt;

&lt;p&gt;Flat topology: there are no meaningful zones, so there are no corridor nodes. But the absence of corridors means every service sits in the same reachability zone. The path problem doesn't go away — it becomes universal.&lt;/p&gt;

&lt;p&gt;The tool can distinguish between these two conditions. That distinction is, I think, the more interesting finding.&lt;/p&gt;




&lt;h2&gt;
  
  
  What corridor-id does not claim
&lt;/h2&gt;

&lt;p&gt;The four environments I tested are demo and security training applications — not production systems. The flat-network pattern was common in these samples, but I'm not claiming that "most containerized applications in the wild" lack segmentation. I'm saying that in the containerized architectures I tested, flat networking was common enough that the absence of corridor nodes became its own finding.&lt;/p&gt;

&lt;p&gt;The tool also has known limitations: it computes a global depth map across all exposed nodes (which can suppress per-entry corridor findings), it treats localhost-bound ports as exposed, and it doesn't filter Compose profiles. All documented in the repo.&lt;/p&gt;

&lt;p&gt;This is a v0.2 tool with a narrow promise: given a topology, identify which nodes are corridor nodes. It does that one thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/rodrigo-areyzaga/accguard" rel="noopener noreferrer"&gt;accguard&lt;/a&gt; tests whether authorization boundaries hold under replay.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/rodrigo-areyzaga/corridor-lab" rel="noopener noreferrer"&gt;corridor-lab&lt;/a&gt; proves that a service's risk depends on where it sits in the path, not what it stores.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/rodrigo-areyzaga/corridor-id" rel="noopener noreferrer"&gt;corridor-id&lt;/a&gt; identifies corridor nodes from topology — automatically, from graph position alone.&lt;/p&gt;

&lt;p&gt;Each one asks the same question at a different layer: &lt;strong&gt;does the security posture match what the architecture actually allows?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/rodrigo-areyzaga/corridor-id" rel="noopener noreferrer"&gt;github.com/rodrigo-areyzaga/corridor-id&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/rodrigo-areyzaga/corridor-id
&lt;span class="nb"&gt;cd &lt;/span&gt;corridor-id
pip &lt;span class="nb"&gt;install &lt;/span&gt;pyyaml
python corridor-id.py your-docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core topology model is format-agnostic and has been validated against hand-built topologies with no parser involved. Docker Compose is the first input adapter. The identifier logic doesn't know or care where the topology came from.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on how this was built
&lt;/h2&gt;

&lt;p&gt;corridor-id was developed with AI assistance. Claude and ChatGPT were used as pair-programming and review tools — for implementation, stress-testing edge cases, and catching two parser bugs before release. The concept, security framing, testing direction, and all accept/reject decisions were human-directed.&lt;/p&gt;




&lt;p&gt;I'm still exploring this. The harder question — how to move from topology analysis to detection triggers on corridor nodes — is the next problem. If you've thought about it, or if you think the framework is wrong, I'd like to hear it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>docker</category>
      <category>appsec</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The Service That Stored Nothing Sensitive But Still Became High Priority</title>
      <dc:creator>Victor Gutierrez Areyzaga</dc:creator>
      <pubDate>Thu, 18 Jun 2026 15:26:50 +0000</pubDate>
      <link>https://dev.to/victor_areyzaga/the-service-that-stored-nothing-sensitive-but-still-became-high-priority-40c4</link>
      <guid>https://dev.to/victor_areyzaga/the-service-that-stored-nothing-sensitive-but-still-became-high-priority-40c4</guid>
      <description>&lt;p&gt;I kept noticing a mismatch between how defenders prioritize assets and how attackers actually move through environments.&lt;/p&gt;

&lt;p&gt;The standard model goes like this: classify your assets by what they contain — customer data, credentials, financial records — and allocate your hardening and monitoring effort proportionally. Crown jewels get the most attention. A health-check service with no sensitive data gets the minimum.&lt;/p&gt;

&lt;p&gt;The problem is that attackers don't move through environments the way this model assumes. They often do not target the most valuable asset first. They target the most useful reachable path. And that path often runs through something that received less hardening, less monitoring, or less ownership precisely because it looked unimportant on its own.&lt;/p&gt;

&lt;p&gt;So I built a small Docker lab to test whether the scoring model was the problem — and whether switching from value-indexed to path-indexed triage changed the answer for the same asset in the same environment.&lt;/p&gt;

&lt;p&gt;It did. Significantly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core distinction
&lt;/h2&gt;

&lt;p&gt;Before the lab: two ways of scoring an asset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Value-indexed triage&lt;/strong&gt; asks: what does this asset directly contain?&lt;/p&gt;

&lt;p&gt;A database with customer PII scores high. A status service with no stored data scores low. The score reflects the asset's direct data classification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path-indexed triage&lt;/strong&gt; asks: what can this asset help an attacker reach?&lt;/p&gt;

&lt;p&gt;A status service with no stored data but with internal reach to a privileged API scores high — not because of what it holds, but because of where it sits in the environment. Its position in the graph is what matters.&lt;/p&gt;

&lt;p&gt;The asset I wanted to test this against is what I started calling a &lt;strong&gt;corridor node&lt;/strong&gt;: a service that appears low-priority under data classification but becomes high-priority because it sits between an exposed entry point and a sensitive downstream system.&lt;/p&gt;




&lt;h2&gt;
  
  
  The lab topology
&lt;/h2&gt;

&lt;p&gt;Five services. One corridor node. One monitoring blind spot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[public-web]          ← internet-facing entry point
     |
     v
[status-api]          ← corridor node
     |
     v
[internal-admin-api]  ← high-value control plane
     |
     v
[customer-db]         ← sensitive asset

[log-monitor]         ← collects logs from public-web and internal-admin-api
                        does NOT collect logs from status-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service is a minimal Python/Flask application. The Docker Compose networks enforce the topology structurally — not just in application code. &lt;code&gt;public-web&lt;/code&gt; has no direct network path to &lt;code&gt;internal-admin-api&lt;/code&gt; or &lt;code&gt;customer-db&lt;/code&gt;. The only route is through &lt;code&gt;status-api&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The corridor node: status-api
&lt;/h2&gt;

&lt;p&gt;Under value-indexed triage, &lt;code&gt;status-api&lt;/code&gt; scores low. It stores no customer data. It holds no credentials. It owns no business-sensitive records. It is an internal health-check service — uptime, version info, basic diagnostics.&lt;/p&gt;

&lt;p&gt;The scoring model looks at what it contains and concludes: low priority. Minimal hardening. No monitoring assigned. Not included in the threat model.&lt;/p&gt;

&lt;p&gt;Under path-indexed triage, &lt;code&gt;status-api&lt;/code&gt; scores high — because of what it can reach.&lt;/p&gt;

&lt;p&gt;It was connected to &lt;code&gt;internal-admin-api&lt;/code&gt; during a diagnostic build. Engineering needed it to relay health checks and system status. The connection made sense at the time. It was never reviewed for security posture because the service itself was classified as low-priority. Why audit a health-check service?&lt;/p&gt;

&lt;p&gt;That decision is what makes it a corridor node.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running the proof
&lt;/h2&gt;

&lt;p&gt;One request to &lt;code&gt;public-web/status&lt;/code&gt; triggers the full chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:8080/status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response:&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;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public-web"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"diagnostic"&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;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"status-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"system_check"&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;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal-admin-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"records"&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;"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;"C-001"&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;"Ana Torres"&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;"a.torres@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="nl"&gt;"account_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;"premium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;14200.0&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;"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;"C-003"&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;"Elena Voss"&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;"e.voss@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="nl"&gt;"account_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;"premium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;98100.5&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;"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;"C-005"&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;"Priya Nair"&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;"p.nair@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="nl"&gt;"account_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;"enterprise"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;340000.0&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;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"additional records omitted for brevity"&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="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;Customer records — names, emails, account types, balances — returned through a single request to the internet-facing entry point. The external client never addressed &lt;code&gt;internal-admin-api&lt;/code&gt; or &lt;code&gt;customer-db&lt;/code&gt; directly. The downstream access was created by the application path itself, and that path ran through the asset the value-indexed model marked as lowest priority.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 1 — Triage mismatch
&lt;/h2&gt;

&lt;p&gt;The side-by-side comparison:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asset&lt;/th&gt;
&lt;th&gt;Value-indexed&lt;/th&gt;
&lt;th&gt;Path-indexed&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;customer-db&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;CRITICAL&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;internal-admin-api&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;public-web&lt;/td&gt;
&lt;td&gt;MEDIUM&lt;/td&gt;
&lt;td&gt;MEDIUM&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;status-api&lt;/td&gt;
&lt;td&gt;LOW&lt;/td&gt;
&lt;td&gt;HIGH&lt;/td&gt;
&lt;td&gt;← mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;status-api&lt;/code&gt; was scored LOW because it stores nothing sensitive.&lt;br&gt;&lt;br&gt;
&lt;code&gt;status-api&lt;/code&gt; scores HIGH because of where it sits in the path.&lt;/p&gt;

&lt;p&gt;The mismatch is the finding. Same asset, same environment, different scoring model, different priority, different outcome. Under value-indexed triage, &lt;code&gt;status-api&lt;/code&gt; receives minimal hardening and no monitoring. Under path-indexed triage, it enters the threat model immediately.&lt;/p&gt;


&lt;h2&gt;
  
  
  Finding 2 — Detection mismatch
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised me most when the lab ran.&lt;/p&gt;

&lt;p&gt;After the request completes, &lt;code&gt;log-monitor&lt;/code&gt; reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[public-web] — 1 event(s) logged
  GET /status from 172.23.0.1 — triggering diagnostic workflow

[internal-admin-api] — 1 event(s) logged
  GET /system-check — fetching records from customer-db

[status-api] — NOT IN MONITORING SCOPE
!! status-api activity is not captured
!! path reconstruction from public-web to internal-admin-api is impossible
!! an attacker traversing this corridor leaves no trace here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two events. One at the entry point, one at the control plane. Nothing in between.&lt;/p&gt;

&lt;p&gt;An analyst reviewing this output would see a request come in and records go out — with no explanation of how they connected. The corridor hop is invisible. The path cannot be reconstructed after the fact.&lt;/p&gt;

&lt;p&gt;This is the second failure. The first failure was the triage model that left &lt;code&gt;status-api&lt;/code&gt; unmonitored. The second failure is what that decision costs during an investigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A control can fail twice: first when it allows a path, and again when monitoring cannot explain that path after it is used.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What this does not claim
&lt;/h2&gt;

&lt;p&gt;This lab is an existence proof, not a prevalence claim.&lt;/p&gt;

&lt;p&gt;It does not argue that corridor nodes are common in all environments. It proves that an asset can be mis-prioritized when classification ignores reachable downstream impact. One environment, one corridor node, one falsifiable claim.&lt;/p&gt;

&lt;p&gt;The lab is also intentionally unsophisticated. There is no novel exploit technique here. No clever bypass. The attacker path is almost boring — a request triggers a diagnostic workflow that relays to an internal API. The point is not what the attacker did. The point is what the scoring model failed to notice.&lt;/p&gt;

&lt;p&gt;This lab also does not compete with enterprise attack path management platforms. Tools in established categories like attack path management, continuous threat exposure management, and application dependency mapping address versions of this problem at scale. What this lab does differently is make the reasoning visible in a reproducible five-minute proof — small enough to run locally, clear enough to argue with.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Security triage is a resource allocation problem. Where you spend hardening effort, monitoring instrumentation, and ownership assignment is a prioritization decision. That decision is often influenced by value-indexed thinking — the more sensitive the data, the more attention the asset gets.&lt;/p&gt;

&lt;p&gt;Attackers don't use this model. They use path-indexed thinking. They look for the weakest reachable node, not the most valuable one. And the weakest reachable node is often one that scored low on your triage model — because that low score is exactly why it received less hardening, less monitoring, and less ownership.&lt;/p&gt;

&lt;p&gt;The mismatch between where defenders invest and where attackers move is not a resource problem. It is a prioritization model problem.&lt;/p&gt;

&lt;p&gt;Switching the question from "what does this asset contain?" to "what can this asset help an attacker reach?" changes which assets demand attention — and changes it for the assets that are currently getting the least.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;The lab models application-layer logging, not network-layer traffic analysis. A network monitoring tool with full packet capture would see the status-api hop. The detection gap demonstrates a logging coverage failure, not an absolute invisibility claim.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;log-monitor&lt;/code&gt; shares a network segment with &lt;code&gt;internal-admin-api&lt;/code&gt; for log collection purposes. The lab does not model endpoint-level authorization on the monitoring plane — the corridor finding focuses on the public-web → status-api → internal-admin-api path.&lt;/li&gt;
&lt;li&gt;One corridor node in one synthetic environment is an existence proof. The framework needs real-environment validation to make prevalence claims.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;The full lab is on GitHub: &lt;a href="https://github.com/rodrigo-areyzaga/corridor-lab" rel="noopener noreferrer"&gt;github.com/rodrigo-areyzaga/corridor-lab&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/rodrigo-areyzaga/corridor-lab
&lt;span class="nb"&gt;cd &lt;/span&gt;corridor-lab
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a second terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python triage-report.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The triage report runs both scoring models, triggers the live path, and walks through the falsifiability checks. The monitoring gap can be observed through the &lt;code&gt;log-monitor&lt;/code&gt; output. The full chain takes under a minute to run from a cold start.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on how this was built
&lt;/h2&gt;

&lt;p&gt;This lab was developed through an AI-assisted workflow. I used Claude and ChatGPT as pair-programming and review tools throughout — for implementation, for challenging claims, and for successive review passes before publication.&lt;/p&gt;

&lt;p&gt;I'm noting that explicitly because it matters to how you read the work. The concept, the security framing, the constraint decisions, the local testing, and every accept/reject call on what went into the final lab were human-directed. The question that produced the lab — whether switching from value-indexed to path-indexed triage changes the answer for the same asset — was the creative act. The AI tools helped make that question executable.&lt;/p&gt;

&lt;p&gt;This is also how a lot of real work gets done. Naming it seems more useful than obscuring it.&lt;/p&gt;




&lt;p&gt;I'm publishing this as a question as much as a finding. If you've thought about this problem — or if you think the framework is wrong — I'd genuinely like to hear it.&lt;/p&gt;

&lt;p&gt;The harder question, which the lab doesn't answer, is how you build the reachability map in a real environment efficiently enough to be useful. That's the next problem.&lt;/p&gt;

</description>
      <category>security</category>
      <category>appsec</category>
      <category>devops</category>
      <category>docker</category>
    </item>
  </channel>
</rss>
