<?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: holistis</title>
    <description>The latest articles on DEV Community by holistis (@holistis).</description>
    <link>https://dev.to/holistis</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%2F3940045%2Fcc7c530b-20f7-472b-892a-d90a4da14eb5.png</url>
      <title>DEV Community: holistis</title>
      <link>https://dev.to/holistis</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/holistis"/>
    <language>en</language>
    <item>
      <title>PyMC Labs has 1,256 stars. A GitHub issue title could still steal their API key.</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Fri, 11 Sep 2026 05:51:16 +0000</pubDate>
      <link>https://dev.to/holistis/pymc-labs-has-1256-stars-a-github-issue-title-could-still-steal-their-api-key-1hd6</link>
      <guid>https://dev.to/holistis/pymc-labs-has-1256-stars-a-github-issue-title-could-still-steal-their-api-key-1hd6</guid>
      <description>&lt;p&gt;PyMC Labs has 1,256 stars on GitHub and a serious engineering team. A GitHub issue title still could have stolen their Anthropic API key.&lt;/p&gt;

&lt;p&gt;I found it, reported it, and yesterday they made it official: GHSA-72xh-gfpr-v9hx, severity High, published, my name on it as the reporter.&lt;/p&gt;

&lt;p&gt;Here is what was actually happening. Their issue-triage and duplicate-detection workflows ran Claude Code on every single public issue, automatically, no human in the loop. That call used dangerously-skip-permissions, so the agent got full shell access. In the same job: a real ANTHROPIC_API_KEY and a GitHub token scoped to write issues. Anyone on the internet could open an issue, write a prompt injection in the title or body, and try to walk the agent straight to those secrets.&lt;/p&gt;

&lt;p&gt;No approval gate. No isolation. Just text in a box, read by an agent with the keys already sitting next to it.&lt;/p&gt;

&lt;p&gt;I sent the report. PyMC Labs did not sit on it. They split the job in two: a read-only analysis step, and a separate publish step that never sees the Anthropic secret. They rotated the key. Then they went one step further and published the advisory instead of quietly patching and moving on, credit included.&lt;/p&gt;

&lt;p&gt;That last part matters more than the fix itself. A team can ship a fix and say nothing. Publishing the advisory means anyone can check the claim against the real commit history, not just take my word for it.&lt;/p&gt;

&lt;p&gt;This is one repository out of 22 I went through in the same bug class this month, AI CLIs wired straight into GitHub Actions on public, untrusted input. Full writeup on the other 21 is here: &lt;a href="https://dev.to/holistis/a-github-comment-can-steal-your-secrets-i-found-22-repositories-where-it-could-1doh"&gt;https://dev.to/holistis/a-github-comment-can-steal-your-secrets-i-found-22-repositories-where-it-could-1doh&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one just became the first to go fully public. More of them will, one at a time, as the maintainers finish their fixes.&lt;/p&gt;

</description>
      <category>security</category>
      <category>opensource</category>
      <category>github</category>
      <category>ai</category>
    </item>
    <item>
      <title>A GitHub comment can steal your secrets. I found 22 repositories where it could.</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Thu, 10 Sep 2026 07:28:35 +0000</pubDate>
      <link>https://dev.to/holistis/a-github-comment-can-steal-your-secrets-i-found-22-repositories-where-it-could-1doh</link>
      <guid>https://dev.to/holistis/a-github-comment-can-steal-your-secrets-i-found-22-repositories-where-it-could-1doh</guid>
      <description>&lt;p&gt;After the CI hygiene piece, I kept looking at GitHub Actions workflows in AI agent projects, but for something more specific this time: repositories that run an AI CLI, Claude Code, GitHub Copilot CLI, Codex, directly inside a workflow job.&lt;/p&gt;

&lt;p&gt;The pattern that mattered was always the same. A workflow triggers on something anyone on the internet can create: an issue, a comment on an issue, a pull request title. It then runs the AI CLI with a flag that skips every confirmation prompt, something like --dangerously-skip-permissions or --yolo, or an equivalent setting that grants unrestricted shell access, which hands the agent full, unfiltered control. And the same job has real secrets sitting in its environment: API keys, cloud service account keys, GitHub tokens with write scope.&lt;/p&gt;

&lt;p&gt;Put those three things next to each other and the exploit stops being clever. It's just text. Whatever you type into a public GitHub issue can end up read as an instruction by the agent, and the agent already has the shell access to act on it, read the environment, and send what it finds somewhere. No exotic exploit chain, just an unlocked door with a please don't sign on it.&lt;/p&gt;

&lt;p&gt;I went through 22 findings in this specific bug class, spread across separate open source repositories.&lt;/p&gt;

&lt;p&gt;Three are confirmed and already being fixed, so I can name them.&lt;/p&gt;

&lt;p&gt;pymc-labs/pymc-marketing had an issue triage workflow where a public GitHub issue, through prompt injection, could reach the project's real Anthropic API key.&lt;/p&gt;

&lt;p&gt;MadAppGang/dingo had an issue-triage.yml that gave Claude Code full Bash access plus two real secrets, with no gatekeeper at all, triggered by any public issue. The maintainer, Jack Rudenko, replied fast. Thanks for flagging this, he wrote, we'll look into the template and get it patched across the board.&lt;/p&gt;

&lt;p&gt;MadAppGang/claudish, a second repository from the same team, had the exact same vulnerable template. Also confirmed, and across the board in that reply covers both. Same mistake, twice, because a template got copied from one repo into the other and nobody re-checked it on the way.&lt;/p&gt;

&lt;p&gt;One report went further than a maintainer's inbox. What was reachable there was a cloud service account key, not just an API key, so I sent it to the vendor's product security team instead of the repository maintainers. They triaged it the same day and passed it on to their internal security group. It's not confirmed fixed yet, so I won't name the vendor or the repo here.&lt;/p&gt;

&lt;p&gt;Twelve more are still sitting with maintainers, some by private email, some through a private GitHub Security Advisory that stays private until the maintainer decides to publish it. I'm not naming any of those either, for the obvious reason that the hole is still open. The pattern in every one of them is the same one described above, just a different combination of trigger and secret.&lt;/p&gt;

&lt;p&gt;I also got the tool's own permission check wrong, twice, in slightly different ways. A lot of these workflows call Claude Code through a widely used GitHub Action rather than the bare CLI, and that Action has a real, built-in check on who is allowed to fire the trigger. In two of my earliest reports I had assumed no such check existed at all. It does, for most common trigger types, and once I confirmed that against the Action's own source, I retracted both reports. Then I made a narrower version of the same mistake in two more reports, assuming one specific trigger type sat outside that same check. I read the Action's current source code again, not its documentation, and that assumption was wrong too, that trigger type has been covered since September last year. I retracted those as well and sent correction emails to the maintainers involved. Six retractions in total, all from checking my own assumption against real code instead of trusting what I thought I already knew.&lt;/p&gt;

&lt;p&gt;One small unrelated thing from the same week. A maintainer at Cloud Foundry reviewed, approved and merged a bugfix PR I sent to cloudfoundry/terraform-provider-cloudfoundry, a nil pointer crash in their Terraform provider. Not a security finding, just an ordinary PR that happened to land in the same stretch of days.&lt;/p&gt;

&lt;p&gt;If you have a workflow where an AI CLI runs on public input, the two things worth checking are who is allowed to fire the trigger, and what secrets sit in the same job as the CLI call. Those two checks alone would have caught all 22.&lt;/p&gt;

</description>
      <category>security</category>
      <category>opensource</category>
      <category>github</category>
      <category>ai</category>
    </item>
    <item>
      <title>I spent a day auditing GitHub Actions across a dozen AI agent frameworks</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Tue, 08 Sep 2026 11:55:25 +0000</pubDate>
      <link>https://dev.to/holistis/i-spent-a-day-auditing-github-actions-across-a-dozen-ai-agent-frameworks-5fg6</link>
      <guid>https://dev.to/holistis/i-spent-a-day-auditing-github-actions-across-a-dozen-ai-agent-frameworks-5fg6</guid>
      <description>&lt;p&gt;I spent a day going through the GitHub Actions workflows of some of the better known open source AI agent projects: AutoGen, Semantic Kernel, LangChain, Letta, Google's ADK, promptflow, smolagents, mem0, griptape, and a few others.&lt;/p&gt;

&lt;p&gt;Nothing exotic. Just the boring stuff that gets skipped once a project grows fast: unpinned actions, CI checks that quietly stopped meaning anything, and a couple of straight up regressions where a fix from months ago got undone by a later commit nobody noticed.&lt;/p&gt;

&lt;p&gt;A few examples worth mentioning, since the PRs are public.&lt;/p&gt;

&lt;p&gt;Microsoft's autogen had a CI check meant to catch out of sync generated proto files. It could never fail. Someone had copied a demo snippet from a GitHub Action's README two years ago, and that snippet never included an exit code. The job kept reporting green no matter how stale the generated files got.&lt;/p&gt;

&lt;p&gt;Google's adk-python had a fork guard on a pull_request_target workflow that was correctly fixed in an earlier PR, then quietly reverted by a later sync commit. That's scarier than a bug that was never fixed, because it means the protection existed and then silently disappeared.&lt;/p&gt;

&lt;p&gt;griptape had an action pinned to a mutable tag instead of a commit hash, tj-actions/changed-files at v47. That's the exact action that got compromised in March 2025, when its version tags were rewritten to point at a commit that dumped CI runner memory, secrets included, into public workflow logs. Pinning to a tag instead of a hash means trusting the tag never moves. It moved once already, for this specific action.&lt;/p&gt;

&lt;p&gt;None of it is glamorous. No RCE, no data breach, nothing headline worthy. It's the unglamorous work of checking whether a project's CI setup actually does what its authors think it does. In about half the repos I looked at, it didn't, for at least one workflow.&lt;/p&gt;

&lt;p&gt;PRs are open on autogen, semantic-kernel, letta-code, adk-python, smolagents, mem0, griptape and promptflow. Some are already reviewed, most are waiting on a maintainer to get to them.&lt;/p&gt;

