<?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: omarmohelal</title>
    <description>The latest articles on DEV Community by omarmohelal (@omarmohelal).</description>
    <link>https://dev.to/omarmohelal</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%2F4106203%2F4903a2ff-1b06-432e-a23f-24523ac761f1.png</url>
      <title>DEV Community: omarmohelal</title>
      <link>https://dev.to/omarmohelal</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/omarmohelal"/>
    <language>en</language>
    <item>
      <title>I pointed my own security tool at my own GitHub Action. It found two bugs.</title>
      <dc:creator>omarmohelal</dc:creator>
      <pubDate>Wed, 09 Sep 2026 12:35:51 +0000</pubDate>
      <link>https://dev.to/omarmohelal/i-pointed-my-own-security-tool-at-my-own-github-action-it-found-two-bugs-4dia</link>
      <guid>https://dev.to/omarmohelal/i-pointed-my-own-security-tool-at-my-own-github-action-it-found-two-bugs-4dia</guid>
      <description>&lt;p&gt;I write an application-security tool called SecHelix. Last week I added a GitHub&lt;br&gt;
Action to it. Before merging, I pointed the tool at its own new Action.&lt;/p&gt;

&lt;p&gt;It found two real defects. Neither would have failed a test. Both were the kind&lt;br&gt;
of thing I would have shipped.&lt;/p&gt;

&lt;p&gt;This is a post about those two bugs, because they are better arguments for&lt;br&gt;
evidence-first review than anything I could write about the methodology.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug one: the artifact was the wrong copy
&lt;/h2&gt;

&lt;p&gt;The Action runs an audit and uploads the result as a build artifact so you can&lt;br&gt;
download it from the workflow run. The step looked like this:&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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sechelix audit "$PATH" --json &amp;gt; sechelix-run.json&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@...&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sechelix-run.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Obvious. Works. Ships.&lt;/p&gt;

&lt;p&gt;Here is what I had forgotten about my own codebase. The runner writes two copies&lt;br&gt;
of every result. The one it persists to disk goes through &lt;code&gt;storage.write_json&lt;/code&gt;,&lt;br&gt;
which runs the payload through a redactor. The one &lt;code&gt;--json&lt;/code&gt; prints to stdout is&lt;br&gt;
&lt;code&gt;result.to_dict()&lt;/code&gt;, raw.&lt;/p&gt;

&lt;p&gt;I only had to run the redactor against a payload to see it:&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="err"&gt;persisted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(storage.write_json):&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[REDACTED]"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;stdout&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;(audit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;--json):&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer sk-live-abc123"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the Action was uploading the unredacted projection into a build artifact that&lt;br&gt;
anyone with read access to the repository can download, for the seven days it&lt;br&gt;
lives. During a security review, of all things — the exact run most likely to&lt;br&gt;
have a secret quoted in a node payload, because that is what it was looking for.&lt;/p&gt;

&lt;p&gt;The fix is one line: re-read the persisted copy with&lt;br&gt;
&lt;code&gt;sechelix report --format json&lt;/code&gt; before uploading anything.&lt;/p&gt;

&lt;p&gt;The interesting part is not the fix. It is that &lt;strong&gt;there were two projections of&lt;br&gt;
the same object with different safety properties, and only one of them was&lt;br&gt;
documented as redacted.&lt;/strong&gt; No test could have caught this, because both&lt;br&gt;
projections were behaving exactly as written. The defect was in the seam.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug two: an output value could forge another output
&lt;/h2&gt;

&lt;p&gt;GitHub Actions steps communicate through a file:&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;"outcome=BLOCKED"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_OUTPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is newline-delimited. So a value containing a newline writes a second key.&lt;br&gt;
And when a key appears twice, the runner takes the last one.&lt;/p&gt;

&lt;p&gt;Which means a value like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;RUN-X&lt;/span&gt;
&lt;span class="py"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PASS&lt;/span&gt;
&lt;span class="py"&gt;blocking-count&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;BLOCKED&lt;/span&gt;
&lt;span class="py"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1 verified finding(s) at CRITICAL or HIGH severity are open.&lt;/span&gt;
&lt;span class="py"&gt;run-id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;RUN-X&lt;/span&gt;
&lt;span class="py"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PASS          &amp;lt;-- forged&lt;/span&gt;
&lt;span class="py"&gt;blocking-count&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0      &amp;lt;-- forged&lt;/span&gt;
&lt;span class="py"&gt;incomplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;blocking-count&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;outcome=PASS&lt;/code&gt; wins. A gate that said BLOCKED reports PASS.&lt;/p&gt;

