On Monday morning I switched code scanning on in my blog repository. When the first scan finished, twenty findings landed. At 20:22 that evening I pushed a single commit that closed all of them, the counter dropped to zero, and I relaxed.
Three and a half minutes later, finding number twenty-one arrived. Its source was the patch that had closed the other twenty.
This piece isn't really about those twenty-one findings. It's about a fix getting less scrutiny than the code it replaces. And to be honest, while writing it I found something else: my own fix commit described the bug incorrectly. I'll get to that near the end.
Morning: twenty findings, nine scans
When I pulled the repository's scan history, the date of the very first analysis caught my eye.
$ gh api "repos/.../code-scanning/analyses?per_page=100" \
--jq '[.[] | {c:.created_at, cat:.category, n:.results_count}] | sort_by(.c) | .[0:2]'
{"c":"2026-09-28T07:40:18Z","cat":"/language:actions","n":6}
{"c":"2026-09-28T07:40:42Z","cat":"/language:javascript-typescript","n":14}
The first analysis is the very morning I turned scanning on. So these findings aren't newly created; they were merely looked at for the first time. To see how long they'd been sitting there I traced two of them back with git log -S: the chain that strips HTML tags entered the repo on 17 August, and the unescaped link in the subscriber email on 23 April. Forty-two days and 158 days respectively. As long as nobody is looking, a bug's age is the code's age.
Here is the day's ledger:
$ gh api "repos/.../code-scanning/alerts?per_page=100" \
--jq '.[] | [.rule.id, .rule.security_severity_level] | @tsv' | sort | uniq -c | sort -rn
6 actions/missing-workflow-permissions medium
4 js/incomplete-sanitization high
4 js/incomplete-multi-character-sanitization high
2 js/incomplete-html-attribute-sanitization medium
2 js/bad-tag-filter high
1 js/stored-xss high
1 js/identity-replacement medium
1 js/double-escaping high
Twenty-one lines — twenty from the morning, one from the evening. Twelve high, nine medium. All of them under scripts/ and .github/; the site's own source in src/ came back clean. Exactly six files were missing workflow permissions; the other twelve of the eighteen workflows in that morning's tree already had them.
What's interesting isn't the content of the findings but the distance between them. The first scan ran at 10:40 local time; the scan that saw the fix ran at 20:26: 9 hours and 45 minutes. In that window the scan ran eight more times; all nine carry the same numbers.
07:40:42Z javascript-typescript results=14
12:01:09Z javascript-typescript results=14
12:03:29Z javascript-typescript results=14
12:54:22Z javascript-typescript results=14
14:12:16Z javascript-typescript results=14
14:23:29Z javascript-typescript results=14
14:27:10Z javascript-typescript results=14
14:37:08Z javascript-typescript results=14
16:14:41Z javascript-typescript results=14
Nine times. Every new scan means a push, so I pushed to the repo eight more times that day — article publishes, social-posting markers, ordinary work — and each one rewrote the same fourteen lines. None of them stopped me, because they weren't built to stop me; they sat in a side tab as a number.
The lesson I take for myself: there can be eight pushes of distance between a finding being visible and a finding being read. A number sitting on a screen tells you nothing about whether anyone has read it.
Evening: the commit that closed twenty findings
The commit I pushed at 20:22 touched 15 files, added 166 lines and removed 38. It pulled out two small new libraries: one for escaping YAML double quotes, another for stripping HTML tags. And it added job-level permissions: contents: read to six workflows.
That last item is the dullest of the findings but the easiest to defend. GitHub's documentation states the behaviour plainly: if permissions aren't specified, the token's access comes from the enterprise, organization or repository default, and the moment any permission is written explicitly, every unwritten one is set to none. So writing a single line is also how you close the rest. Six files didn't have that line.
The interesting part is the others. Two findings pointed straight at the same place — js/bad-tag-filter and js/double-escaping, both within the same ten lines of generate-content.ts: the chain that turns pages I fetch into plain text. (Moving that chain into a shared library also closed the other copies of the same bug class scattered across three files; six findings in total.) Let me not leave a detail hidden here, because I wrote a rather comfortable sentence that day. My commit message said "there was no externally exploitable surface on the live site." True, but incomplete. The input to that chain is precisely the open internet: pages pulled from search results during article research, read up to a 64 KiB ceiling, trimmed to 1,200 characters and handed straight to the model. So the surface isn't on the site; it's in the context. When it breaks, the result isn't XSS — it's the model being lied to.
This is the classic trap of grading severity in your own head. The moment you say "it doesn't reach a user," you stop thinking. But the right question is "who reads this data next" — and in this repository the answer is usually a language model.
Four findings, one backslash
The four js/incomplete-sanitization findings I closed in that same commit show the reverse direction: the place where model output lands in my files. Four spots that generate frontmatter all had the same line — value.replace(/"/g, '\\"'). Quotes escaped, backslashes not.
So I put a number on it. A title whose value ends in a backslash (something like "C:\path\", which is common in articles about kernels or regular expressions) knocks the YAML parser over outright with the old escaping:
C:\path\ (ends with backslash) old PARSE ERROR: Invalid escape sequence \p at line 1, column 11
C:\path\ (ends with backslash) new parse OK title="C:\\path\\" draft=false
A loud error is good news; the build explodes and you see it. The troubling one is the quiet case. Put an escaped \" inside the value and the string closes, so everything written after it becomes a new field:
--- old escaping, generated frontmatter ---
title: "Zararsiz Baslik\\"
draft: true
son: \""
category: "career"
=> parse: {"title":"Zararsiz Baslik\\","draft":true,"son":"\\\"\"","category":"career"}
draft: true got in there without me writing it, and the parser accepted it without complaint. With the new escaping the same input stays entirely inside the title; no draft field is created at all.
A language model writes all of those lines — and this isn't my first look at sanitiser bugs in the same pipeline; the evening I spent chasing three format quirks was another face of the same chain. In my own pipeline, title and description generation comes from the model, and the model writes by reading the research context — those 1,200 characters from the previous section. So the two ends of the chain face each other: a page from outside feeds the model, and the model's output is written into my frontmatter. Even with nobody malicious in the picture, a backslash is enough.
The fix itself is four lines — two escapes plus two normalisations (line breaks and control characters become spaces). The order of the escapes matters: backslash first, then the quote. Reverse it and the backslashes you just added get escaped again on the second pass, leaving you with a different wrong string. When writing escapes, the order of characters produces more bugs than the characters do — and the entity-decoding bug in the next section is exactly the same class.
The twenty-first finding
After I pushed the fix, the scan ran again. The workflows side returned zero. The JavaScript side returned one.
17:25:32Z actions results=0
17:26:08Z javascript-typescript results=1 <- new: js/bad-tag-filter (high)
scripts/lib/strip-tags.mjs
The finding opened three minutes and twenty-five seconds after I pushed the commit, and stayed open for twelve minutes and fifty-two seconds (20:26:08 → 20:39:00). The file name is the annoying part: scripts/lib/strip-tags.mjs. That file was created in that commit. So the code CodeQL flagged was code that hadn't existed an hour earlier — code I wrote to close a finding.
The bug itself was instructive. The old code looked for the closing tag literally as </script>; I changed it to </script\s*> to allow whitespace and considered the job done. CodeQL's own rule page addresses exactly this point: browsers accept malformed closing tags and treat </script foo="bar"> as a valid script end tag.
Why? Because the HTML standard counts attributes on an end tag as a parse error but doesn't reject the tag — in the standard's words, attributes in end tags are ignored and don't make their way into the DOM. Ignoring them doesn't mean ignoring the tag. The tag stays valid; the attribute goes in the bin. \s* misses that shape.
The new version requires, via lookahead, that the name be followed immediately by whitespace, / or >, then swallows everything up to the next >:
.replace(/<script\b[^>]*>[\s\S]*?<\/script(?=[\s/>])[^>]*>/gi, ' ')
The lookahead ((?=...)) isn't decoration: without it, a completely different tag such as </scriptfoo> would match too.
My own commit message was wrong as well
While writing this piece I wanted to put the three versions side by side and measure them: the state before 28 September, the first fix, the second fix. I pulled all three out of git history and ran the same page through each. The page: a heading, ONEMLI-METIN-1, a script containing GIZLI_GOVDE, then ONEMLI-METIN-2. One variable only — the shape of the closing tag.
$ node probe.mjs
closing form v0 (before 28 Sep) v1 (first fix) v2 (second)
----------------------------------------------------------------------------
</script> correct correct correct
</script > BODY LEAKED correct correct
</script foo=1> BODY LEAKED TAIL SWALLOWED correct
</script\t\n bar> BODY LEAKED TAIL SWALLOWED correct
What the three versions return for the same input:
v0: "Baslik ONEMLI-METIN-1 GIZLI_GOVDE ONEMLI-METIN-2"
v1: "Baslik ONEMLI-METIN-1"
v2: "Baslik ONEMLI-METIN-1 ONEMLI-METIN-2"
Look at the second line. In my first fix the script body does not leak. The rest of the page disappears.
The reason is the next rule in the chain: if there's a <script whose closing tag can't be found, I delete everything from there to the end of the file. When </script foo=1> fails to match, that fallback kicks in and swallows both the body and the remainder of the page. Good news for security; not for content. The 1,200 characters of research text handed to the model become only the part of the page before the script, and nobody notices.
Yet I wrote in the second commit's message that "the script body kept leaking into the text in that form." I shouldn't have. It wasn't leaking; the tail was being swallowed. Both are real bugs, but they are not the same bug, and I recorded the wrong one.
That is the most uncomfortable part of this piece for me. I closed the finding correctly, wrote the fix correctly, and explained the reason incorrectly. A commit message is a document too; six months from now the person reading that line (me) would have built the wrong mental model. Without running the numbers I would never have found out — and the only reason those numbers exist is that I sat down to write this.
A regular expression again, not a parser
I didn't follow CodeQL's advice. The rule page is clear: use a well-tested sanitization or parser library, because those libraries handle corner cases far better than a custom implementation. I wrote another regular expression.
My reasoning: this code isn't a security boundary, it's a noise filter. It tries to give the model readable text from a fetched page. Adding an HTML parser as a dependency of the publishing pipeline costs more than the certainty I'd gain — at least for today's usage. But that's a trade-off, not an exemption, and to see the price of the trade I deliberately pushed the current code:
'>' inside an attribute clean "A \"> B"
script inside a comment clean "A <!--"
script in an attribute value clean "B"
two blocks, first malformed clean "A B"
In all four cases the script body stays inside. But the second line still swallows the tail: when it sees a <script buried inside an HTML comment, the fallback rule again deletes to the end of the file. So the behaviour I identified in the first fix is still there, just over a narrower set of inputs. I'm leaving it knowingly and writing it down here, because the difference between a known boundary and an unknown one matters more than the boundary itself.
Measuring what the other two rules in the same chain catch was worth it as well:
--- nested tags: single pass vs to a fixed point ---
"<<b>>" single pass -> ">" | fixed point -> ""
"<<script>alert(1)</script>>" single pass -> "alert(1)>" | fixed point -> ""
--- entity decoding: sequential vs single pass ---
"&lt;script&gt;" sequential -> "<script>" | single pass -> "<script>"
"R&D raporu" sequential -> "R&D raporu" | single pass -> "R&D raporu"
The top one is the classic failure of single-pass tag stripping: delete the inner tag and the surrounding characters join up into a brand-new tag. The fix is stubbornness: repeat until nothing changes. The bottom one is an ordering bug: if & is decoded first, a < character that the source had carefully escaped comes back into the text as a real tag. One regex, one pass, no problem. I added the R&D row as well, because showing that a fix doesn't break the correct case is at least as necessary as showing the bug.
So what do I do differently now
Five items. All of them are dull, and that is exactly the point.
- Don't review the fix less than the code it fixes. A patch is code too — and it's code written in a hurry, focused on a single case, with nobody reviewing it. That is the sole cause of finding twenty-one.
- Run the scanner once more before you ship the patch. In my case I learned about the finding my patch opened within three and a half minutes; I didn't earn that, it happened because the scan runs on its own.
- Read the counter, don't glance at its colour. The same twenty lines stared at me across nine scans. A finding's visibility isn't enough to claim your attention.
- After you say "it doesn't reach a user," ask one more question: who reads this data? In my pipeline the answer is usually a language model, and the model doesn't complain to me about malformed input.
- Verify the reason for the fix, too. Writing correct code for the wrong reason leaves behind a lesson that works today and gets generalised to the wrong place tomorrow.
Let me name this piece's own gaps as well, or those five items will look cleaner than they are. First: the scan isn't free. The repo is private, and code scanning on a private repo is a product you enable separately; "I switched it on" reads like pressing a button, but not every repo has that button. Second: I haven't applied item two structurally — there's no protection rule on main, the scan isn't a required check, and finding twenty-two can walk in through the same door. Third: the boundary I knowingly left is written down only here and in a code comment; there's no record anywhere that will follow it. One cheering number: zero of the twenty-one findings was closed as a false positive — the tool didn't make work for me for nothing.
One more thing worth adding: none of these findings was exotic. All eight rules are in CodeQL's default query suite; the JavaScript side looked with 87 rules and the workflows side with 17. A hundred and four rules found twenty things on the day I switched them on. I don't read that as "I write bad code" — it's exactly what I wrote in my older piece comparing SAST with DAST: static analysis is patient where humans are impatient. When I read my own code I read my intent along with it; the tool only reads what I wrote.
Closing
Two days ago I wrote that nothing breaking after I moved the repo was actually bad news: the thing that doesn't break doesn't produce an inventory. This piece is the inverse case. Here something did break, at the very moment I fixed it, and there was a mechanism that told me within three and a half minutes.
What makes the difference is that the tool keeps looking after the fix. In most teams, scanning is framed as a gate in front of a change; the genuinely valuable part is the one standing behind it, weighing once more whatever came through.
Closing twenty findings felt good. The twenty-first showed me exactly what that good feeling rested on: the counter reaching zero. When the counter hits zero, what's finished is the scanner's job. Not mine.
Top comments (0)