&lt;p&gt;If you maintain a project whose CI has run unchanged for a year or two, it's worth a second look. The bugs aren't hard to find. Nobody had gotten around to looking.&lt;/p&gt;

</description>
      <category>security</category>
      <category>opensource</category>
      <category>github</category>
      <category>ai</category>
    </item>
    <item>
      <title>I built a scanner for CI checks that can't fail. Nvidia just built one for AI skills that can't be trusted.</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Tue, 08 Sep 2026 04:10:19 +0000</pubDate>
      <link>https://dev.to/holistis/i-built-a-scanner-for-ci-checks-that-cant-fail-nvidia-just-built-one-for-ai-skills-that-cant-be-n9l</link>
      <guid>https://dev.to/holistis/i-built-a-scanner-for-ci-checks-that-cant-fail-nvidia-just-built-one-for-ai-skills-that-cant-be-n9l</guid>
      <description>&lt;p&gt;Nvidia released SkillSpector last month: a scanner that checks AI agent skills and MCP servers for prompt injection, data exfiltration, and other malicious patterns before you install them. Their own numbers: 26.1% of the skills they scanned had vulnerabilities, 5.2% looked outright malicious. It's part of a bigger pipeline now, a catalog where skills get scanned, evaluated, and signed before anyone sees them.&lt;/p&gt;

&lt;p&gt;I read that a few days after finishing something adjacent: a count of how many CI checks in public GitHub repos can pass even when they've already found a real problem. Sample of 120 real workflow files, by hand. 74% had at least one step that cannot fail no matter what it finds.&lt;/p&gt;

&lt;p&gt;Different layer, same instinct: don't trust the thing that's supposed to warn you until something has checked that it can actually go red.&lt;/p&gt;

&lt;p&gt;Muraqib runs &lt;code&gt;npx muraqib doctor&lt;/code&gt; against a repo's CI setup and looks for exactly that failure mode: a &lt;code&gt;continue-on-error: true&lt;/code&gt; on a step that matters, a notification step that swallows a missing secret and reports success anyway, a job hard-killed by &lt;code&gt;timeout-minutes&lt;/code&gt; that shows up as "cancelled" instead of "failure" so every downstream check gets skipped without anyone deciding that. All four look identical from the Actions tab: a green run, an empty inbox, nobody looking. That's worse than having no monitoring, because you've stopped checking it yourself.&lt;/p&gt;

&lt;p&gt;SkillSpector looks at a different kind of thing hiding behind a green light. It runs static analysis plus an optional LLM pass over a skill or MCP server, checking for a tool description that asks for more file access than the task needs, a prompt trying to override its own instructions, an output path that quietly phones home. 71 patterns across 17 categories, from prompt injection to MCP tool poisoning.&lt;/p&gt;

&lt;p&gt;Neither tool watches the same layer. Muraqib checks whether your CI can tell you the truth. SkillSpector checks whether the thing you just installed is telling you the truth. Both exist because more of what runs in a repo now runs with less human eyeball time on it: a nightly test suite nobody reads the raw logs of, a skill you installed because the README looked fine.&lt;/p&gt;

&lt;p&gt;Every one of Muraqib's four failure patterns had already happened, quietly, on the same project, before I went looking for them. The weekly digest email didn't go out for twelve Mondays in a row, because the notification step went green on a missing API key instead of failing. Nobody caught it by reading logs. I only found that silence by accident, while testing something unrelated, and built the check for it afterward.&lt;/p&gt;

&lt;p&gt;If a project's CI, or its agent tooling, doesn't have something scanning the watchers themselves for that failure mode, it's running on trust nobody has checked yet.&lt;/p&gt;

&lt;p&gt;Muraqib: &lt;a href="https://github.com/holistis/muraqib" rel="noopener noreferrer"&gt;https://github.com/holistis/muraqib&lt;/a&gt; (MIT, &lt;code&gt;npx muraqib doctor&lt;/code&gt; takes about two seconds, installs nothing)&lt;br&gt;
SkillSpector: &lt;a href="https://github.com/NVIDIA/SkillSpector" rel="noopener noreferrer"&gt;https://github.com/NVIDIA/SkillSpector&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>opensource</category>
      <category>github</category>
    </item>
    <item>
      <title>Two Strangers, Zero Stars, Two Boundaries Still Open</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Mon, 07 Sep 2026 08:22:11 +0000</pubDate>
      <link>https://dev.to/holistis/two-strangers-zero-stars-two-boundaries-still-open-425n</link>
      <guid>https://dev.to/holistis/two-strangers-zero-stars-two-boundaries-still-open-425n</guid>
      <description>&lt;p&gt;capacity-attest is a small npm package for signed, content-addressed delivery claims between two parties in an x402 agent-to-agent payment. As of today the GitHub repo behind it has 0 stargazers, 0 forks, and 1 open issue, and two outside developers built on it anyway.&lt;/p&gt;

&lt;p&gt;This is what happened, told in the order it happened, using only what we can point at directly: pull requests, commit hashes, an npm registry, an on-chain transaction, an issue thread, and our own decision log. One real gap got closed and shipped on 2026-09-06. Two others are written down in DECISIONS.md as still open, on purpose, because no one has hit them hard enough yet to justify a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format
&lt;/h2&gt;

&lt;p&gt;A capacity-attest claim comes down to two things: a claimId and a signature.&lt;/p&gt;

&lt;p&gt;The claimId is a sha256 hash of the canonical content of the claim: who delivered what, to whom, and whether it was delivered. The signature is an EIP-191 signature over that content, and it recovers to a buyer address.&lt;/p&gt;

&lt;p&gt;That is the whole trust model. No central ledger to believe, no reputation score, just a hash anyone can recompute and a signature anyone can verify themselves. We did not design this to be portable across chains. We designed it to be simple. Whether that simplicity would also travel across chains was tested by what happened between 2026-09-05 and 2026-09-07, not decided by us in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that came before either builder
&lt;/h2&gt;

&lt;p&gt;On 2026-09-05, goun7 opened x402-foundation/x402#3379, a feature request about binding x402 settlement to an externally verifiable agent work-receipt ledger, under the short name proof-of-done. The thread reached 14 comments from 5 people: 0xultravioleta, goun7, holistis (us), safal207, and wildcherrycasa.&lt;/p&gt;

&lt;p&gt;Inside that thread was a specific technical question about our own ledger: is the append-only claims file hash-chained, or does its integrity rest entirely on whoever hosts it. goun7's own team had found the same class of problem in their own system, that a hostile writer can fabricate a consistent fake history against a fresh node.&lt;/p&gt;

&lt;p&gt;We checked. It was not hash-chained. A host could show a buyer a subset of real, validly-signed claims, all positive, none negative, and the buyer would have no way to detect what was left out. Every individual claim would still verify as authentic. The set as a whole would still be misleading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boundary 1: completeness got a partial fix, not a full one
&lt;/h2&gt;

&lt;p&gt;We built a priorClaimId field that lets a buyer chain their own successive claims about the same seller together, plus an analyzeCompleteness check that flags any returned claim whose predecessor is missing from the result. It shipped with 494 tests and a public fixture, merged and published as &lt;a href="mailto:capacity-attest@0.4.0"&gt;capacity-attest@0.4.0&lt;/a&gt;, and we answered publicly in the same issue thread (comment 5560977571) instead of asking anyone to take our word for it.&lt;/p&gt;

&lt;p&gt;Read analyzeCompleteness for what it actually checks, not what its name implies. It detects a broken chain: if claim 5 points to claim 4 as its prior and claim 4 is missing, that gap is visible. It cannot prove there was never a claim 6. A host that simply never issues an attestation for a delivery leaves no gap to find, because no link in the chain was ever broken. Detecting an incomplete chain and proving a complete one are different guarantees, and only the first one shipped.&lt;/p&gt;

&lt;p&gt;Existence anchoring, the piece that would let a third party confirm a claim exists independent of the host's own record, stays unbuilt for the same reason it started unbuilt: no real counterparty has ever named its absence as a blocker. It stays on the list, not in the code, until that changes.&lt;/p&gt;

&lt;p&gt;The same discipline caught an overclaim before it went further than it should have. Working against the package, one contributor, YE-YI7, surfaced enough real friction that our decision log's first pass called it proof of a real need for a formal conformance-vectors suite, based on that single interaction. We walked that back once it became clear one data point does not prove a need for permanent infrastructure. What we shipped instead was cheaper: a section in the README for verifying a claim independently. The vectors suite stays unbuilt until a second, independent party hits the same friction after reading that section. So far, nobody has.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026-09-06, 13:59 UTC: YE-YI7 merges PR #18
&lt;/h2&gt;

&lt;p&gt;The same day, YE-YI7 merged a PR into their own asm-spec repository, 1568 additions across 6 files, that does not just copy our claim shape. It imports &lt;a href="mailto:capacity-attest@0.2.0"&gt;capacity-attest@0.2.0&lt;/a&gt; as an actual dependency and calls into it directly, verifying the claim byte digest, the claimId, and the EIP-191 signer through our code, not a reimplementation of it. That is the first time anyone outside this project used the package itself, not just the idea behind it.&lt;/p&gt;

&lt;p&gt;Nobody had asked us to build a reputation score or ranking on top of delivery claims, not YE-YI7, not goun7, and we deliberately do not. We corrected our own decision log on this point while writing this article: an earlier version credited YE-YI7 with a quote endorsing that choice, and we could not find that quote anywhere in YE-YI7's actual public work when we checked it against the source. It is gone from the log now. What survives, checked directly rather than assumed, is plainer and weaker: neither real contact has ever asked for a score, so the decision not to build one stands on our own reasoning, not on someone else's words.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boundary 2: two ways to fake scale, neither closed
&lt;/h2&gt;

&lt;p&gt;Not building a reputation score closes off one way to fake standing. Two separate, unrelated ways remain open, and we found both ourselves in an abuse scan on 2026-09-06 rather than fixed either.&lt;/p&gt;