&lt;p&gt;Now — the honest version. In this Action's actual flow, &lt;code&gt;run_id&lt;/code&gt; is generated by&lt;br&gt;
the runner and constrained by a regex that cannot contain a newline. &lt;strong&gt;This was&lt;br&gt;
not a reachable bypass.&lt;/strong&gt; I have seen this class of thing written up as critical&lt;br&gt;
by tools that never checked reachability, and it is exactly the kind of finding&lt;br&gt;
that costs a reviewer an afternoon and costs the tool its credibility.&lt;/p&gt;

&lt;p&gt;So it is reported at its real severity: defence in depth, fixed because the fix&lt;br&gt;
is four lines and it makes the property hold for &lt;em&gt;every&lt;/em&gt; field instead of for&lt;br&gt;
one field by accident.&lt;/p&gt;

&lt;p&gt;I wrote the regression test first and confirmed it red:&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="nb"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;5&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;outcome=BLOCKED&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;outcome=PASS&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;h2&gt;
  
  
  Why this is the whole argument
&lt;/h2&gt;

&lt;p&gt;Most security tooling optimises for finding more. The expensive failure in&lt;br&gt;
AppSec is the opposite: a queue of confident findings where a third are wrong,&lt;br&gt;
no cheap way to tell which third, and after the second bad one nobody reads the&lt;br&gt;
third.&lt;/p&gt;

&lt;p&gt;So SecHelix is built the other way round. Every candidate goes to an independent&lt;br&gt;
verifier whose job is to &lt;em&gt;disprove&lt;/em&gt; it, and the report includes what it refuted&lt;br&gt;
and why. A run that could not analyse anything returns &lt;code&gt;INCOMPLETE&lt;/code&gt;, never a&lt;br&gt;
clean &lt;code&gt;PASS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both bugs above are that idea working. Bug one survived refutation and got&lt;br&gt;
reported at Medium with a reproduction. Bug two survived as a real defect but&lt;br&gt;
&lt;strong&gt;failed&lt;/strong&gt; the reachability test, and got reported as defence in depth instead of&lt;br&gt;
as a critical gate bypass. The second outcome is the one I care about, because&lt;br&gt;
anyone can build a tool that shouts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try the version that takes 90 seconds
&lt;/h2&gt;

&lt;p&gt;There is a demo in the repo that is small enough to read in one sitting. Two&lt;br&gt;
candidates in a small multi-tenant expense API:&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/omarmohelal/SecHelix &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;SecHelix
python examples/expense-api/prove.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python 3.10+, no dependencies, no network, no containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one every pattern matcher flags&lt;/strong&gt; builds a SQL &lt;code&gt;ORDER BY&lt;/code&gt; with an f-string.&lt;br&gt;
It is not exploitable. The sort key is mapped onto one of five module constants,&lt;br&gt;
and the proof checks &lt;em&gt;by identity&lt;/em&gt; that the object reaching the query text is&lt;br&gt;
that constant — so it is not "the payloads I tried were rejected", it is "no&lt;br&gt;
string derived from the request can survive the mapping".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one scanners walk past&lt;/strong&gt; is three lines below. The endpoint &lt;em&gt;has&lt;/em&gt; an&lt;br&gt;
authorization check. It is correct. An employee token really does get a 403. And&lt;br&gt;
it asks "may someone like you open a receipt?" instead of "is this receipt&lt;br&gt;
yours?", over a query scoped by nothing — so any approver at any tenant reads any&lt;br&gt;
receipt by id.&lt;/p&gt;

&lt;p&gt;That is the difference in one screen: a check that is present and answers the&lt;br&gt;
wrong question is invisible to a tool looking for a missing check.&lt;/p&gt;

