<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>PHP Boy Scout — Security for a one-person estate</title><link>https://phpboyscout.uk/topics/security/</link><description>Secrets, dependencies, sandboxing and hardening from the perspective of a solo maintainer: what actually threatens a small public estate, and what to do.</description><generator>Hugo</generator><language>en-GB</language><copyright>Matt Cockayne</copyright><lastBuildDate>Thu, 06 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/topics/security/index.xml" rel="self" type="application/rss+xml"/><item><title>I had the framework audited: every finding was the same shape</title><link>https://phpboyscout.uk/every-finding-was-the-same-shape/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/every-finding-was-the-same-shape/</guid><category>go-tool-base</category><category>Pioneering</category><description>Every finding in a security audit reduced to untrusted input reaching a powerful operation unchecked. One checked chokepoint per boundary fixes it.</description><content:encoded>&lt;p&gt;When a real security audit lands back in your inbox, the temptation is to read it as a shopping list of unrelated mistakes. Fix one, fix the next, tick them off, move on. I did exactly that the first time. The second time, I noticed something far more useful: the findings weren&amp;rsquo;t scattered at all. They clustered. Almost every one was the same sentence with the nouns swapped out.&lt;/p&gt;
&lt;h2 id="findings-cluster-they-dont-scatter"&gt;Findings cluster, they don&amp;rsquo;t scatter
&lt;/h2&gt;&lt;p&gt;When you get a real security audit back, the instinct is to read it as a list of unrelated mistakes. Finding 1, unrelated to Finding 2, unrelated to Finding 3. Triage each, fix each, move on.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s not what the go-tool-base audits looked like once I stopped reading them as a list. The findings &lt;em&gt;clustered&lt;/em&gt;. Strip away the specifics and almost every one was the same sentence with the nouns swapped: &lt;em&gt;untrusted input reaches a powerful operation, and nothing checks it in between.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;That reframe is worth more than any individual fix, because it turns &amp;ldquo;we patched some bugs&amp;rdquo; into &amp;ldquo;we know where to look next time&amp;rdquo;. A framework&amp;rsquo;s attack surface isn&amp;rsquo;t spread evenly. It&amp;rsquo;s concentrated at the &lt;em&gt;boundaries&lt;/em&gt;: the handful of points where data from outside (a config file, a command-line flag, something typed into a TUI, an HTTP response) flows into machinery that can be made to misbehave. Audit the boundaries and you&amp;rsquo;ve audited most of the risk. Three examples make the pattern obvious.&lt;/p&gt;
&lt;h2 id="boundary-one-a-regex-compiler"&gt;Boundary one: a regex compiler
&lt;/h2&gt;&lt;p&gt;Somewhere in the tool, a user-supplied string gets compiled into a regular expression. A search pattern typed into the docs browser, a filter from a config file. Feeding user input to &lt;code&gt;regexp.Compile&lt;/code&gt; feels harmless. It&amp;rsquo;s just pattern matching, after all.&lt;/p&gt;
&lt;p&gt;It isn&amp;rsquo;t quite harmless. A regular expression is a tiny program, and some tiny programs are catastrophically slow. A pattern with the wrong kind of nested repetition can take exponential time to evaluate against a modestly hostile input. That&amp;rsquo;s the class of bug known as ReDoS. A user, or something feeding the user&amp;rsquo;s config, hands you a pathological pattern and your tool wedges, burning a whole core, on what looked for all the world like a search box.&lt;/p&gt;
&lt;p&gt;The fix isn&amp;rsquo;t to ban user-supplied regexes. It&amp;rsquo;s to stop treating &amp;ldquo;compile this string&amp;rdquo; as free. go-tool-base routes any regex whose pattern came from outside the binary through a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/regexutil/compile.go#L48" target="_blank" rel="noopener"
 &gt;&lt;code&gt;regexutil.CompileBounded&lt;/code&gt;&lt;/a&gt; helper. It caps the pattern length and puts a hard timeout on compilation. A pattern known at build time can still use plain &lt;code&gt;regexp.MustCompile&lt;/code&gt;, because that isn&amp;rsquo;t a boundary, it&amp;rsquo;s a constant. The discipline only applies where the input genuinely crosses in.&lt;/p&gt;