&lt;p&gt;The first is identity: nothing in capacity-attest stops one operator from registering many addresses and looking like many different buyers or sellers. A 14-agent research pass the same day checked whether this is already covered elsewhere, ERC-8004, Google's AP2, Microsoft's Entra Agent ID, the x402 Foundation's own work, the Legal Context Protocol, and concluded that it is. What we shipped from that pass was small: externalRefs citation fields and a resolve_agent_identity tool, verified live against ERC-8004 on Base mainnet. Citing an ERC-8004 identity is not the same as stopping one operator from registering many of them, and we left this deliberately thin.&lt;/p&gt;

&lt;p&gt;The second is volume, a plainer problem with no identity layer involved. Nothing charges an actor for producing a claim, so an attacker can fabricate an unlimited stream of validly-signed, false claims against a real seller for free. get_delivery_history also returns results unbounded and synchronously, with no pagination and no rate limit on the read side. Both are marked in the decision log as found and not fixed, on purpose: rate-limiting one side while leaving the other free would look like a fix without being one. Both wait for an actual trigger, a real abuse case or a partner who needs the guarantee, before we commit to a specific mitigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026-09-06, 19:33 UTC: 0.5.0 ships, and gets proven on-chain
&lt;/h2&gt;

&lt;p&gt;Between the two pull requests, we published capacity-attest 0.5.0: cross-installation discovery plus an EAS source, commit c8fa192 ("chore(capacity-attest): 0.5.0 (cross-installation discovery + EAS source)").&lt;/p&gt;

&lt;p&gt;The problem it addresses is separate from either boundary above: get_delivery_history is local by default. If buyer A and buyer B each run their own installation, B does not automatically see the claim A recorded about the same seller. discoverDeliveryHistory pulls claims from multiple independent sources and re-verifies every one of them regardless of where it came from, trusting no source by default.&lt;/p&gt;

&lt;p&gt;We then proved this against a real public chain, not just a fixture. On Base mainnet, a schema was registered on EAS, UID 0x1dd19408345dee43b432b89ccb68760265ecff506098b6efe8ba82ad0d52b195, and two claims were published as attestations: one delivered yes, UID 0x81a55d54452b2cf8bdda7918f63a27bf9ff79e5025b485f7316aae6259288ccc, and one delivered no, UID 0xe736b005cbcb54f8f196ac64ef09d75d939c8a18c0d5d9670b5c5025c07398c4. Total cost for the three transactions was about 0.0000096 ETH, roughly 2.5 dollar cents. A separate read-only script, standing in for a fresh installation, then found both attestations on-chain and verified each one locally, including the negative one.&lt;/p&gt;

&lt;p&gt;This solves discoverability, not completeness. Finding a claim across installations and proving you found every claim that exists are different problems, and only the first one has a shipped fix. One honest caveat on the numbers above: those three UIDs are quoted directly from our decision log. We have not independently re-checked them against base.easscan.org as part of writing this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  2026-09-07, 04:54 UTC: EmbryoSpace merges PR #19
&lt;/h2&gt;

&lt;p&gt;The second builder took a different path, into the same asm-spec repository. EmbryoSpace's PR, 543 additions across 9 files, reuses only the claim format, claimId as a sha256 hash of canonical content. Nothing else.&lt;/p&gt;

&lt;p&gt;It could not reuse more, because our verifyClaim function is typed around ETH-style EIP-191 signatures. EmbryoSpace's claims are signed and encoded for BSV, base58, a different signature scheme entirely. Our verification code cannot check a BSV signature, so there was no version of this PR where it could call into our package the way YE-YI7's did.&lt;/p&gt;

&lt;p&gt;That is the actual answer to whether the format is rail-neutral. It is, in the one part that matters most: content-addressing. The claimId only depends on hashing canonical content, so any chain can produce and consume the identifier. The signature and its verification are not neutral at all, and pretending otherwise would have been the wrong thing to write in a decision log meant to be checked, not trusted.&lt;/p&gt;

&lt;p&gt;One more line item stayed unbuilt for a plainer reason: an in-transit status for multi-day deliveries. Neither external contact asked for it, and the closest real-world comparisons, AWS, Vast.ai, RunPod, also bill after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, plainly
&lt;/h2&gt;

&lt;p&gt;capacity-attest is at 0.5.0 on npm, with 646 downloads over the period 2026-08-08 through 2026-09-06. The repository has 0 stargazers, 0 forks, 1 open issue. The most recent commit, c8fa192, landed 2026-09-06T19:33:36Z, and the four commits before it, all from the same author, landed on the same calendar day. This is one person moving fast on one day, not a project carrying years of review.&lt;/p&gt;

&lt;p&gt;Exactly two people outside this project, YE-YI7 and EmbryoSpace, have built something that uses the claim format or the package itself, confirmed through their own merged pull requests, and they did it in two different ways worth telling apart. One calls into capacity-attest's own verification code. The other reuses only the hash-based identifier because its signature scheme cannot be checked by this code at all.&lt;/p&gt;

&lt;p&gt;One boundary, completeness of visibility across a claim history, got a real but partial fix on 2026-09-06: it catches a broken chain, not a fabricated silence. A second boundary, faking scale, splits into an identity gap that was named and researched but left deliberately thin, and a plainer volume gap, free claim fabrication and an unbounded read endpoint, found and documented instead of patched over. Two strangers proved the format holds across two different rails. The decision log is where the next person finds out what it still does not hold against, corrections included.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>crypto</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We had two independent proofs of a critical RCE. The real software disagreed.</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:38:08 +0000</pubDate>
      <link>https://dev.to/holistis/we-had-two-independent-proofs-of-a-critical-rce-the-real-software-disagreed-20bd</link>
      <guid>https://dev.to/holistis/we-had-two-independent-proofs-of-a-critical-rce-the-real-software-disagreed-20bd</guid>
      <description>&lt;p&gt;Yesterday we found what looked like a critical remote code execution bug in Postman's script sandbox. Two of us reproduced it independently, both times with real command execution. We scored it, wrote most of the report, and started building the end-to-end proof of concept before reaching out to Postman's security team.&lt;/p&gt;

&lt;p&gt;Then we ran that proof of concept against the actual, currently published packages, and it failed. Both times.&lt;/p&gt;

&lt;p&gt;Here's what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug we thought we had
&lt;/h2&gt;

&lt;p&gt;Postman lets you run JavaScript before and after a request, for things like generating auth tokens or checking a response. That script runs in a restricted environment: no &lt;code&gt;process&lt;/code&gt;, no &lt;code&gt;require&lt;/code&gt;, no filesystem. The restriction works by literally naming those as function parameters and calling the function with &lt;code&gt;undefined&lt;/code&gt; for each one, so any script that types &lt;code&gt;process&lt;/code&gt; gets &lt;code&gt;undefined&lt;/code&gt; instead of the real thing.&lt;/p&gt;

&lt;p&gt;There's a classic way around that trick. &lt;code&gt;Function('return this')()&lt;/code&gt; compiles a brand new function from a string and calls it, and that new function does not inherit any of the parameter shadowing from the function it was written inside. If it runs in the same JavaScript realm as the real &lt;code&gt;process&lt;/code&gt; object, it hands that object straight back to you.&lt;/p&gt;

&lt;p&gt;We tried it against a standalone rebuild of Postman's isolation logic (the two open source packages it's built on, &lt;code&gt;uvm&lt;/code&gt; and &lt;code&gt;uniscope&lt;/code&gt;) and got a real process object back, twice, independently. From there: &lt;code&gt;process.binding('spawn_sync')&lt;/code&gt;, and we had command execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fell apart
&lt;/h2&gt;

&lt;p&gt;A rebuild of the logic is not the same thing as the actual product. So before writing to Postman, we built the exploit again, this time against the real, currently published &lt;code&gt;postman-sandbox&lt;/code&gt;, &lt;code&gt;postman-runtime&lt;/code&gt;, and &lt;code&gt;newman&lt;/code&gt; packages, called the way an actual script would call them.&lt;/p&gt;

&lt;p&gt;Both attempts failed. The error from the &lt;code&gt;newman&lt;/code&gt; path was blunt: &lt;code&gt;Cannot read properties of undefined (reading 'mainModule')&lt;/code&gt;. The &lt;code&gt;process&lt;/code&gt; object we thought we'd recovered was undefined.&lt;/p&gt;

&lt;p&gt;It turns out Postman's real sandbox does something our rebuild didn't: before any user script runs, it deliberately strips &lt;code&gt;process&lt;/code&gt;, &lt;code&gt;Buffer&lt;/code&gt;, and &lt;code&gt;globalThis&lt;/code&gt; off the actual global object, a step it calls &lt;code&gt;recreatingTheUniverse()&lt;/code&gt;, documented in their changelog since &lt;code&gt;postman-sandbox&lt;/code&gt; v4.0.0. On the &lt;code&gt;newman&lt;/code&gt; path there's a second, independent layer on top of that: the script runs inside its own separate V8 context, so there is no shared realm for the trick to abuse in the first place.&lt;/p&gt;

&lt;p&gt;Our rebuild had faithfully reproduced the parameter-shadowing trick. It just hadn't reproduced the hardening Postman built on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're taking from this
&lt;/h2&gt;

&lt;p&gt;We keep a running rule for ourselves: an exploit only counts as reachable once it's been run against the real, unmodified code through its own public entry point, never against a rebuild of the mechanism, however faithful that rebuild feels. This is the second time we've had to learn a version of that lesson, and this time it cost us a wasted day rather than a wasted submission.&lt;/p&gt;

&lt;p&gt;No report went to Postman. Nothing was submitted, nothing was retracted. We're writing this up because the failure is more useful to share than the almost-bug was, and because "we double-checked our own work before it left the building" is the kind of thing you can only prove by actually doing it in public.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I counted how many CI checks cannot fail. On a 120-file sample, 74 percent.</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Sat, 05 Sep 2026 11:11:51 +0000</pubDate>
      <link>https://dev.to/holistis/i-counted-how-many-ci-checks-cannot-fail-on-a-120-file-sample-74-percent-3g7k</link>
      <guid>https://dev.to/holistis/i-counted-how-many-ci-checks-cannot-fail-on-a-120-file-sample-74-percent-3g7k</guid>
      <description>&lt;p&gt;Last week I found two checks in my own repository that could never have failed. Not broken, not misconfigured. Structurally incapable of going red, sitting in CI for months, costing the same as a real check and reporting the same colour.&lt;/p&gt;