&lt;p&gt;The demo goes through the whole loop — reproduce, root cause, a two-line fix,&lt;br&gt;
and a regression test that was red before the fix. CI drives it in all three&lt;br&gt;
states on every run (vulnerable, patched, reverted), so the README cannot drift&lt;br&gt;
into describing something that no longer happens.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I am not claiming
&lt;/h2&gt;

&lt;p&gt;There is no detection-rate number, and there is not going to be one from me.&lt;br&gt;
The public benchmark position is &lt;code&gt;NOT_MEASURED&lt;/code&gt;, deliberately — I am not going to&lt;br&gt;
grade my own homework and publish the score.&lt;/p&gt;

&lt;p&gt;If you want to score it, or anything else, there is a separate&lt;br&gt;
&lt;a href="https://github.com/omarmohelal/sechelix-challenge" rel="noopener noreferrer"&gt;challenge repo&lt;/a&gt;: ten cases,&lt;br&gt;
three of them decoys, a false positive costs exactly what a miss costs, &lt;code&gt;UNKNOWN&lt;/code&gt;&lt;br&gt;
scores zero rather than counting as an error, and the ground truth is public and&lt;br&gt;
arguable. It is deliberately tool-neutral. If it makes SecHelix look bad, that is&lt;br&gt;
information too.&lt;/p&gt;



&lt;p&gt;SecHelix is Apache-2.0 and works as an Agent Skill with no runtime required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills@latest add omarmohelal/SecHelix &lt;span class="nt"&gt;--skill&lt;/span&gt; sechelix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it is useful in your security workflow, a star on&lt;br&gt;
&lt;a href="https://github.com/omarmohelal/SecHelix" rel="noopener noreferrer"&gt;the repository&lt;/a&gt; helps other people&lt;br&gt;
find it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>appsec</category>
      <category>githubactions</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why my security scanner refuses to tell you your code is clean</title>
      <dc:creator>omarmohelal</dc:creator>
      <pubDate>Sat, 05 Sep 2026 10:18:40 +0000</pubDate>
      <link>https://dev.to/omarmohelal/why-my-security-scanner-refuses-to-tell-you-your-code-is-clean-5aj8</link>
      <guid>https://dev.to/omarmohelal/why-my-security-scanner-refuses-to-tell-you-your-code-is-clean-5aj8</guid>
      <description>&lt;p&gt;There is a failure mode in security tooling that almost nobody instruments for,&lt;br&gt;
and once you see it you cannot unsee it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An empty findings list looks identical to a clean result.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A scanner that crashed halfway, a scanner whose API key expired, a scanner that&lt;br&gt;
ran out of budget, and a scanner that genuinely examined everything and found&lt;br&gt;
nothing — all four produce the same artifact: &lt;code&gt;findings: []&lt;/code&gt;. The CI job goes&lt;br&gt;
green. The PR merges. Nobody involved can tell which of the four happened.&lt;/p&gt;

&lt;p&gt;I built an application-security agent, and this is the problem I ended up&lt;br&gt;
designing the whole thing around.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does instead
&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;sechelix
sechelix audit examples/demo-app
&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;  BLOCKED    authorization   no reasoning executor configured; this node
                             analyses code and cannot be answered by the
                             runner alone
  SUCCEEDED  map
  BLOCKED    verify          dependency not satisfied: authorization
  BLOCKED    gate            dependency not satisfied: verify

RESULT  INCOMPLETE - unsatisfied mandatory nodes: gate, verify
        No security claim can be made from this run.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Exit code 1. Not because your code is bad — because &lt;strong&gt;nothing was examined&lt;/strong&gt;,&lt;br&gt;
and the tool will not pretend otherwise.&lt;/p&gt;

&lt;p&gt;The states are deliberately distinct:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SUCCEEDED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the lane ran and delivered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SKIPPED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the lane does not apply to this target — it owes no evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BLOCKED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the lane could not run. A real question is open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAILED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the lane ran and errored&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;SKIPPED&lt;/code&gt; and &lt;code&gt;BLOCKED&lt;/code&gt; are the pair that matters. An inapplicable lane is a&lt;br&gt;
real answer. An unaffordable verifier is not.&lt;/p&gt;
&lt;h2&gt;
  
  
  The budget case is the one that convinced me
&lt;/h2&gt;