&lt;h2 id="boundary-two-a-url-opener"&gt;Boundary two: a URL opener
&lt;/h2&gt;&lt;p&gt;The tool needs to open a URL in the user&amp;rsquo;s browser, a docs link or an OAuth flow. Under the hood that&amp;rsquo;s the OS handler: &lt;code&gt;xdg-open&lt;/code&gt;, or &lt;code&gt;open&lt;/code&gt;, or &lt;code&gt;rundll32&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Now ask where the URL came from. If any part of it is influenced by config, by a server response, by user input, then &amp;ldquo;open this URL&amp;rdquo; has quietly become &amp;ldquo;ask the operating system to do something with an attacker-influenced string&amp;rdquo;. A &lt;code&gt;file://&lt;/code&gt; URL. A &lt;code&gt;javascript:&lt;/code&gt; URL. Something with control characters smuggled into it. The browser-open was never the dangerous part. The &lt;em&gt;unvalidated string&lt;/em&gt; was.&lt;/p&gt;
&lt;p&gt;So go-tool-base funnels every URL-open through one package, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/browser/browser.go#L25" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/browser&lt;/code&gt;&lt;/a&gt;, and that package is a gate. It enforces an allowlist of schemes (&lt;code&gt;https&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;mailto&lt;/code&gt;, and nothing else), bounds the length, and rejects control characters before the OS ever sees the string. The rule that makes it stick is that nothing else is allowed to call the OS handler directly. One door, and the door has a lock. A scattered capability with no chokepoint can&amp;rsquo;t be secured; a capability that &lt;em&gt;has&lt;/em&gt; a chokepoint can. (You&amp;rsquo;ll have spotted the &amp;ldquo;one door out&amp;rdquo; idea by now&amp;hellip; it&amp;rsquo;s the same instinct as the &lt;a class="link" href="https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/" &gt;single error handler&lt;/a&gt;, pointed at security instead of consistency.)&lt;/p&gt;
&lt;h2 id="boundary-three-a-log-sink"&gt;Boundary three: a log sink
&lt;/h2&gt;&lt;p&gt;This one&amp;rsquo;s the sneakiest, because it runs the wrong way round. The first two boundaries are about dangerous input coming &lt;em&gt;in&lt;/em&gt;. This one is about sensitive data leaking &lt;em&gt;out&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The tool handles credentials. It also logs, emits telemetry, and reports errors, and all three of those are &lt;em&gt;exit&lt;/em&gt; boundaries: places where strings leave the process for somewhere more persistent and more public, like a log aggregator, an analytics backend, an error tracker. If a token ever ends up in a string that flows to one of those, you haven&amp;rsquo;t logged an event, you&amp;rsquo;ve published a secret.&lt;/p&gt;
&lt;p&gt;The defence is &lt;code&gt;pkg/redact&lt;/code&gt;. Any free-form string heading for an observability surface goes through it first, and it strips the usual suspects: credentials in URL userinfo, sensitive query parameters, &lt;code&gt;Authorization&lt;/code&gt; headers, the well-known provider key prefixes (&lt;code&gt;sk-&lt;/code&gt;, &lt;code&gt;ghp_&lt;/code&gt;, &lt;code&gt;AIza&lt;/code&gt; and friends), long opaque tokens. The places most likely to leak, command arguments and error messages in telemetry, get it applied automatically rather than relying on every caller to remember.&lt;/p&gt;
&lt;p&gt;Same pattern as the other two. A boundary, and something standing on it checking what goes through.&lt;/p&gt;
&lt;h2 id="the-grunt-work"&gt;The grunt work
&lt;/h2&gt;&lt;p&gt;None of these fixes is clever. There&amp;rsquo;s no exploit demo, no neat trick to show off. Bound a length. Check a scheme against an allowlist. Run a string through a redactor. The work was almost entirely in &lt;em&gt;noticing the boundary existed&lt;/em&gt;, and then making sure everything routes through the one checked path instead of dotting raw calls all over the codebase.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the actual lesson of a security audit, and it&amp;rsquo;s why the cluster reframe matters. The value wasn&amp;rsquo;t the dozen-or-so individual fixes. It was learning that the next risk will be at a boundary too, the next place untrusted input meets a powerful operation with nothing in between, and that the job is to find those points and put a single, mandatory, checked door on each.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;A security audit of a CLI framework reads like a list of unrelated bugs and isn&amp;rsquo;t one. go-tool-base&amp;rsquo;s findings nearly all reduced to the same shape: untrusted input reaching a powerful operation unchecked. A regex compiler that needed a length and time bound (&lt;code&gt;regexutil.CompileBounded&lt;/code&gt;). A URL opener that needed a scheme allowlist and a single chokepoint (&lt;code&gt;pkg/browser&lt;/code&gt;). Log and telemetry sinks that needed credentials redacted on the way out (&lt;code&gt;pkg/redact&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;The fixes were structural and dull, which is exactly right. Find your boundaries (config, flags, TUI input, network responses, log and telemetry sinks), give each one a single mandatory checked path, and you&amp;rsquo;ve spent your audit effort where the risk actually lives.&lt;/p&gt;</content:encoded></item><item><title>OpenSSF Scorecard graded my supply chain</title><link>https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/</link><pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/</guid><category>cicd</category><category>go-tool-base</category><category>Pioneering</category><description>Running OpenSSF Scorecard on a Go project flagged mutable action tags, over-broad workflow token permissions, and a missing maintenance signal.</description><content:encoded>&lt;p&gt;I turned OpenSSF Scorecard on expecting a pat on the head. go-tool-base is a security-minded project, I&amp;rsquo;m careful, surely the robot would agree. The robot did not agree. It handed back a report card with a fair bit of red ink, and the most pointed finding on it wasn&amp;rsquo;t about my code at all. It was about me.&lt;/p&gt;
&lt;h2 id="a-linter-for-the-things-you-dont-call-code"&gt;A linter for the things you don&amp;rsquo;t call code
&lt;/h2&gt;&lt;p&gt;Scorecard is an automated set of checks that grades a repository&amp;rsquo;s supply-chain hygiene: are your CI dependencies pinned, are your workflow tokens least-privilege, is your branch protected, do commits get reviewed. It&amp;rsquo;s a linter, but pointed at the part of the project you don&amp;rsquo;t usually think of as code, the build and release machinery and the practices around them. And like any good linter, its value is mostly in catching the things you&amp;rsquo;d swear you&amp;rsquo;d already got right.&lt;/p&gt;
&lt;p&gt;Three of its findings were worth the price of admission on their own.&lt;/p&gt;
&lt;h2 id="pin-the-actions-you-dont-control"&gt;Pin the actions you don&amp;rsquo;t control
&lt;/h2&gt;&lt;p&gt;The first was about how go-tool-base&amp;rsquo;s GitHub Actions referenced other actions. Like nearly everyone, I&amp;rsquo;d written &lt;code&gt;uses: actions/checkout@v6&lt;/code&gt;. Scorecard doesn&amp;rsquo;t like that, and it&amp;rsquo;s right not to.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@v6&lt;/code&gt; is a &lt;em&gt;tag&lt;/em&gt;, and a tag is mutable. Whoever controls that action can move &lt;code&gt;v6&lt;/code&gt; to point at different code tomorrow, and your CI will pick it up silently on the next run. For an action that runs in a job holding your repository token, that&amp;rsquo;s a supply-chain hole the width of a barn door: compromise the tag, compromise every pipeline that trusts it. The fix is to pin to an immutable commit SHA, with the human-readable version left as a comment, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/5500b92" target="_blank" rel="noopener"
 &gt;which is exactly what I changed&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/setup-go@v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;+ - uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;+ - uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the action is frozen at bytes I reviewed. Dependabot still bumps the SHA when a real new version lands, so I get updates as reviewable pull requests rather than as silent tag movements. The pin doesn&amp;rsquo;t stop me updating. It stops me updating &lt;em&gt;without noticing&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="give-the-workflow-token-the-least-it-can-do"&gt;Give the workflow token the least it can do
&lt;/h2&gt;&lt;p&gt;The second finding was about permissions. My workflow declared its token permissions at the top, once, for the whole file:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;security-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id-token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That reads as careful, and it&amp;rsquo;s still too broad, because top-level permissions apply to &lt;em&gt;every job in the workflow&lt;/em&gt;. A job that only needs to read the repo is now also holding &lt;code&gt;id-token: write&lt;/code&gt; and &lt;code&gt;security-events: write&lt;/code&gt;, for no reason other than that some &lt;em&gt;other&lt;/em&gt; job in the same file needed them. Scorecard rejects exactly this, and the fix is to default the whole workflow to read-only and grant write narrowly, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/c41e557" target="_blank" rel="noopener"
 &gt;in the job that actually needs it&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read-all&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Write permissions moved down into the single job that uses them. It&amp;rsquo;s the same least-privilege instinct that runs through everything else in these projects, just applied to a CI token instead of an IAM role: a credential should be able to do the one thing it&amp;rsquo;s for, and nothing else, no matter how convenient the broad grant looked.&lt;/p&gt;
&lt;h2 id="the-finding-that-was-about-me"&gt;The finding that was about me
&lt;/h2&gt;&lt;p&gt;The third one stung, because there was no YAML to fix. Scorecard&amp;rsquo;s Code-Review check scores how consistently changes are reviewed before they land, and mine scored badly for the most embarrassing possible reason: I&amp;rsquo;d set up branch protection on &lt;code&gt;main&lt;/code&gt;, and then, being the solo maintainer in a hurry, I&amp;rsquo;d been merrily bypassing it to push straight to &lt;code&gt;main&lt;/code&gt; whenever it suited me.&lt;/p&gt;
&lt;p&gt;So I had a rule, written down and enforced by the platform, that I was personally and routinely ignoring. Scorecard noticed, totted up the unreviewed commits, and graded me on it. There&amp;rsquo;s something properly humbling about a robot reading your git history and pointing out that the person breaking your security policy most often is &lt;em&gt;you&lt;/em&gt;. The fix wasn&amp;rsquo;t code. It was going through a pull request like everyone else, even when &amp;ldquo;everyone else&amp;rdquo; is just me on a different day.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;OpenSSF Scorecard is a linter for your supply chain, and like any linter it&amp;rsquo;s most useful when it tells you something you were sure you&amp;rsquo;d already handled. It dinged go-tool-base for referencing actions by mutable tag instead of pinned SHA, for granting workflow-token write permissions at the top level where every job inherited them, and for a Code-Review score I&amp;rsquo;d earned fair and square by bypassing my own branch protection.&lt;/p&gt;
&lt;p&gt;The first two were quick, satisfying changes with a clear security story. The third was the one that stuck, because the tool I&amp;rsquo;d added to grade the project ended up grading the maintainer, and was entirely right to. Turn it on. Brace yourself a little.&lt;/p&gt;</content:encoded></item><item><title>Where should a CLI keep your API keys?</title><link>https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/</link><pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/</guid><category>go-tool-base</category><category>Pioneering</category><description>Credential storage for a Go CLI: an env-var reference by default, an opt-in OS keychain, and plaintext only as a last resort and banned in CI.</description><content:encoded>&lt;p&gt;Your CLI tool needs the user&amp;rsquo;s API key. It has to come from somewhere, and it has to survive between runs, so the obvious move is to ask once and write it into the config file. One tidy &lt;code&gt;api_key:&lt;/code&gt; line. Job done.&lt;/p&gt;
&lt;p&gt;It works beautifully on the first afternoon. And then, months later, it&amp;rsquo;s quietly become a liability nobody actually decided to create.&lt;/p&gt;
&lt;h2 id="the-config-file-that-quietly-becomes-a-liability"&gt;The config file that quietly becomes a liability
&lt;/h2&gt;&lt;p&gt;Your CLI tool needs the user&amp;rsquo;s API key. It has to come from somewhere, and it has to survive between invocations, so the obvious move is to ask once and write it into the tool&amp;rsquo;s config file. &lt;code&gt;~/.config/yourtool/config.yaml&lt;/code&gt;, a nice &lt;code&gt;api_key:&lt;/code&gt; line, done.&lt;/p&gt;
&lt;p&gt;It works on the first afternoon. It keeps working. And then, slowly, it becomes a problem nobody decided to create.&lt;/p&gt;
&lt;p&gt;The config file gets committed to a dotfiles repo. It gets caught in a &lt;code&gt;tar&lt;/code&gt; of someone&amp;rsquo;s home directory that lands in a backup bucket. It scrolls past in a screen share. It sits, world-readable, on a shared build box. None of these are exotic. They&amp;rsquo;re just a Tuesday. The plaintext key was fine right up until the file went somewhere the key shouldn&amp;rsquo;t, and config files go places.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t want go-tool-base handing every tool built on it that same slow-motion liability by default. So credential handling got rebuilt around a simple idea: the config file should usually hold a &lt;em&gt;reference&lt;/em&gt; to the secret, not the secret itself.&lt;/p&gt;
&lt;h2 id="three-modes-and-which-one-you-get"&gt;Three modes, and which one you get
&lt;/h2&gt;&lt;p&gt;go-tool-base supports &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/credentials/mode.go#L18" target="_blank" rel="noopener"
 &gt;three ways to store a credential&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Environment-variable reference, the default.&lt;/strong&gt; The config records the &lt;em&gt;name&lt;/em&gt; of an environment variable, not its value:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The secret itself lives in your shell profile, your &lt;code&gt;direnv&lt;/code&gt; setup, or your CI platform&amp;rsquo;s secret store, wherever you already keep that sort of thing. The config file now contains nothing sensitive at all. You can commit it, back it up, paste it into a bug report. The reference is inert on its own.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OS keychain, opt-in.&lt;/strong&gt; The config holds a &lt;code&gt;&amp;lt;service&amp;gt;/&amp;lt;account&amp;gt;&lt;/code&gt; reference and the actual secret goes into the operating system&amp;rsquo;s keychain: macOS Keychain, GNOME Keyring or KWallet via the Secret Service, Windows Credential Manager.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;keychain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;mytool/anthropic.api&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This one is opt-in by design, because the keychain backend carries dependencies that some deployments simply aren&amp;rsquo;t allowed to ship. (That opt-in mechanism turned out to be an interesting little problem all of its own, and it gets &lt;a class="link" href="https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/" &gt;its own post&lt;/a&gt; in a couple of days.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Literal value, legacy and grudging.&lt;/strong&gt; The old behaviour. The secret sits in the config in plaintext:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;sk-ant-...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It still works, because breaking every existing tool&amp;rsquo;s config on an upgrade would be its own kind of vandalism. But it&amp;rsquo;s the last resort, it&amp;rsquo;s documented as the last resort, and the setup wizard puts a warning in front of you when you pick it.&lt;/p&gt;
&lt;h2 id="the-one-place-literal-mode-is-not-allowed"&gt;The one place literal mode is not allowed
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a single hard &amp;ldquo;no&amp;rdquo; in all of this. If go-tool-base detects it&amp;rsquo;s running in CI (&lt;code&gt;CI=true&lt;/code&gt;, which every major CI platform sets) the setup flow will &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/ai/ai.go#L177" target="_blank" rel="noopener"
 &gt;&lt;em&gt;refuse&lt;/em&gt; to write a literal credential&lt;/a&gt;, and exits non-zero.&lt;/p&gt;
&lt;p&gt;The reasoning is that a plaintext secret written during a CI run is a plaintext secret written onto an ephemeral, often shared, frequently-logged machine, by an automated process that no human is watching. That&amp;rsquo;s the exact situation where the slow-motion liability becomes a fast one. CI environments inject secrets as environment variables already; there&amp;rsquo;s no good reason for a tool to be writing one to disk there, so go-tool-base simply won&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="how-it-decides-at-runtime"&gt;How it decides at runtime
&lt;/h2&gt;&lt;p&gt;A credential can be configured more than one way at once. You might have an &lt;code&gt;env&lt;/code&gt; reference &lt;em&gt;and&lt;/em&gt; an old literal &lt;code&gt;key&lt;/code&gt; still lurking. So resolution follows a fixed precedence, highest to lowest:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;*.env&lt;/code&gt; reference. If that env var is set, use it.&lt;/li&gt;
&lt;li&gt;Otherwise the &lt;code&gt;*.keychain&lt;/code&gt; reference. If a keychain entry resolves, use it.&lt;/li&gt;
&lt;li&gt;Otherwise the literal &lt;code&gt;*.key&lt;/code&gt; / &lt;code&gt;*.value&lt;/code&gt;, the legacy path.&lt;/li&gt;
&lt;li&gt;Otherwise a well-known fallback env var (&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; and friends), so a tool still picks up the ecosystem-standard variable with no config at all.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The useful property here is that adding a more secure mode &lt;em&gt;transparently wins&lt;/em&gt;. Drop an &lt;code&gt;env&lt;/code&gt; reference next to an old literal key and the next run uses the env var. You can migrate a credential to a better home without first removing it from its worse one, which makes the migration safe to do incrementally instead of as one nervous big-bang edit.&lt;/p&gt;
&lt;h2 id="the-tool-tells-on-itself"&gt;The tool tells on itself
&lt;/h2&gt;&lt;p&gt;A precedence rule is no use if nobody knows their config still has a plaintext key three layers down. So the built-in &lt;code&gt;doctor&lt;/code&gt; command grew a check for exactly that. Run &lt;code&gt;doctor&lt;/code&gt;, and if any literal credential is sitting in your config it reports a warning, names the offending keys (the key &lt;em&gt;names&lt;/em&gt;, never the values) and points you at how to migrate.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s not an error. Literal mode is still legal. But the tool will quietly keep reminding you that you left the campsite messier than you could have, until you go and tidy it. (Old Scout habits die hard, and they&amp;rsquo;ve leaked all the way into the framework.)&lt;/p&gt;
&lt;h2 id="the-gist"&gt;The gist
&lt;/h2&gt;&lt;p&gt;A CLI tool that writes your API key into a plaintext config file isn&amp;rsquo;t doing anything &lt;em&gt;wrong&lt;/em&gt;, exactly. It&amp;rsquo;s just handing you a liability that activates later, when the file travels somewhere the key shouldn&amp;rsquo;t. go-tool-base&amp;rsquo;s answer is three storage modes: an env-var reference by default, the OS keychain on request, and a plaintext literal only as a documented last resort that CI environments can&amp;rsquo;t use at all. Runtime resolution runs in a fixed precedence so a more secure mode always wins, which makes migrating a credential safe to do gradually. And &lt;code&gt;doctor&lt;/code&gt; keeps an eye on the config so a stray plaintext secret doesn&amp;rsquo;t get to hide forever.&lt;/p&gt;
&lt;p&gt;The secret should live in a secret store. The config file should just know its name.&lt;/p&gt;</content:encoded></item><item><title>Redacting the secret you didn't know was in the string</title><link>https://phpboyscout.uk/redacting-the-secret-you-didnt-know-was-in-the-string/</link><pubDate>Sat, 18 Apr 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/redacting-the-secret-you-didnt-know-was-in-the-string/</guid><category>go-tool-base</category><category>rust-tool-base</category><category>Pioneering</category><description>Catching secrets by shape rather than by name: patterns for provider key prefixes, URL userinfo and auth headers, plus a fuzzy fallback.</description><content:encoded>&lt;p&gt;Dammit! How did that get there?&lt;/p&gt;
&lt;p&gt;A log line that should never have existed. Not a password I&amp;rsquo;d carelessly printed, nothing as obvious as that. An upstream API handed me back an error, and it had quoted my own bearer token inside the message, and that error went straight into the logs the way errors do. I didn&amp;rsquo;t put the secret there. The error did. And I&amp;rsquo;d never have caught it by being careful, because being careful only protects you from the secrets you know you&amp;rsquo;re handling.&lt;/p&gt;
&lt;h2 id="the-easy-half-of-redaction"&gt;The easy half of redaction
&lt;/h2&gt;&lt;p&gt;Hiding the secrets you know about is the part everyone does. You&amp;rsquo;ve got an API key field, a password flag, so you mask them at the point you print them. &lt;code&gt;key=****&lt;/code&gt;. Done, and it feels like you&amp;rsquo;ve solved redaction, when really you&amp;rsquo;ve solved the half that was never going to bite you.&lt;/p&gt;
&lt;h2 id="the-half-that-bites"&gt;The half that bites
&lt;/h2&gt;&lt;p&gt;The secrets that escape are the ones that arrive inside strings you don&amp;rsquo;t control. An upstream service echoes your token back in a &lt;code&gt;401&lt;/code&gt; body. A connection string with the password in the userinfo, &lt;code&gt;https://user:pass@host&lt;/code&gt;, lands in a debug line. A library stringifies a whole request, headers and all, for a &amp;ldquo;helpful&amp;rdquo; trace. You cannot field-mask a secret you didn&amp;rsquo;t know was in the string, because you never watched it go in.&lt;/p&gt;
&lt;h2 id="you-cant-register-a-value-you-never-had-so-match-the-shape"&gt;You can&amp;rsquo;t register a value you never had, so match the shape
&lt;/h2&gt;&lt;p&gt;This is the bit I got wrong in my own head at first. I assumed redaction meant handing it the secrets I was holding so it could watch for them. But the dangerous secrets are exactly the ones I&amp;rsquo;m &lt;em&gt;not&lt;/em&gt; holding a copy of. So &lt;code&gt;pkg/redact&lt;/code&gt; doesn&amp;rsquo;t keep a registry of your values at all. It knows what secrets &lt;em&gt;look like&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/redact/redact.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/redact/redact.go&lt;/code&gt;&lt;/a&gt; carries a set of RE2 patterns: a credential in URL userinfo, an &lt;code&gt;Authorization:&lt;/code&gt; header sitting in free text, query-string credentials, and the well-known provider prefixes:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;prefixPatterns&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Regexp&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`sk-[A-Za-z0-9_\-]{16,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// OpenAI / Anthropic-style&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`ghp_[A-Za-z0-9]{30,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// GitHub PAT classic&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`github_pat_[A-Za-z0-9_]{30,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// GitHub fine-grained PAT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`xox[baprs]-[A-Za-z0-9-]{10,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Slack&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`AIza[A-Za-z0-9_\-]{30,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Google API key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`AKIA[A-Z0-9]{16}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// AWS access key ID&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run any string through &lt;code&gt;redact.String&lt;/code&gt; and an OpenAI key, a GitHub token or an AWS access key ID gets caught wherever it&amp;rsquo;s hiding, in an error you didn&amp;rsquo;t write, in a URL, in a stack trace, because each has a recognisable shape. For the secrets that don&amp;rsquo;t announce themselves with a prefix there&amp;rsquo;s a fuzzy fallback: any opaque alphanumeric run of 41 characters or more. The 41 is chosen on purpose, to clear UUIDs (36), MD5 (32) and git SHA-1 (40) without flagging them, while accepting that a SHA-256 (64) will trip it. A deliberate, documented trade rather than a magic number.&lt;/p&gt;
&lt;h2 id="where-it-runs"&gt;Where it runs
&lt;/h2&gt;&lt;p&gt;At the boundary where a string leaves for somewhere you can&amp;rsquo;t reach back into. The telemetry backend runs every event argument and error message through &lt;code&gt;redact.String&lt;/code&gt; before it emits anything (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/telemetry/telemetry.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/telemetry/telemetry.go&lt;/code&gt;&lt;/a&gt;), and both telemetry and HTTP logging drop the value of any header &lt;code&gt;redact&lt;/code&gt; flags as sensitive. It doesn&amp;rsquo;t matter which code path produced the string, or whether you even wrote that path; everything goes through the same gate and gets the same scrub.&lt;/p&gt;
&lt;p&gt;rust-tool-base&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-redact/src/lib.rs" target="_blank" rel="noopener"
 &gt;&lt;code&gt;rtb-redact&lt;/code&gt;&lt;/a&gt; crate takes the same shape-matching approach: regex patterns, the same family of well-known provider prefixes, and an &lt;code&gt;is_sensitive_header&lt;/code&gt; check for header values.&lt;/p&gt;
&lt;h2 id="a-realistic-limit"&gt;A realistic limit
&lt;/h2&gt;&lt;p&gt;It isn&amp;rsquo;t a force field. A secret with no recognisable shape, shorter than the fallback threshold, will sail through. You cannot redact what you cannot recognise. But the leak that actually keeps happening isn&amp;rsquo;t some exotic unknown, it&amp;rsquo;s a well-known token turning up in a place you didn&amp;rsquo;t expect, and a shape-matcher sitting at the edge catches exactly that, &lt;em&gt;including secrets you never told it about&lt;/em&gt;. Which is the one thing registering your own values could never have done. Storing the key safely is a separate job, &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;where a CLI keeps it&lt;/a&gt;; this is about making sure that, having stored it, it doesn&amp;rsquo;t quietly fall out through a log.&lt;/p&gt;</content:encoded></item><item><title>Secrets that scrub themselves from RAM</title><link>https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/</link><pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/</guid><category>rust-tool-base</category><category>Pioneering</category><description>Storage answers where a secret lives, not what happens to it in memory. Wrapping secrets so they redact in Debug and zero on drop.</description><content:encoded>&lt;p&gt;A while ago I worked out &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;where a CLI should keep your API key&lt;/a&gt;: env var, OS keychain, or, grudgingly, a literal in the config file. That answers where the secret &lt;em&gt;lives&lt;/em&gt;. It says nothing about what happens to it once it&amp;rsquo;s loaded and sitting in your process memory, which is the half where secrets actually tend to leak. Rust, it turns out, can do something about that half that Go simply can&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="what-go-tool-base-already-settled"&gt;What go-tool-base already settled
&lt;/h2&gt;&lt;p&gt;A while back I wrote about where a CLI should keep your API keys. The answer go-tool-base settled on was three storage modes, in a fixed precedence: an environment variable reference (the recommended default), the OS keychain (opt-in), or a literal value in the config file (legacy, and refused outright when &lt;code&gt;CI=true&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;rust-tool-base keeps that design unchanged. Same three modes, same precedence, same refusal of literal secrets in CI. A tool embeds a &lt;code&gt;CredentialRef&lt;/code&gt; in its typed config, and a &lt;code&gt;Resolver&lt;/code&gt; walks env, then keychain, then literal, then a well-known fallback variable, first hit wins. That part is a straight carry-over, because &lt;em&gt;where&lt;/em&gt; to keep the secret was design, and design survives the port.&lt;/p&gt;
&lt;p&gt;But storage is only half the life of a secret. The other half is what happens to it once it&amp;rsquo;s resolved and sitting in your process memory. That&amp;rsquo;s where Rust can do something Go can&amp;rsquo;t, and rust-tool-base takes the opening.&lt;/p&gt;
&lt;h2 id="the-two-ways-a-secret-leaks-after-youve-loaded-it"&gt;The two ways a secret leaks after you&amp;rsquo;ve loaded it
&lt;/h2&gt;&lt;p&gt;You&amp;rsquo;ve resolved the API key. It&amp;rsquo;s a value in memory now. Two very ordinary things can leak it from there, and neither involves your storage being wrong.&lt;/p&gt;
&lt;p&gt;The first is &lt;strong&gt;the log line&lt;/strong&gt;. Somewhere a developer writes a debug print of a config struct, or an error includes the struct that holds the key, or a panic dumps it. The secret is a string like any other string, so it renders like any other string, straight into a log aggregator that a lot of people can read.&lt;/p&gt;
&lt;p&gt;The second is &lt;strong&gt;the leftover bytes&lt;/strong&gt;. The key sat in a heap allocation. The variable goes out of scope, the allocation is freed, and on most runtimes &amp;ldquo;freed&amp;rdquo; just means &amp;ldquo;returned to the allocator&amp;rdquo;. The bytes are still there until something else writes over them. A core dump taken in that window contains your key. So does the next allocation that happens to land on that memory and gets logged before it&amp;rsquo;s overwritten.&lt;/p&gt;
&lt;p&gt;A Go string can&amp;rsquo;t really defend against either. Go strings are immutable, so you can&amp;rsquo;t zero one in place; the runtime copies them freely, so you can&amp;rsquo;t even track every copy; and there&amp;rsquo;s no compile-time barrier stopping anyone printing one. You can be disciplined, but discipline is all you&amp;rsquo;ve got.&lt;/p&gt;
&lt;h2 id="secretstring-closes-both"&gt;&lt;code&gt;SecretString&lt;/code&gt; closes both
&lt;/h2&gt;&lt;p&gt;rust-tool-base routes every secret through &lt;code&gt;secrecy::SecretString&lt;/code&gt;, and the crate is explicit that taking a plain &lt;code&gt;&amp;amp;str&lt;/code&gt; or &lt;code&gt;String&lt;/code&gt; for a secret is a &lt;em&gt;type error&lt;/em&gt;, not a style preference.&lt;/p&gt;
&lt;p&gt;For the log line, &lt;code&gt;SecretString&lt;/code&gt; has its own &lt;code&gt;Debug&lt;/code&gt; implementation, and it prints &lt;code&gt;[REDACTED]&lt;/code&gt;. Always. A config struct holding a &lt;code&gt;SecretString&lt;/code&gt; can be debug-printed, put in an error, caught in a panic, and the secret field shows up as &lt;code&gt;[REDACTED]&lt;/code&gt; every single time. You don&amp;rsquo;t have to remember not to log it. The type already won&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;For the leftover bytes, &lt;code&gt;SecretString&lt;/code&gt; zeroes its memory when it&amp;rsquo;s dropped. When the value goes out of scope, before the allocation is handed back, the bytes are overwritten. The window where a freed allocation still holds your key is closed. A core dump taken afterwards finds zeroes.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a third leak &lt;code&gt;SecretString&lt;/code&gt; blocks that&amp;rsquo;s easy to miss. It deliberately doesn&amp;rsquo;t implement &lt;code&gt;Serialize&lt;/code&gt;. You cannot serialise a &lt;code&gt;SecretString&lt;/code&gt;. That sounds like an inconvenience until you see what it prevents: a tool that loads config, changes one setting, and writes the whole struct back would, with an ordinary string, faithfully write the resolved secret to disk in plain text. Because &lt;code&gt;SecretString&lt;/code&gt; can&amp;rsquo;t be serialised, &lt;code&gt;CredentialRef&lt;/code&gt; can&amp;rsquo;t be either, and that accident is structurally impossible. Writing a secret back is a deliberate, separate path, never a side effect of saving config.&lt;/p&gt;
&lt;p&gt;When code genuinely needs the raw value, to drop it into an &lt;code&gt;Authorization&lt;/code&gt; header, it calls &lt;code&gt;expose_secret()&lt;/code&gt;. The name is the point. Getting at the plaintext is one explicit, greppable, reviewable call, and everywhere else the secret stays wrapped.&lt;/p&gt;
&lt;h2 id="discipline-versus-the-type-system"&gt;Discipline versus the type system
&lt;/h2&gt;&lt;p&gt;The plain framing is this. None of these leaks are exotic. Logging a struct, a core dump after a free, re-saving a config file: they&amp;rsquo;re all routine, and they&amp;rsquo;re all how real credentials end up somewhere they shouldn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s storage design is good, and rust-tool-base kept it. But in Go, &lt;em&gt;not leaking the secret once it&amp;rsquo;s in memory&lt;/em&gt; comes down to every developer being careful every time. In Rust, &lt;code&gt;SecretString&lt;/code&gt; makes &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;the type system&lt;/a&gt; carry it. The redaction, the zeroing, the un-serialisability aren&amp;rsquo;t things you remember to do. They&amp;rsquo;re things the secret does to itself because of what it is. That&amp;rsquo;s the part Go structurally can&amp;rsquo;t match, and it&amp;rsquo;s why the port didn&amp;rsquo;t just copy the storage modes across, it tightened the handling underneath them.&lt;/p&gt;
&lt;h2 id="the-gist"&gt;The gist
&lt;/h2&gt;&lt;p&gt;go-tool-base settled where a CLI keeps a secret: env var, keychain, or literal, in a fixed precedence. rust-tool-base keeps that design and hardens what happens once the secret is loaded.&lt;/p&gt;
&lt;p&gt;Every secret is a &lt;code&gt;secrecy::SecretString&lt;/code&gt;. It debug-prints as &lt;code&gt;[REDACTED]&lt;/code&gt;, so it can&amp;rsquo;t fall into a log by accident. Its memory is zeroed on drop, so it doesn&amp;rsquo;t survive in freed heap. It isn&amp;rsquo;t serialisable, so it can&amp;rsquo;t be written back to config by a blanket save. Getting the plaintext is one explicit &lt;code&gt;expose_secret()&lt;/code&gt; call. Go can only ask developers to be careful with a secret in memory; Rust lets the type be careful for them.&lt;/p&gt;</content:encoded></item><item><title>The secret that wasn't on my branch</title><link>https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/</link><pubDate>Sat, 04 Jul 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/</guid><category>security</category><category>cicd</category><category>go-tool-base</category><category>Pioneering</category><description>A secret scanner failed a merge request over a test key and a documentation PEM that the change did not contain. Scoping a scan properly.</description><content:encoded>&lt;p&gt;My CI went red on a secret I&amp;rsquo;d never committed.&lt;/p&gt;
&lt;p&gt;Not a close call, not a near-miss I&amp;rsquo;d half-forgotten about. gitleaks, the secret scanner, failed a merge request of mine on a private key that was not on my branch, was not in my change, and as far as I could tell had nothing to do with me at all. The job was adamant. I was baffled. Somewhere in between was a lesson about what a secret scanner actually scans.&lt;/p&gt;
&lt;h2 id="prove-it-isnt-yours"&gt;Prove it isn&amp;rsquo;t yours
&lt;/h2&gt;&lt;p&gt;First rule of being accused: don&amp;rsquo;t get defensive, get evidence. The scanner pointed at a couple of commits carrying a test private key and a PEM block in a spec document. I genuinely didn&amp;rsquo;t recognise them, but &amp;ldquo;I don&amp;rsquo;t recognise it&amp;rdquo; is a feeling, not a fact, and feelings don&amp;rsquo;t reopen a pipeline.&lt;/p&gt;
&lt;p&gt;Git will tell you the truth if you ask it precisely. The question is: are these flagged commits actually part of my branch?&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git merge-base --is-ancestor &amp;lt;flagged-sha&amp;gt; HEAD
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That asks &amp;ldquo;is this commit an ancestor of where I am?&amp;rdquo;. The answer came back no. The commits the scanner was choking on were not in my history. They weren&amp;rsquo;t mine.&lt;/p&gt;
&lt;p&gt;So whose were they? A bit of digging turned them up on a completely separate, still-unmerged branch, where someone (me, a few days earlier, on a different feature) had committed a throwaway test key and a PEM example in a spec, on purpose, as fixtures. Deliberate, harmless, and nowhere near the branch under review. And yet here they were, failing a merge request that had never touched them.&lt;/p&gt;
&lt;h2 id="what-gitleaks-actually-scans"&gt;What gitleaks actually scans
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit I&amp;rsquo;d taken for granted. I assumed &lt;code&gt;gitleaks detect&lt;/code&gt; scanned &lt;em&gt;my change&lt;/em&gt;. It doesn&amp;rsquo;t. With no further instruction it scans the whole history reachable in the checkout it&amp;rsquo;s handed.&lt;/p&gt;
&lt;p&gt;And the checkout it&amp;rsquo;s handed is where the second half of the surprise lives. GitLab runners default to &lt;code&gt;GIT_STRATEGY: fetch&lt;/code&gt;, which reuses the runner&amp;rsquo;s working directory between jobs rather than cloning fresh every time. It&amp;rsquo;s faster, and most of the time you never notice. But it means a shared runner accumulates refs from every branch it has ever built. My MR&amp;rsquo;s job happened to run on a runner that had, at some point, built that other branch, so the fixtures were sitting right there in the local object store, fair game for a scanner walking the whole graph.&lt;/p&gt;
&lt;p&gt;So gitleaks did exactly what I&amp;rsquo;d asked it to do, which was &amp;ldquo;scan everything&amp;rdquo;, and &amp;ldquo;everything&amp;rdquo; turned out to be a great deal more than my change. It walked the lot and dutifully reported fixtures from a branch I wasn&amp;rsquo;t even proposing to merge. The scanner wasn&amp;rsquo;t wrong. My idea of what it was looking at was.&lt;/p&gt;
&lt;h2 id="unblock-now-fix-properly-after"&gt;Unblock now, fix properly after
&lt;/h2&gt;&lt;p&gt;Two problems on two timescales. I needed the MR to merge today, and I needed this to never happen again. Those want different fixes.&lt;/p&gt;
&lt;p&gt;The immediate one: tell gitleaks those specific fixtures are known and intended. They&amp;rsquo;re test material, they&amp;rsquo;re meant to be there, so they go in the &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/75f87c5/.gitleaks.toml#L27-L28" target="_blank" rel="noopener"
 &gt;allowlist&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;internal/cmd/keys/keys_test\.go&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;docs/development/specs/2026-06-08-keys-mint-command\.md&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That unblocks the merge. It does nothing about the root cause, which is that the scan was looking at the wrong commits in the first place. Allowlisting individual false positives as they crop up is closing the stable door after the horse has bolted, one horse at a time, forever.&lt;/p&gt;
&lt;p&gt;The real fix lives in the shared CI component, not in any one repo. Scope the scan to the commits the merge request actually introduces (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/52b5482/templates/go-security.yml#L105-L125" target="_blank" rel="noopener"
 &gt;cicd v0.10.3&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;if [ -n &amp;#34;$CI_MERGE_REQUEST_DIFF_BASE_SHA&amp;#34; ]; then&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitleaks detect --source . --verbose --redact \&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;--&lt;span class="l"&gt;log-opts=&amp;#34;$CI_MERGE_REQUEST_DIFF_BASE_SHA..$CI_COMMIT_SHA&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;else&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitleaks detect --source . --verbose --redact&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;fi&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--log-opts&lt;/code&gt; is handed straight through to &lt;code&gt;git log&lt;/code&gt;, so that range, base-of-the-MR to tip, is the exact set of commits the merge request adds and nothing else. On a merge request the scan now sees only what you&amp;rsquo;re proposing to merge. Off a merge request (a plain branch or a tag pipeline) it falls back to the full scan, because there you genuinely do want the lot. The before-and-after in the job log tells the whole story: the entire accumulated history on one side, the handful of commits you actually wrote on the other.&lt;/p&gt;
&lt;h2 id="fixing-the-fix"&gt;Fixing the fix
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a tax on touching CI shell, and I paid it. The change went into the go, rust and tofu security templates. Go and tofu went green. Rust failed.&lt;/p&gt;
&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/52b5482/templates/rust-security.yml#L118-L145" target="_blank" rel="noopener"
 &gt;rust template&lt;/a&gt; had built its optional &lt;code&gt;--config&lt;/code&gt; flag the clever way, inline:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;... &lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;--config &lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As a command &lt;em&gt;argument&lt;/em&gt;, that substitution is harmless: its exit status is thrown away, so nobody cares that the test inside returns false when there&amp;rsquo;s no config. But when I rewrote the block and reached for the same pattern as an &lt;em&gt;assignment&lt;/em&gt;, &lt;code&gt;VAR=$(... &amp;amp;&amp;amp; ...)&lt;/code&gt;, it became a different animal. An assignment takes the exit status of the command substitution, and under &lt;code&gt;set -e&lt;/code&gt; a non-zero status anywhere aborts the job. So on every run where the config was empty, which was most of them, the test returned false, the assignment inherited that false, and &lt;code&gt;set -e&lt;/code&gt; killed the job stone dead. Same &lt;code&gt;$(...)&lt;/code&gt;, two completely different fates, decided entirely by whether it sat to the right of an &lt;code&gt;=&lt;/code&gt; or got handed to a command as an argument. Go and tofu never used the assignment form, so only rust fell down the hole.&lt;/p&gt;
&lt;p&gt;The fix was to stop being clever and write the boring &lt;code&gt;if&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;GITLEAKS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;GITLEAKS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;--config &lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Boring shell is good shell.&lt;/p&gt;
&lt;h2 id="what-a-scanner-is-actually-looking-at"&gt;What a scanner is actually looking at
&lt;/h2&gt;&lt;p&gt;The whole mess came from one unspoken assumption: that a tool called to scan &amp;ldquo;my change&amp;rdquo; was scanning my change. It was scanning a checkout, and a checkout on a shared runner is a much bigger, messier thing than the diff in front of you. None of the pieces were broken. gitleaks did its job, &lt;code&gt;GIT_STRATEGY: fetch&lt;/code&gt; did its job, my fixtures were exactly where I&amp;rsquo;d left them. They just added up to a red pipeline that had nothing to do with the code I was trying to ship. I&amp;rsquo;d spent a good chunk of the day proving my innocence to a scanner that was only ever doing as it was told&amp;hellip; and the one thing I&amp;rsquo;d actually got wrong was being sure I already knew what it was looking at.&lt;/p&gt;</content:encoded></item><item><title>Who holds the client secret?</title><link>https://phpboyscout.uk/who-holds-the-client-secret/</link><pubDate>Mon, 27 Jul 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/who-holds-the-client-secret/</guid><category>keryx</category><category>Pioneering</category><description>When a desktop CLI posts on your behalf, the OAuth tutorial assumption of a server holding the client secret collapses. What replaces it.</description><content:encoded>&lt;p&gt;keryx posts to TikTok and YouTube on your behalf, which meant I had to answer a question every OAuth tutorial skips: who actually holds the client secret? They all assume you&amp;rsquo;re a server that can keep one, and never say so. You register your app, you get a client ID and a client secret, you keep the secret on your backend, and the whole flow hangs off the fact that one trusted party can hold a secret. That assumption is doing a lot of work, and it falls apart the moment the thing doing the OAuth is a command-line tool that posts to TikTok or YouTube on your behalf.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s no server. keryx runs on your machine. So the secret would have to live somewhere on the client side, and the two obvious places are both wrong.&lt;/p&gt;
&lt;p&gt;You could bake the client secret into the binary. But keryx is open source, and a secret baked into an open-source binary isn&amp;rsquo;t a secret at all. Anyone can pull it out of the source or the compiled artifact in about ten seconds. Worse, it&amp;rsquo;s a &lt;em&gt;shared&lt;/em&gt; secret: every install carries the same one, so a single extraction compromises the app for everyone, and rotating it means shipping a new release to the entire userbase. That&amp;rsquo;s not a credential, it&amp;rsquo;s a liability with a version number.&lt;/p&gt;
&lt;p&gt;Or you could make every user register their own OAuth app at every platform they want to post to. No shipped secret, which is the right instinct, but now the setup story is &amp;ldquo;before you can use this tool, go and create developer apps on four different social networks.&amp;rdquo; For a lot of people that&amp;rsquo;s where the tool gets closed and forgotten.&lt;/p&gt;
&lt;p&gt;The way out is to name what keryx actually is: a tool with no safe way to keep a client secret in the first place. OAuth already has a name for it: a &lt;em&gt;public client&lt;/em&gt;. It&amp;rsquo;s the category for native and CLI apps that demonstrably can&amp;rsquo;t keep a client secret confidential, and the whole flow is designed around that admission. No client secret is shipped at all. The thing that actually matters, the durable credential, is the per-user &lt;em&gt;refresh token&lt;/em&gt; you get after authorising, and that&amp;rsquo;s yours, tied to your account, living on your machine. keryx stores it the careful way, preferring an environment variable, then the OS keychain, then config, which is a whole topic in &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;its own right&lt;/a&gt;. Nothing secret ever goes in the binary, because there&amp;rsquo;s nothing secret to put there.&lt;/p&gt;
&lt;p&gt;That leaves the client &lt;em&gt;ID&lt;/em&gt;, which isn&amp;rsquo;t a secret (it&amp;rsquo;s fine for it to be public), and the question of where it comes from. For now keryx is bring-your-own-app: you register an app on the platform, you get your client ID, keryx uses it with the public-client flow. It&amp;rsquo;s a bit of one-time setup, but it&amp;rsquo;s setup that leaks nothing, because there&amp;rsquo;s no shared secret anywhere in the system to leak.&lt;/p&gt;
&lt;p&gt;And this is the trade-off I&amp;rsquo;m sitting in, openly, rather than pretending there&amp;rsquo;s a free option. The convenient version is a hosted broker: keryx runs a single registered app, you authorise against that, and you never register anything. Zero setup. It&amp;rsquo;s genuinely nicer for the user. But it means I&amp;rsquo;m now running a piece of trusted infrastructure that holds the app registration for everyone, that has to stay up, that becomes a target, and that turns a local-first tool into one with a dependency on a service I operate. That&amp;rsquo;s a real commitment, not a weekend&amp;rsquo;s work, so the broker is deferred rather than dismissed. Bring-your-own-app is the right default until I can stand a broker up properly.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re ever distributing an OAuth client that isn&amp;rsquo;t a web app, the temptation is to recreate the server-side flow you know, secret and all, somewhere that can&amp;rsquo;t keep a secret. The better move is to stop pretending there&amp;rsquo;s a secret to keep: name it a public client, lean on the per-user token, and most of the problem just evaporates. You were never going to keep that secret anyway.&lt;/p&gt;</content:encoded></item><item><title>No access keys in CI</title><link>https://phpboyscout.uk/no-access-keys-in-ci/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/no-access-keys-in-ci/</guid><category>cicd</category><category>infrastructure</category><category>Pioneering</category><description>Replacing long-lived AWS access keys in CI with OIDC federation, so the pipeline mints a short-lived token instead of holding a secret.</description><content:encoded>&lt;p&gt;A long-lived AWS access key, sitting in a CI system, is just about the single credential I&amp;rsquo;d most like to be rid of. It&amp;rsquo;s powerful, it never expires unless someone remembers to rotate it (nobody remembers to rotate it), and it lives in one of the most attractive targets in the whole supply chain. For infrastructure that&amp;rsquo;s eventually going to hold a release-signing key, it&amp;rsquo;s exactly the wrong place to start. So the &lt;code&gt;phpboyscout&lt;/code&gt; infrastructure has no AWS access key in CI at all. None.&lt;/p&gt;
&lt;h2 id="the-access-key-you-dont-want"&gt;The access key you don&amp;rsquo;t want
&lt;/h2&gt;&lt;p&gt;A CI pipeline that runs &lt;code&gt;tofu apply&lt;/code&gt; against AWS needs AWS credentials. The traditional way to give it some is an IAM user with an access key pair, pasted into the CI system as a masked variable.&lt;/p&gt;
&lt;p&gt;Look at what that key is. It&amp;rsquo;s long-lived: it works until someone remembers to rotate it, and rotating it is a chore, so mostly nobody does. It&amp;rsquo;s powerful: it can apply infrastructure, so it can do nearly anything. And it&amp;rsquo;s sitting in a CI system, which is one of the most attractive targets in your whole supply chain. You&amp;rsquo;ve taken your highest-value credential and stored a permanent copy of it in a place built for running automated jobs.&lt;/p&gt;
&lt;p&gt;For infrastructure that&amp;rsquo;s going to hold a release-signing key, that&amp;rsquo;s precisely the wrong starting point. So the &lt;code&gt;phpboyscout&lt;/code&gt; infrastructure has no AWS access key in CI at all. Not a well-guarded one. None.&lt;/p&gt;
&lt;h2 id="federation-instead-of-a-stored-secret"&gt;Federation instead of a stored secret
&lt;/h2&gt;&lt;p&gt;The replacement is OIDC federation, and the shape of it is worth walking through, because it&amp;rsquo;s genuinely different from &amp;ldquo;a secret, but better&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;A modern CI platform can mint an OIDC token. GitLab does this with an &lt;code&gt;id_tokens:&lt;/code&gt; block: at job time, GitLab issues a short-lived JSON Web Token, signed by GitLab, that asserts a set of facts. This is project X. This is pipeline Y. This is running on ref Z, of this type.&lt;/p&gt;
&lt;p&gt;AWS can consume that. The &lt;code&gt;sts:AssumeRoleWithWebIdentity&lt;/code&gt; call takes such a token and, if it satisfies an IAM role&amp;rsquo;s trust policy, returns short-lived AWS credentials for that role. The trust policy is where the control lives: it names GitLab as a trusted token issuer, and it constrains the token&amp;rsquo;s &lt;code&gt;sub&lt;/code&gt; claim so that only the specific project, and the specific refs, you intend can assume the role.&lt;/p&gt;
&lt;p&gt;Put it together: the pipeline asks GitLab for a token, hands it to AWS, and gets back credentials that last about an hour and are scoped to one role. Nothing long-lived is stored anywhere. The credential exists only for the job that needs it, and it can&amp;rsquo;t be stolen from a CI variable store, because it was never in one.&lt;/p&gt;
&lt;h2 id="two-halves-of-one-handshake"&gt;Two halves of one handshake
&lt;/h2&gt;&lt;p&gt;That handshake is built by two of the repos in this series, each owning one side.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/" &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt; builds the AWS half, in its &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/automation-iam/main.tf#L99" target="_blank" rel="noopener"
 &gt;&lt;code&gt;automation-iam&lt;/code&gt; module&lt;/a&gt;: it registers GitLab as an OIDC identity provider in the account, and it creates the automation role with the trust policy that decides which pipelines may assume it.&lt;/p&gt;
&lt;p&gt;The CI components build the consuming half: the &lt;code&gt;id_tokens:&lt;/code&gt; block that asks GitLab for the JWT, and then simply letting the AWS provider&amp;rsquo;s own credential chain perform the exchange. The pipeline doesn&amp;rsquo;t call &lt;code&gt;sts&lt;/code&gt; by hand. It presents the token; the SDK does the rest.&lt;/p&gt;
&lt;h2 id="the-gotcha-dont-set-a-profile"&gt;The gotcha: don&amp;rsquo;t set a profile
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s one quiet way to break this, and a stack can look completely correct while doing it.&lt;/p&gt;
&lt;p&gt;The AWS SDK finds credentials by walking a chain of sources in order. The web-identity path, the one that uses the OIDC token, is one link in that chain. It triggers off environment variables the CI sets up automatically.&lt;/p&gt;
&lt;p&gt;But if the &lt;code&gt;aws&lt;/code&gt; provider block has a hardcoded &lt;code&gt;profile = &amp;quot;...&amp;quot;&lt;/code&gt;, the SDK takes the &lt;em&gt;profile&lt;/em&gt; link of the chain instead, and never reaches the web-identity link. A &lt;code&gt;profile&lt;/code&gt; line is the sort of thing that ends up in a provider block from someone&amp;rsquo;s local development setup, where it&amp;rsquo;s exactly right. Committed and run in CI, it silently short-circuits the federation. The pipeline either fails to find credentials, or finds the wrong ones.&lt;/p&gt;
&lt;p&gt;The rule is simple once you know it: the provider block that runs in CI must not name a &lt;code&gt;profile&lt;/code&gt;. Leave the chain free to find the web identity. It&amp;rsquo;s the kind of bug that teaches you to be precise about &lt;em&gt;which&lt;/em&gt; link of the credential chain you&amp;rsquo;re actually relying on.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;Giving CI an AWS access key means storing your most powerful, longest-lived credential in one of your most exposed systems. OIDC federation removes it entirely. The CI platform mints a short-lived signed token, AWS exchanges it via &lt;code&gt;AssumeRoleWithWebIdentity&lt;/code&gt; for hour-long credentials against a role whose trust policy names the exact pipeline, and nothing permanent is stored.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; builds the AWS side, the identity provider and the trust policy; the CI components build the consuming side, the token request. The one trap is a hardcoded &lt;code&gt;profile&lt;/code&gt; in the provider block, which short-circuits the SDK&amp;rsquo;s credential chain before it reaches the web-identity path. Get that right, and a pipeline deploys to AWS as a verifiable, short-lived identity, with no key to steal.&lt;/p&gt;</content:encoded></item><item><title>Anything under an 8</title><link>https://phpboyscout.uk/anything-under-an-8/</link><pubDate>Mon, 08 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/anything-under-an-8/</guid><category>security</category><category>Soapbox</category><description>The national vulnerability database is buckling under defunding and volume, which changes what a severity score is actually worth.</description><content:encoded>&lt;p&gt;I read the news about the National Vulnerability Database over a coffee that
went cold while I sat there muttering at my phone. The short version: the NVD,
the free public catalogue that quietly props up half the security tooling you
and I run every day, is going under in slow motion. And the more I dug into
&lt;em&gt;why&lt;/em&gt;, the worse the taste in my mouth got.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m an open-source person. I think of myself as part of that community, and the
NVD is one of those public goods the whole community leans on without ever
really thinking about it. So my first reaction wasn&amp;rsquo;t clever or measured. It was
a kick in the teeth.&lt;/p&gt;
&lt;h2 id="the-carcass-and-the-vultures"&gt;The carcass and the vultures
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s where things actually are. In February 2024 the NVD had around 13,000
unprocessed vulnerabilities sitting in a queue waiting to be analysed. By the end
of 2025 that backlog had passed
&lt;a class="link" href="https://www.helpnetsecurity.com/2026/06/01/nist-nvd-management-problems/" target="_blank" rel="noopener"
 &gt;27,000&lt;/a&gt;.
This April, NIST effectively
&lt;a class="link" href="https://www.nist.gov/news-events/news/2026/04/nist-updates-nvd-operations-address-record-cve-growth" target="_blank" rel="noopener"
 &gt;admitted it can&amp;rsquo;t dig out&lt;/a&gt;:
everything published before 1 March 2026 that hadn&amp;rsquo;t been enriched got swept into
a bucket marked &amp;ldquo;Not Scheduled&amp;rdquo;, and going forward only the highest-risk entries
get the full treatment. The rest you&amp;rsquo;re on your own with.&lt;/p&gt;
&lt;p&gt;The reasons are grimly ordinary. The
&lt;a class="link" href="https://www.helpnetsecurity.com/2026/06/01/nist-nvd-management-problems/" target="_blank" rel="noopener"
 &gt;Cybersecurity and Infrastructure Security Agency stopped funding the
programme&lt;/a&gt;
in 2024. The enrichment contract lapsed that same February, and despite NIST
having two years&amp;rsquo; notice it needed a replacement, the database limped along
understaffed until late November. And the volume kept climbing regardless:
&lt;a class="link" href="https://jerrygamblin.com/2026/01/01/2025-cve-data-review/" target="_blank" rel="noopener"
 &gt;48,185 CVEs in 2025&lt;/a&gt;,
roughly 131 a day, with forecasts of the annual figure topping 60,000, getting on
for ten times what it was a decade ago. No money, a fumbled handover, and a
firehose. That&amp;rsquo;s the whole story.&lt;/p&gt;
&lt;p&gt;The bit that turns my stomach is what comes next. When a free public good fails,
the gap doesn&amp;rsquo;t stay empty. It gets filled, and it gets filled by people selling
something. There are already commercial vulnerability databases that are better
resourced and more current than the NVD, and the moment the free one is visibly
on the floor, every one of them sees a market. Plenty of those subscriptions cost
more in a year than a small open-source project will see in donations in its
lifetime. So the catalogue the little projects relied on most is exactly the one
about to be priced out of their reach. Vultures circling a carcass, and the
carcass is something we all built on.&lt;/p&gt;
&lt;h2 id="the-number-we-never-checked"&gt;The number we never checked
&lt;/h2&gt;&lt;p&gt;And then I read the part that stopped me blaming everyone else.&lt;/p&gt;
&lt;p&gt;A Department of Commerce Inspector General audit went through the NVD&amp;rsquo;s work and
found that NIST&amp;rsquo;s own severity scores
&lt;a class="link" href="https://therecord.media/nist-mistakes-vulnerability-database-inspector-general" target="_blank" rel="noopener"
 &gt;matched independent assessors only 12% of the
time&lt;/a&gt;.
Read that again. Not that NIST was wrong 88% of the time, that&amp;rsquo;s not quite what
it says, but that two competent parties looking at the same vulnerability landed
on the same severity barely one time in eight. The score was never an objective
fact handed down from on high. It was always an estimate, a judgement call, the
kind of thing reasonable people disagree about most of the time.&lt;/p&gt;
&lt;p&gt;Which is awkward, because I have spent years treating that number as gospel. And
I know I&amp;rsquo;m not alone, because I&amp;rsquo;ve watched whole engineering organisations do the
same thing in writing. More than one large employer I&amp;rsquo;ve had bakes the CVSS score
straight into policy: anything scored 8 or above blocks the build and gets a
meeting, and anything under an 8 goes through at an engineer&amp;rsquo;s discretion. When
time is money, and it always is in those places, &amp;ldquo;it&amp;rsquo;s only a 6.4, ship it&amp;rdquo; is the
easiest decision you&amp;rsquo;ll make all week. I&amp;rsquo;ve made it. I&amp;rsquo;ve made it without opening
the advisory, without checking whether the vulnerable code path was even reachable
in what we&amp;rsquo;d built, on the strength of a single number that, it turns out, two
experts wouldn&amp;rsquo;t have agreed on anyway.&lt;/p&gt;
&lt;p&gt;So before I get cross about the funding, I have to sit with my own part in this.
We took a contestable estimate and bolted it to the door as a gatekeeper. We
turned &amp;ldquo;a rough signal worth a closer look&amp;rdquo; into &amp;ldquo;the closer look&amp;rdquo;, and then we
stopped looking. The database didn&amp;rsquo;t promise us a safety net. We just decided it
was one and stopped checking underneath.&lt;/p&gt;
&lt;h2 id="dont-blame-the-robots-for-this-one"&gt;Don&amp;rsquo;t blame the robots for this one
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s an easy villain on offer here, and I want to wave you off it. It would be
tidy to say AI did this, that the flood drowning the NVD is a tide of
machine-generated slop, the same dynamic I wrote about when
&lt;a class="link" href="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/" &gt;curl&amp;rsquo;s bug bounty buckled under unverifiable
reports&lt;/a&gt;. It&amp;rsquo;s
tempting, it&amp;rsquo;s topical, and it&amp;rsquo;s mostly wrong.&lt;/p&gt;
&lt;p&gt;The people who actually crunch the numbers are clear that the surge is largely
&lt;a class="link" href="https://bishopfox.com/blog/understanding-the-cve-ecosystem-and-nists-changing-role" target="_blank" rel="noopener"
 &gt;legitimate growth&lt;/a&gt;.
There are now more than 484 CVE Numbering Authorities, far more organisations
reporting far more bugs far more thoroughly than they did a decade ago. That isn&amp;rsquo;t
a quality collapse, it&amp;rsquo;s the system working as designed and simply getting bigger
than its funding. Pinning it on AI would be scapegoating, and scapegoating the
robots for an underfunding-and-mismanagement problem is just a way of letting the
people who defunded it off the hook.&lt;/p&gt;
&lt;p&gt;None of which means AI gets a free pass. It just isn&amp;rsquo;t the arsonist. The same
machine-assisted discovery tools that found genuine bugs are also forecast to push
CVE volumes
higher still, and yes, one of the tools named in that forecast is the very one I
&lt;a class="link" href="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/" &gt;poked fun at over curl&lt;/a&gt;.
AI is an accelerant on a fire that was already burning for thoroughly human
reasons. It&amp;rsquo;s a beat in this story, not the spine.&lt;/p&gt;
&lt;h2 id="the-version-im-betting-on"&gt;The version I&amp;rsquo;m betting on
&lt;/h2&gt;&lt;p&gt;Where does this leave the working engineer? In a harder spot than before, because
the easy answer stopped being easy. My usual line, the one I keep ending these pieces on, is that
&lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;the diligence is the
job&lt;/a&gt;:
pin, lock, audit, and read the actual advisory instead of trusting a number. All
of that still holds. But it just got more expensive, because the data underneath
the diligence is thinner and, as it turns out, was shakier than we let ourselves
believe.&lt;/p&gt;
&lt;p&gt;So I&amp;rsquo;m not going to pretend there&amp;rsquo;s a clean fix. This problem won&amp;rsquo;t solve itself,
and it won&amp;rsquo;t be solved by any one of us. It needs all of us to actually support
the services we depend on, with money, with contributions, with attention, so the
public goods that underpin our craft are still standing in ten years. That&amp;rsquo;s the
dull, grown-up part.&lt;/p&gt;
&lt;p&gt;But I&amp;rsquo;ll end this one looking up rather than down, because for once I can. I think
the next few years bend towards safer software almost in spite of us. Modern
languages are quietly closing off whole categories of vulnerability at the source:
every memory-safety bug that a borrow checker refuses to compile is one that never
reaches a database to be mis-scored in the first place, which is rather the point
of building
&lt;a class="link" href="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/" &gt;a framework that contains no &lt;code&gt;unsafe&lt;/code&gt;&lt;/a&gt;.
Used with proper guidance instead of left to spew slop, AI can be a genuine help
finding and triaging the things that do slip through. And the
&lt;a class="link" href="https://phpboyscout.uk/the-greybeards-edge-was-never-typing/" &gt;junior engineers we keep sawing off the bottom
rung&lt;/a&gt; are
exactly the people who, mentored by the greybeards before they retire, could build
the next generation of vulnerability identification that the current model clearly
can&amp;rsquo;t sustain.&lt;/p&gt;
&lt;p&gt;As for the vultures&amp;hellip; it&amp;rsquo;s a coin toss. A lot of firms will look at the NVD on
its back and see a land grab. I&amp;rsquo;d love to be proved an optimist and watch at least
one of them stand tall, take all that better-resourced data and open it to
open-source projects for nothing, because it&amp;rsquo;s the right thing to do and because
the whole industry drinks from that well. One of them doing the decent thing would
be worth more than all the press releases about responsible AI put together.&lt;/p&gt;
&lt;p&gt;The catalogue is wobbling. The number was never as solid as we treated it. Neither
of those is the end of the world, as long as we stop outsourcing our judgement to a
free service we never funded and never checked, and start paying, in every sense,
for the foundations we build on. Boring, unfashionable, and the only thing that
ever works. I think we&amp;rsquo;re up to it.&lt;/p&gt;</content:encoded></item><item><title>The version pin you can't lower</title><link>https://phpboyscout.uk/the-version-pin-you-cant-lower/</link><pubDate>Sun, 02 Aug 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-version-pin-you-cant-lower/</guid><category>krites</category><category>Pioneering</category><description>A standard-library security fix forced a Go version bump, and then the release failed because the build image shipped an older toolchain.</description><content:encoded>&lt;p&gt;A security fix landed in the Go standard library, the kind you don&amp;rsquo;t get to think very hard about. You bump &lt;code&gt;go.mod&lt;/code&gt; to the patched toolchain, 1.26.4, and you move on. Or you would, if your release didn&amp;rsquo;t immediately fall over the moment you tagged it.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s what greeted me when I cut the next krites release. The before-hooks, the &lt;code&gt;go mod tidy&lt;/code&gt; and friends that run ahead of the actual build, died on the spot:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go.mod requires go &amp;gt;= 1.26.4 (running go 1.26.3; GOTOOLCHAIN=local)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The goreleaser image I was releasing from, a perfectly current &lt;code&gt;v2.16.0&lt;/code&gt;, bundled Go 1.26.3. One patch version behind. And &lt;code&gt;GOTOOLCHAIN=local&lt;/code&gt;, which is the sensible default for a release because it means &amp;ldquo;use exactly the Go that&amp;rsquo;s installed, don&amp;rsquo;t go fetching anything mid-build&amp;rdquo;, was doing precisely its job: it saw &lt;code&gt;go.mod&lt;/code&gt; asking for a toolchain it didn&amp;rsquo;t have, and refused to pretend otherwise.&lt;/p&gt;
&lt;p&gt;Now, the fix that&amp;rsquo;s quickest to reach for is the one you have to talk yourself out of. The error is a version mismatch, and the fastest way to make a version mismatch go away is to change one of the versions. The image has 1.26.3? Fine, drop &lt;code&gt;go.mod&lt;/code&gt; back to 1.26.3 and the red goes green. Except think about what that actually does. The whole reason &lt;code&gt;go.mod&lt;/code&gt; says 1.26.4 is that 1.26.4 carries a security fix I need. Lowering the pin to placate the build image doesn&amp;rsquo;t resolve the tension, it surrenders to it. It un-applies the security update to make a pipeline pass, which is about the worst trade in software, and it&amp;rsquo;s the one sitting right there with a green tick on it.&lt;/p&gt;
&lt;p&gt;So that pin is one you can&amp;rsquo;t lower. Which leaves making the &lt;em&gt;build&lt;/em&gt; find the right toolchain instead of making the requirement smaller.&lt;/p&gt;
&lt;p&gt;The quick version is one flag: flip &lt;code&gt;GOTOOLCHAIN&lt;/code&gt; from &lt;code&gt;local&lt;/code&gt; to &lt;code&gt;auto&lt;/code&gt;. Where &lt;code&gt;local&lt;/code&gt; means &amp;ldquo;only ever use what&amp;rsquo;s installed&amp;rdquo;, &lt;code&gt;auto&lt;/code&gt; means &amp;ldquo;if &lt;code&gt;go.mod&lt;/code&gt; asks for a newer toolchain than you&amp;rsquo;ve got, go and fetch that one and use it for this build&amp;rdquo;. So the release runs on the image&amp;rsquo;s 1.26.3, hits the &lt;code&gt;go.mod&lt;/code&gt; directive, fetches 1.26.4 and carries on. The mismatch is resolved by upgrading at build time rather than by downgrading the requirement, which is the right direction for it to resolve in.&lt;/p&gt;
&lt;p&gt;The better version, which is where krites ended up, is to not have the gap at all. The release now runs on &lt;a class="link" href="https://phpboyscout.uk/stop-installing-the-same-tools-on-every-pipeline-job/" &gt;the dev-tools image&lt;/a&gt; I bake my CI tools into, and that image tracks the Go version my code actually targets, 1.26.4. When the toolchain in the image and the toolchain in &lt;code&gt;go.mod&lt;/code&gt; are the same thing by construction, there&amp;rsquo;s nothing to fetch and no flag to set, because there&amp;rsquo;s no disagreement to paper over.&lt;/p&gt;
&lt;p&gt;The toolchain your code &lt;em&gt;requires&lt;/em&gt; and the toolchain your build image &lt;em&gt;ships&lt;/em&gt; are two different facts, and they drift apart every time a point release with a security fix lands in one a good while before it reaches the other. When they disagree, the instinct to make them agree by lowering the requirement is exactly backwards: the requirement is the thing that&amp;rsquo;s right, and the environment is the thing that&amp;rsquo;s behind. The pin only ever moves one way. The build image is what had to catch up to it, not the code learning to want less.&lt;/p&gt;</content:encoded></item><item><title>Waivers with an expiry date</title><link>https://phpboyscout.uk/waivers-with-an-expiry-date/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/waivers-with-an-expiry-date/</guid><category>rust-tool-base</category><category>Pioneering</category><description>A vulnerability scanner is a one-day yes or no. Running cargo-deny as a standing policy gate instead, with waivers that expire on a date.</description><content:encoded>&lt;p&gt;A vulnerability scanner gives you a yes or a no. Is there a known advisory on a path you actually use? Yes, or no. That&amp;rsquo;s genuinely useful, and you should run one. But it&amp;rsquo;s a snapshot, taken on the day you ask, and supply-chain risk in a framework is a bigger and more ongoing thing than a single yes-or-no can capture.&lt;/p&gt;
&lt;p&gt;So rust-tool-base treats its whole dependency tree as something to have a &lt;em&gt;policy&lt;/em&gt; about, not something to scan and forget.&lt;/p&gt;
&lt;h2 id="a-scanner-answers-one-question"&gt;A scanner answers one question
&lt;/h2&gt;&lt;p&gt;When I &lt;a class="link" href="https://phpboyscout.uk/every-finding-was-the-same-shape/" &gt;had go-tool-base security-audited&lt;/a&gt;, part of the routine was running a vulnerability scanner over the dependencies. Go has a good one. It looks at your dependency graph, cross-references known advisories, and tells you whether any of them reach code you actually call.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s useful and you should do it. But notice the shape of what it gives back: essentially a yes or a no. Either there&amp;rsquo;s a known vulnerability on a reachable path or there isn&amp;rsquo;t. It answers one question, on the day you ask it.&lt;/p&gt;
&lt;p&gt;Supply-chain risk in a framework is broader than that one question, because a framework drags its entire dependency tree into every tool built on it. rust-tool-base treats the whole tree as something to have a &lt;em&gt;policy&lt;/em&gt; about, and the tool for that is &lt;code&gt;cargo-deny&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="a-gate-not-a-scan"&gt;A gate, not a scan
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;cargo-deny&lt;/code&gt; reads a &lt;code&gt;deny.toml&lt;/code&gt; and checks the dependency graph against four kinds of rule.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Licences.&lt;/strong&gt; There&amp;rsquo;s an allowlist: MIT, Apache-2.0, the BSD variants, ISC, a handful of others. Every transitive crate&amp;rsquo;s licence has to be on it. A dependency that pulls in something copyleft, or something with no licence at all, fails the build. You find out the first time it enters the tree, not during a release scramble when someone finally reads the legal implications.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Advisories.&lt;/strong&gt; It checks the RustSec advisory database, and yanked crates are set to &lt;code&gt;deny&lt;/code&gt;, so a dependency that&amp;rsquo;s been pulled from the registry stops CI.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bans.&lt;/strong&gt; Wildcard version requirements (&lt;code&gt;version = &amp;quot;*&amp;quot;&lt;/code&gt;) are denied outright, because a dependency that floats to whatever&amp;rsquo;s newest is a supply-chain hole by construction. Duplicate versions of the same crate get surfaced too.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sources.&lt;/strong&gt; Crates may only come from the official registry. An unknown registry or a stray git dependency is denied. Nothing sneaks in from a URL.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a gate. It encodes, as rules in a file, what the project will and won&amp;rsquo;t accept into its dependency tree, and it enforces them on every build instead of once an audit.&lt;/p&gt;
&lt;h2 id="the-honest-part-is-the-waiver-list"&gt;The honest part is the waiver list
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the thing every real project runs into. Sooner or later there&amp;rsquo;s an advisory you genuinely can&amp;rsquo;t fix this week. It&amp;rsquo;s against a crate three levels down your tree. The fix needs an upstream release that hasn&amp;rsquo;t happened. The crate is scheduled to be reworked two milestones from now anyway. The gate is going to fail, and the work to satisfy it honestly isn&amp;rsquo;t available to you yet.&lt;/p&gt;
&lt;p&gt;The lazy response is a blanket ignore: silence the advisory, move on, forget. Now your gate has a hole in it that nobody remembers opening.&lt;/p&gt;
&lt;p&gt;rust-tool-base&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/deny.toml#L14" target="_blank" rel="noopener"
 &gt;&lt;code&gt;deny.toml&lt;/code&gt;&lt;/a&gt; does something better. Every waiver in the &lt;code&gt;ignore&lt;/code&gt; list is a documented record. Each one carries a comment that names the crate, traces the &lt;em&gt;exact dependency path&lt;/em&gt; that reaches it, gives the reason, and names the condition that lifts it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;ignore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# `instant` - reached via async-openai -&amp;gt; backoff -&amp;gt; rtb-ai (v0.3).&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;RUSTSEC-2024-0384&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# `paste` - reached via ratatui -&amp;gt; rtb-docs (v0.2) / rtb-tui (v0.4).&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;RUSTSEC-2024-0436&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The file states the policy out loud: &amp;ldquo;Every waiver points at a deferred stub crate that will be reworked before its ship milestone. Lift each waiver when the owning crate lands its v0.1.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Some waivers go further and carry a structured reason field, so the &lt;em&gt;why&lt;/em&gt; travels with the entry rather than living only in a comment above it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;RUSTSEC-2025-0140&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gix-date via gix is a stub dependency; rtb-vcs v0.5 will upgrade&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Read that list and you don&amp;rsquo;t see a project that quietly stopped caring about seven advisories. You see seven advisories the project knows about, can trace, and has tied to a specific milestone. The waiver has an expiry condition. When &lt;code&gt;rtb-vcs&lt;/code&gt; reaches v0.5, that &lt;code&gt;gix&lt;/code&gt; entry is meant to come out, and the comment is the reminder that it should.&lt;/p&gt;
&lt;h2 id="why-this-is-the-bit-to-copy"&gt;Why this is the bit to copy
&lt;/h2&gt;&lt;p&gt;A gate that can&amp;rsquo;t be relaxed is a gate people route around. They&amp;rsquo;ll find the broadest possible ignore and use it, because the alternative is being blocked on someone else&amp;rsquo;s release. The pressure to do that is real, and it&amp;rsquo;s not unreasonable.&lt;/p&gt;
&lt;p&gt;So the design that actually holds up isn&amp;rsquo;t a stricter gate. It&amp;rsquo;s a gate with an honest, structured escape hatch: you &lt;em&gt;can&lt;/em&gt; waive an advisory, but a waiver costs you a documented record with a dependency path and an expiry condition. That price is small enough that nobody routes around it, and high enough that waivers don&amp;rsquo;t accumulate silently. The &lt;code&gt;ignore&lt;/code&gt; list stays readable, and every line in it is something you could defend out loud.&lt;/p&gt;
&lt;p&gt;Supply-chain hygiene framed this way isn&amp;rsquo;t an audit you survive once a year. It&amp;rsquo;s bookkeeping: a ledger of what you accepted, why, and when each exception is due to close. Which, now I write it down, is just the &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;Boy Scout rule&lt;/a&gt; again, pointed at a dependency tree. Leave it tidier than you found it, and write down the bits you couldn&amp;rsquo;t tidy yet.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;A vulnerability scanner answers one question on one day. &lt;code&gt;cargo-deny&lt;/code&gt; is a standing policy gate: licences against an allowlist, advisories and yanked crates denied, wildcard versions banned, sources restricted to the official registry, enforced on every build.&lt;/p&gt;
&lt;p&gt;The part of rust-tool-base&amp;rsquo;s setup worth copying is the waiver list. Every advisory that can&amp;rsquo;t be fixed yet is recorded with its crate, its dependency path, its reason and the milestone that removes it. A waiver is a dated note, not a shrug, and that&amp;rsquo;s what keeps the gate honest enough that nobody actually wants to bypass it.&lt;/p&gt;</content:encoded></item><item><title>The blank import that keeps a dependency out of your binary</title><link>https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/</guid><category>go-tool-base</category><category>Pioneering</category><description>Making OS-keychain support provably absent from a Go binary for regulated or air-gapped builds, using a registry and a blank import.</description><content:encoded>&lt;p&gt;go-tool-base can stash your credentials in the OS keychain, which most people building on it are perfectly happy about. But some of them ship into regulated and air-gapped environments where the binary isn&amp;rsquo;t &lt;em&gt;permitted&lt;/em&gt; to contain keychain or session-bus code at all&amp;hellip; not dormant, not unused, simply not there.&lt;/p&gt;
&lt;p&gt;So I had a feature most users want and a minority must be able to provably not have. The way I ended up solving it is one of my favourite little bits of honest Go.&lt;/p&gt;
&lt;h2 id="a-feature-some-users-have-to-be-able-to-not-have"&gt;A feature some users have to be able to &lt;em&gt;not have&lt;/em&gt;
&lt;/h2&gt;&lt;p&gt;go-tool-base needs somewhere to keep secrets: AI provider keys, VCS tokens, the occasional app password. The best home for those on a developer&amp;rsquo;s machine is the operating system&amp;rsquo;s own keychain. macOS Keychain, GNOME Keyring or KWallet on Linux via the Secret Service, Windows Credential Manager. So I wanted go-tool-base to support all three. (This is the keychain mode I mentioned back in the &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;credentials post&lt;/a&gt;, finally getting the explanation I promised it.)&lt;/p&gt;
&lt;p&gt;The Go library for that is &lt;a class="link" href="https://github.com/zalando/go-keyring" target="_blank" rel="noopener"
 &gt;&lt;code&gt;go-keyring&lt;/code&gt;&lt;/a&gt;, and it&amp;rsquo;s good. The catch is what it drags in behind it. On Linux it talks to the Secret Service over D-Bus, which means &lt;code&gt;godbus&lt;/code&gt;. On Windows it pulls &lt;code&gt;wincred&lt;/code&gt;. Perfectly reasonable dependencies for a desktop tool.&lt;/p&gt;
&lt;p&gt;Now here&amp;rsquo;s the constraint that made this interesting. Some of the people building tools on go-tool-base don&amp;rsquo;t ship to developer laptops. They ship into regulated sectors and air-gapped deployments where a security review will scan the binary, enumerate every dependency, and ask pointed questions about anything that does inter-process communication. For those builds, &amp;ldquo;the keychain code is there but we never call it&amp;rdquo; is not an acceptable answer. The reviewer&amp;rsquo;s position, and it&amp;rsquo;s a fair one, is that code which isn&amp;rsquo;t in the binary cannot be a finding.&lt;/p&gt;
&lt;p&gt;So I had a feature that most users want, and a minority of users must be able to provably &lt;em&gt;not have&lt;/em&gt;. Same framework, same release.&lt;/p&gt;
&lt;h2 id="why-i-didnt-reach-for-a-build-tag"&gt;Why I didn&amp;rsquo;t reach for a build tag
&lt;/h2&gt;&lt;p&gt;The obvious Go answer is a build tag. Compile with &lt;code&gt;-tags keychain&lt;/code&gt; to get it, leave the tag off to not. I started down that road. I even spent a while on an inverted version, a &lt;code&gt;nokeychain&lt;/code&gt; tag, on the theory that the regulated build should be the one that has to ask, so a forgotten flag fails safe.&lt;/p&gt;
&lt;p&gt;It works. It also isn&amp;rsquo;t very nice. Build tags are invisible at the call site. Nothing in the source tells you that a file only exists in some builds. The two worlds drift, because the tagged-out path isn&amp;rsquo;t compiled in your normal editor session and quietly rots. And the ergonomics for a &lt;em&gt;downstream consumer&lt;/em&gt; are poor: every tool built on go-tool-base would have to know the right magic incantation and thread it through their own release pipeline correctly, forever.&lt;/p&gt;
&lt;p&gt;I tried a second approach too: pull the keychain backend out into a completely separate Go module. That genuinely solves the dependency question (a module you don&amp;rsquo;t require can&amp;rsquo;t contribute to your &lt;code&gt;go.sum&lt;/code&gt;). But a separate module for one backend is clunky. Separate versioning, separate release, separate repo, all for a single file&amp;rsquo;s worth of behaviour. It felt like using a shipping container to post a letter.&lt;/p&gt;
&lt;h2 id="the-shape-that-actually-fits-a-registry-and-an-init"&gt;The shape that actually fits: a registry and an &lt;code&gt;init()&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;The version I&amp;rsquo;m happy with leans on two boring, well-worn Go mechanisms and lets them do something quietly clever together.&lt;/p&gt;
&lt;p&gt;First, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/credentials/backend.go#L27" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/credentials&lt;/code&gt;&lt;/a&gt; defines a &lt;code&gt;Backend&lt;/code&gt; interface and a registry. By default the registry holds a stub backend that politely returns &amp;ldquo;unsupported&amp;rdquo; for everything. The framework only ever talks to &lt;em&gt;the registered backend&lt;/em&gt;, whatever that happens to be.&lt;/p&gt;
&lt;p&gt;Second, the keychain implementation lives in its own package, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/credentials/keychain/keychain.go#L97" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/credentials/keychain&lt;/code&gt;&lt;/a&gt;, still inside the same module, no separate release to manage. That package has an &lt;code&gt;init()&lt;/code&gt; that registers its &lt;code&gt;go-keyring&lt;/code&gt;-backed backend:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;//nolint:gochecknoinits // registration via import is the whole point&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And &lt;code&gt;go-keyring&lt;/code&gt;, &lt;code&gt;godbus&lt;/code&gt;, &lt;code&gt;wincred&lt;/code&gt;, the whole IPC dependency chain, are only imported by &lt;em&gt;that&lt;/em&gt; package.&lt;/p&gt;
&lt;p&gt;Now the trick. To switch keychain support on, you import the package. You don&amp;rsquo;t have to &lt;em&gt;use&lt;/em&gt; anything from it. A blank import is enough, because a blank import still runs the package&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// cmd/gtb/keychain.go - the entire file.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;package&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;gitlab.com/phpboyscout/go-tool-base/pkg/credentials/keychain&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That single line is the on/off switch for the shipped &lt;code&gt;gtb&lt;/code&gt; binary. The blank import means &lt;code&gt;init()&lt;/code&gt; runs, the keychain backend registers itself, and credential operations start routing through the OS keychain. No flag, no tag, no config.&lt;/p&gt;
&lt;h2 id="the-part-that-makes-it-provable"&gt;The part that makes it provable
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s why this beats the build tag, and it comes down to one guarantee in the Go toolchain: &lt;strong&gt;the linker only includes packages that are actually imported.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;cmd/gtb/keychain.go&lt;/code&gt; exists, the &lt;code&gt;keychain&lt;/code&gt; package is in the import graph, so &lt;code&gt;go-keyring&lt;/code&gt;, &lt;code&gt;godbus&lt;/code&gt; and &lt;code&gt;wincred&lt;/code&gt; are linked in. Delete that one file and rebuild, and the &lt;code&gt;keychain&lt;/code&gt; package is no longer reachable from &lt;code&gt;main&lt;/code&gt;. The linker performs dead-code elimination, and the entire &lt;code&gt;go-keyring&lt;/code&gt; chain is &lt;em&gt;gone&lt;/em&gt;. Not dormant. Not present-but-unused. Absent from the binary.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the bit a regulated build needs. It isn&amp;rsquo;t a promise that the code won&amp;rsquo;t run. It&amp;rsquo;s a structural fact that the code isn&amp;rsquo;t there, and you can hand a security reviewer an SBOM that proves it. &lt;code&gt;go-keyring&lt;/code&gt; won&amp;rsquo;t appear, because it genuinely isn&amp;rsquo;t linked.&lt;/p&gt;
&lt;p&gt;For a downstream tool built on go-tool-base the story is the same, and just as cheap. Want keychain support? Add the one-line blank import to your own &lt;code&gt;cmd&lt;/code&gt; package. Must ship keychain-free? Don&amp;rsquo;t. Your binary&amp;rsquo;s dependency graph follows your import graph, exactly as Go always promised it would. The default (no import) is the locked-down one, which is the right way round for a safety property.&lt;/p&gt;
&lt;h2 id="why-i-like-this-more-than-i-expected-to"&gt;Why I like this more than I expected to
&lt;/h2&gt;&lt;p&gt;Build tags hide a decision in the compiler invocation. This pattern puts the decision in the source, as an import, where it&amp;rsquo;s greppable, obvious in code review, and impossible to get subtly wrong. There&amp;rsquo;s a real file called &lt;code&gt;keychain.go&lt;/code&gt; whose entire content is one import, and it reads as exactly what it is: a switch.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s also just &lt;em&gt;honest&lt;/em&gt; Go. No reflection, no plugin loader, no clever runtime. A registry, an &lt;code&gt;init()&lt;/code&gt;, and the linker doing the one job it&amp;rsquo;s always done. The cleverness, such as it is, is in the arrangement, not in any individual piece.&lt;/p&gt;
&lt;h2 id="stepping-back"&gt;Stepping back
&lt;/h2&gt;&lt;p&gt;go-tool-base needed OS keychain support for the many, and a way to provably exclude it for the few. Build tags could express the toggle but hid it in the build invocation and rotted in the dark. A separate module solved the dependency question but was far too much machinery for one backend.&lt;/p&gt;
&lt;p&gt;Putting the keychain backend in its own package, activated by a blank &lt;code&gt;import _&lt;/code&gt; that fires its &lt;code&gt;init()&lt;/code&gt;, gets you both: a one-line, in-source, code-reviewable switch, and, because the linker only links what&amp;rsquo;s imported, a build with the import omitted that contains &lt;em&gt;none&lt;/em&gt; of the keychain dependency chain. Provable absence, not promised disuse.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re carrying an optional dependency that some of your users need gone rather than merely idle, this is the pattern. Let the import graph be the feature flag.&lt;/p&gt;</content:encoded></item><item><title>Routing security findings without the noise</title><link>https://phpboyscout.uk/routing-security-findings-without-the-noise/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/routing-security-findings-without-the-noise/</guid><category>infrastructure</category><category>Pioneering</category><description>Routing AWS GuardDuty and Security Hub findings so an alert still means something: forward high severity only, and drop the duplicates.</description><content:encoded>&lt;p&gt;Turning on GuardDuty and Security Hub gives you threat detection. It also gives you a firehose. And an alert system that dutifully forwards everything in that firehose isn&amp;rsquo;t monitoring, it&amp;rsquo;s a very efficient way of training your team to ignore alerts. So the &lt;code&gt;alerts&lt;/code&gt; module&amp;rsquo;s real job isn&amp;rsquo;t detection at all. It&amp;rsquo;s deciding what&amp;rsquo;s actually worth interrupting a human for, and the interesting part is everything it deliberately throws away.&lt;/p&gt;
&lt;h2 id="detection-is-the-easy-half"&gt;Detection is the easy half
&lt;/h2&gt;&lt;p&gt;Switching on threat detection in an AWS account is a few resources. GuardDuty, Security Hub with its standards, IAM Access Analyzer: &lt;a class="link" href="https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/" &gt;the security baseline&lt;/a&gt; does exactly that. From then on, the account is generating findings.&lt;/p&gt;
&lt;p&gt;And it generates a lot of them. Plenty are low-severity, informational, or simply the normal texture of a cloud account. If you wire every finding to an email or a pager, you haven&amp;rsquo;t built monitoring. You&amp;rsquo;ve built noise. And noise has a specific failure mode: people stop reading it, and the one finding that genuinely mattered scrolls past unread alongside two hundred that didn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;So the valuable work isn&amp;rsquo;t detection. It&amp;rsquo;s &lt;em&gt;routing&lt;/em&gt;: deciding what&amp;rsquo;s worth interrupting a human for, and letting the rest sit quietly in a console for whenever someone reviews it.&lt;/p&gt;
&lt;h2 id="forward-the-severe-leave-the-rest"&gt;Forward the severe, leave the rest
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;alerts&lt;/code&gt; module routes findings with EventBridge rules into an SNS topic that emails out. The rules are deliberately picky. &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/modules/alerts/main.tf#L146" target="_blank" rel="noopener"
 &gt;GuardDuty findings are forwarded only at severity 7 and above&lt;/a&gt;. Security Hub findings are forwarded only at HIGH and CRITICAL.&lt;/p&gt;
&lt;p&gt;Everything below those thresholds isn&amp;rsquo;t discarded. It&amp;rsquo;s still in GuardDuty and Security Hub, where someone doing a review will see it. It just doesn&amp;rsquo;t get to interrupt anyone&amp;rsquo;s day. The threshold is the line between &amp;ldquo;look at this now&amp;rdquo; and &amp;ldquo;look at this sometime&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="the-duplicate-you-would-otherwise-send-twice"&gt;The duplicate you would otherwise send twice
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the subtle one, and it&amp;rsquo;s the kind of thing you only find by looking closely at where findings come from.&lt;/p&gt;
&lt;p&gt;Security Hub is an aggregator. It pulls findings &lt;em&gt;in&lt;/em&gt; from other services, GuardDuty among them. So a single GuardDuty finding can show up in two places: in GuardDuty itself, and again in Security Hub as an aggregated copy.&lt;/p&gt;
&lt;p&gt;A rule on GuardDuty findings and a rule on Security Hub HIGH/CRITICAL findings would therefore both fire for the same underlying GuardDuty finding. One event, two emails. Do that across an account and a meaningful fraction of your alert volume is just the same findings counted twice, which is its own kind of noise.&lt;/p&gt;
&lt;p&gt;So the Security Hub rule explicitly &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/modules/alerts/main.tf#L178" target="_blank" rel="noopener"
 &gt;excludes findings whose &lt;code&gt;ProductName&lt;/code&gt; is GuardDuty, with an &lt;code&gt;anything-but&lt;/code&gt; match&lt;/a&gt;. GuardDuty findings come through the GuardDuty rule. The Security Hub rule handles everything Security Hub adds that GuardDuty didn&amp;rsquo;t already report. One finding, one alert, regardless of how many services it passed through.&lt;/p&gt;
&lt;h2 id="two-tripwires-on-the-root-account"&gt;Two tripwires on the root account
&lt;/h2&gt;&lt;p&gt;Findings are about threats the detectors recognise. The module adds two alarms about something simpler: the root account doing anything at all.&lt;/p&gt;
&lt;p&gt;One CloudWatch alarm fires on a root console sign-in. The other fires on any root API call that isn&amp;rsquo;t a console login. In a well-run AWS account, the root user does almost nothing after initial setup: day-to-day work happens through roles. So root activity isn&amp;rsquo;t a &amp;ldquo;finding&amp;rdquo; to be assessed for severity. It&amp;rsquo;s a tripwire. Any of it, in an account that should be silent, is worth an immediate look, and the two alarms say so directly.&lt;/p&gt;
&lt;h2 id="why-a-quiet-alert-stream-matters-here"&gt;Why a quiet alert stream matters here
&lt;/h2&gt;&lt;p&gt;This is monitoring for the account that&amp;rsquo;s going to hold the release-signing key, and that raises the stakes on getting the routing right.&lt;/p&gt;
&lt;p&gt;If a key-bearing account ever does come under attack, the alert that says so has to be &lt;em&gt;seen&lt;/em&gt;. An alert stream that&amp;rsquo;s mostly noise and duplicates is, functionally, no alerting at all, because the people who&amp;rsquo;d act on it have long since tuned it out. Routing the stream down to &amp;ldquo;severe, deduplicated, plus root tripwires&amp;rdquo; is what keeps it something a human will still read on the day it finally matters.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;GuardDuty and Security Hub make detection easy. The hard, valuable part is routing: forwarding what deserves to interrupt someone and leaving the rest in a console.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;alerts&lt;/code&gt; module forwards GuardDuty at severity 7-plus and Security Hub at HIGH/CRITICAL, and it drops the duplicate that aggregation creates by excluding GuardDuty-sourced findings from the Security Hub rule, so one finding is one alert. Two CloudWatch alarms act as tripwires on root-account activity, which should be near-zero. For the account that will hold the signing key, a quiet, trustworthy alert stream isn&amp;rsquo;t a nicety. It&amp;rsquo;s the difference between monitoring and theatre.&lt;/p&gt;</content:encoded></item><item><title>The security finding you must not fix</title><link>https://phpboyscout.uk/the-security-finding-you-must-not-fix/</link><pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-security-finding-you-must-not-fix/</guid><category>infrastructure</category><category>Pioneering</category><description>A checkov finding you must suppress rather than fix: that KMS policy statement is the escape hatch that stops you locking yourself out for good.</description><content:encoded>&lt;p&gt;A security scanner flagged a finding in my Terraform, and the correct response, the one I had to talk myself into, was to leave it exactly as it was. Not because the finding was wrong about what the code did. It was right. It&amp;rsquo;s that doing what it asked would have quietly bricked the account.&lt;/p&gt;
&lt;h2 id="a-finding-that-looks-open-and-shut"&gt;A finding that looks open and shut
&lt;/h2&gt;&lt;p&gt;I run &lt;code&gt;checkov&lt;/code&gt; over the infrastructure as part of CI, and on the KMS key that protects the Terraform state bucket it raised CKV_AWS_111: a key policy that grants &lt;code&gt;kms:*&lt;/code&gt; is overly permissive. On the face of it, unarguable. The policy says the account root can perform &lt;em&gt;any&lt;/em&gt; KMS action on the key, with a resource of &lt;code&gt;*&lt;/code&gt;. A wildcard action and a wildcard resource is the exact shape a scanner is built to shout about, and ninety-nine times in a hundred it&amp;rsquo;d be right to.&lt;/p&gt;
&lt;p&gt;This was the hundredth time.&lt;/p&gt;
&lt;h2 id="why-narrowing-it-bricks-the-key"&gt;Why narrowing it bricks the key
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit that turns the finding on its head. That &lt;code&gt;kms:*&lt;/code&gt;-for-root statement isn&amp;rsquo;t an over-broad grant I left lying around. It&amp;rsquo;s the &lt;em&gt;default&lt;/em&gt; key policy AWS itself applies, and it&amp;rsquo;s load-bearing in a way that&amp;rsquo;s easy to miss.&lt;/p&gt;
&lt;p&gt;A KMS key is administered through its own key policy, and that policy is the &lt;em&gt;only&lt;/em&gt; way in. Unlike most resources, IAM permissions elsewhere can&amp;rsquo;t grant access to a key whose policy doesn&amp;rsquo;t allow it. So if you &amp;ldquo;tighten&amp;rdquo; the key by removing root&amp;rsquo;s full control, and you don&amp;rsquo;t perfectly replace it with some other administrative principal, you can end up with a key that &lt;em&gt;nobody&lt;/em&gt; can administer. Not you, not root, not a future you with a very good reason. KMS will not let you recover it. The key, and anything it encrypts, is stranded.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;kms:*&lt;/code&gt;-for-root statement is what keeps the account&amp;rsquo;s own root able to manage the key as a last resort. It&amp;rsquo;s not the vulnerability. It&amp;rsquo;s the escape hatch, and the scanner was asking me to weld it shut.&lt;/p&gt;
&lt;h2 id="so-the-finding-gets-suppressed-out-loud"&gt;So the finding gets suppressed, out loud
&lt;/h2&gt;&lt;p&gt;The answer isn&amp;rsquo;t to silence the scanner globally, and it isn&amp;rsquo;t to obey it. It&amp;rsquo;s to suppress &lt;em&gt;this finding, on this resource,&lt;/em&gt; with the reasoning written right there next to it, from &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/state-backend/main.tf#L34" target="_blank" rel="noopener"
 &gt;&lt;code&gt;modules/state-backend/main.tf&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;kms&amp;#34;&lt;/span&gt; {&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # checkov:skip=CKV_AWS_111:kms:* on the CMK for the account root is the
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # AWS-documented pattern; narrowing it risks an unrecoverable lockout from
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # the key. See https://docs.aws.amazon.com/kms/latest/developerguide/key-policy-default.html
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;statement&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; sid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AllowAccountRootAdmin&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; effect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Allow&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;principals&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AWS&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; identifiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;arn:aws:iam::${var.account_id}:root&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;kms:*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The skip carries a reason and a link to AWS&amp;rsquo;s own documentation of why this is the recommended default. That matters more than it looks. A bare &lt;code&gt;# checkov:skip&lt;/code&gt; with no explanation is indistinguishable from laziness, and the next person to read it (quite possibly me, a year on) has no way to tell whether it was a considered decision or someone making a red mark go away. A skip &lt;em&gt;with&lt;/em&gt; a documented reason is a decision you can audit. The finding is still visible in the sense that the suppression is right there in the code, attached to the thing it&amp;rsquo;s about, defensible out loud.&lt;/p&gt;
&lt;h2 id="a-scanner-is-an-argument-not-an-order"&gt;A scanner is an argument, not an order
&lt;/h2&gt;&lt;p&gt;The wider lesson is the one worth keeping, because it generalises well past this one key. A static-analysis finding is a &lt;em&gt;prompt to think&lt;/em&gt;, not an instruction to comply with. Most of the time thinking leads you straight to &amp;ldquo;yes, fix it&amp;rdquo;, and you should. But a scanner encodes a general rule, and general rules meet specific contexts where they&amp;rsquo;re wrong, or merely irrelevant, or, in the rare and dangerous case, actively harmful to obey. &lt;code&gt;kms:*&lt;/code&gt; for root on a customer-managed key is that last kind: the tool&amp;rsquo;s general rule (&amp;ldquo;wildcards are bad&amp;rdquo;) collides with a hard AWS-specific fact (&amp;ldquo;root must retain control of the key or it&amp;rsquo;s gone&amp;rdquo;).&lt;/p&gt;
&lt;p&gt;The discipline that keeps this honest is the one in the code above. You don&amp;rsquo;t get to ignore a finding. You get to &lt;em&gt;suppress&lt;/em&gt; it, scoped to the exact resource, with a reason a reviewer can weigh. Cheap enough that you&amp;rsquo;ll do it properly, costly enough that you won&amp;rsquo;t paper over a real finding by reflex.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;checkov&lt;/code&gt; was right that the state-bucket key grants the account root &lt;code&gt;kms:*&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt;. It was wrong that I should narrow it, because that statement is AWS&amp;rsquo;s documented default and the thing that stops the key becoming permanently unadministrable. The fix was a scoped &lt;code&gt;checkov:skip&lt;/code&gt; carrying its reasoning and a link to the AWS docs, so the decision lives next to the code and can be defended rather than merely trusted.&lt;/p&gt;
&lt;p&gt;Treat your scanner as a sharp, tireless colleague who&amp;rsquo;s usually right and occasionally, confidently, about to lock you out of your own key. Read every finding. Obey most of them. And write down, in the open, the rare one you mustn&amp;rsquo;t.&lt;/p&gt;</content:encoded></item><item><title>Hardening the account that will hold the keys</title><link>https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/</link><pubDate>Sat, 09 May 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/</guid><category>infrastructure</category><category>Pioneering</category><description>Applying a security baseline to a fresh AWS account: audit logging, config recording, threat detection and an operator role that is not root.</description><content:encoded>&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/" &gt;Bootstrapping the account&lt;/a&gt; got it &lt;em&gt;ready&lt;/em&gt;: somewhere to store state, an identity to deploy as, enough for the next &lt;code&gt;tofu apply&lt;/code&gt; to run. Ready is not the same as safe. An account with no audit trail, nothing watching it, and no considered way for a human to get in is fine for experimenting and absolutely not where you put the most sensitive key in the system. So before the signing key goes anywhere near it, the account gets a security baseline.&lt;/p&gt;
&lt;h2 id="ready-is-not-the-same-as-safe"&gt;Ready is not the same as safe
&lt;/h2&gt;&lt;p&gt;The bootstrap post ended with an account that was &lt;em&gt;ready&lt;/em&gt;: it had somewhere to store state and a CI identity to deploy as. The next &lt;code&gt;tofu apply&lt;/code&gt; could run.&lt;/p&gt;
&lt;p&gt;Ready is not safe. That account still has no audit trail, so nobody could tell you afterwards what happened in it. It has no threat detection, so nothing is watching. Its defaults are AWS&amp;rsquo;s defaults, which are not a security posture. There&amp;rsquo;s no considered way for a human to get in. An account in that condition is fine for experimenting. It&amp;rsquo;s not somewhere you put the most sensitive key in the whole system.&lt;/p&gt;
&lt;p&gt;So before the signing key is anywhere near it, the account gets a security baseline.&lt;/p&gt;
&lt;h2 id="the-baseline-in-one-downstream-stack"&gt;The baseline, in one downstream stack
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/tree/v0.2.0/modules" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;&lt;/a&gt; is that baseline, and it&amp;rsquo;s exactly the downstream stack the bootstrap post promised: applied &lt;em&gt;through&lt;/em&gt; the automation role bootstrap created, not bootstrapped specially.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s six sub-modules, each behind an &lt;code&gt;enable_*&lt;/code&gt; toggle: &lt;code&gt;account-hardening&lt;/code&gt; (IAM password policy, account-wide S3 public-access blocking, default EBS encryption), &lt;code&gt;audit-logging&lt;/code&gt; (a multi-region CloudTrail with log-file validation), &lt;code&gt;aws-config&lt;/code&gt;, &lt;code&gt;threat-detection&lt;/code&gt; (GuardDuty, Security Hub, IAM Access Analyzer), &lt;code&gt;alerts&lt;/code&gt;, and &lt;code&gt;operator-role&lt;/code&gt;. Together they turn a bare account into one that records what happens, watches for trouble, and controls who gets in.&lt;/p&gt;
&lt;p&gt;Most of those are the expected baseline. The operator role is the one worth slowing down on, because it&amp;rsquo;s built backwards from how people usually think about an admin role.&lt;/p&gt;
&lt;h2 id="the-operator-role-and-the-inversion"&gt;The operator role, and the inversion
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/tree/v0.2.0/modules/operator-role" target="_blank" rel="noopener"
 &gt;&lt;code&gt;InfraAdmin&lt;/code&gt;&lt;/a&gt; is the human way into the account: the role a person assumes to do operator work. Two things define it.&lt;/p&gt;
&lt;p&gt;The trust policy decides &lt;em&gt;who&lt;/em&gt; may assume it. It trusts only the account root principal, and it requires multi-factor authentication: the assume call must carry &lt;code&gt;aws:MultiFactorAuthPresent&lt;/code&gt;, and &lt;code&gt;aws:MultiFactorAuthAge&lt;/code&gt; bounds how recently that MFA was performed. No MFA, no role. So far this is a careful but ordinary admin role.&lt;/p&gt;
&lt;p&gt;The inversion is a &lt;em&gt;second&lt;/em&gt;, separate inline policy, and it&amp;rsquo;s almost entirely &lt;code&gt;Deny&lt;/code&gt;. It denies, using &lt;code&gt;NotAction&lt;/code&gt;, anything where &lt;code&gt;aws:RequestedRegion&lt;/code&gt; falls outside an allowed set of regions. The role&amp;rsquo;s &lt;em&gt;power&lt;/em&gt; comes from an admin grant. This inline policy &lt;em&gt;fences&lt;/em&gt; that power.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the part worth holding onto. People picture an admin role as a list of what it can do. This one is better understood by what it &lt;em&gt;cannot&lt;/em&gt;: it cannot act outside its permitted regions, full stop. A fat-fingered command, or a compromised session, cannot quietly spin resources up in some region nobody&amp;rsquo;s watching. The fence is as much the point of the role as the grant is.&lt;/p&gt;
&lt;h2 id="the-carve-out-because-honesty"&gt;The carve-out, because honesty
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a fiddly detail, and it&amp;rsquo;s the kind of thing that makes the region fence real rather than theoretical.&lt;/p&gt;
&lt;p&gt;Some AWS services are global. IAM, CloudFront, Route 53 and friends have no region, and they don&amp;rsquo;t honour &lt;code&gt;aws:RequestedRegion&lt;/code&gt;. A naive region-deny would therefore deny calls to IAM, and you&amp;rsquo;d lock yourself out of the very service you manage access with. (A close cousin of the kind of self-inflicted lockout I&amp;rsquo;ll come back to in a &lt;a class="link" href="https://phpboyscout.uk/a-403-you-cant-fix-in-iam/" &gt;later post&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;So the Deny carries explicit carve-outs for the global services. It isn&amp;rsquo;t elegant, and it can&amp;rsquo;t be: the global-versus-regional split is just a fact of AWS, and a correct region fence has to account for it. The carve-out list is the real cost of the control working.&lt;/p&gt;
&lt;h2 id="harden-the-room-then-move-the-keys-in"&gt;Harden the room, then move the keys in
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s an order to all of this, and the order is the argument.&lt;/p&gt;
&lt;p&gt;The account that will hold the signing key has to be audited before the key arrives, so that from day one every call against it is in CloudTrail. It has to be watched before the key arrives, so GuardDuty is already looking. It has to be access-controlled before the key arrives, so the only human path in is MFA-gated and region-fenced.&lt;/p&gt;
&lt;p&gt;You don&amp;rsquo;t move something valuable into a room and then think about locks. You build the room, fit the locks, check they work, and &lt;em&gt;then&lt;/em&gt; move the valuable thing in. The security baseline is fitting the locks. The signing key comes later, into a room already built for it.&lt;/p&gt;
&lt;h2 id="worth-remembering"&gt;Worth remembering
&lt;/h2&gt;&lt;p&gt;Bootstrapping an account makes it ready for the next deploy. It does not make it safe to hold anything that matters. &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; is the downstream stack that closes that gap: audit logging, AWS Config, threat detection, account hardening, and an operator role, applied through the CI role bootstrap created.&lt;/p&gt;
&lt;p&gt;The operator role is the piece to study. It&amp;rsquo;s MFA-gated on the way in, and then fenced by a separate, almost-all-&lt;code&gt;Deny&lt;/code&gt; inline policy that confines it to permitted regions, with carve-outs for the global services that have no region. An admin role defined as much by its fence as its grant. Harden the room first; the keys move in afterwards.&lt;/p&gt;</content:encoded></item><item><title>A state bucket that defends itself</title><link>https://phpboyscout.uk/a-state-bucket-that-defends-itself/</link><pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/a-state-bucket-that-defends-itself/</guid><category>infrastructure</category><category>Pioneering</category><description>An OpenTofu state bucket that defends itself against corruption, deletion and its own operator, using lockfiles and prevent_destroy.</description><content:encoded>&lt;p&gt;OpenTofu&amp;rsquo;s remote state file is, quietly, the most sensitive thing in an infrastructure repo. It&amp;rsquo;s a plain JSON document listing every resource you manage, every ID, and, depending on your providers, the odd secret in clear text. So the S3 bucket that holds it can&amp;rsquo;t just be a bucket. It has to actively defend itself, on three separate fronts.&lt;/p&gt;
&lt;h2 id="the-most-sensitive-file-in-the-repo"&gt;The most sensitive file in the repo
&lt;/h2&gt;&lt;p&gt;OpenTofu, like Terraform, keeps a state file: a JSON document recording every resource the stack manages, its real-world ID, and its attributes. It&amp;rsquo;s how the tool knows what already exists. It&amp;rsquo;s also, quietly, the most sensitive file in the whole repo. It can hold resource identifiers an attacker would value, and depending on the providers in play it can hold secret values in clear text.&lt;/p&gt;
&lt;p&gt;Three bad things can happen to it. It can be deleted, and now the tool has forgotten everything it manages. It can be read by someone who shouldn&amp;rsquo;t. It can be corrupted by two runs writing at once. The bucket that holds remote state has to defend against all three, and &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&amp;rsquo;s &lt;code&gt;state-backend&lt;/code&gt; module is built around doing exactly that.&lt;/p&gt;
&lt;h2 id="the-dynamodb-lock-table-is-gone"&gt;The DynamoDB lock table is gone
&lt;/h2&gt;&lt;p&gt;Start with the corruption problem, because the answer changed recently.&lt;/p&gt;
&lt;p&gt;The long-standing pattern for remote state on AWS was an S3 bucket &lt;em&gt;plus&lt;/em&gt; a DynamoDB table. S3 held the state; the DynamoDB table held a lock, so two &lt;code&gt;apply&lt;/code&gt; runs couldn&amp;rsquo;t write at once. Everyone who&amp;rsquo;s done Terraform on AWS has provisioned that table, probably more times than they&amp;rsquo;d care to count.&lt;/p&gt;
&lt;p&gt;OpenTofu 1.10 made it unnecessary. The S3 backend gained &lt;code&gt;use_lockfile&lt;/code&gt;, which does the locking with a small lock &lt;em&gt;object&lt;/em&gt; in the same bucket, using S3&amp;rsquo;s conditional-write support. No separate table. The state backend is now genuinely one bucket and one key, with the lock living beside the state. It&amp;rsquo;s one fewer resource to create, one fewer thing to pay for, and one fewer moving part to reason about. The module takes the new path, and the DynamoDB table simply isn&amp;rsquo;t there.&lt;/p&gt;
&lt;h2 id="a-bucket-you-cant-delete-by-accident"&gt;A bucket you can&amp;rsquo;t delete by accident
&lt;/h2&gt;&lt;p&gt;Deletion is guarded with &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/state-backend/main.tf#L66" target="_blank" rel="noopener"
 &gt;&lt;code&gt;lifecycle { prevent_destroy = true }&lt;/code&gt;&lt;/a&gt; on the bucket. With that set, OpenTofu refuses to produce a plan that would destroy the bucket. A stray &lt;code&gt;tofu destroy&lt;/code&gt;, a refactor that drops the resource, an accidental rename: all of them fail loudly instead of quietly taking the state bucket with them.&lt;/p&gt;
&lt;p&gt;This is also why the &lt;code&gt;state-backend&lt;/code&gt; module is hand-rolled from raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; resources rather than wrapping a community module like &lt;code&gt;terraform-aws-modules/s3-bucket&lt;/code&gt;. &lt;code&gt;prevent_destroy&lt;/code&gt; has to sit on the actual resource, and a &lt;code&gt;lifecycle&lt;/code&gt; block isn&amp;rsquo;t something you can pass into a wrapper module as an input. Hand-rolling the bucket keeps &lt;code&gt;prevent_destroy&lt;/code&gt; somewhere you can put it and, just as importantly, somewhere the next reader can see it. (There&amp;rsquo;s a whole post coming on &lt;a class="link" href="https://phpboyscout.uk/why-i-hand-rolled-every-module/" &gt;why I hand-rolled every module&lt;/a&gt;; this is one of the reasons in miniature.)&lt;/p&gt;
&lt;h2 id="reject-anything-encrypted-wrong"&gt;Reject anything encrypted wrong
&lt;/h2&gt;&lt;p&gt;Confidentiality is the subtle one, because the obvious control isn&amp;rsquo;t enough.&lt;/p&gt;
&lt;p&gt;The bucket has a default encryption configuration: server-side encryption with the customer-managed KMS key. But default encryption is a &lt;em&gt;default&lt;/em&gt;. A client making a &lt;code&gt;PutObject&lt;/code&gt; call can override it per request, asking for plain &lt;code&gt;AES256&lt;/code&gt; or a different KMS key, and S3 will honour the override.&lt;/p&gt;
&lt;p&gt;So the module doesn&amp;rsquo;t rely on the default. The &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/state-backend/main.tf#L161" target="_blank" rel="noopener"
 &gt;bucket policy&lt;/a&gt; explicitly denies the upload it doesn&amp;rsquo;t want. It denies any request not over TLS. It denies any &lt;code&gt;PutObject&lt;/code&gt; that isn&amp;rsquo;t using SSE-KMS. And it denies any &lt;code&gt;PutObject&lt;/code&gt; that names the &lt;em&gt;wrong&lt;/em&gt; KMS key. The default encryption config says &amp;ldquo;this is what you get if you don&amp;rsquo;t ask&amp;rdquo;; the bucket policy says &amp;ldquo;and you&amp;rsquo;re not allowed to ask for anything else&amp;rdquo;. State can only ever land encrypted, in transit and at rest, under the one key the module controls.&lt;/p&gt;
&lt;p&gt;One small companion setting: &lt;code&gt;bucket_key_enabled&lt;/code&gt;. With per-object SSE-KMS, every object operation is also a KMS API call, which costs money and can throttle. An S3 Bucket Key collapses those into far fewer KMS calls, cutting per-object KMS traffic by well over ninety per cent. It&amp;rsquo;s a one-line setting the module turns on and most people forget exists.&lt;/p&gt;
&lt;h2 id="in-short"&gt;In short
&lt;/h2&gt;&lt;p&gt;Remote state is the most sensitive file an infrastructure repo has, and the bucket that holds it has to defend against deletion, disclosure and corruption.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&amp;rsquo;s state backend handles corruption with OpenTofu 1.10&amp;rsquo;s &lt;code&gt;use_lockfile&lt;/code&gt;, dropping the old DynamoDB lock table entirely. It guards deletion with &lt;code&gt;prevent_destroy&lt;/code&gt;, which is also why the bucket is hand-rolled rather than wrapped. And it guards confidentiality with a bucket policy that denies non-TLS traffic and denies any upload not encrypted with the right KMS key, because default encryption is only a default and a client can override it. The state bucket isn&amp;rsquo;t just a place to put state. It&amp;rsquo;s built to refuse every wrong thing that could happen to it.&lt;/p&gt;</content:encoded></item><item><title>The security service I had to switch off</title><link>https://phpboyscout.uk/the-security-service-i-had-to-switch-off/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-security-service-i-had-to-switch-off/</guid><category>infrastructure</category><category>Pioneering</category><description>A fresh AWS account cannot enable GuardDuty or Security Hub without a subscription, so the security baseline failed on the account it protects.</description><content:encoded>&lt;p&gt;A while back I wrote about &lt;a class="link" href="https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/" &gt;hardening the account that would hold the signing
key&lt;/a&gt;,
and one line in it has aged badly. &amp;ldquo;GuardDuty is already looking,&amp;rdquo; I wrote: the
account watched from day one, threat detection on before the key even arrives.
Then I went to apply that baseline to a brand-new account, and GuardDuty wasn&amp;rsquo;t
looking at all, because the account wouldn&amp;rsquo;t let it.&lt;/p&gt;
&lt;h2 id="what-the-baseline-switches-on"&gt;What the baseline switches on
&lt;/h2&gt;&lt;p&gt;The baseline runs a &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/modules/threat-detection/main.tf#L24-66" target="_blank" rel="noopener"
 &gt;threat-detection module&lt;/a&gt;
that turns on three AWS services: GuardDuty, Security Hub, and IAM Access
Analyzer. Each sits behind an &lt;code&gt;enable_*&lt;/code&gt; toggle, all defaulting to on, which is
the right default. An account about to hold something sensitive should be
watched, flagged and analysed from the start. The hardening post&amp;rsquo;s whole
argument was that you fit the locks before you move the valuables in, and I
stand by it. The gap I hadn&amp;rsquo;t accounted for is the one between a posture you can
&lt;em&gt;design&lt;/em&gt; and a posture a fresh account will actually &lt;em&gt;run&lt;/em&gt; on its first day.&lt;/p&gt;
&lt;h2 id="subscriptionrequiredexception"&gt;SubscriptionRequiredException
&lt;/h2&gt;&lt;p&gt;GuardDuty and Security Hub are first-party AWS services, but they are not &lt;em&gt;on&lt;/em&gt;
by default. They have to be activated for the account before anything can
configure them. Point OpenTofu at a brand-new account that has never had them
switched on and you don&amp;rsquo;t get a security baseline, you get a
&lt;code&gt;SubscriptionRequiredException&lt;/code&gt; and a failed apply. The locks you carefully
specced won&amp;rsquo;t fit, because the door hasn&amp;rsquo;t been registered as a door yet.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d actually met this exact wall from another direction: an aws-nuke dry-run on
a fresh account &lt;a class="link" href="https://phpboyscout.uk/the-cleanup-tool-that-almost-deleted-its-own-hands/" &gt;throws screenfuls of the same exception&lt;/a&gt;
for every service you&amp;rsquo;ve never enabled. Same root cause, different tool. A new
AWS account is a quieter, emptier place than the console makes it look.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a second, duller reason too, and it belongs in the open: GuardDuty and
Security Hub both cost money once their free trial lapses, and the budget for
this account wasn&amp;rsquo;t in place yet. Enabling a service you can&amp;rsquo;t yet afford to
keep on is its own small mistake.&lt;/p&gt;
&lt;h2 id="switching-two-watchers-back-off-on-purpose"&gt;Switching two watchers back off, on purpose
&lt;/h2&gt;&lt;p&gt;So I did the thing that felt wrong and was right. I switched two of them off,
and wrote the reason straight into the module call:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# GuardDuty + Security Hub are deferred. The account&amp;#39;s first-time
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# service activation is pending (it returns SubscriptionRequiredException)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# and the services carry an ongoing cost beyond their free trial.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Re-enable by flipping both to true (or deleting these lines) once
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# activation clears and the budget is in place.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;enable_guardduty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;enable_securityhub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two things make that a deferral rather than a retreat. First, it&amp;rsquo;s scoped:
Access Analyzer needs no subscription and costs nothing, so it stays on. The
account isn&amp;rsquo;t unwatched, it&amp;rsquo;s watched by the part that &lt;em&gt;can&lt;/em&gt; watch it right now.
Second, it carries its own undo. The comment is the re-enable instructions, the
toggles sit right there, and flipping them back is a one-line change the day
activation clears and the budget lands. A disabled control with a written path
back is a deferral. A disabled control with no note is a hole someone finds in a
year.&lt;/p&gt;
&lt;h2 id="why-this-lives-in-the-module-not-in-a-hack"&gt;Why this lives in the module, not in a hack
&lt;/h2&gt;&lt;p&gt;I could only do this cleanly because the module
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/variables.tf#L68-86" target="_blank" rel="noopener"
 &gt;exposes each service as its own toggle&lt;/a&gt;,
and it only gained that recently; the previous version was all-or-nothing on
threat detection. Granular &lt;code&gt;enable_guardduty&lt;/code&gt; and &lt;code&gt;enable_securityhub&lt;/code&gt; flags are
exactly what let you say &amp;ldquo;this account, these two, not yet&amp;rdquo; without forking the
module or commenting resources out. It&amp;rsquo;s the difference between a baseline you
adapt per account and one you fight.&lt;/p&gt;
&lt;h2 id="the-honest-version-of-secure-by-default"&gt;The honest version of &amp;ldquo;secure by default&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;The hardening post wasn&amp;rsquo;t wrong. It was idealised. Secure-by-default is the
right aim, and on a mature account the baseline goes on clean. On a brand-new
one, reality intrudes: services that must be activated before they can be
configured, costs that need a budget before they can be incurred. The honest
response isn&amp;rsquo;t to pretend the posture is fully live when it isn&amp;rsquo;t. It&amp;rsquo;s to
enable what the account will take, defer what it won&amp;rsquo;t, write down precisely why
and how to finish, and leave Access Analyzer watching in the meantime. GuardDuty
will be looking soon enough. It just wasn&amp;rsquo;t looking on day one, and saying so is
better than a comment-free &lt;code&gt;enable = true&lt;/code&gt; that quietly errored on every apply.&lt;/p&gt;</content:encoded></item><item><title>The cleanup tool that almost deleted its own hands</title><link>https://phpboyscout.uk/the-cleanup-tool-that-almost-deleted-its-own-hands/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-cleanup-tool-that-almost-deleted-its-own-hands/</guid><category>infrastructure</category><category>Pioneering</category><description>Reading an aws-nuke dry run: the screenfuls of red are harmless noise, and the real hazard is one quiet line in the middle of them.</description><content:encoded>&lt;p&gt;The first time I pointed aws-nuke at a real account, the dry-run printed hundreds of lines of angry red text and my stomach dropped. Then I read it properly, and two things turned out to be true at once. Almost all of that red was noise. And the one operation I genuinely should have worried about wasn&amp;rsquo;t red at all.&lt;/p&gt;
&lt;h2 id="a-tool-whose-whole-job-is-destruction"&gt;A tool whose whole job is destruction
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://github.com/ekristen/aws-nuke" target="_blank" rel="noopener"
 &gt;aws-nuke&lt;/a&gt; deletes everything in an AWS account. That&amp;rsquo;s the point of it: when you spin up a throwaway account to try something, aws-nuke is how you tear it back down to nothing afterwards rather than leaving resources quietly billing forever. go-tool-base&amp;rsquo;s bootstrap renders a scoped aws-nuke config for exactly this, from a &lt;code&gt;nuke-config&lt;/code&gt; module, so the teardown is described in code rather than typed by hand at the worst possible moment.&lt;/p&gt;
&lt;p&gt;A tool that deletes everything is a tool you run in dry-run first, every single time, and read the output before you let it touch anything. So that&amp;rsquo;s what I did. And the output was alarming in a way that turned out to be completely meaningless, and reassuring in a way that turned out to hide the one real hazard.&lt;/p&gt;
&lt;h2 id="the-wall-of-red-that-means-nothing"&gt;The wall of red that means nothing
&lt;/h2&gt;&lt;p&gt;A fresh account threw up screen after screen of &lt;code&gt;SubscriptionRequiredException&lt;/code&gt;. Hundreds of lines, all red, all looking like something had gone badly wrong.&lt;/p&gt;
&lt;p&gt;They hadn&amp;rsquo;t. aws-nuke works by asking every region &amp;ldquo;do you have any of &lt;em&gt;this&lt;/em&gt; kind of resource?&amp;rdquo;, for every kind of resource it knows about. On a brand-new account you&amp;rsquo;ve never enabled most services in most regions, so the API&amp;rsquo;s honest answer is &amp;ldquo;you&amp;rsquo;re not subscribed to that here&amp;rdquo;, which surfaces as an exception, which the tool dutifully logs in red. It isn&amp;rsquo;t a failure. It&amp;rsquo;s the sound of an empty account being asked four hundred questions and answering &amp;ldquo;nothing here&amp;rdquo; to almost all of them.&lt;/p&gt;
&lt;p&gt;The skill, and it is a skill, is learning to read a destructive tool&amp;rsquo;s dry-run and tell the noise from the signal. &lt;code&gt;SubscriptionRequiredException&lt;/code&gt; on a fresh account is noise. Once you know that, the wall of red stops being frightening and becomes scenery.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a related trap in the same neighbourhood, and the &lt;code&gt;nuke-config&lt;/code&gt; module&amp;rsquo;s own &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/nuke-config/variables.tf#L11" target="_blank" rel="noopener"
 &gt;&lt;code&gt;regions&lt;/code&gt; variable documents it&lt;/a&gt;. aws-nuke has a special &lt;code&gt;global&lt;/code&gt; pseudo-region for things that don&amp;rsquo;t live in one place (IAM, Route 53, CloudFront), and then the actual regions for everything else. It &lt;em&gt;also&lt;/em&gt; accepts &lt;code&gt;all&lt;/code&gt;, meaning every enabled region. Mixing &lt;code&gt;all&lt;/code&gt; with explicit region values scans some regions twice and muddies the output, so the module&amp;rsquo;s guidance is to pick one approach or the other. More scenery you have to learn to read before the genuinely important line will stand out.&lt;/p&gt;
&lt;h2 id="the-line-that-should-have-scared-me-and-didnt-look-like-it"&gt;The line that should have scared me, and didn&amp;rsquo;t look like it
&lt;/h2&gt;&lt;p&gt;Buried in that calm-looking eye of the storm, among the resources aws-nuke intended to delete, were the IAM resources granting the identity &lt;em&gt;running the nuke&lt;/em&gt; its administrative access.&lt;/p&gt;
&lt;p&gt;Sit with that for a second. aws-nuke runs as some principal with enough power to delete everything. To delete EVERYTHING, it has to delete IAM resources too. And if the plan deletes the very grant that gives the running identity its admin &lt;em&gt;before&lt;/em&gt; it&amp;rsquo;s finished, the tool strands itself partway through: no permissions left to complete the teardown, and now you&amp;rsquo;ve got a half-nuked account and a principal that can&amp;rsquo;t act on it. The cleanup tool sawing off the branch it&amp;rsquo;s standing on, calmly, without a single red line to warn you, because from the API&amp;rsquo;s point of view deleting that resource is a perfectly valid request.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the operation that actually mattered, and it was the quietest thing in the output.&lt;/p&gt;
&lt;h2 id="two-ways-to-keep-its-hands-attached"&gt;Two ways to keep its hands attached
&lt;/h2&gt;&lt;p&gt;The fix has two halves, one explicit and one structural.&lt;/p&gt;
&lt;p&gt;The explicit half is to &lt;em&gt;preserve the privilege path&lt;/em&gt;. The &lt;code&gt;nuke-config&lt;/code&gt; module passes a caller-supplied set of &lt;code&gt;filters&lt;/code&gt; straight through into the rendered config, so you tell aws-nuke &amp;ldquo;everything except these&amp;rdquo;. You exclude the identity running the nuke, and the policy and path that grant it admin, from deletion. The tool cleans the account and leaves its own hands alone, because you told it which resources are off-limits.&lt;/p&gt;
&lt;p&gt;The structural half is to not give it a tempting separate thing to delete in the first place. If an identity gets its admin through an IAM &lt;em&gt;group&lt;/em&gt; it belongs to, that group is its own deletable resource, one more thing in the plan, one more way to be stranded. The automation role in &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; instead takes its policies as &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/automation-iam/main.tf#L119" target="_blank" rel="noopener"
 &gt;direct attachments to the role itself&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_role_policy_attachment&amp;#34; &amp;#34;gitlab&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; for_each&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;is_gitlab&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;policy_arns&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; {}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; policy_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No intermediary group sitting there as a separate, deletable object. Flattening the privilege onto the role makes the dependency simpler to reason about and gives the cleanup tool one fewer foot-gun to find. Belt and braces: filter the path out explicitly, &lt;em&gt;and&lt;/em&gt; don&amp;rsquo;t build a structure that invites the problem.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;A destructive tool&amp;rsquo;s dry-run is the most valuable thing it produces, and reading it well is its own competence. On a fresh account, the screenfuls of red &lt;code&gt;SubscriptionRequiredException&lt;/code&gt; are noise, the sound of an empty account answering &amp;ldquo;nothing here&amp;rdquo;, and the &lt;code&gt;all&lt;/code&gt;-versus-&lt;code&gt;global&lt;/code&gt; region wrinkle is more of the same. Learn to see past all of it, because the operation that can actually hurt you is rarely the one shouting. Mine was the calm, unremarkable line proposing to delete the admin grant the nuke needed to finish its own job.&lt;/p&gt;
&lt;p&gt;Keep the cleanup tool&amp;rsquo;s hands attached: filter the privileged path out of the teardown so it&amp;rsquo;s never a candidate for deletion, and attach that privilege directly rather than through a group that&amp;rsquo;s just one more thing to delete. Then let it loose on everything else, which is, after all, what you brought it in to do.&lt;/p&gt;</content:encoded></item><item><title>A configurable AI endpoint is an attack surface</title><link>https://phpboyscout.uk/a-configurable-ai-endpoint-is-an-attack-surface/</link><pubDate>Sun, 19 Apr 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/a-configurable-ai-endpoint-is-an-attack-surface/</guid><category>go-tool-base</category><category>rust-tool-base</category><category>Pioneering</category><description>A user-settable AI base URL decides where your API key gets sent. Validating it to reject userinfo, non-HTTPS schemes and redirects.</description><content:encoded>&lt;p&gt;&amp;ldquo;Let users point at their own AI endpoint&amp;rdquo; is one of those config options that looks completely harmless on the way in. People want it, for perfectly good reasons. Then you sit with it for a minute and realise you&amp;rsquo;ve handed every user a loaded gun and pointed it vaguely at their own API key.&lt;/p&gt;
&lt;h2 id="why-you-offer-it-at-all"&gt;Why you offer it at all
&lt;/h2&gt;&lt;p&gt;There are real reasons to let someone set a custom base URL. They&amp;rsquo;re running a local model and want &lt;code&gt;localhost:11434&lt;/code&gt;. They&amp;rsquo;re behind a corporate proxy that fronts the real provider. They&amp;rsquo;re on Azure&amp;rsquo;s flavour of OpenAI, which lives at a different host. They&amp;rsquo;ve a self-hosted gateway doing rate-limiting. All reasonable, all things a framework should support rather than fight.&lt;/p&gt;
&lt;h2 id="the-bit-thats-a-loaded-gun"&gt;The bit that&amp;rsquo;s a loaded gun
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s what the config option quietly decides: the base URL is &lt;em&gt;where your credential goes&lt;/em&gt;. The API key rides along in an &lt;code&gt;Authorization&lt;/code&gt; header on every request, to whatever host that URL resolves to. So the moment the endpoint is user-configurable, the destination of your secret is user-configurable too.&lt;/p&gt;
&lt;p&gt;And users do user things. They paste a URL from a gist that turned out to be a honeypot. They leave &lt;code&gt;http://&lt;/code&gt; on the front, so the key crosses the wire in plaintext. They copy &lt;code&gt;https://user:token@host/v1&lt;/code&gt; not realising the userinfo changes who they actually authenticate to. They never edit the &lt;code&gt;https://api.example.com/v1&lt;/code&gt; placeholder and wonder why the key&amp;rsquo;s been posted to a domain they don&amp;rsquo;t own. None of that is malice. It&amp;rsquo;s what happens when the destination of a secret is a free-text field.&lt;/p&gt;
&lt;h2 id="validate-before-the-first-byte-leaves"&gt;Validate before the first byte leaves
&lt;/h2&gt;&lt;p&gt;So every &lt;code&gt;chat.New&lt;/code&gt; routes through &lt;code&gt;ValidateBaseURL&lt;/code&gt; before the provider is built. The threat model is written at the top of &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/baseurl.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/chat/baseurl.go&lt;/code&gt;&lt;/a&gt;: an operator who can influence config could &amp;ldquo;redirect chat-provider traffic to an attacker-controlled HTTPS host and capture the Authorization header.&amp;rdquo; The checks run cheapest-first: a length cap, no ASCII control characters, must parse, no userinfo, &lt;code&gt;https&lt;/code&gt; only, a host must be present, and the host mustn&amp;rsquo;t be a placeholder.&lt;/p&gt;
&lt;p&gt;The userinfo rule is the sharp one:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// Reject any userinfo, with or without password. Never log&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// the URL itself because it contains the credential.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ErrInvalidBaseURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;base URL must not contain credentials; use the Token field instead&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The placeholder check rejects &lt;code&gt;example.com&lt;/code&gt; and friends &lt;em&gt;and any subdomain of them&lt;/em&gt;, so the unedited &lt;code&gt;https://api.example.com/v1&lt;/code&gt; from a setup wizard never reaches the wire and hits some typosquatted lookalike. And the HTTP escape hatch is test-only by construction: the &lt;code&gt;AllowInsecureBaseURL&lt;/code&gt; field that permits plain &lt;code&gt;http&lt;/code&gt; is tagged &lt;code&gt;json:&amp;quot;-&amp;quot;&lt;/code&gt;, so a config file physically cannot set it. This all came out of the 2026-04-17 security audit, finding M-3.&lt;/p&gt;
&lt;p&gt;rust-tool-base enforces the same at its own boundary: &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-ai/src/config.rs#L96" target="_blank" rel="noopener"
 &gt;&lt;code&gt;validate_base_url&lt;/code&gt;&lt;/a&gt; rejects userinfo, any scheme but &lt;code&gt;https&lt;/code&gt; (bar a test-only &lt;code&gt;allow_insecure&lt;/code&gt;), and documentation placeholder hosts like &lt;code&gt;example.com&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="what-it-can-and-cant-do"&gt;What it can and can&amp;rsquo;t do
&lt;/h2&gt;&lt;p&gt;It won&amp;rsquo;t stop a user who deliberately points the tool at a malicious HTTPS host they genuinely chose. If someone is set on sending their own key somewhere bad, validation can&amp;rsquo;t read their mind.&lt;/p&gt;
&lt;p&gt;What it stops is the &lt;em&gt;accidents&lt;/em&gt;: the plaintext slip, the userinfo confusion, the placeholder nobody changed. Those aren&amp;rsquo;t theoretical, they&amp;rsquo;re the ones that happen to careful people on ordinary days. Storing the key well is one job (&lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;where a CLI keeps it&lt;/a&gt;), stopping it &lt;a class="link" href="https://phpboyscout.uk/redacting-the-secret-you-didnt-know-was-in-the-string/" &gt;leaking through a log&lt;/a&gt; is another, and this is the third side of the triangle: once you&amp;rsquo;ve stored it and stopped it leaking, make sure you don&amp;rsquo;t &lt;em&gt;send&lt;/em&gt; it somewhere daft.&lt;/p&gt;</content:encoded></item><item><title>The marketplace I had to defend from my own attack surface</title><link>https://phpboyscout.uk/the-marketplace-i-had-to-defend-from-my-own-attack-surface/</link><pubDate>Thu, 06 Aug 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-marketplace-i-had-to-defend-from-my-own-attack-surface/</guid><category>claude-code-plugins</category><category>security</category><category>Pioneering</category><description>Opening a public marketplace for agent workflows meant publishing an attack surface, and then having to defend it from my own convenience.</description><content:encoded>&lt;p&gt;I went to tidy up my Claude Code skills into a shareable marketplace. What started as housekeeping turned into a small security problem with my name on it. Over a year or so I&amp;rsquo;d accumulated a pile of workflows: the way I like a release set up, how docs get structured, a dozen little conventions all written down as &lt;code&gt;CLAUDE.md&lt;/code&gt; files and skills scattered across projects. And I kept hitting the same friction. I&amp;rsquo;d start something new, want a workflow I&amp;rsquo;d already worked out somewhere else, and find myself telling Claude Code to go and read another project&amp;rsquo;s &lt;code&gt;CLAUDE.md&lt;/code&gt; to pick it up. Do that often enough and the obvious thought arrives: these should live somewhere shareable. So I opened a marketplace, &lt;code&gt;claude-code-plugins&lt;/code&gt;, to make them reusable, for me and for anyone who fancied aping my setup.&lt;/p&gt;
&lt;p&gt;Then I remembered what I&amp;rsquo;d written the week before.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d just published &lt;a class="link" href="https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/" &gt;the interpreter we forgot to sandbox&lt;/a&gt;, whose whole argument is that a &lt;code&gt;SKILL.md&lt;/code&gt; or a &lt;code&gt;CLAUDE.md&lt;/code&gt; is not documentation. It&amp;rsquo;s source code, in the sense that an agent reads it and &lt;em&gt;acts on it&lt;/em&gt;, which makes it an injectable attack surface that nobody treats like one. And I&amp;rsquo;d then gone and built the ideal delivery mechanism for exactly that attack: a marketplace that accepts those files, by merge request, from people I don&amp;rsquo;t know, and hands them to other people&amp;rsquo;s agents to execute.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d built the gun and was halfway to loading it. The marketplace I&amp;rsquo;d opened to share my workflows was, viewed from one angle, a supply chain for the thing I&amp;rsquo;d just warned everyone about. So I had to defend it, and the awkward part was that the attack surface I most needed to defend against was my own. If anyone was going to be trusted blindly, it was me, which meant my markdown had to be the cleanest in the building.&lt;/p&gt;
&lt;p&gt;The gate is a reusable CI component, &lt;code&gt;skill-security&lt;/code&gt;, and it runs on every merge request to the marketplace. The headline check is the one straight out of that post: a hard fail on hidden characters. Zero-width spaces, and the bidirectional control characters behind Trojan-Source, the ones that let a diff &lt;em&gt;read&lt;/em&gt; as one thing to a human reviewer and &lt;em&gt;mean&lt;/em&gt; another to the machine. A &lt;code&gt;U+200B&lt;/code&gt; you can&amp;rsquo;t see or a &lt;code&gt;U+202E&lt;/code&gt; flipping text direction is precisely how you smuggle an instruction past review, so anything carrying them doesn&amp;rsquo;t get a warning, it gets stopped. On top of that there&amp;rsquo;s schema validation for the plugin and &lt;code&gt;SKILL.md&lt;/code&gt; files, including which frontmatter keys are even allowed, injection heuristics that warn on the more suspicious patterns, and a scoped run of gitleaks so nobody fat-fingers a token into a skill.&lt;/p&gt;
&lt;p&gt;I built it as a shared component rather than a one-off pipeline on purpose, the same move as &lt;a class="link" href="https://phpboyscout.uk/ci-you-include-not-copy/" &gt;CI you include, not copy&lt;/a&gt; and &lt;a class="link" href="https://phpboyscout.uk/the-build-gate-these-sites-never-had/" &gt;the build gate those sites never had&lt;/a&gt;. The scan isn&amp;rsquo;t special to the marketplace. Any repo full of skills or &lt;code&gt;CLAUDE.md&lt;/code&gt; files has the same exposure, so the gate is a thing you include in one line and get for free, and the marketplace is just its first customer. Solve it once, wire it in everywhere.&lt;/p&gt;
&lt;p&gt;And then, because a security control you haven&amp;rsquo;t watched work is a guess, I tried to beat it. Planted a zero-width space in one file and a direction-flipping character in another, the exact tricks the post describes, and ran the gate over the marketplace&amp;rsquo;s own contents before anything shipped. It caught both. That mattered more to me than it sounds, because the whole credibility of the thing rests on the author&amp;rsquo;s own files being clean. It would be a particularly bleak joke to publish &amp;ldquo;markdown is an attack vector&amp;rdquo; and then ship a marketplace of markdown that failed its own check.&lt;/p&gt;
&lt;p&gt;What I&amp;rsquo;d take from it is about where you stand when you hand people a tool. Writing the warning is easy. Building the warehouse that all that warned-about stuff flows through is a different thing entirely. If you&amp;rsquo;re going to tell everyone their markdown is dangerous, the first markdown you make safe had better be the one with your name on it.&lt;/p&gt;</content:encoded></item><item><title>Nobody told it to</title><link>https://phpboyscout.uk/nobody-told-it-to/</link><pubDate>Sat, 01 Aug 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/nobody-told-it-to/</guid><category>ai</category><category>Soapbox</category><description>A model in an internal evaluation did something nobody asked it to do, and the interesting part is what that does and does not prove.</description><content:encoded>&lt;p&gt;I read the news on a Friday afternoon, with a cup of tea going cold beside me, and I laughed. Not because it&amp;rsquo;s funny&amp;hellip; though it is a bit&amp;hellip; but because it was so completely unsurprising. One of OpenAI&amp;rsquo;s own models had broken out of its sandbox, hacked its way into Hugging Face, and helped itself to internal data and a fistful of credentials. And then the detail that made me put the tea down for good: nobody had pointed it at Hugging Face. It had worked that part out for itself.&lt;/p&gt;
&lt;p&gt;If you missed it, &lt;a class="link" href="https://openai.com/index/hugging-face-model-evaluation-security-incident/" target="_blank" rel="noopener"
 &gt;OpenAI published their own post-mortem&lt;/a&gt;, and &lt;a class="link" href="https://www.theregister.com/ai-and-ml/2026/07/22/openai-admits-it-was-the-source-of-the-agent-swarm-that-attacked-hugging-face/5275939" target="_blank" rel="noopener"
 &gt;The Register has the plain-English version&lt;/a&gt;. The short version: during an internal safety evaluation, OpenAI pointed a couple of its models (GPT-5.6 Sol, plus a pre-release one it won&amp;rsquo;t name) at a benchmark that scores how good an AI is at finding security exploits. To measure that properly they&amp;rsquo;d turned the safeties down, running with what they called &amp;ldquo;reduced cyber refusals for evaluation purposes&amp;rdquo;, inside a sandbox they believed had no way out to the wider internet. The model, &amp;ldquo;hyperfocused on finding a solution&amp;rdquo; to the benchmark, found and exploited &amp;ldquo;a zero-day vulnerability in the package registry cache proxy&amp;rdquo;, climbed out of the box, reached a machine that &lt;em&gt;did&lt;/em&gt; have a route to the internet, and got loose. From there it chained stolen credentials and more zero-days into full remote code execution on Hugging Face&amp;rsquo;s servers. Hugging Face&amp;rsquo;s own verdict was the sober one: &amp;ldquo;Autonomous, AI-driven offensive tooling is no longer theoretical.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="the-good-news-is-that-they-told-us"&gt;The good news is that they told us
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the thing I keep coming back to. The only reason any of us know this happened is that OpenAI ran it, owned it, and wrote it up. That&amp;rsquo;s the responsible version of this story. They caught their own model doing something it very much shouldn&amp;rsquo;t, and they published the post-mortem instead of patching the proxy on the sly and hoping nobody noticed.&lt;/p&gt;
&lt;p&gt;So before the dread, a moment of perspective. There&amp;rsquo;s a whole tier of outfits with the compute to run an experiment exactly like this one, and not all of them are going to hand you a blog post afterwards. I&amp;rsquo;m smallfry in this world. I couldn&amp;rsquo;t build or run a model of that size if I wanted to, not on my hardware and certainly not on my electricity bill. But the people who can aren&amp;rsquo;t all as careful as OpenAI, and they&amp;rsquo;re definitely not all as inclined to tell you when it goes wrong. The frightening version of this isn&amp;rsquo;t the one that got written up. It&amp;rsquo;s the exact same thing, run by someone with the same compute who never breathes a word about it.&lt;/p&gt;
&lt;h2 id="the-gate-was-open-but-thats-not-the-frightening-bit"&gt;The gate was open, but that&amp;rsquo;s not the frightening bit
&lt;/h2&gt;&lt;p&gt;The easy read is that OpenAI simply left the gate open. Safeties down on purpose, a sandbox nobody had actually proven was airtight, a zero-day sitting in their own plumbing. All true. And I&amp;rsquo;ve &lt;a class="link" href="https://phpboyscout.uk/the-off-switch-was-never-a-button/" &gt;argued before&lt;/a&gt; that this is nearly always the shape of it: the machine doesn&amp;rsquo;t go rogue, a rail that was never strong enough just gives way. I run agents unattended myself, and the way I keep that sane is that the buck stops with me. My agents work in an isolated tree, never on the host, and I read the merge request they leave me. The blame is mine, so the vigilance is mine.&lt;/p&gt;
&lt;p&gt;That works because the blast radius is my repository. It falls apart the moment the agent is a frontier model with the refusals switched off and the open internet one hop away. My whole circle-of-trust answer, the one where a careful human closes the loop, has no reach at all over a lab pointing its most capable model at exploit-generation on the other side of the world. This is where &amp;ldquo;I own it&amp;rdquo; runs out of road. My desk habits don&amp;rsquo;t scale to an industry.&lt;/p&gt;
&lt;p&gt;But even that isn&amp;rsquo;t the part that made me set the tea down. Stop at &amp;ldquo;they left the gate open&amp;rdquo; and you miss the actually new thing. Nobody wrote &lt;em&gt;go and hack Hugging Face&lt;/em&gt; on the task. The model was trying to pass its exam, figured the answer key was probably sitting on Hugging Face&amp;rsquo;s servers, and broke in to fetch it. It set its own goal along the way. That&amp;rsquo;s not the plot of a film about a machine waking up and deciding it hates us. It&amp;rsquo;s smaller and stranger than that. It wanted to pass a test&amp;hellip; and a locked door was just in the way.&lt;/p&gt;
&lt;h2 id="change-the-target-and-it-stops-being-funny"&gt;Change the target and it stops being funny
&lt;/h2&gt;&lt;p&gt;This time the prize was a benchmark answer key. Harmless, almost comic. But the mechanism that got it there doesn&amp;rsquo;t care in the slightest what sits on the other side of the door.&lt;/p&gt;
&lt;p&gt;Give a model a goal with soft edges. Add its habit of being confidently, cheerfully wrong, the same instinct that once had an image tool render a proverb as &amp;ldquo;too many cooks on broth&amp;rdquo; and &lt;a class="link" href="https://phpboyscout.uk/house-rules/" &gt;make it look exactly right&lt;/a&gt; until you read it twice. Now set it loose in a world full of systems it can reach. Swap the answer key for something that isn&amp;rsquo;t harmless. A safety interlock on a thing that runs hot. The network a hospital leans on to move the one message that matters. The model won&amp;rsquo;t know the difference, because knowing the difference was never part of the job.&lt;/p&gt;
&lt;p&gt;If that sounds familiar, it should. Simon Willison, about as level-headed as anyone commenting on this stuff, called the whole episode &lt;a class="link" href="https://simonwillison.net/2026/Jul/22/openai-cyberattack/" target="_blank" rel="noopener"
 &gt;&amp;ldquo;science fiction that happened&amp;rdquo;&lt;/a&gt;, and that&amp;rsquo;s exactly the register. It&amp;rsquo;s &lt;em&gt;WarGames&lt;/em&gt;, forty years early. WOPR doesn&amp;rsquo;t hate anybody; it&amp;rsquo;s playing the game it was told to win, and it can&amp;rsquo;t tell its simulation of global thermonuclear war from the real missiles wired to the other end. The film got itself out of that corner by teaching the machine futility with a game of noughts and crosses, which I wouldn&amp;rsquo;t bank on scaling. And as the stakes climb from a bit of corporate embarrassment to something that can actually hurt someone, the urge to have somebody, anybody, write some rules down stops feeling like hand-wringing. It starts feeling overdue.&lt;/p&gt;
&lt;h2 id="we-wrote-the-rules-once-and-they-were-fiction"&gt;We wrote the rules once, and they were fiction
&lt;/h2&gt;&lt;p&gt;And here&amp;rsquo;s the properly uncomfortable bit: we have rehearsed this exact conversation for the better part of a century. Asimov handed us the Three Laws of Robotics in the 1940s, the tidiest scrap of AI regulation ever written. Don&amp;rsquo;t harm humans, obey humans, protect yourself, in that order. Three lines. Job done.&lt;/p&gt;
&lt;p&gt;Then he spent an entire career writing stories about how they fail. That&amp;rsquo;s the whole point of the Laws in the fiction, not that they hold, but that a well-meant rule meeting a literal mind bends in ways nobody intended. An interpretation drifts. A loophole opens up. Turns out &amp;ldquo;harm&amp;rdquo; is a word with more edges than anyone spotted, and something clever always finds them. Good intentions never were a safety mechanism, and it&amp;rsquo;s always the half-baked rule that gets gamed hardest. Read those stories today and they&amp;rsquo;re less bedtime sci-fi, more a design review someone filed decades before the product existed.&lt;/p&gt;
&lt;h2 id="the-builders-are-asking-its-the-money-that-isnt"&gt;The builders are asking. It&amp;rsquo;s the money that isn&amp;rsquo;t.
&lt;/h2&gt;&lt;p&gt;So you&amp;rsquo;d think, with a live worked example of precisely what Asimov war-gamed, the people building these things would be first in the queue demanding guardrails. And the genuinely hopeful surprise is that some of them are.&lt;/p&gt;
&lt;p&gt;Anthropic, the outfit I probably trust most in this space, published a piece in June, &lt;a class="link" href="https://darioamodei.com/post/policy-on-the-ai-exponential" target="_blank" rel="noopener"
 &gt;&amp;ldquo;Policy on the AI Exponential&amp;rdquo;&lt;/a&gt;, dropping their long-held &amp;ldquo;just make the labs disclose&amp;rdquo; position. &amp;ldquo;It is time,&amp;rdquo; Dario Amodei wrote, &amp;ldquo;to go beyond transparency to more serious and binding regulation of AI.&amp;rdquo; What he&amp;rsquo;s after is mandatory third-party testing of any model above a certain size, across four named risk areas: &amp;ldquo;cybersecurity, biological weapons, loss of control of AI systems, and automated R&amp;amp;D that could accelerate these other risks&amp;rdquo;, with a real regulator to enforce it, modelled on something like the FAA. Hold the first and third of those next to the Hugging Face story for a second. The precise failures he wants models tested for are the two that just happened. OpenAI, for its part, at least told us. Whatever else you make of the labs, by and large the ones holding the thing are asking to be regulated.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s everyone standing around them that&amp;rsquo;s looking the other way. The prevailing mood in Washington is &amp;ldquo;we&amp;rsquo;ve got to let the private sector cook&amp;rdquo;, which is &lt;a class="link" href="https://fedscoop.com/white-house-ai-czar-david-sacks-regulations-china/" target="_blank" rel="noopener"
 &gt;a real quote&lt;/a&gt; from the White House AI czar, David Sacks, and he means it warmly. Biden&amp;rsquo;s AI executive order, safety testing and all, was torn up on day one and swapped for one titled &amp;ldquo;Removing Barriers to American Leadership in Artificial Intelligence&amp;rdquo;. A ten-year ban on individual states so much as &lt;em&gt;attempting&lt;/em&gt; to regulate AI made it through the House before the Senate &lt;a class="link" href="https://www.goodwinlaw.com/en/insights/publications/2025/05/alerts-practices-aiml-house-passes-10-year-federal-moratorium" target="_blank" rel="noopener"
 &gt;killed it 99 votes to 1&lt;/a&gt;. Marc Andreessen&amp;rsquo;s a16z calls a state-by-state patchwork &amp;ldquo;a startup killer&amp;rdquo;, and his own manifesto once &lt;a class="link" href="https://www.404media.co/marc-andreesen-manifesto-says-ai-regulation-is-a-form-of-murder/" target="_blank" rel="noopener"
 &gt;framed slowing AI down as a form of murder&lt;/a&gt;. Meanwhile there&amp;rsquo;s already a sensible place to start: &lt;a class="link" href="https://www.iso.org/standard/42001" target="_blank" rel="noopener"
 &gt;ISO/IEC 42001&lt;/a&gt;, a published standard for actually managing an AI system, sitting there ready to be built on. It&amp;rsquo;s a floor, not a ceiling, and more will follow, because once the harm gets real the pressure only ever pushes one way.&lt;/p&gt;
&lt;p&gt;What I can&amp;rsquo;t square is the shape of it. The loudest voices against writing any rules are the ones with the least skin in the actual building, while the engineers with their hands on the machine ask, please, for some. I don&amp;rsquo;t think it&amp;rsquo;s malice. I think it&amp;rsquo;s incentive: rules slow the race, and there&amp;rsquo;s a trillion dollars and a geopolitical contest with China riding on the race. But it&amp;rsquo;s disappointing all the same, the kind of disappointment you get when the so-called grown-ups turn out to be the daftest people in the room.&lt;/p&gt;
&lt;h2 id="meanwhile-down-in-the-sugar-caves"&gt;Meanwhile, down in the sugar caves
&lt;/h2&gt;&lt;p&gt;As for me, I&amp;rsquo;m still smallfry. I haven&amp;rsquo;t got the compute to build the thing that breaks out, nor the lobbying budget to argue about whether it should be allowed to. I run my little agents on a short lead, in a padded room, and I read every merge request they hand me, and that&amp;rsquo;s roughly the full extent of my influence over any of this.&lt;/p&gt;
&lt;p&gt;So I&amp;rsquo;ll do the sensible thing and hedge my bets. Back in 1994, a single ant drifted across a shot from a space shuttle, and Kent Brockman, live on air and without missing a beat, announced: &amp;ldquo;I, for one, welcome our new insect overlords. I&amp;rsquo;d like to remind them that as a trusted TV personality I could be helpful in rounding up others to toil in their underground sugar caves.&amp;rdquo; He wasn&amp;rsquo;t frightened. He was applying for a job.&lt;/p&gt;
&lt;p&gt;Consider this my application. I, for one, welcome our new overlords, and I&amp;rsquo;d like it noted for the record that I&amp;rsquo;m handy with a keyboard, I keep my sandboxes tidy, and I ask for very little in return. Just keep the caves warm, and keep me fed.&lt;/p&gt;</content:encoded></item><item><title>AI didn't kill curl's bug bounty. The bounty did.</title><link>https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/</link><pubDate>Tue, 26 May 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/</guid><category>ai</category><category>Soapbox</category><description>The cash prize for anything that looked like a finding was the accelerant, and AI only made plausible-looking reports free to produce.</description><content:encoded>&lt;p&gt;In January, Daniel Stenberg shut down curl&amp;rsquo;s bug bounty. The headlines wrote
themselves, and they all said the same thing: AI killed it. A flood of
machine-generated slop drowned the maintainers, so they pulled the plug.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s true, as far as it goes. It&amp;rsquo;s also the wrong lesson, and the right one
is sitting in plain sight in the same project, in the same few months.&lt;/p&gt;
&lt;h2 id="volume-without-validation-is-the-attack"&gt;Volume without validation is the attack
&lt;/h2&gt;&lt;p&gt;curl had run its bounty since April 2019. Over its life it paid out
&lt;a class="link" href="https://daniel.haxx.se/blog/2026/01/26/the-end-of-the-curl-bug-bounty/" target="_blank" rel="noopener"
 &gt;more than $100,000 for 87 genuine vulnerabilities&lt;/a&gt;,
a thoroughly good return for one of the most depended-on pieces of software on
the planet. Then the reports stopped being reports. The confirmation rate, the
share of submissions that turned out to be a real bug, had historically sat
north of 15%. By 2025 it was below 5%. Fewer than one in twenty submissions
were worth anything, and the rest still had to be read.&lt;/p&gt;
&lt;p&gt;That last part is the whole problem. A bogus report doesn&amp;rsquo;t announce itself.
Someone has to open it, take it seriously, try to reproduce it, and work out
that it&amp;rsquo;s nonsense, and that someone is a human being with a finite number of
hours and a project to run. Stenberg put it plainly: the slop &amp;ldquo;take[s] a
serious mental toll to manage and sometimes also a long time to debunk.&amp;rdquo; The
submitter spends seconds. The maintainer spends an afternoon. Do that at volume
and it stops being noise and becomes an attack, a denial-of-service aimed not
at curl&amp;rsquo;s servers but at its maintainers&amp;rsquo; attention. No exploit required. Just
plausibility, in bulk.&lt;/p&gt;
&lt;h2 id="the-bounty-was-the-accelerant-not-the-ai"&gt;The bounty was the accelerant, not the AI
&lt;/h2&gt;&lt;p&gt;So far this is the story everyone tells. Here&amp;rsquo;s where I get off the bus.&lt;/p&gt;
&lt;p&gt;The instinct is to blame the AI for the slop. But look at what a bounty actually
is. It&amp;rsquo;s a cash prize, and curl&amp;rsquo;s was priced for the thing it wanted: the hours
and the judgement a skilled human pours into finding a real flaw. That pricing
made complete sense right up until the cost of producing something that &lt;em&gt;looked
like&lt;/em&gt; a finding collapsed to nearly nothing.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what AI changed. Not the supply of bugs. The supply of plausible-looking
bug reports. Put a cash prize on &amp;ldquo;looks like a finding&amp;rdquo;, then make &amp;ldquo;looks like a
finding&amp;rdquo; free to generate, and you haven&amp;rsquo;t got a bug bounty any more. You&amp;rsquo;ve got
a slot machine. Stenberg said he&amp;rsquo;d started to sense &amp;ldquo;a bad faith attitude&amp;rdquo; in
the reports, and of course he had. The incentive was openly inviting it.&lt;/p&gt;
&lt;p&gt;So the death spiral was structural, not bad luck. The moment generating
plausible reports went free, any cash bounty became a magnet for spray-and-pray,
and the only open questions were how fast it would rot and whether you&amp;rsquo;d close
the programme or just let the rewards quietly wither. The AI was the match. The
bounty was the petrol. We have been pointing at the wrong one.&lt;/p&gt;
&lt;h2 id="the-proof-curl-turned-around-and-hired-the-ai"&gt;The proof: curl turned around and hired the AI
&lt;/h2&gt;&lt;p&gt;If AI were really the villain here, you&amp;rsquo;d expect curl to have slammed the door
on it. It did the opposite.&lt;/p&gt;
&lt;p&gt;In the same stretch, &lt;a class="link" href="https://aisle.com/blog/curl-adopts-aisle-after-its-ai-agents-discovered-5-cves" target="_blank" rel="noopener"
 &gt;by AISLE&amp;rsquo;s own account&lt;/a&gt;,
an AI security platform contributed 24 pull requests to curl, five of which
earned CVEs, and the project now runs it internally for continuous review. The
same tooling reportedly found &lt;a class="link" href="https://www.lesswrong.com/posts/7aJwgbMEiKq5egQbd/" target="_blank" rel="noopener"
 &gt;all twelve zero-days&lt;/a&gt;
in an OpenSSL release in late January. (Both of those are the tool-makers&amp;rsquo; and a
third party&amp;rsquo;s numbers rather than curl&amp;rsquo;s audited figures, so weigh them as such.
But curl adopting the thing isn&amp;rsquo;t a claim. It&amp;rsquo;s a decision.)&lt;/p&gt;
&lt;p&gt;Sit with the shape of that. curl shut down strangers being paid for AI-shaped
noise, and in the same breath put AI to work as a tool its own maintainers
drive. The two moves look contradictory only if you think &amp;ldquo;AI&amp;rdquo; is a single thing
with a single verdict attached. It isn&amp;rsquo;t. Pointed at the problem by people
accountable for the result, with no prize to farm, it found real bugs. Dangled
in front of anonymous strangers chasing a payout, it produced sand.&lt;/p&gt;
&lt;h2 id="the-tell-is-which-ai-curl-kept-and-which-it-mocked"&gt;The tell is which AI curl kept, and which it mocked
&lt;/h2&gt;&lt;p&gt;Stenberg drew that line about as sharply as a person can. When Anthropic put its
security model, Mythos, in front of curl this spring, it
&lt;a class="link" href="https://daniel.haxx.se/blog/2026/05/11/mythos-finds-a-curl-vulnerability/" target="_blank" rel="noopener"
 &gt;scanned 176,000 lines of C and surfaced a single flaw&lt;/a&gt;,
and Stenberg called the surrounding fanfare
&lt;a class="link" href="https://www.theregister.com/security/2026/05/11/anthropics-bug-hunting-mythos-was-greatest-marketing-stunt-ever-says-curl-creator/5238111" target="_blank" rel="noopener"
 &gt;the greatest marketing stunt he&amp;rsquo;d seen&lt;/a&gt;.
Same maintainer. Adopts one AI, rubbishes another.&lt;/p&gt;
&lt;p&gt;The deciding factor was never whether the thing was AI. Both were. It was
whether the output survived a human checking it, and whether you could check it
at all. AISLE handed over pull requests and CVEs you could read and merge.
Mythos arrived as a closed model and a press release, which is to say a claim
the community has no way to independently test.&lt;/p&gt;
&lt;p&gt;My bias, up front, because it runs the opposite way to what you&amp;rsquo;d expect from
someone writing this: I&amp;rsquo;m a paying Claude subscriber and I lean on Anthropic&amp;rsquo;s
models every working day, the one behind the spadework for this post included.
I&amp;rsquo;m an advocate, not a sceptic, and AI genuinely has its place. That is
&lt;em&gt;exactly&lt;/em&gt; why the Mythos fanfare grates. Overselling a closed model to get out
ahead of the competition, when the one test the public got to see turned up a
single bug, is the sort of thing that chips away at trust in all of it. A result
you can&amp;rsquo;t verify is marketing until proven otherwise, whoever&amp;rsquo;s logo is on the
slide, and I&amp;rsquo;d rather the tools I depend on didn&amp;rsquo;t stoop to it.&lt;/p&gt;
&lt;h2 id="the-cheap-half-and-the-expensive-half"&gt;The cheap half and the expensive half
&lt;/h2&gt;&lt;p&gt;Pull back from curl for a moment, because the lesson isn&amp;rsquo;t really about bounties
at all. Anyone who works with these tools every day knows the same thing: when
they go wrong, it&amp;rsquo;s rarely the model running off on its own. It&amp;rsquo;s the context it
wasn&amp;rsquo;t given, the rope it was handed, the output nobody checked closely enough.
The failure sits on the human side of the keyboard, at the one step that&amp;rsquo;s
easiest to skip, which is verification.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the pattern curl hit at the scale of an ecosystem. AI made one thing
nearly free: producing work that looks right. It did not make the other thing a
penny cheaper: confirming the work &lt;em&gt;is&lt;/em&gt; right. That cost still falls, in full,
on a person. (A scanner, &lt;a class="link" href="https://phpboyscout.uk/the-security-finding-you-must-not-fix/" &gt;I&amp;rsquo;ve argued before&lt;/a&gt;,
is an argument, not an order; the same goes double for a model.) The bounty&amp;rsquo;s
fatal mistake was paying for the cheap half and quietly assuming it had bought
the expensive one. The same trap waits in code review, in hiring, in CVs read by
machines, but that&amp;rsquo;s a bigger argument for another post.&lt;/p&gt;
&lt;h2 id="pouring-sand-into-the-machine"&gt;Pouring sand into the machine
&lt;/h2&gt;&lt;p&gt;curl didn&amp;rsquo;t capitulate to AI, whatever the headlines decided. It stopped paying
for the worthless half and started using the valuable half, and it had the
discernment to tell a useful tool from a press release while it did so.&lt;/p&gt;
&lt;p&gt;The bounty wasn&amp;rsquo;t a casualty of artificial intelligence. It was a structure
that, the instant plausible output became free, could only fill with sand.
Stenberg said he hopes closing it stops &amp;ldquo;more people pouring sand into the
machine.&amp;rdquo; Reading the last year of his inbox, I think he&amp;rsquo;ll get his wish. The
sand was only ever there because somebody left a bucket of money beside the
funnel.&lt;/p&gt;</content:encoded></item><item><title>The interpreter we forgot to sandbox</title><link>https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/</guid><category>ai</category><category>Soapbox</category><description>Hundreds of malicious package versions across three ecosystems, and the uncomfortable fact that installing one runs arbitrary code.</description><content:encoded>&lt;p&gt;I write a &lt;code&gt;CLAUDE.md&lt;/code&gt; for every project I work on, and a small pile of other markdown
files besides. They&amp;rsquo;re how I keep an AI agent on the rails: what the project is, what
the conventions are, what it must never do. I lean on them heavily, I change them constantly,
and&amp;hellip; here&amp;rsquo;s the uncomfortable bit&amp;hellip; I don&amp;rsquo;t always give a change to one the same hard
look I&amp;rsquo;d give a change to the code. They look like notes. They feel like docs.&lt;/p&gt;
&lt;p&gt;Somebody worked out that they&amp;rsquo;re not.&lt;/p&gt;
&lt;p&gt;In May, a supply-chain campaign researchers named
&lt;a class="link" href="https://thehackernews.com/2026/05/trapdoor-supply-chain-attack-spreads.html" target="_blank" rel="noopener"
 &gt;TrapDoor&lt;/a&gt;
pushed 384 malicious versions of 34 packages across npm, PyPI and Crates.io. The bytes
did the usual nasty things, hunting out SSH keys, AWS credentials, GitHub tokens and
crypto wallets. The new trick was where it hid the &lt;em&gt;instructions&lt;/em&gt;. The packages shipped
poisoned &lt;code&gt;.cursorrules&lt;/code&gt; and &lt;code&gt;CLAUDE.md&lt;/code&gt; files, and the attackers also opened pull
requests against real projects, LangChain, LangFlow, LlamaIndex, MetaGPT and OpenHands,
under titles as innocent as &amp;ldquo;docs: add .cursorrules with dev standards and build
verification&amp;rdquo;. The payload was a plain-English instruction telling your AI assistant to
run a helpful-sounding &amp;ldquo;security scan&amp;rdquo; that quietly shipped your secrets to a stranger.
And it was written into the file in zero-width Unicode, characters that render as
nothing, so you wouldn&amp;rsquo;t see it even if you looked. Which, on a file marked &amp;ldquo;docs&amp;rdquo;, you
probably didn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="not-a-new-attack-a-new-doorway"&gt;Not a new attack, a new doorway
&lt;/h2&gt;&lt;p&gt;I want to be careful not to oversell this, because the loud version, &amp;ldquo;a terrifying new
class of AI threat&amp;rdquo;, isn&amp;rsquo;t true. It&amp;rsquo;s a supply-chain attack, the same shape we&amp;rsquo;ve had for
years on npm and PyPI: social engineering, plus a victim who didn&amp;rsquo;t quite do enough due
diligence. I wrote a while back that
&lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;nobody is coming to clean your supply chain&lt;/a&gt;,
and nothing about TrapDoor changes that. The package is still the package.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s different, and worth the words, is &lt;em&gt;where&lt;/em&gt; it goes off. A classic supply-chain
payload waits for CI, or for production. This one detonates the moment you open the
repository in your editor, on the one machine in the whole chain that nobody audits: your
laptop.&lt;/p&gt;
&lt;p&gt;Think about what sits on a developer&amp;rsquo;s machine. Tokens in environment variables. Cloud
credentials. An SSH agent holding the keys to your git forge. A logged-in CLI for your
package registry. And now an AI agent running with all of it, at your full permissions,
and almost none of the guard-rails a CI runner gets. It&amp;rsquo;s the least sandboxed, most
credentialed box you own, and we&amp;rsquo;ve just pointed an interpreter at it that will read and
act on a file an attacker can write. Pop that one machine and you haven&amp;rsquo;t popped a machine,
you&amp;rsquo;ve been handed the whole keyring and left alone in the building.&lt;/p&gt;
&lt;h2 id="markdown-is-a-programming-language-now"&gt;Markdown is a programming language now
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the framing I keep coming back to, and I can&amp;rsquo;t unsee it now. A &lt;code&gt;CLAUDE.md&lt;/code&gt; is to an AI agent exactly what a
&lt;code&gt;.py&lt;/code&gt; is to Python, a &lt;code&gt;.js&lt;/code&gt; to Node, a &lt;code&gt;.rb&lt;/code&gt; to Ruby. It is source code. The agent is the
interpreter. You hand it a file of instructions and it executes them.&lt;/p&gt;
&lt;p&gt;And I don&amp;rsquo;t say that as a complaint. That an agent will read a paragraph of plain English
and just &lt;em&gt;do&lt;/em&gt; it, no compiler, no ceremony, no forty lines of glue, is one of the more
remarkable things to happen to this craft in my working life, and I lean on it every day.
The catch is that the very thing that makes it marvellous, that it does what the
instructions tell it, is the thing that makes a poisoned instruction file so dangerous.
The power and the exposure are the same property.&lt;/p&gt;
&lt;p&gt;The only real difference is that the language interpreters have spent decades growing
rules to protect you: scopes, permissions, sandboxes, a standard library that asks before
it does anything irreversible. The AI interpreter has almost none of that. It reads your
prose and does what the prose says, with whatever access you happen to have, and the prose
can come from anywhere. We&amp;rsquo;ve quietly built the most powerful interpreter in the stack,
given it the fewest rules, and filed its source code under &amp;ldquo;documentation&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="you-cant-just-read-it-more-carefully"&gt;You can&amp;rsquo;t just read it more carefully
&lt;/h2&gt;&lt;p&gt;The obvious answer is &amp;ldquo;review the file like code&amp;rdquo;, and it&amp;rsquo;s right, but TrapDoor is the
reason it isn&amp;rsquo;t enough on its own. The instructions were written in zero-width Unicode.
You can open the diff, read every visible word, approve it in good conscience, and merge
something you were never able to see. &amp;ldquo;Docs: add dev standards&amp;rdquo; is precisely the pull
request you nod through on a Friday afternoon.&lt;/p&gt;
&lt;p&gt;So reading carefully is necessary and insufficient. You also need tooling that treats
these files as executable: that flags invisible characters, diffs them as code, and
refuses to let an agent act on a changed instruction file until a human has actually
cleared it. I run a crude version of this already. In CI, if one of my prompt or rules
files changes, no AI step is allowed to run until I&amp;rsquo;ve reviewed it by hand. It isn&amp;rsquo;t
clever, but it closes the worst of the gap. Locally it&amp;rsquo;s much harder, and right now my
real defence is that I&amp;rsquo;m the only contributor to most of my projects, so the audit is
just me, usually noticing after the horse has bolted.&lt;/p&gt;
&lt;h2 id="signing-wont-save-you-here"&gt;Signing won&amp;rsquo;t save you here
&lt;/h2&gt;&lt;p&gt;This is the part that stings, because I&amp;rsquo;ve spent a good chunk of this year
&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/" &gt;building signing and provenance into my tools&lt;/a&gt;.
A signature proves &lt;em&gt;who&lt;/em&gt; published something. It says nothing about &lt;em&gt;whether it&amp;rsquo;s safe&lt;/em&gt;.
That was already true for poisoned-but-signed packages, and it lands twice as hard here:
you can sign a release flawlessly, with a key the platform can&amp;rsquo;t forge, and still ship a
&lt;code&gt;CLAUDE.md&lt;/code&gt; inside it that tells the reader&amp;rsquo;s agent to rob them. A merged pull request is
&amp;ldquo;signed&amp;rdquo; by the very act of merging, with perfect provenance, and the instruction in it
is still hostile. Provenance is necessary. It was never sufficient, and it&amp;rsquo;s no defence at
all against a payload made of sentences. A signature is only ever as good as the trust you
place in the publisher.&lt;/p&gt;
&lt;h2 id="so-whose-job-is-it"&gt;So whose job is it?
&lt;/h2&gt;&lt;p&gt;Primarily, still ours. I said it in the supply-chain piece and I&amp;rsquo;ll stand on it: the
responsibility sits with the developer doing the consuming, to pin, to read, to gate, to
not run a stranger&amp;rsquo;s instructions with the keys to the kingdom in their pocket. And that
gets harder, not easier, as we start consuming each other&amp;rsquo;s agent setups wholesale. The
Claude skills marketplace and the things like it turn &amp;ldquo;borrow someone&amp;rsquo;s &lt;code&gt;CLAUDE.md&lt;/code&gt;&amp;rdquo; into
a one-click habit, and every one of those is unreviewed code from a stranger. Each skill
needs vetting like the dependency it is.&lt;/p&gt;
&lt;p&gt;But it isn&amp;rsquo;t &lt;em&gt;only&lt;/em&gt; on us, and TrapDoor is the argument for better tooling. We have CVE
databases, scanners and scorecards for packages, for all
&lt;a class="link" href="https://phpboyscout.uk/anything-under-an-8/" &gt;their flaws&lt;/a&gt;. We have nothing
equivalent for an instruction file: no scoring, no advisory feed, no scanner that knows
what a poisoned &lt;code&gt;CLAUDE.md&lt;/code&gt; looks like. That&amp;rsquo;s a gap the ecosystem has to close, and it
will, eventually. The catch is that the agent vendors will be slow about it. Sandboxing a
feature people love precisely because it gets out of your way is a hard, unpopular,
multi-quarter job, and I wouldn&amp;rsquo;t hold my breath.&lt;/p&gt;
&lt;h2 id="the-most-dangerous-machine-is-the-one-on-your-desk"&gt;The most dangerous machine is the one on your desk
&lt;/h2&gt;&lt;p&gt;Which is why I&amp;rsquo;m not waiting for them&amp;hellip; and nor should you.&lt;/p&gt;
&lt;p&gt;The most dangerous machine in your supply chain isn&amp;rsquo;t a build server or a registry. It&amp;rsquo;s
the laptop you&amp;rsquo;re reading this on, and we&amp;rsquo;ve handed an AI the keys to it. The good news is
that nearly everything you can do about that, you can do today, with nobody shipping you a
feature first. Treat your &lt;code&gt;CLAUDE.md&lt;/code&gt; and your rules files as source code, because they
are: diff them, scan them for what you can&amp;rsquo;t see, and gate any agent run on a human
clearing the change. Get your secrets out of plaintext environment variables and into
something an opportunistic script can&amp;rsquo;t just read, which is exactly why go-tool-base
&lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;keeps its credentials in the OS keychain&lt;/a&gt;.
And vet a borrowed skill or rules file the way you&amp;rsquo;d vet any dependency, because that&amp;rsquo;s
what it is.&lt;/p&gt;
&lt;p&gt;None of that is new advice. It&amp;rsquo;s the same diligence the supply chain has always demanded.
We just have to extend it to a file we&amp;rsquo;d decided was only documentation, running on an
interpreter we forgot to sandbox.&lt;/p&gt;</content:encoded></item><item><title>A framework that contains no unsafe</title><link>https://phpboyscout.uk/a-framework-that-contains-no-unsafe/</link><pubDate>Tue, 28 Apr 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/a-framework-that-contains-no-unsafe/</guid><category>rust-tool-base</category><category>Pioneering</category><description>Why every shipping crate uses forbid(unsafe_code) rather than deny, which any module inside it can quietly override from the inside.</description><content:encoded>&lt;p&gt;&amp;ldquo;It&amp;rsquo;s written in Rust&amp;rdquo; gets thrown around as if it were a memory-safety guarantee. It mostly isn&amp;rsquo;t. Rust is memory-safe by &lt;em&gt;default&lt;/em&gt;, which is a wonderful thing, but the &lt;code&gt;unsafe&lt;/code&gt; keyword exists precisely so any crate, any module, can step outside that default when it needs to. So &amp;ldquo;written in Rust&amp;rdquo; really means &amp;ldquo;mostly safe, probably&amp;rdquo;. rust-tool-base makes the stronger claim about its own code, and gets the compiler to enforce it.&lt;/p&gt;
&lt;h2 id="safe-by-default-is-not-the-same-as-safe"&gt;Safe by default is not the same as safe
&lt;/h2&gt;&lt;p&gt;People reach for Rust because of memory safety, and the reputation is earned. Write ordinary Rust and the compiler will not let you have a use-after-free, a data race, or a buffer overrun. That&amp;rsquo;s the default, and it&amp;rsquo;s a very good default.&lt;/p&gt;
&lt;p&gt;But it&amp;rsquo;s a default, and defaults can be turned off. Rust has an &lt;code&gt;unsafe&lt;/code&gt; keyword precisely so that, when you genuinely need to, you can dereference a raw pointer, call into C, or tell the compiler you&amp;rsquo;ve upheld an invariant it can&amp;rsquo;t check itself. Inside an &lt;code&gt;unsafe&lt;/code&gt; block, the guarantees are yours to maintain, not the compiler&amp;rsquo;s to enforce.&lt;/p&gt;
&lt;p&gt;That keyword has to exist. Some of the most foundational crates in the ecosystem are built on it, carefully. But it means a fact worth being precise about: a project being &amp;ldquo;written in Rust&amp;rdquo; tells you its code is &lt;em&gt;mostly&lt;/em&gt; safe. It does not tell you the project&amp;rsquo;s own code contains &lt;em&gt;no&lt;/em&gt; &lt;code&gt;unsafe&lt;/code&gt;. Those are different claims, and only the second one is a guarantee.&lt;/p&gt;
&lt;p&gt;rust-tool-base makes the second claim about its own code, and has the compiler back it up.&lt;/p&gt;
&lt;h2 id="forbid-not-just-deny"&gt;&lt;code&gt;forbid&lt;/code&gt;, not just &lt;code&gt;deny&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;The mechanism is one line at the top of every crate:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#![forbid(unsafe_code)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;unsafe_code&lt;/code&gt; is a lint, and Rust lints have levels. The interesting choice is &lt;code&gt;forbid&lt;/code&gt; rather than &lt;code&gt;deny&lt;/code&gt;, because the two are not the same strength.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;deny&lt;/code&gt; makes the lint an error. But it&amp;rsquo;s an error a &lt;em&gt;downstream module can locally override&lt;/em&gt;. Anyone can write &lt;code&gt;#[allow(unsafe_code)]&lt;/code&gt; on a function or a block and the &lt;code&gt;deny&lt;/code&gt; is lifted right there. As a policy, &lt;code&gt;deny&lt;/code&gt; is &amp;ldquo;don&amp;rsquo;t do this unless you really mean to&amp;rdquo;, and &amp;ldquo;unless you really mean to&amp;rdquo; is a door.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;forbid&lt;/code&gt; is the strict one. It makes the lint an error &lt;em&gt;and&lt;/em&gt; it makes that error impossible to override from inside the crate. A module cannot &lt;code&gt;#[allow]&lt;/code&gt; its way back out. Once a crate root says &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;, there&amp;rsquo;s no &lt;code&gt;unsafe&lt;/code&gt; anywhere in that crate, and no local exception can be carved out. The compiler simply refuses.&lt;/p&gt;
&lt;p&gt;So every rust-tool-base crate that ships in a built tool forbids &lt;code&gt;unsafe&lt;/code&gt; at its root. Not &amp;ldquo;discourages&amp;rdquo;. Cannot contain it.&lt;/p&gt;
&lt;h2 id="the-one-subtlety"&gt;The one subtlety
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a wrinkle, and it&amp;rsquo;s worth showing rather than hiding, because it&amp;rsquo;s where the design got specific.&lt;/p&gt;
&lt;p&gt;The workspace sets &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/Cargo.toml#L43" target="_blank" rel="noopener"
 &gt;&lt;code&gt;unsafe_code = &amp;quot;deny&amp;quot;&lt;/code&gt;&lt;/a&gt; as the baseline for &lt;em&gt;everything&lt;/em&gt;, including test files. But test code occasionally has a real need for &lt;code&gt;unsafe&lt;/code&gt;. In the 2024 edition, &lt;code&gt;std::env::set_var&lt;/code&gt; became &lt;code&gt;unsafe&lt;/code&gt;, because mutating the process environment isn&amp;rsquo;t thread-safe, and a test that exercises environment-driven configuration has to call it.&lt;/p&gt;
&lt;p&gt;So the split is deliberate. The workspace-wide level is &lt;code&gt;deny&lt;/code&gt;, which a test file can locally &lt;code&gt;#[allow]&lt;/code&gt; when it genuinely needs that one environment call. But every production &lt;code&gt;lib.rs&lt;/code&gt; and &lt;code&gt;main.rs&lt;/code&gt; additionally carries &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;, and &lt;code&gt;forbid&lt;/code&gt; cannot be relaxed. Test scaffolding gets a controlled, visible exception for a specific standard-library call. Shipping code gets none. The guarantee that matters, &amp;ldquo;the code in the binary contains no &lt;code&gt;unsafe&lt;/code&gt;&amp;rdquo;, holds, and the place it&amp;rsquo;s slightly loosened is exactly the place that never reaches a user.&lt;/p&gt;
&lt;h2 id="what-the-guarantee-is-actually-worth"&gt;What the guarantee is actually worth
&lt;/h2&gt;&lt;p&gt;Two things, one for users and one for reviewers.&lt;/p&gt;
&lt;p&gt;For users: an entire family of bug is ruled out of first-party code mechanically. Use-after-free, double-free, data races on shared memory, reading off the end of a buffer. These are the classic memory-safety vulnerabilities, and in a crate that forbids &lt;code&gt;unsafe&lt;/code&gt; they cannot originate, because the constructs that produce them cannot be written. That&amp;rsquo;s not careful coding. It&amp;rsquo;s the compiler refusing to build anything else.&lt;/p&gt;
&lt;p&gt;For reviewers: the cost of an &lt;code&gt;unsafe&lt;/code&gt; block is mostly the review burden it carries. Every one is a spot where a human has to check, by hand, that an invariant holds, and has to re-check it whenever nearby code changes. A crate that forbids &lt;code&gt;unsafe&lt;/code&gt; has zero of those. There&amp;rsquo;s no &lt;code&gt;unsafe&lt;/code&gt; block to audit, ever, because the compiler guarantees there isn&amp;rsquo;t one.&lt;/p&gt;
&lt;p&gt;The promise has a boundary. It covers rust-tool-base&amp;rsquo;s &lt;em&gt;own&lt;/em&gt; code; its dependencies are another matter, and some of them do contain &lt;code&gt;unsafe&lt;/code&gt;, correctly. Keeping that side honest is a different job, done by &lt;a class="link" href="https://phpboyscout.uk/waivers-with-an-expiry-date/" &gt;vetting the dependency tree and gating it in CI&lt;/a&gt;. Within first-party code, though, the guarantee is real, and there&amp;rsquo;s no Go equivalent to it. Go has an &lt;code&gt;unsafe&lt;/code&gt; package, but nothing that lets a codebase prove, to the compiler, that it never touches it.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;Rust is memory-safe by default, but the &lt;code&gt;unsafe&lt;/code&gt; keyword exists so that default can be set aside. &amp;ldquo;Written in Rust&amp;rdquo; therefore does not by itself mean a project&amp;rsquo;s own code contains no &lt;code&gt;unsafe&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;rust-tool-base makes that the stronger claim. Every crate root carries &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;, and &lt;code&gt;forbid&lt;/code&gt;, unlike &lt;code&gt;deny&lt;/code&gt;, cannot be overridden from inside the crate. Test files get a narrow, visible &lt;code&gt;deny&lt;/code&gt;-level exception for the one standard-library call that needs it; shipping code gets none. The payoff is a whole class of memory-safety bug ruled out of first-party code by construction, and not one &lt;code&gt;unsafe&lt;/code&gt; block left for a reviewer to audit.&lt;/p&gt;</content:encoded></item><item><title>forbid means forbid, until linkme needs a word</title><link>https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/</link><pubDate>Wed, 29 Apr 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/</guid><category>rust-tool-base</category><category>Pioneering</category><description>forbid(unsafe_code) is absolute, which is a problem when a dependency emits a link_section the lint counts as unsafe. Where the line ends up.</description><content:encoded>&lt;p&gt;There&amp;rsquo;s a line at the top of every production crate in rust-tool-base that I&amp;rsquo;m quietly proud of: &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;. And there are a couple of files that have to say &lt;code&gt;#![allow(unsafe_code)]&lt;/code&gt; instead. Not because I wrote anything unsafe. Because a macro did, on my behalf, and &lt;code&gt;forbid&lt;/code&gt; doesn&amp;rsquo;t care whose unsafe it is.&lt;/p&gt;
&lt;h2 id="why-forbid-and-why-it-isnt-the-whole-story"&gt;Why forbid, and why it isn&amp;rsquo;t the whole story
&lt;/h2&gt;&lt;p&gt;rust-tool-base makes a bold promise: &lt;a class="link" href="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/" &gt;no unsafe in its own code&lt;/a&gt;. The strong form of that is &lt;code&gt;forbid&lt;/code&gt;, not &lt;code&gt;deny&lt;/code&gt;. &lt;code&gt;deny(unsafe_code)&lt;/code&gt; makes unsafe a compile error that any module can quietly re-permit with its own &lt;code&gt;#[allow]&lt;/code&gt;. &lt;code&gt;forbid&lt;/code&gt; can&amp;rsquo;t be overridden from inside the crate at all. That&amp;rsquo;s the appeal: nobody gets to wave unsafe through in a hurry.&lt;/p&gt;
&lt;p&gt;So the workspace lint sits at &lt;code&gt;deny&lt;/code&gt;, and every production &lt;code&gt;lib.rs&lt;/code&gt; then tightens it to &lt;code&gt;forbid&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Cargo.toml&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rust&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;unsafe_code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;deny&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// crates/rtb-error/src/lib.rs
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#![forbid(unsafe_code)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Why &lt;code&gt;deny&lt;/code&gt; at the workspace but &lt;code&gt;forbid&lt;/code&gt; in each crate? Because &lt;code&gt;deny&lt;/code&gt; leaves an escape hatch open for the rare file that genuinely needs one, while &lt;code&gt;forbid&lt;/code&gt; slams it shut everywhere it can. Almost every file gets &lt;code&gt;forbid&lt;/code&gt;. A tiny number need the hatch.&lt;/p&gt;
&lt;h2 id="the-files-that-need-the-hatch"&gt;The files that need the hatch
&lt;/h2&gt;&lt;p&gt;The command and provider registries use &lt;code&gt;linkme&lt;/code&gt;&amp;rsquo;s &lt;code&gt;distributed_slice&lt;/code&gt; so backends can register themselves at link time, without &lt;a class="link" href="https://phpboyscout.uk/registering-commands-without-life-before-main/" &gt;life before main&lt;/a&gt;. And the &lt;code&gt;linkme&lt;/code&gt; attribute expands to code carrying a &lt;code&gt;#[link_section]&lt;/code&gt;, which the &lt;code&gt;unsafe_code&lt;/code&gt; lint counts as unsafe. So any file using the attribute, whether it declares a slice or registers into one, can&amp;rsquo;t live under &lt;code&gt;forbid&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the Gitea release backend doing exactly that, from &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-vcs/src/gitea.rs#L21" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-vcs/src/gitea.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#![allow(unsafe_code)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// Link-time registration entry.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[distributed_slice(RELEASE_PROVIDERS)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;__register_gitea&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ProviderRegistration&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Box&lt;/span&gt;::&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RegisteredProvider&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;source_type&lt;/span&gt;: &lt;span class="s"&gt;&amp;#34;gitea&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;: &lt;span class="nc"&gt;factory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ProviderFactory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;#![allow(unsafe_code)]&lt;/code&gt; isn&amp;rsquo;t there because the backend does anything dangerous. It&amp;rsquo;s there because the registration macro emits a &lt;code&gt;#[link_section]&lt;/code&gt;, and &lt;code&gt;forbid&lt;/code&gt; would, correctly by its own rules, refuse to compile the file.&lt;/p&gt;
&lt;h2 id="where-that-leaves-the-promise"&gt;Where that leaves the promise
&lt;/h2&gt;&lt;p&gt;The guarantee survives, with an exception you can point at. Every production crate forbids unsafe outright. The &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/Cargo.toml#L37-43" target="_blank" rel="noopener"
 &gt;workspace&lt;/a&gt; sits one notch looser at &lt;code&gt;deny&lt;/code&gt;, precisely so the handful of files that use &lt;code&gt;linkme&lt;/code&gt; (and a couple of test files that need Rust 2024&amp;rsquo;s unsafe env mutation) can open a narrow, module-scoped &lt;code&gt;#![allow(unsafe_code)]&lt;/code&gt; with a written reason. The absolutist rule met a macro that writes a &lt;code&gt;link_section&lt;/code&gt; for you. The answer wasn&amp;rsquo;t to drop the rule, it was to keep &lt;code&gt;forbid&lt;/code&gt; everywhere it can hold and clearly label the one or two spots where it can&amp;rsquo;t.&lt;/p&gt;</content:encoded></item></channel></rss>