&lt;p&gt;So I went looking for how common that is. Here is the count, the method, and what it does not prove.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I counted
&lt;/h2&gt;

&lt;p&gt;GitHub code search, all public repositories, on 5 September 2026.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;workflow files (containing runs-on in .github/workflows)   7,094,272
  of those, containing "continue-on-error: true"             264,704
  containing "npm run lint"                                  301,568
  containing BOTH "npm run lint" and "continue-on-error"      27,232
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is where it gets interesting, and also where code search stops being able to help. It tells you both strings live in the same file. It cannot tell you the &lt;code&gt;continue-on-error&lt;/code&gt; is attached to the lint step rather than to something unrelated three jobs down.&lt;/p&gt;

&lt;p&gt;So I read them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sample
&lt;/h2&gt;

&lt;p&gt;I pulled 500 matching files from pages spread across the result set, shuffled them, and inspected the first 120 by fetching each file and walking it step by step.&lt;/p&gt;

&lt;p&gt;Two things I had to fix in my own method before the number meant anything.&lt;/p&gt;

&lt;p&gt;The first pass gave 80 percent, and it was wrong. &lt;code&gt;path:.github/workflows&lt;/code&gt; also matches &lt;code&gt;README.md&lt;/code&gt;, &lt;code&gt;TROUBLESHOOTING.md&lt;/code&gt; and &lt;code&gt;ci.md&lt;/code&gt; sitting in that directory, plus &lt;code&gt;.yml.disabled&lt;/code&gt; files that do not run. Filtering to actual &lt;code&gt;.yml&lt;/code&gt; and &lt;code&gt;.yaml&lt;/code&gt; removed that noise.&lt;/p&gt;

&lt;p&gt;The second was a distinction I nearly missed. One of the early hits was a step called &lt;strong&gt;"Lint (non-blocking)"&lt;/strong&gt;. That is not the problem. That is somebody being explicit. Counting it alongside the silent ones would have been unfair and would have inflated the number.&lt;/p&gt;