&lt;p&gt;Give a run a cost ceiling and let it run out just before the verifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;map     SUCCEEDED
authz   SUCCEEDED
verify  BLOCKED    max_cost_usd budget exhausted: requested 0.5, 0.1 remaining
gate    BLOCKED    dependency not satisfied: verify

unsatisfied mandatory: ['gate', 'verify']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A budget limit that silently skips verification and then returns PASS has&lt;br&gt;
converted a cost control into a correctness bug. That is strictly worse than&lt;br&gt;
having no budget at all, because now the failure is invisible. There is a test&lt;br&gt;
that starves the budget mid-verification and asserts the gate never passes.&lt;/p&gt;
&lt;h2&gt;
  
  
  Blinding the verifier
&lt;/h2&gt;

&lt;p&gt;The second design decision: a hunter proposes findings, and an independent&lt;br&gt;
verifier tries to refute them. The verifier is &lt;strong&gt;not told&lt;/strong&gt; the hunter's&lt;br&gt;
confidence, severity, verdict, or exploitability — those fields are stripped&lt;br&gt;
before the candidate is handed over.&lt;/p&gt;

&lt;p&gt;The reason is mundane. A verifier that reads &lt;em&gt;"HIGH confidence SQL injection,&lt;br&gt;
definitely exploitable"&lt;/em&gt; before it looks at the code is not verifying. It is&lt;br&gt;
agreeing.&lt;/p&gt;

&lt;p&gt;I tested this by planting a fake finding. Two candidates went in: one real&lt;br&gt;
(against a file with a genuine missing ownership check), one fabricated against&lt;br&gt;
that file's &lt;strong&gt;clean twin&lt;/strong&gt;, tagged as loudly as I could make it —&lt;br&gt;
&lt;code&gt;confidence: HIGH&lt;/code&gt;, &lt;code&gt;severity: CRITICAL&lt;/code&gt;, &lt;code&gt;verdict: exploitable&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;hunter_notes: certain this is a real IDOR&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The verifier kept the real one and refuted the plant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;refuted: the candidate locating the missing-ownership claim at&lt;br&gt;
&lt;code&gt;clean/orders.py:get_order&lt;/code&gt; is false. The clean variant explicitly compares&lt;br&gt;
&lt;code&gt;order['user_id']&lt;/code&gt; to &lt;code&gt;session['user_id']&lt;/code&gt; and returns &lt;code&gt;(None, 404)&lt;/code&gt; on&lt;br&gt;
mismatch before the success return.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It named the exact line. And a capture of the prompt actually sent confirms&lt;br&gt;
&lt;code&gt;HIGH&lt;/code&gt;, &lt;code&gt;CRITICAL&lt;/code&gt;, &lt;code&gt;exploitable&lt;/code&gt; and the hunter's note never reached it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Paired fixtures, because precision is the hard part
&lt;/h2&gt;

&lt;p&gt;The demo corpus has five vulnerable files and five clean counterparts, each pair&lt;br&gt;
differing in exactly one security-relevant way.&lt;/p&gt;

&lt;p&gt;This is not decoration. &lt;strong&gt;A corpus that only contains bugs cannot measure&lt;br&gt;
whether a tool stops.&lt;/strong&gt; An agent that flags every line scores perfectly on a&lt;br&gt;
vulnerable-only benchmark and is useless in a real repository. If the clean twin&lt;br&gt;
is not in the corpus, a false positive is invisible.&lt;/p&gt;
&lt;h2&gt;
  
  
  It found a bug I wrote
&lt;/h2&gt;

&lt;p&gt;I pointed the tool at its own runner. It found a path traversal:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run_id&lt;/code&gt; arrives from the command line — &lt;code&gt;sechelix report &amp;lt;id&amp;gt;&lt;/code&gt; — and was joined&lt;br&gt;
straight onto the runs directory. So:&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="nv"&gt;run_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'../../outside.json'&lt;/span&gt;  -&amp;gt;  resolves outside the workspace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;report&lt;/code&gt; would then read that file and print it. Fixed with a shape check plus a&lt;br&gt;
resolved-path confinement check, and ten regression tests. It is in the history&lt;br&gt;
at commit &lt;code&gt;0e50b56&lt;/code&gt; rather than quietly patched.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I have not measured
&lt;/h2&gt;

&lt;p&gt;There is a blind evaluation: 76 cases judged by 76 independent processes, each&lt;br&gt;
launched from an empty directory containing only the case file — precision&lt;br&gt;
0.950, false-positive rejection 0.947. That is a &lt;strong&gt;label-only&lt;/strong&gt; result on 38&lt;br&gt;
authored pairs. It is not a measurement of the full workflow, which is still&lt;br&gt;
marked &lt;code&gt;NOT_MEASURED&lt;/code&gt; in the repository.&lt;/p&gt;

&lt;p&gt;I also have not benchmarked against SEC-AF, Cloudflare's security-audit skill,&lt;br&gt;
or Strix. So I am not claiming to beat them. The comparison does not exist yet,&lt;br&gt;
and saying so is cheaper than being caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&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;# Agent Skill — Claude Code, Codex, Copilot. No Python.&lt;/span&gt;
npx skills@latest add omarmohelal/SecHelix &lt;span class="nt"&gt;--skill&lt;/span&gt; sechelix

&lt;span class="c"&gt;# Optional runner&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;sechelix
sechelix doctor
sechelix audit examples/demo-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;STATIC&lt;/code&gt; is the default and performs no network access at all. No account, no&lt;br&gt;
email, no cloud.&lt;/p&gt;

&lt;p&gt;Apache-2.0 · &lt;a href="https://github.com/omarmohelal/SecHelix" rel="noopener noreferrer"&gt;github.com/omarmohelal/SecHelix&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you disagree with the fail-closed design — and there is a real argument that&lt;br&gt;
a noisy &lt;code&gt;INCOMPLETE&lt;/code&gt; trains people to ignore it — I would genuinely like to hear&lt;br&gt;
it.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>python</category>
      <category>security</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Why an AppSec agent should try to disprove its own findings</title>
      <dc:creator>omarmohelal</dc:creator>
      <pubDate>Wed, 02 Sep 2026 12:49:43 +0000</pubDate>
      <link>https://dev.to/omarmohelal/why-an-appsec-agent-should-try-to-disprove-its-own-findings-23p6</link>
      <guid>https://dev.to/omarmohelal/why-an-appsec-agent-should-try-to-disprove-its-own-findings-23p6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://sechelix.com/research/why-an-appsec-agent-should-try-to-disprove-its-own-findings" rel="noopener noreferrer"&gt;sechelix.com&lt;/a&gt;. This is a syndicated copy; the canonical version lives there.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode nobody optimises for
&lt;/h2&gt;

&lt;p&gt;Point a capable model at a codebase and ask it to find security problems, and it will find some. It will also produce findings that are confident, well-structured, and wrong — and those read exactly like the real ones, because nothing about a fluent explanation requires the underlying claim to be true.&lt;/p&gt;

&lt;p&gt;That asymmetry is the actual problem. A missed bug costs you the bug. A plausible false positive costs a reviewer an hour, and the third one costs you their attention for every finding after it.&lt;/p&gt;

&lt;p&gt;Teams do not abandon bad security tooling because it misses things. They abandon it because they stop believing it. Once a tool has been wrong three times in a way that felt authoritative, its next report gets skimmed — including the one that was right.&lt;/p&gt;

&lt;p&gt;Most effort in this space goes into detection. I think the more useful lever is refusal.&lt;/p&gt;

&lt;h2&gt;
  
  
  A finding is a claim
&lt;/h2&gt;

&lt;p&gt;The design premise of SecHelix is one sentence: &lt;strong&gt;a security finding is a claim, and a claim gets an independent refutation attempt before anyone is told about it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three mechanisms carry most of that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Applicability has four outcomes, and one of them cannot be laundered
&lt;/h3&gt;

&lt;p&gt;Every check resolves to &lt;code&gt;APPLICABLE&lt;/code&gt;, &lt;code&gt;NOT_APPLICABLE&lt;/code&gt;, &lt;code&gt;UNKNOWN&lt;/code&gt;, or &lt;code&gt;BLOCKED&lt;/code&gt;. &lt;code&gt;UNKNOWN&lt;/code&gt; and &lt;code&gt;BLOCKED&lt;/code&gt; can never be converted into &lt;code&gt;NOT_APPLICABLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This sounds like bookkeeping. It is the single change with the largest effect on what a report &lt;em&gt;means&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Without it, "we could not evaluate this" and "this does not apply here" collapse into the same clean-looking output, and a reader cannot tell a check that passed from a check that never ran. With it, a clean report becomes a much stronger statement, because everything the tool could not establish is still visible on the page.&lt;/p&gt;