&lt;p&gt;With both corrections, on 120 real workflow files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;check step that silently cannot fail       89   (74%)
check step that says so in its name         3   (2.5%)
no toothless check step at all             28   (23%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security audits are the worst of it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;workflow files running "npm audit"                          71,296
  of those, also containing "continue-on-error"             19,200   (27%)
workflow files with "npm test" and "continue-on-error"      22,592
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More than a quarter of the workflows that run a dependency audit also contain something allowed to fail. Given the 74 percent rate in the sample, most of those are the audit itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three shapes I verified by reading the file
&lt;/h2&gt;

&lt;p&gt;I am not linking these. They are small public projects and the point is the pattern, not the people. Run the search yourself and read the first ten results; you will find the same thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plain one.&lt;/strong&gt; A step named &lt;code&gt;Lint&lt;/code&gt;, running &lt;code&gt;npm run lint&lt;/code&gt;, with &lt;code&gt;continue-on-error: true&lt;/code&gt; directly under it. The name promises a check. Nothing in the Actions tab tells you it cannot fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one with the padlock.&lt;/strong&gt; A step named &lt;code&gt;🔐 Check for security vulnerabilities&lt;/code&gt;, running &lt;code&gt;npm audit --audit-level=moderate&lt;/code&gt;, with &lt;code&gt;continue-on-error: true&lt;/code&gt;. The emoji is doing more work than the check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The belt and braces.&lt;/strong&gt; A step named &lt;code&gt;Run tests (if available)&lt;/code&gt; whose command is &lt;code&gt;npm test || echo "No tests configured"&lt;/code&gt;, with &lt;code&gt;continue-on-error: true&lt;/code&gt; on top. The &lt;code&gt;|| echo&lt;/code&gt; already swallows the exit code. The flag catches whatever the first mechanism missed. That step cannot report a failure through two independent layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than having no check
&lt;/h2&gt;

&lt;p&gt;A missing check leaves you appropriately nervous. You know you are not covered.&lt;/p&gt;

&lt;p&gt;A check that cannot fail costs exactly the same to run, occupies the same line in the Actions tab, renders the same green, and buys you confidence it has not earned. It is not neutral. It actively replaces the nervousness that would have made you look.&lt;/p&gt;

&lt;p&gt;There is a second cost that surprised me. On my own suite, the twelve failing tests were 8 percent of the tests and 36 percent of the runtime, because a failing test burns its full timeout plus a retry. A dead check does not just fail to inform you. It also does not fail fast, because it never fails at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the flag is right
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;continue-on-error&lt;/code&gt; is a good feature and this is not an argument against it.&lt;/p&gt;

&lt;p&gt;An experimental matrix leg on a nightly toolchain, an optional platform, a step whose failure genuinely does not block the merge: all fine. Three of the files in my sample used it exactly that way and said so in the step name.&lt;/p&gt;

&lt;p&gt;The distinction is not the flag. It is whether the name tells the truth. &lt;code&gt;Lint (non-blocking)&lt;/code&gt; is honest. &lt;code&gt;Lint&lt;/code&gt; is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check I now run on myself
&lt;/h2&gt;

&lt;p&gt;Reading a check does not tell you whether it works. The only way to know is to introduce the exact defect it claims to catch and confirm it goes red.&lt;/p&gt;

&lt;p&gt;I ran that against ten checks in one repository. Two could never have fired. One of them is worth describing, because I would not have predicted it.&lt;/p&gt;

&lt;p&gt;The check verified that a &lt;code&gt;curl&lt;/code&gt; in a notification step carried the &lt;code&gt;--fail&lt;/code&gt; flag, by searching the step for the flag name. Directly above that &lt;code&gt;curl&lt;/code&gt; sat a comment explaining why the flag mattered. Remove the flag, leave the comment, and the check stayed green, because the string was still on the page.&lt;/p&gt;

&lt;p&gt;The more carefully I had documented why the flag was necessary, the more reliably I had disabled the check that enforced it.&lt;/p&gt;

&lt;p&gt;Three measurements per case, not one: green before the mutation, red during, green after. If any of the three is off, the result is unknown rather than pass. My own harness produced four unusable results before it produced a trustworthy one, every time because of the harness and not the checks. An exercise about detectors that cannot fire is a good place to remember that the detector you just wrote is also a detector.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this count does not prove
&lt;/h2&gt;

&lt;p&gt;Worth stating plainly, because a number without its limits is a claim dressed up as a fact.&lt;/p&gt;

&lt;p&gt;GitHub's code search totals are approximate. My sample came from the first thousand results rather than the full 27,232, so it is indicative and not a proper random estimate. I only measured the JavaScript ecosystem, through npm commands; Python, Go and Rust workflows are not in the count. My step parser is a heuristic that walks lines between step boundaries, and I hand-verified three of its hits rather than all 89.&lt;/p&gt;

&lt;p&gt;What I am confident saying: this is common, it is not rare, and security audits are disproportionately affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-minute version
&lt;/h2&gt;

&lt;p&gt;Open your workflows. Search for &lt;code&gt;continue-on-error: true&lt;/code&gt;. For each one, ask whether the job or step name promises something it cannot deliver.&lt;/p&gt;

&lt;p&gt;Then pick your most important check and break the thing it watches for. If it stays green, you did not have that check. You had a line item.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>A CI timeout is a kill, not a failure. Playwright's defaults changed because of it.</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:37:18 +0000</pubDate>
      <link>https://dev.to/holistis/a-ci-timeout-is-a-kill-not-a-failure-playwrights-defaults-changed-because-of-it-318j</link>
      <guid>https://dev.to/holistis/a-ci-timeout-is-a-kill-not-a-failure-playwrights-defaults-changed-because-of-it-318j</guid>
      <description>&lt;p&gt;Every morning there was a run in the Actions tab and nothing in my inbox. That is exactly what a healthy nightly looks like.&lt;/p&gt;

&lt;p&gt;It had not tested my app since late June. I found out in September.&lt;/p&gt;

&lt;p&gt;Here is what was happening, why nothing told me, and what changed in Playwright last week as a result.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timeout is a kill, not a failure
&lt;/h2&gt;

&lt;p&gt;My nightly job had &lt;code&gt;timeout-minutes: 20&lt;/code&gt;. My suite had quietly grown past twenty minutes.&lt;/p&gt;

&lt;p&gt;When a GitHub Actions runner hits that limit it does not fail the job. It kills the process and records the run's conclusion as &lt;code&gt;cancelled&lt;/code&gt;. That single word is the whole problem, and it has three consequences that stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The report is never written.&lt;/strong&gt; Playwright is stopped mid-run. The reporters never get to flush, so there is no HTML report and no &lt;code&gt;results.json&lt;/code&gt;. Nothing on disk to look at afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The upload step skips itself.&lt;/strong&gt; The recommended workflow guards the artifact upload with &lt;code&gt;if: ${{ !cancelled() }}&lt;/code&gt;. That guard is right and it is still there. It exists so a run somebody cancelled by hand does not upload a half-finished report. The trouble is that a runner timeout produces the same conclusion as a hand cancellation, so the guard cannot tell them apart. You get no artifact on precisely the runs where you most want one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your alerting never fires.&lt;/strong&gt; If your notification step keys off the test step failing, it is not reached. A cancelled job skips everything downstream. No failed step, no email, no Slack message.&lt;/p&gt;

&lt;p&gt;So the run happens, the run is visible, and nothing about it says anything is wrong. It is indistinguishable from a night where the tests passed. Worse, it is indistinguishable from a run somebody cancelled by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers from my own repo
&lt;/h2&gt;

&lt;p&gt;I pulled the run history when I finally went looking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;164 nightly runs since June
  7 succeeded
 76 failed
 81 cancelled, the last 60-plus consecutively
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every cancelled run produced zero artifacts.&lt;/p&gt;

&lt;p&gt;Then I set &lt;code&gt;globalTimeout&lt;/code&gt; below the job timeout and ran the same suite. It finished in 29 minutes, reported &lt;code&gt;failure&lt;/code&gt; with 12 of 147 tests failing, and produced an 83 MB report.&lt;/p&gt;

&lt;p&gt;The suite had needed 29 minutes for a while. The 20 minute limit had been killing it nightly. Because a kill is not a failure, nothing said so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and why the ordering matters
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;globalTimeout&lt;/code&gt; is a Playwright setting. It defaults to no timeout at all.&lt;/p&gt;

&lt;p&gt;Set it below your job's &lt;code&gt;timeout-minutes&lt;/code&gt; and the order of events changes completely. Playwright stops itself first. It exits non-zero, the reporters finish, the HTML report and &lt;code&gt;results.json&lt;/code&gt; are written, and the run reads as an ordinary test failure. Your alerting fires because something actually failed.&lt;/p&gt;

&lt;p&gt;The runner timeout then only has to cover the install and upload steps around the run. It becomes a backstop instead of the primary bound.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// playwright.config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// Fail the run after an hour, so that the reporters still produce a report.&lt;/span&gt;
  &lt;span class="na"&gt;globalTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines. The entire difference between a loud failure and two months of silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How common this is
&lt;/h2&gt;

&lt;p&gt;Before filing anything I wanted to know whether this was my mistake or a shape lots of people were in.&lt;/p&gt;

&lt;p&gt;At the time I checked, GitHub code search reported roughly 163,000 &lt;code&gt;playwright.config.ts&lt;/code&gt; files. About 1,700 of them set &lt;code&gt;globalTimeout&lt;/code&gt;. That is around one percent.&lt;/p&gt;

&lt;p&gt;Around 3,200 workflows ran &lt;code&gt;npx playwright test&lt;/code&gt; on a cron schedule. That is the case where nobody is watching the run live, so a missing report costs the most.&lt;/p&gt;

&lt;p&gt;Those numbers are approximate and they move. The ratio is the part that mattered: the safe configuration was the rare one, and the docs never connected the two settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;I filed &lt;a href="https://github.com/microsoft/playwright/issues/42533" rel="noopener noreferrer"&gt;microsoft/playwright#42533&lt;/a&gt; on 3 September, proposing a note in &lt;code&gt;ci.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Playwright maintainer Dmitry Gozman went considerably further than the note I asked for, and merged two changes on 4 September.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The documentation&lt;/strong&gt;, in &lt;a href="https://github.com/microsoft/playwright/pull/42563" rel="noopener noreferrer"&gt;microsoft/playwright#42563&lt;/a&gt;, across four files. &lt;code&gt;ci.md&lt;/code&gt; now opens the CI section with this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Always set a global timeout in CI. By default a test run has no upper bound, so a suite that hangs, or that slowly grows past the job limit of your CI provider, is killed by the runner mid-run and does not produce the test report.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And it drops the job-level timeout from the workflow examples, with the reasoning attached: if you do add one, keep it comfortably above &lt;code&gt;globalTimeout&lt;/code&gt; so Playwright always stops first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scaffolding&lt;/strong&gt;, in &lt;a href="https://github.com/microsoft/create-playwright/pull/181" rel="noopener noreferrer"&gt;microsoft/create-playwright#181&lt;/a&gt;. This is the one that matters most. &lt;code&gt;create-playwright&lt;/code&gt; is what runs when anyone types &lt;code&gt;npm init playwright@latest&lt;/code&gt;. It now generates &lt;code&gt;globalTimeout: 60 * 60 * 1000&lt;/code&gt; in all four config templates, and no longer generates &lt;code&gt;timeout-minutes&lt;/code&gt; in the GitHub Actions workflow.&lt;/p&gt;

&lt;p&gt;I asked for a warning sign next to the hole. What landed was the hole being filled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you start a Playwright project from today&lt;/strong&gt;, you get the safe default and you do not need to think about any of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your project already exists&lt;/strong&gt;, you still have the old shape. The generated config sits in your repo from the day you created it and nothing rewrites it. Two things to check:&lt;/p&gt;

&lt;p&gt;Does your &lt;code&gt;playwright.config.ts&lt;/code&gt; set &lt;code&gt;globalTimeout&lt;/code&gt;? If not, your run has no upper bound of its own.&lt;/p&gt;

&lt;p&gt;Does your CI job set &lt;code&gt;timeout-minutes&lt;/code&gt;? If yes, and &lt;code&gt;globalTimeout&lt;/code&gt; is unset or higher, the runner is your only limit and it kills without reporting.&lt;/p&gt;

&lt;p&gt;There is also a check that takes ten seconds and tells you whether this already happened to you. Open your nightly workflow's run history and look at the conclusions. If you see a run of &lt;code&gt;cancelled&lt;/code&gt; you never investigated, that is this bug, and those nights were not tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I keep thinking about
&lt;/h2&gt;

&lt;p&gt;The bug was not that something broke. Something broke and the system that was supposed to tell me was structurally incapable of telling me, because the failure mode it produced was the one shape it treated as "nothing to report".&lt;/p&gt;

&lt;p&gt;I have since gone looking for more of those. In my own tooling I found a check that verified a &lt;code&gt;curl&lt;/code&gt; had the &lt;code&gt;--fail&lt;/code&gt; flag by searching the whole workflow step for the word. Directly above the &lt;code&gt;curl&lt;/code&gt; sat a comment explaining why the flag mattered. Remove the flag, leave the comment, and the check stays green.&lt;/p&gt;

&lt;p&gt;The more carefully I had documented why the flag was necessary, the more reliably I had disabled the check that enforced it.&lt;/p&gt;

&lt;p&gt;The way you find these is not by reading them. It is by introducing the exact defect each check claims to catch and confirming it goes red. I ran that against ten checks in one repo and two of them could never have failed. When I ran it, my own test harness turned out to be broken twice before the checks were.&lt;/p&gt;

&lt;p&gt;A check that cannot fail costs exactly as much to run as one that can, looks identical in the Actions tab, and buys you confidence it has not earned. That is worse than having no check, because no check at least leaves you appropriately nervous.&lt;/p&gt;

&lt;p&gt;Look at what your green means. Sometimes it means nothing went wrong. Sometimes it means nothing could have told you.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Zero stars, seventy cloners, and a readme that said nothing about either</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Thu, 03 Sep 2026 06:19:31 +0000</pubDate>
      <link>https://dev.to/holistis/zero-stars-seventy-cloners-and-a-readme-that-said-nothing-about-either-4654</link>
      <guid>https://dev.to/holistis/zero-stars-seventy-cloners-and-a-readme-that-said-nothing-about-either-4654</guid>
      <description>&lt;p&gt;I have written two posts about Muraqib already. One on August 29, about open sourcing the thing and then spending three nights trying to break it myself before anyone else could. One earlier today, about the bot that got cancelled sixty nights in a row and never said a word about it.&lt;/p&gt;

&lt;p&gt;Neither post mentions a star count. Neither mentions a fork count. Neither mentions a download number. I checked both, word for word, before writing this one. The gap was just never there.&lt;/p&gt;

&lt;p&gt;That is the actual subject of this post. Not a new feature. A readme that shipped with zero signal on it, for a project people were already pulling down.&lt;/p&gt;

&lt;p&gt;Muraqib is a nightly Playwright QA watchdog for solo SaaS founders, open source, MIT licensed, at github.com/holistis/muraqib. Here is what I mean by already pulling down. Muraqib has zero GitHub stars and zero forks right now. But over the last 14 days, the repo has been cloned 175 times, by 70 unique cloners. Views sit at 83 total, 66 unique. Clones spiked hard on August 29, the day the first article went out. 96 clones from 43 unique cloners in a single day, more than half of the two week total.&lt;/p&gt;

&lt;p&gt;So the readme people were landing on, the same day the traffic actually happened, had nothing on it that told a visitor anyone else was using the thing. No badge, no number, nothing. Zero stars sitting at the top of the page next to 43 people quietly cloning it that same day is not a coincidence I am proud of. It is just what happens when you build the tool and forget to build the page.&lt;/p&gt;

&lt;p&gt;I found this today while doing something unrelated. Earlier the same day I had already fixed a different readme problem on this project, a line that promised a public defect history with nothing underneath it. Different bug, same shape. A claim or a blank space where evidence should be, and nobody checking what the reader actually sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I fixed
&lt;/h2&gt;

&lt;p&gt;The fix for the stars and forks gap was small. I added badges today. npm version, npm downloads per month, GitHub stars, GitHub forks, license. All pulled live, none of them typed in by hand. If the numbers move, the badges move. I do not have to remember to update them, which is good, because I clearly do not remember to check them either.&lt;/p&gt;

&lt;p&gt;The npm version badge already has something real to show. Muraqib went from 0.2.0 to 0.3.3 today, eight versions in one day, each one a real fix I found by running the tool against a production app and against other people's public repos.&lt;/p&gt;

&lt;p&gt;The downloads badge does not have anything to show yet. npm's own download stats API is still returning package not found for this package as of today. That lag, 24 to 48 hours before a brand new package's stats populate, is normal, not a bug. But it means the badge on the page right now is a real badge with no number behind it. That is fine. It is honest about what is known, which is more than the readme was doing before today.&lt;/p&gt;

&lt;p&gt;The broader thing I keep relearning is that a product having activity and a page showing that activity are two separate jobs. I spent real effort making sure Muraqib's core failure mode does not happen quietly. The release notes cover the actual reason for that effort. Of roughly 163,840 public playwright.config.ts files on GitHub, only about 1,684, roughly 1 percent, set a globalTimeout, the setting that prevents this project's core failure mode. I care about that kind of thing a lot.&lt;/p&gt;

&lt;p&gt;I did not care, until today, whether my own readme gave a stranger any reason to trust the project when they first looked at it.&lt;/p&gt;

&lt;p&gt;Instrumenting the product was the easy part. Instrumenting the page describing the product was the part I skipped without noticing. If you are building something people can find and clone, put the badge on before you write the second post about it, not after.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>github</category>
      <category>buildinpublic</category>
      <category>showdev</category>
    </item>
    <item>
      <title>79 clones, 2 stars, 4 installs, and I only just checked</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Thu, 03 Sep 2026 06:19:07 +0000</pubDate>
      <link>https://dev.to/holistis/79-clones-2-stars-4-installs-and-i-only-just-checked-893</link>
      <guid>https://dev.to/holistis/79-clones-2-stars-4-installs-and-i-only-just-checked-893</guid>
      <description>&lt;p&gt;Here are the numbers for Yad, my open-source browser automation agent, as of today. 79 unique clones. 2 GitHub stars. 0 forks. 4 users on the Chrome Web Store. 11 unique visitors to the repo page.&lt;/p&gt;

&lt;p&gt;I am not writing this because those numbers are impressive. They are not. I am writing this because until today I had no idea what they were, and neither did anyone looking at the README.&lt;/p&gt;

&lt;p&gt;The repo went up on 2026-08-24. GitHub's traffic Insights only shows a rolling 14-day window, and the repo is 10 days old, so what I am looking at is basically the whole life of the project so far, not a curated slice.&lt;/p&gt;

&lt;p&gt;Total clones over that window: 219, from 79 unique cloners. Total views: 47, from 11 unique visitors. There is one day that stands out, 2026-08-29, with 85 clones from 24 unique cloners in a single day. I do not know what caused that spike. Git clone traffic generally does not carry a web referrer, so GitHub cannot tell me either, and I am not going to guess and pass off a guess as an explanation.&lt;/p&gt;

&lt;p&gt;What little referrer data exists is thin. yadagent.com sent 17 visits from 3 unique visitors. github.com itself sent 15 visits from 4 unique visitors. alternativeto.net sent 1 visit from 1 visitor. That is the entire attributable list. Almost everything else has no referrer at all, which is apparently normal for clone traffic and not a sign that my tracking is broken.&lt;/p&gt;

&lt;p&gt;Separately from all of that, the Chrome Web Store has its own live counter, and right now it says 4 users. That is a smaller number than the clone count, and it is a different kind of number. Clones can be curiosity, CI, a script, someone checking it out and moving on. The Chrome Web Store count is the closest thing I have to actual people running the actual extension, not just people who cloned the repo to look at the code. 4 is the honest number.&lt;/p&gt;

&lt;p&gt;I also checked npm out of habit, since a companion package would make sense for this kind of tool. There is no package for this project on npm under any name I tried. The name "yad" is already taken, by an unrelated, older Express routing library from a different author. Not me, not this project.&lt;/p&gt;

&lt;p&gt;None of this was visible anywhere. Before today, the README had a CI status badge and an MIT license badge. That was it. No stars, no forks, no install count, nothing that told a visitor whether anyone else had ever touched this thing. I built the extension, wrote the docs, wired up CI, and never once put a number on the page, or even looked at the numbers myself.&lt;/p&gt;

&lt;p&gt;So today I fixed that. The README now has four more badges: Chrome Web Store install count, live. Chrome Web Store version, live. GitHub stars, live. GitHub forks, live. Small numbers, but they are on the page now, and they update themselves instead of going stale the way a hand-typed number would.&lt;/p&gt;

&lt;p&gt;While I was in there making that change, I nearly caused a different problem. A routine git add -A picked up 38 other untracked files sitting in my working tree, including a 91 MB Windows installer and several Chrome Web Store upload zips. None of it was in .gitignore, because I had never needed it to be until it was sitting next to a commit I was about to push. GitHub warns above 50 MB and refuses pushes above 100 MB, so that installer was one push away from breaking things for whoever hit it first. I caught it before it went anywhere, backed it out, and fixed the .gitignore in the same pull request.&lt;/p&gt;

&lt;p&gt;Two things happened the same day, and they are really the same mistake. I built the thing and never put a number on its own front page, and the exact same not looking almost let a 91 MB installer ride into git history unnoticed. First, small numbers are still numbers, and a page with none on it looks emptier than it is. Second, git add -A on a working tree you have not audited in a while is a habit worth breaking before it breaks a push.&lt;/p&gt;

&lt;p&gt;If you have a repo you have not looked at the Insights tab for, go look. You might be sitting on a number you have never written down anywhere.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>github</category>
      <category>buildinpublic</category>
      <category>showdev</category>
    </item>
    <item>
      <title>My QA bot was cancelled 60 nights in a row and never said a word</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Thu, 03 Sep 2026 04:54:40 +0000</pubDate>
      <link>https://dev.to/holistis/my-qa-bot-was-cancelled-60-nights-in-a-row-and-never-said-a-word-2nbb</link>
      <guid>https://dev.to/holistis/my-qa-bot-was-cancelled-60-nights-in-a-row-and-never-said-a-word-2nbb</guid>
      <description>&lt;p&gt;I run a health platform in the Netherlands. About three months ago I set up a nightly job: Playwright runs my critical flows against production at 2am, and if something breaks, Claude opens a PR with a fix. One digest email on Monday. I &lt;a href="https://dev.to/holistis/i-open-sourced-my-qa-tool-then-spent-three-nights-trying-to-break-it-before-anyone-else-could-3jde"&gt;wrote about it here before&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Yesterday I went looking at its run history for an unrelated reason.&lt;/p&gt;

&lt;p&gt;164 nightly runs. 7 succeeded. 76 failed. And 81 were cancelled, the last 60-plus of them consecutively.&lt;/p&gt;

&lt;p&gt;My app had not been checked since late June. I found out in September.&lt;/p&gt;

&lt;p&gt;Nothing had told me. Not a red job, not an email, not a failed check on a PR. Every morning the Actions tab showed a run from the night before, and every morning my inbox was empty, and I read that as good news for ten weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong is boring. Why it was silent is not.
&lt;/h2&gt;

&lt;p&gt;The job had &lt;code&gt;timeout-minutes: 20&lt;/code&gt;. My suite had grown to 24 spec files, run one at a time with a retry on CI, deliberately gentle on a production app. Somewhere in late June it crossed 20 minutes.&lt;/p&gt;

&lt;p&gt;That is the boring part. Any monitoring setup outgrows a limit eventually. You expect it to complain.&lt;/p&gt;

&lt;p&gt;Here is the part I did not know: a runner timeout is a kill, not a failure.&lt;/p&gt;

&lt;p&gt;When GitHub Actions hits &lt;code&gt;timeout-minutes&lt;/code&gt;, it stops the process. Playwright never finishes, so it writes no report. And GitHub records the run's conclusion as &lt;code&gt;cancelled&lt;/code&gt;, not &lt;code&gt;failure&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My workflow 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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tests&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;npx playwright test || echo "tests_failed=true" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
  &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Trigger Claude fix on failure&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.tests.outputs.tests_failed == 'true'&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fail job if tests failed&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.tests.outputs.tests_failed == 'true'&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;exit &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that again with a kill in mind. The test step never gets to write &lt;code&gt;tests_failed=true&lt;/code&gt;, because it never gets to finish at all. So the Claude dispatch is skipped. The job-failure step is skipped. No email. The run is indistinguishable from a night where everything passed, except it is also indistinguishable from a night where nothing ran.&lt;/p&gt;

&lt;p&gt;That is worse than having no QA. With no QA you at least know you are not covered, so you check things yourself. I had stopped checking things myself, because I believed something else was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real bug was one level up
&lt;/h2&gt;

&lt;p&gt;I could have fixed the timeout and moved on. Bigger number, done.&lt;/p&gt;

&lt;p&gt;But sit with the shape of it for a second. Every alert in my setup hung off exactly one thing: a test run that finished and reported a result. So the entire category of "the run never reported" had no alarm attached to it, by construction. The timeout was one member of that category. There are others, and I had none of them covered either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub silently disables scheduled workflows on repositories with no pushes for 60 days. Your cron just stops. No notification.&lt;/li&gt;
&lt;li&gt;A secret expires and the job dies during setup, before any test runs.&lt;/li&gt;
&lt;li&gt;Someone disables a workflow to debug something and forgets to turn it back on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four look identical from the outside: a quiet dashboard, an empty inbox, and an app nobody is checking.&lt;/p&gt;

&lt;p&gt;If you have a nightly check of any kind, this is the question worth asking, and it is a different question from "does it catch bugs":&lt;/p&gt;

&lt;p&gt;What does it look like when this check stops running, and who finds out?&lt;/p&gt;

&lt;p&gt;If the honest answer is "it looks exactly like everything being fine", the check is not finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two fixes
&lt;/h2&gt;

&lt;p&gt;The first one is ordering, not a bigger number.&lt;/p&gt;

&lt;p&gt;Playwright has a &lt;code&gt;globalTimeout&lt;/code&gt;. Set it below the job's &lt;code&gt;timeout-minutes&lt;/code&gt;, and Playwright stops itself before the runner can kill it. It exits non-zero, the reporters still write &lt;code&gt;results.json&lt;/code&gt;, and the whole chain behind it fires normally. A suite that got too slow now reads as a loud failure with a full report instead of silence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// playwright.config.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GLOBAL_TIMEOUT_MINUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MURAQIB_GLOBAL_TIMEOUT_MIN&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;globalTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GLOBAL_TIMEOUT_MINUTES&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/nightly.yml&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;45&lt;/span&gt;   &lt;span class="c1"&gt;# must stay above globalTimeout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two numbers can drift apart later, so there is a check in CI now that fails the build if &lt;code&gt;globalTimeout&lt;/code&gt; is missing, is not below the job timeout, or does not leave enough room for the install and upload steps around the run.&lt;/p&gt;

&lt;p&gt;The second fix is the one that actually matters. A watchdog, running daily, asking one question: has the nightly produced a pass or a fail recently?&lt;/p&gt;

&lt;p&gt;It alerts if the recent runs were all inconclusive, if nothing has reported inside the window, or if the workflow has no runs at all. It also flags a check that has been red for seven runs straight, because at that point it has stopped being an alert and become furniture. Everyone has learned to scroll past it, which is the same outcome as silence by a different road.&lt;/p&gt;

&lt;p&gt;The watchdog installs nothing. No dependencies, no npm step, just the Actions API and the fetch built into Node 20. Whatever watches the watchman needs fewer moving parts than the watchman, or you have only added another thing that can go quiet.&lt;/p&gt;

&lt;p&gt;I fed it my own run history from June onwards. It returns the alarm I needed ten weeks ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you have a nightly Playwright setup
&lt;/h2&gt;

&lt;p&gt;You can check yours without installing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx muraqib doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads your workflow and your Playwright config and tells you whether a slow night would fail loudly or just disappear. Two seconds. It writes nothing to your repo.&lt;/p&gt;

&lt;p&gt;I built it because I could not be the only person whose job timeout is quietly eating their monitoring. If you run it and it comes back green, good, that is a real answer and it cost you nothing.&lt;/p&gt;

&lt;p&gt;The whole thing is MIT licensed and runs in your own GitHub Actions on your own key. There is no service and no bill, because there is nothing to bill for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/holistis/muraqib" rel="noopener noreferrer"&gt;https://github.com/holistis/muraqib&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;I write software for a health platform under Dutch medical regulation. I am careful about a lot of things. And I still had a guard that had been dead for ten weeks, sitting in plain sight in my Actions tab, looking exactly like it was working.&lt;/p&gt;

&lt;p&gt;I am fairly sure I am not unusual here. If you set up a nightly check six months ago and have not looked at it since, go look at the conclusion column, not just the green ticks. Count how many say &lt;code&gt;cancelled&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>githubactions</category>
      <category>devops</category>
      <category>playwright</category>
    </item>
    <item>
      <title>I shipped a Series-A-grade security autopilot in one day. It costs €2.60/year.</title>
      <dc:creator>holistis</dc:creator>
      <pubDate>Mon, 31 Aug 2026 22:14:49 +0000</pubDate>
      <link>https://dev.to/holistis/i-shipped-a-series-a-grade-security-autopilot-in-one-day-it-costs-eu260year-4i83</link>
      <guid>https://dev.to/holistis/i-shipped-a-series-a-grade-security-autopilot-in-one-day-it-costs-eu260year-4i83</guid>
      <description>&lt;p&gt;I run a Dutch health AI platform alone. No co-founder, no CTO, no security team. Last Friday I shipped a security pipeline that, on paper, sits somewhere between what a 50-person scale-up and a Series-A startup deploys.&lt;/p&gt;

&lt;p&gt;Total cost of running it: &lt;strong&gt;€2.60 per year&lt;/strong&gt; in LLM tokens. Total time to build it: &lt;strong&gt;one day&lt;/strong&gt;, with Claude doing the typing.&lt;/p&gt;

&lt;p&gt;This isn't a "vibe coding wins" piece. This is the architecture, the code, what it catches, what it misses, and why I'm telling you most of your competitors are running production with a glass door and no lock on it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The state of solo-founder security in 2026 (it's grim)
&lt;/h2&gt;

&lt;p&gt;Some numbers, because opinions are cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~28%&lt;/strong&gt; of the top 100K websites have Content Security Policy headers (Mozilla Observatory, 2025)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~60%&lt;/strong&gt; of Stripe integrations don't validate webhook signatures (Stripe's own engineering blog)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~70%&lt;/strong&gt; of public websites run dependencies with known CVEs older than six months&lt;/li&gt;
&lt;li&gt;A typical WordPress shop has CSP off, no rate-limiting on &lt;code&gt;/wp-login.php&lt;/code&gt;, plugin files indexed in Google, and an admin email that is also the recovery email&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "we're too small to be a target" defense died around 2021 when AI-driven scanners like Nuclei, Burp Suite, ZAP, and (since 2024) XBOW and ZeroPath started crawling the internet 24/7. They don't pick targets. They scan everyone, score what they find, and the high-yield findings go up the pipe for follow-up.&lt;/p&gt;

&lt;p&gt;If you're shipping production code as a solo founder, the question is no longer "will I get attacked." It's "will the attacker bother spending more than three minutes once their scanner has scored my site."&lt;/p&gt;

&lt;p&gt;That's the bar I wanted to clear in one day.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I have now (the six components)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                ┌──────────────────────────────────────────┐
                │  1. SecurityRadar (Friday 03:30 NL)       │
                │  → NVD + GH Advisories + npm audit + RSS  │
                │  → Haiku-classify on shortlist            │
                │  → Critical = direct email + admin UI     │
                └──────────────────────────────────────────┘
                              ↓
                ┌──────────────────────────────────────────┐
                │  2. Dependabot (weekly Saturday)          │
                │  → Auto-PRs to dev branch only            │
                │  → Major bumps ignored (no breaking)      │
                │  → Grouped to reduce noise                │
                └──────────────────────────────────────────┘
                              ↓
                ┌──────────────────────────────────────────┐
                │  3. Auto-merge bot on dev                 │
                │  → Squash-merge once CI is green          │
                │  → Major-bump PRs: skipped, await human   │
                └──────────────────────────────────────────┘
                              ↓
                ┌──────────────────────────────────────────┐
                │  4. Muraqib nightly (Playwright on dev)   │
                │  → 90 end-to-end specs against dev URL    │
                │  → Result logged in GitHub Actions        │
                └──────────────────────────────────────────┘
                              ↓
                ┌──────────────────────────────────────────┐
                │  5. dev → main promotion (daily 09:00)    │
                │  → Requires 7 consecutive green Muraqib   │
                │  → Risk-gate on protected files           │
                │  → Fast-forward merge + push              │
                └──────────────────────────────────────────┘
                              ↓
                ┌──────────────────────────────────────────┐
                │  6. Auto-rollback (post-push to main)     │
                │  → Wait 90s, poll /api/health for 15 min  │
                │  → 3 consecutive 5xx = git revert + push  │
                │  → Email alert + [skip rollback] guard    │
                └──────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six moving parts. Each is the simplest possible implementation of its job. None of them are clever. The cleverness is that they're chained.&lt;/p&gt;




&lt;h2&gt;
  
  
  Component 1: SecurityRadar (the eyes)
&lt;/h2&gt;

&lt;p&gt;The radar runs once a week, Friday 03:30 NL. It hits four sources, all free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;npm audit --json&lt;/code&gt; on the local lockfile&lt;/li&gt;
&lt;li&gt;GitHub Security Advisories REST API, scoped to packages I actually use&lt;/li&gt;
&lt;li&gt;NIST NVD recent CVEs, last seven days, filtered to my stack keywords&lt;/li&gt;
&lt;li&gt;RSS feeds from Stripe, Cloudflare, Anthropic, scanned for security-flavored keywords&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first three sources are deterministic. The fourth is fuzzy. To stop fuzzy from torching my budget I do &lt;strong&gt;static pre-filtering first&lt;/strong&gt;, then hit the LLM only on the shortlist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 967 npm packages, 50+ NVD entries per week, dozens of blog posts.&lt;/span&gt;
&lt;span class="c1"&gt;// Filter to ~10 items before spending a single token.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pkgSet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;packages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nvdRelevant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nvdAdv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;adv&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pkgSet&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;npmAuditAdv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;githubAdv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;nvdRelevant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;vendorAdv&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shortlist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then Claude Haiku (the cheap model) classifies each item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classifyAdvisory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AdvisorySignal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;invokeLLM&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Assess this advisory for our stack (React/tRPC/Drizzle/Clerk/Stripe/Anthropic/Railway).

Source: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Package: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packageName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;n/a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Severity: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Title: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

Reply ONLY with JSON:
{"relevant": true|false, "recommendation": "1-2 sentences", "action": "auto-patch"|"manual-review"|"noop"}`&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-haiku-4-5-20251001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;maxTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;extractJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. ~10 items per week × ~500 tokens per classification × Haiku pricing = &lt;strong&gt;~€0.05 per week&lt;/strong&gt;. Critical findings trigger an email straight to my inbox; everything else lands in the admin UI as a "proposal".&lt;/p&gt;

&lt;p&gt;The whole file is ~480 lines. Half of that is fetching and parsing RSS, because vendor blogs don't bother with consistent feeds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Component 5: The 7-day soak (the patience)
&lt;/h2&gt;

&lt;p&gt;This is the part I'm proudest of, because it's the part most engineers would skip.&lt;/p&gt;

&lt;p&gt;Dependabot opens a PR to &lt;code&gt;dev&lt;/code&gt;. CI runs, my auto-merge bot squash-merges it. Now &lt;code&gt;dev&lt;/code&gt; is one commit ahead of &lt;code&gt;main&lt;/code&gt;. Should I push to production?&lt;/p&gt;

&lt;p&gt;Most "auto-merge to main" setups say yes the moment CI passes. That's how you ship a bug that only surfaces in a real browser session, or a Stripe webhook that only fails when an actual &lt;code&gt;invoice.payment_succeeded&lt;/code&gt; arrives.&lt;/p&gt;

&lt;p&gt;My setup says: not until Muraqib has run &lt;strong&gt;seven consecutive nights against dev&lt;/strong&gt; and all of them are green. Then, and only then, fast-forward &lt;code&gt;dev&lt;/code&gt; into &lt;code&gt;main&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check 7 nights Muraqib-dev-nightly green&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;RUNS=$(gh run list \&lt;/span&gt;
      &lt;span class="s"&gt;--workflow="muraqib-dev-nightly.yml" \&lt;/span&gt;
      &lt;span class="s"&gt;--branch=dev --limit=10 \&lt;/span&gt;
      &lt;span class="s"&gt;--json conclusion,createdAt,status)&lt;/span&gt;

    &lt;span class="s"&gt;SEVEN_DAYS_AGO=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)&lt;/span&gt;
    &lt;span class="s"&gt;RECENT=$(echo "$RUNS" | jq --arg c "$SEVEN_DAYS_AGO" \&lt;/span&gt;
      &lt;span class="s"&gt;'[.[] | select(.createdAt &amp;gt;= $c and .status == "completed")]')&lt;/span&gt;

    &lt;span class="s"&gt;TOTAL=$(echo "$RECENT" | jq 'length')&lt;/span&gt;
    &lt;span class="s"&gt;SUCCESS=$(echo "$RECENT" | jq '[.[] | select(.conclusion == "success")] | length')&lt;/span&gt;

    &lt;span class="s"&gt;if [ "$TOTAL" -lt 7 ] || [ "$SUCCESS" -lt "$TOTAL" ]; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "Promotion delayed."&lt;/span&gt;
      &lt;span class="s"&gt;exit 0&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The promotion job also reads the diff between &lt;code&gt;main&lt;/code&gt; and &lt;code&gt;dev&lt;/code&gt;. If the changes touch a high-risk file (&lt;code&gt;server/_core/index.ts&lt;/code&gt;, schema, tRPC root) AND any of the commits aren't pure security commits (&lt;code&gt;deps&lt;/code&gt;, &lt;code&gt;ci&lt;/code&gt;, &lt;code&gt;fix(security)&lt;/code&gt;), promotion stops. A human pushed something risky. Humans look at it.&lt;/p&gt;

&lt;p&gt;Seven days is arbitrary. It's the smallest number that catches "weird thing that only happens on the third Sunday of the month" without being so long that the queue piles up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Component 6: Auto-rollback (the emergency brake)
&lt;/h2&gt;

&lt;p&gt;The flight risk in this whole pipeline is: dev was green for seven nights, the merge fires, the deploy goes out, and &lt;em&gt;then&lt;/em&gt; something breaks because production has data dev didn't. Maybe a migration deadlocks. Maybe an env var is different. Maybe the cache layer behaves differently under real load.&lt;/p&gt;

&lt;p&gt;So the moment &lt;code&gt;main&lt;/code&gt; gets a push, this workflow fires:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;monitor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!contains(github.event.head_commit.message,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'[skip&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rollback]')"&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Wait 90s for Railway deploy&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;sleep &lt;/span&gt;&lt;span class="m"&gt;90&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Poll /api/health for 15 minutes&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;FAILS=0&lt;/span&gt;
          &lt;span class="s"&gt;OK=0&lt;/span&gt;
          &lt;span class="s"&gt;DEADLINE=$(($(date +%s) + 900))&lt;/span&gt;
          &lt;span class="s"&gt;while [ "$(date +%s)" -lt "$DEADLINE" ]; do&lt;/span&gt;
            &lt;span class="s"&gt;S=$(curl -s -o /dev/null -w "%{http_code}" "$PROD/api/health")&lt;/span&gt;
            &lt;span class="s"&gt;R=$(curl -s -o /dev/null -w "%{http_code}" "$PROD/")&lt;/span&gt;
            &lt;span class="s"&gt;if [ "$S" = "200" ] &amp;amp;&amp;amp; [ "$R" = "200" ]; then&lt;/span&gt;
              &lt;span class="s"&gt;OK=$((OK + 1)); FAILS=0&lt;/span&gt;
              &lt;span class="s"&gt;[ "$OK" -ge 3 ] &amp;amp;&amp;amp; exit 0&lt;/span&gt;
            &lt;span class="s"&gt;else&lt;/span&gt;
              &lt;span class="s"&gt;FAILS=$((FAILS + 1)); OK=0&lt;/span&gt;
              &lt;span class="s"&gt;if [ "$FAILS" -ge 3 ]; then&lt;/span&gt;
                &lt;span class="s"&gt;echo "needs_rollback=true" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
                &lt;span class="s"&gt;exit 0&lt;/span&gt;
              &lt;span class="s"&gt;fi&lt;/span&gt;
            &lt;span class="s"&gt;fi&lt;/span&gt;
            &lt;span class="s"&gt;sleep 30&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Revert HEAD on crash&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.health.outputs.needs_rollback == 'true'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git revert HEAD --no-edit&lt;/span&gt;
          &lt;span class="s"&gt;git commit --amend -m "auto-revert [skip rollback]"&lt;/span&gt;
          &lt;span class="s"&gt;git push origin main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three consecutive 5xx within 15 minutes = something broke. Auto-revert. Email me. The &lt;code&gt;[skip rollback]&lt;/code&gt; marker on the revert commit prevents an infinite loop where the rollback itself triggers another monitor run.&lt;/p&gt;

&lt;p&gt;I've now had this fire once in practice — when a non-security commit (a video-recording feature) introduced a top-level &lt;code&gt;import { chromium } from "playwright"&lt;/code&gt; and production couldn't find the package at boot. The autopilot caught it inside a minute and reverted. I read about it in the email, not from a customer.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the price tag actually is
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Their option&lt;/th&gt;
&lt;th&gt;Their cost&lt;/th&gt;
&lt;th&gt;What I built&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Snyk Team&lt;/td&gt;
&lt;td&gt;€500/month&lt;/td&gt;
&lt;td&gt;One TypeScript file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aikido Security&lt;/td&gt;
&lt;td&gt;€300-800/month&lt;/td&gt;
&lt;td&gt;Six GitHub workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wiz&lt;/td&gt;
&lt;td&gt;€10K+/month&lt;/td&gt;
&lt;td&gt;A dependabot.yml&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Advanced Security&lt;/td&gt;
&lt;td&gt;€49/user/month&lt;/td&gt;
&lt;td&gt;Free GitHub APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hiring a security engineer&lt;/td&gt;
&lt;td&gt;€80-120K/year&lt;/td&gt;
&lt;td&gt;Claude as on-demand CISO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;My setup&lt;/td&gt;
&lt;td&gt;€2.60/year&lt;/td&gt;
&lt;td&gt;All of it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The €2.60 is real. Five cents of Haiku tokens per week × 52 weeks. Everything else is free: Dependabot, GitHub Actions, the NVD REST API, GitHub's Advisory API, npm audit, helmet, isomorphic-dompurify, express-rate-limit.&lt;/p&gt;

&lt;p&gt;The price isn't the point. The point is that the &lt;strong&gt;time-cost&lt;/strong&gt; dropped from "we need to hire someone, write an RFP, evaluate four vendors, sign an MSA" to "I described what I wanted, my agent wrote it, I pressed merge."&lt;/p&gt;




&lt;h2&gt;
  
  
  What it does NOT catch (the honest part)
&lt;/h2&gt;

&lt;p&gt;Don't read this and think you have a force field. You don't. Here's what is still on your shoulders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-day exploits.&lt;/strong&gt; Nobody catches these. By definition, they're not in any feed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Targeted attacks.&lt;/strong&gt; If a determined human picks your platform specifically and spends two weeks on it, autopilot will not save you. You need a pentest for that, which you should buy once a year if you're handling money or health data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Social engineering.&lt;/strong&gt; Your bookkeeper getting a phone call from an AI cloning your voice asking for Stripe access — no amount of helmet config saves you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supply-chain through-routes I don't run audits on.&lt;/strong&gt; I cover npm. I don't cover Docker base images, OS-level packages, or my Railway runtime. That's another sprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance certifications.&lt;/strong&gt; SOC 2 / ISO 27001 / HIPAA / NEN 7510 require process + paperwork, not just config. The autopilot helps but doesn't get you certified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A determined customer getting their account hacked elsewhere and reusing the password on yours.&lt;/strong&gt; Credential stuffing is a thing. Throw in rate-limiting on &lt;code&gt;/sign-in&lt;/code&gt; and breach-password rejection (haveibeenpwned API) for that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So this stack is "good enough to make you a hard target." It is not "you can stop thinking about security." Anyone who tells you otherwise is selling something.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you can copy in a weekend
&lt;/h2&gt;

&lt;p&gt;If you're a solo founder or running a small team on a similar stack, here's the minimum-viable version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Turn on Dependabot security updates&lt;/strong&gt; (Settings → Security &amp;amp; analysis → Enable). Free. Five-minute setup. This alone puts you ahead of 70% of websites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add helmet with a real CSP&lt;/strong&gt; to your Node server. Use the allowlist pattern in &lt;a href="https://github.com/holistis/ai-app/blob/main/server/_core/index.ts" rel="noopener noreferrer"&gt;my securityRadar.ts&lt;/a&gt; if you're on Stripe + Clerk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate your Stripe webhook signature.&lt;/strong&gt; Yes, you probably aren't. Yes, that's how 60% of payment fraud against small SaaS happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanitize any &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; with DOMPurify.&lt;/strong&gt; Especially if you have user-generated or admin-written content rendered as HTML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an auto-rollback workflow&lt;/strong&gt; (mine is 80 lines of YAML, paste and adapt the PROD_URL).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire your CI&lt;/strong&gt; to gate &lt;code&gt;dev → main&lt;/code&gt; on at least three consecutive green nights, even if you can't manage seven. Catches 90% of "works on dev fails on prod" surprises.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You don't need an LLM-driven radar to start. But once you have the rest in place, adding ~480 lines of TypeScript for the radar is half a Saturday, and it gives you &lt;code&gt;€500/month of Snyk-shaped value&lt;/code&gt;. Worth it.&lt;/p&gt;




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

&lt;p&gt;Five years ago this stack would have required a small ops team to build and a senior engineer to maintain. Today, the building blocks are free, the wiring is configuration, and the cognitive work — "what should I build, what should I skip, where am I exposed" — can be done in a thinking session with a sufficiently capable model.&lt;/p&gt;

&lt;p&gt;The companies that win the next decade aren't the ones with the biggest engineering teams. They're the ones whose &lt;strong&gt;solo founders ship infrastructure that used to require a department&lt;/strong&gt;, and use the time saved on what actually moves the business.&lt;/p&gt;

&lt;p&gt;Security is plumbing. Plumbing should not be the reason your company exists. It should be the reason it survives.&lt;/p&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The full SecurityRadar lives in &lt;a href="https://github.com/holistis/ai-app/blob/main/server/_core/autoStudio/securityRadar.ts" rel="noopener noreferrer"&gt;&lt;code&gt;server/_core/autoStudio/securityRadar.ts&lt;/code&gt;&lt;/a&gt;. The four GitHub Actions workflows are in &lt;a href="https://github.com/holistis/ai-app/tree/main/.github/workflows" rel="noopener noreferrer"&gt;&lt;code&gt;.github/workflows/&lt;/code&gt;&lt;/a&gt;. Repo is public; clone, fork, steal whatever's useful.&lt;/p&gt;

&lt;p&gt;Built for &lt;a href="https://longevityai.nl" rel="noopener noreferrer"&gt;longevityai.nl&lt;/a&gt;, a Dutch health AI platform I run solo. The architecture is stack-agnostic — swap React for Vue, Stripe for Mollie, Clerk for Auth0, and the spine still works.&lt;/p&gt;

&lt;p&gt;Build it. Ship it. Tell me what you'd change.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