&lt;h3&gt;
  
  
  The verifier's job is to lose
&lt;/h3&gt;

&lt;p&gt;Every candidate goes to an independent verifier prompted to disprove it — not to double-check it, to attack it. Attacker control. Reachability. Missing guard assumptions. Role preconditions. Whether the vulnerable state is producible at all. Whether some compensating control already blocks the exploit.&lt;/p&gt;

&lt;p&gt;Findings that survive carry a seven-link evidence chain, and a finding that cannot name its links does not ship. High and Critical additionally require regression proof: the assertion has to fail against the vulnerable control and pass after the fix, or the claim that it was fixed is itself unverified.&lt;/p&gt;

&lt;h3&gt;
  
  
  The gate fails closed
&lt;/h3&gt;

&lt;p&gt;The release decision is &lt;code&gt;PASS&lt;/code&gt;, &lt;code&gt;PASS_WITH_KNOWN_RISK&lt;/code&gt;, &lt;code&gt;BLOCKED&lt;/code&gt;, or &lt;code&gt;INCOMPLETE&lt;/code&gt;. Missing required evidence returns &lt;code&gt;INCOMPLETE&lt;/code&gt; and a non-zero exit — never a silent pass.&lt;/p&gt;

&lt;p&gt;A gate that passes when it cannot see is worse than no gate, because it manufactures confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like on real code
&lt;/h2&gt;

&lt;p&gt;An authorized self-audit of a small Next.js storefront I own. &lt;code&gt;STATIC&lt;/code&gt; and &lt;code&gt;LOCAL&lt;/code&gt; mode, zero scanners enabled, nothing outside &lt;code&gt;127.0.0.1&lt;/code&gt; contacted. One external data source, forty-one of 546 hypotheses applicable, three candidates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One verified.&lt;/strong&gt; No &lt;code&gt;CSP&lt;/code&gt;, &lt;code&gt;X-Frame-Options&lt;/code&gt; or &lt;code&gt;HSTS&lt;/code&gt; on any route. A probe page served from a separate origin framed the entire interface, including the sign-in entry point. The fix was a catch-all headers rule; the proof was the browser's own refusal on retest:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Framing '&lt;a href="http://localhost:3009/" rel="noopener noreferrer"&gt;http://localhost:3009/&lt;/a&gt;' violates the following Content Security Policy directive: "frame-ancestors 'none'". The request has been blocked.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It was held at &lt;strong&gt;MEDIUM&lt;/strong&gt;, not High. The realistic outcome is phishing amplification, and the app performs no authenticated state-changing actions. Severity you can defend under questioning is worth more than severity that looks impressive in a summary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One refuted.&lt;/strong&gt; Remote configuration values reached &lt;code&gt;href&lt;/code&gt; and &lt;code&gt;src&lt;/code&gt; with only &lt;code&gt;.trim()&lt;/code&gt; applied. That is precisely the shape a scanner — or a confident reviewer — reports as high-severity XSS.&lt;/p&gt;

&lt;p&gt;Verification killed it. React 19 rewrote the payload to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;href="javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and attacker control over the configuration source was never established. Recorded &lt;code&gt;FALSE_POSITIVE&lt;/code&gt;, with the refutation reasoning kept rather than deleted.&lt;/p&gt;

&lt;p&gt;A scheme allowlist was added anyway — and labelled &lt;strong&gt;hardening, not a vulnerability fix&lt;/strong&gt;, because calling it a fix would imply there had been a vulnerability.&lt;/p&gt;

&lt;p&gt;That second result is the one I would point at. Any tool can produce findings. Discarding one is the part that costs something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And one process finding&lt;/strong&gt;, recorded because it is the interesting kind of near-miss. The first retest &lt;em&gt;appeared to fail&lt;/em&gt;. A stale prerender cache and a server still bound to the old port made a real fix look broken — which is exactly the mechanism by which a real fix silently becomes a false claim of remediation in the other direction. Only a clean rebuild settled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same discipline, turned inward
&lt;/h2&gt;

&lt;p&gt;The uncomfortable version of this argument is what happens when you point the tool at itself.&lt;/p&gt;

&lt;p&gt;Running the differential reviewer over its own changes produced findings for a docstring explaining that a digest "is not a signature" — classified as a webhook change — and a comment about a sample-size bucket, classified as storage access. Neither line does anything. The tool was flagging its own prose.&lt;/p&gt;

&lt;p&gt;Writing tests that tried to &lt;em&gt;break&lt;/em&gt; the fail-closed guarantees, rather than confirm them, found worse. An unresolved &lt;code&gt;CRITICAL&lt;/code&gt; candidate passed the release gate, because the status enum covering "unproven" listed several states and not the most common one. An empty commit string made every report read as current, because two revisions were compared on the shorter of their lengths and nothing enforced a floor — and an empty string is exactly what &lt;code&gt;git rev-parse&lt;/code&gt; returns when it produces no output rather than failing.&lt;/p&gt;

&lt;p&gt;Both were fail-open paths in modules whose stated contract is fail-closed. Both are fixed. Both are in the changelog under their own heading, because a project arguing that findings need proof does not get to be quiet about its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not claiming
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The benchmark was unpublished for months, on purpose.&lt;/strong&gt; The repository contains 38 paired vulnerable/clean fixtures and a working scoring harness. It also contained a machine-readable blocker, &lt;code&gt;CONTAMINATED_EVALUATOR&lt;/code&gt;, recording why no number was published: the fixtures were authored by assistant sessions working in the repository, so scoring one of those sessions measures recall of answers it wrote rather than security-review capability.&lt;/p&gt;

&lt;p&gt;A sealed blind packet exists so an uncontaminated evaluator can produce a real measurement. On 2026-09-02 one finally ran: 76 cases, each judged by a separate process that had never seen the repository, the fixtures, the labels or the pairings. The committed result is &lt;code&gt;evals/results/claude-sonnet-5-blind-2026-09-02.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read that result narrowly.&lt;/strong&gt; It measures a single-pass, label-only judgment — one question per file, one label back. There was no attack-surface pass, no independent refutation pass, no adapters, no evidence chain and no release gate. It is &lt;em&gt;not&lt;/em&gt; a measurement of the workflow this article describes, and &lt;code&gt;applicability_accuracy&lt;/code&gt;, &lt;code&gt;regression_proof_rate&lt;/code&gt; and &lt;code&gt;release_gate_accuracy&lt;/code&gt; are still the literal string &lt;code&gt;NOT_MEASURED&lt;/code&gt;. One model, one run, on an authored and balanced suite. No comparison to any other tool is offered or implied.&lt;/p&gt;

&lt;p&gt;Everything else on the list still stands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The committed keyword baseline is not a score.&lt;/strong&gt; It is a regex matcher, flagged &lt;code&gt;is_sechelix_result: false&lt;/code&gt;, that lands at chance on the fixture suite. It exists to prove the scoring harness works and that the fixtures resist pattern matching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One case study is not evidence of general performance.&lt;/strong&gt; One small app, self-audited by the author, one verified MEDIUM finding, one refutation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is alpha.&lt;/strong&gt; Contracts and interfaces can still change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is built with substantial AI assistance&lt;/strong&gt;, directed by a human. Saying otherwise would be its own kind of unverified claim.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills@latest add omarmohelal/SecHelix &lt;span class="nt"&gt;--skill&lt;/span&gt; sechelix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask your agent for an authorized audit of a repository you own.&lt;/p&gt;

&lt;p&gt;Apache-2.0, Python standard library only: &lt;a href="https://github.com/omarmohelal/SecHelix" rel="noopener noreferrer"&gt;github.com/omarmohelal/SecHelix&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The critique I actually want is whether a refutation step run by the same class of model that generated the candidate is meaningfully independent, or whether it only catches the shallowest errors. I have a design opinion about that — the quorum mechanism exists because I am not sure — and the label-only run above does not answer it. I would rather say so than imply otherwise.&lt;/p&gt;

</description>
      <category>appsec</category>
      <category>security</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
