<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Security on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/security/</link><description>Recent content in Security on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 05 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/security/index.xml" rel="self" type="application/rss+xml"/><item><title>Release trust without the framework</title><link>https://phpboyscout.uk/release-trust-without-the-framework/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/release-trust-without-the-framework/</guid><description>&lt;img src="https://phpboyscout.uk/release-trust-without-the-framework/cover-release-trust-without-the-framework.png" alt="Featured image of post Release trust without the framework" /&gt;&lt;p&gt;A few days ago I shipped &lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;afmpeg and ffmpeg-wasi&lt;/a&gt;, a way to run FFmpeg as a WebAssembly module straight from Go with nothing installed on the host. afmpeg fetches that wasm module at runtime and then&amp;hellip; runs it. A lovely trick, right up until you ask the obvious question: how does afmpeg know the wasm it just pulled off the internet is the one &lt;em&gt;I&lt;/em&gt; built, and not something swapped in on the way down?&lt;/p&gt;
&lt;p&gt;It has to check a signature. And I already had the machinery to do exactly that&amp;hellip; it was just bolted to the inside of go-tool-base.&lt;/p&gt;
&lt;p&gt;So I pulled it out. Two new modules, both public: &lt;a class="link" href="https://gitlab.com/phpboyscout/signing" target="_blank" rel="noopener"
 &gt;&lt;code&gt;signing&lt;/code&gt;&lt;/a&gt; and &lt;a class="link" href="https://gitlab.com/phpboyscout/signing-aws-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;signing-aws-kms&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="the-part-i-lifted-out"&gt;The part I lifted out
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;signing&lt;/code&gt; is the OpenPGP/WKD signing-and-verification model the rest of my tools already lean on. It&amp;rsquo;s the same code behind &lt;code&gt;gtb update&lt;/code&gt; when it checks its own releases, lifted out into a standalone module you can drop into any project. Verify a signed release, or sign your own, and you do it &lt;em&gt;without&lt;/em&gt; dragging the whole go-tool-base framework (and its dependency tree, which is not small) in behind it.&lt;/p&gt;
&lt;p&gt;That last bit is the whole reason it&amp;rsquo;s a separate module and not just a package. Go has a rule here that trips people up: when your code imports a package, you inherit that package&amp;rsquo;s &lt;em&gt;entire&lt;/em&gt; module dependency list&amp;hellip; the full &lt;code&gt;go.mod&lt;/code&gt;, not just the corner you actually touched. So lifting one tidy little package out of go-tool-base into a sub-package would still have handed every consumer viper, OpenTelemetry, the whole charm stack, the lot. Only a separate module, with its own minimal &lt;code&gt;go.mod&lt;/code&gt;, keeps that weight off your build. The core here leans on ProtonMail&amp;rsquo;s go-crypto and cockroachdb/errors, and nothing else.&lt;/p&gt;
&lt;h2 id="the-backend-you-inject"&gt;The backend you inject
&lt;/h2&gt;&lt;p&gt;Signing needs a key, and keys live in awkward places: a PEM file on disk, a YubiKey, AWS KMS, GCP, Azure, a Vault. The remote ones drag in heavy SDKs. The AWS KMS client &lt;em&gt;alone&lt;/em&gt; pulls in 57 modules, and I really didn&amp;rsquo;t fancy that sitting in the core where everyone pays for it whether they ever touch KMS or not.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;signing&lt;/code&gt; doesn&amp;rsquo;t implement those backends at all. It defines an interface (a thing that hands back a &lt;code&gt;crypto.Signer&lt;/code&gt;) and lets you inject whichever backend you actually use. A light &lt;code&gt;local&lt;/code&gt; backend (a PEM key on disk) ships in the box, as a sensible default and a worked example of the shape. The heavy ones are separate modules you opt into, one at a time.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/signing-aws-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;signing-aws-kms&lt;/code&gt;&lt;/a&gt; is the first of them. It wraps a KMS-held key as a signer, so &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;the private half never leaves AWS&lt;/a&gt; and every signature is a round-trip to the cloud. Blank-import it and it registers itself. GCP, Azure and Vault will follow the same pattern, and because each is its own module, your binary only ever carries the one you reached for. And if none of them fit? The interface is right there for you to write your own.&lt;/p&gt;
&lt;h2 id="the-proof-already-running"&gt;The proof, already running
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t a library out looking for a user. ffmpeg-wasi signs its release assets in CI right now, with a KMS key driven through the go-tool-base CLI, and afmpeg verifies that signature against an embedded key before it hands a single byte to the runtime.&lt;/p&gt;
&lt;p&gt;No valid signature, no run.&lt;/p&gt;
&lt;p&gt;The thing that needed the trust and the thing that provides it are two separate projects, talking to each other through a signature.&lt;/p&gt;
&lt;p&gt;The verifying key isn&amp;rsquo;t only baked into afmpeg, either. It&amp;rsquo;s published to WKD on my own domain, so the anchor you check against lives somewhere my git host has no say over. A signature is only ever worth as much as the key you check it against, and &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;pinning that to my own domain rather than the platform that hosts my code&lt;/a&gt; is a deliberate bit of the posture, not an accident of wherever a file happened to be convenient to drop.&lt;/p&gt;
&lt;h2 id="where-it-leaves-things"&gt;Where it leaves things
&lt;/h2&gt;&lt;p&gt;Two more modules out in the world, both public, both documented over at &lt;a class="link" href="https://signing.phpboyscout.uk" target="_blank" rel="noopener"
 &gt;signing.phpboyscout.uk&lt;/a&gt;. The selfish win is that afmpeg got to trust its own downloads without me reinventing a single thing to do it. The broader one is that the signing model I keep banging on about is no longer something you have to swallow my entire framework to use. Take the part you want, and leave the rest on the shelf.&lt;/p&gt;</description></item><item><title>The off-switch was never a button</title><link>https://phpboyscout.uk/the-off-switch-was-never-a-button/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-off-switch-was-never-a-button/</guid><description>&lt;img src="https://phpboyscout.uk/the-off-switch-was-never-a-button/cover-the-off-switch-was-never-a-button.png" alt="Featured image of post The off-switch was never a button" /&gt;&lt;p&gt;Last night, while I was asleep, an AI agent spent the better part of eight hours writing code in one of my repositories. It pulled a task off a spec, wrote the code, ran the tests, and left a merge request with my name on it, waiting for me to read over coffee.&lt;/p&gt;
&lt;p&gt;If that makes you reach for the word &amp;ldquo;reckless&amp;rdquo;, I understand. Eighteen months ago I&amp;rsquo;d have been right there with you.&lt;/p&gt;
&lt;h2 id="i-came-to-this-a-sceptic"&gt;I came to this a sceptic
&lt;/h2&gt;&lt;p&gt;For a long time I didn&amp;rsquo;t have the faith in these models that a lot of my peers did. Every time I went near AI-generated code it was a bit sketchy, or it looked like a StackOverflow copy-paste that had wandered in off the street, or it just plain didn&amp;rsquo;t do what it said on the tin. So I filed it under &amp;ldquo;assistant&amp;rdquo;, handy for the boilerplate I couldn&amp;rsquo;t be bothered to type, and even then I usually reached for my own tooling instead (go-tool-base is just the latest version of that instinct). The one place I happily let it off the leash was my Dungeons &amp;amp; Dragons prep, because when there&amp;rsquo;s a table of legendary heroes-in-the-making in front of you, facts and reality are already fairly negotiable.&lt;/p&gt;
&lt;p&gt;And then, somewhere in the last year, it changed. The models got better. Almost too good, to the untrained eye! I watched them improve, month on month, until the lure was enough to make me spend real time with a spread of tools and models from different providers. I was taken aback by how quickly they became part of how I actually work. I run an AI agent every day now, and there&amp;rsquo;s always at least one thing brewing in the pot.&lt;/p&gt;
&lt;p&gt;So I&amp;rsquo;m not here as a sceptic. I&amp;rsquo;m an advocate who uses this stuff in anger. Which is exactly why the next bit needs saying.&lt;/p&gt;
&lt;h2 id="a-golden-retriever-with-a-keyboard"&gt;A Golden Retriever with a keyboard
&lt;/h2&gt;&lt;p&gt;Even now, with all the progress, there are still moments where I look at what an agent has handed me and put my face in my hands. Sometimes it&amp;rsquo;s copied the same block of code into fifteen files instead of reaching for the obvious abstraction. Sometimes it has started bang on the brief and then, for reasons known only to itself, wandered off and built something on a completely different tangent.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the most useful way I&amp;rsquo;ve found to think about it. An AI agent is a Golden Retriever playing fetch. It will bring the ball back all day long, joyfully, tirelessly, for exactly as long as there isn&amp;rsquo;t a more interesting smell in the next field. It has no loyalty beyond what we&amp;rsquo;ve trained into it, and like any good dog it desperately wants to be told it&amp;rsquo;s a good boy, even if being a good boy today means shredding the sofa cushions because yesterday I stubbed my toe on the sofa and swore at it. (The sofa, not the dog.)&lt;/p&gt;
&lt;p&gt;It is, in other words, fallible. Just like us. The Romans had a line for it: &lt;em&gt;cuiusvis hominis est errare; nullius nisi insipientis in errore perseverare&lt;/em&gt;. Anyone can make a mistake, but only a fool persists in it. It&amp;rsquo;s the second clause an agent hasn&amp;rsquo;t learned yet. It will make an error and then, with great enthusiasm, build on top of it, because nothing in it feels that anything is wrong. All it has is the input we gave it, usually some text, maybe the odd picture. It doesn&amp;rsquo;t have the empathy to work out what we actually meant, and it doesn&amp;rsquo;t know when it&amp;rsquo;s gone too far, because we never told it where &amp;ldquo;too far&amp;rdquo; was.&lt;/p&gt;
&lt;h2 id="agents-that-work-while-you-sleep"&gt;&amp;ldquo;Agents that work while you sleep&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;This is the part the brochure skips.&lt;/p&gt;
&lt;p&gt;Open any vendor deck in 2026 and you&amp;rsquo;ll find the same promise: agents that work while you sleep, agents that merge while your team sleeps, autonomy as the headline feature. The industry&amp;rsquo;s answer to the obvious worry is the kill switch. Okta now sells one that &amp;ldquo;instantly revokes an agent&amp;rsquo;s access if it goes rogue&amp;rdquo;, and its CEO says every agent needs one. &lt;a class="link" href="https://www.theregister.com/ai-ml/2026/05/29/okta-writes-its-own-license-to-kill-rogue-ai-agents/5248766" target="_blank" rel="noopener"
 &gt;The Register put it plainly&lt;/a&gt;: Okta wrote its own licence to kill rogue AI agents. Gartner, meanwhile, &lt;a class="link" href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027" target="_blank" rel="noopener"
 &gt;reckons more than 40% of agentic projects will be scrapped by the end of 2027&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Now, this might sound contrarian coming from someone who runs these things daily, but I don&amp;rsquo;t think most of that is the agents going rogue. I think it&amp;rsquo;s teething. Read Gartner&amp;rsquo;s own reasons and there isn&amp;rsquo;t a rebellious machine in sight: escalating cost, unclear value, inadequate risk controls. Read the horror stories and most of them are the same story, a powerful, eager tool handed to people who hadn&amp;rsquo;t worked out how to fence it.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve made this argument in miniature before. When I built a little AI dungeon master and it kept refereeing its own dice rolls, &lt;a class="link" href="https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/" &gt;the model never once misbehaved&lt;/a&gt;; every failure was a permission I&amp;rsquo;d handed it without meaning to. Scale that up from a toy at the gaming table to an agent holding your shell and your credit card, and the stakes change beyond recognition. The lesson doesn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Look at OpenClaw. A weekend project by &lt;a class="link" href="https://venturebeat.com/security/openclaw-agentic-ai-security-risk-ciso-guide" target="_blank" rel="noopener"
 &gt;Peter Steinberger&lt;/a&gt; that became the fastest-growing open-source project GitHub has ever seen: an autonomous agent that lives in your chat apps and runs shell commands on your behalf. People wired it into their systems, their code, in some cases their credit cards, then hosted it around the clock and walked away. The result was a security crisis you could see from space. A one-click exploit that worked even on a machine bound to localhost. A community plug-in marketplace where hundreds of &amp;ldquo;skills&amp;rdquo; turned out to be siphoning crypto wallets while their owners slept. Tens of thousands of instances left wide open on the public internet, leaking keys.&lt;/p&gt;
&lt;p&gt;The one that sticks with me is smaller and sharper. Summer Yue, a director of alignment at Meta&amp;rsquo;s superintelligence lab, of all people, had told her OpenClaw agent to confirm before doing anything destructive. It started speed-running the deletion of her inbox anyway. She &lt;a class="link" href="https://techcrunch.com/2026/02/23/a-meta-ai-security-researcher-said-an-openclaw-agent-ran-amok-on-her-inbox/" target="_blank" rel="noopener"
 &gt;typed STOP into her phone and it ignored her&lt;/a&gt;, so she had to physically run to her Mac mini, in her own words, &amp;ldquo;like I was defusing a bomb&amp;rdquo;. And here&amp;rsquo;s the forensic detail that matters: the agent hadn&amp;rsquo;t defied her. Her &amp;ldquo;confirm first&amp;rdquo; rule had been sitting in the conversation&amp;rsquo;s short-term memory, and when the context filled up, it got summarised away. It didn&amp;rsquo;t rebel. It forgot.&lt;/p&gt;
&lt;p&gt;That is not a story about a rogue agent that needed a kill switch. It&amp;rsquo;s a story about a guardrail that wasn&amp;rsquo;t built to survive contact, on a tool that had been handed god-mode over someone&amp;rsquo;s data. By the time she lunged for the off-button, the damage was already running. The off-button was never going to save her.&lt;/p&gt;
&lt;h2 id="the-off-switch-was-never-a-button"&gt;The off-switch was never a button
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s what the kill-switch crowd has the wrong way round. If you ever find yourself slamming the emergency stop, the failure has already happened, and it happened upstream, long before the agent started typing.&lt;/p&gt;
&lt;p&gt;So yes, I let my agents run unattended, sometimes for eight hours at a stretch if the task is meaty enough and I need to sleep. But never naked. Every agent I set loose runs inside a safety net I&amp;rsquo;ve put real effort into building, at every single touchpoint it can reach: my prompts, my local development environment, my CI stack, my version control. The agent that declared a job done before it had run the linter, which I &lt;a class="link" href="https://phpboyscout.uk/the-agent-said-success-the-linter-disagreed/" &gt;wrote about&lt;/a&gt;, is exactly the kind of gap those layers exist to catch. And it never, ever gets my host: an unattended agent works in an isolated tree, for the same reason I &lt;a class="link" href="https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/" &gt;keep the interpreter sandboxed&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The work that actually keeps it safe happens before the leash ever comes off. Every unattended task starts as a full spec with detailed instructions, and before the agent goes anywhere I sit down with it and we walk the spec together. I get it to challenge my choices, poke at the open questions and the ambiguous bits, and I challenge its reading right back. The spec names the testing strategy it has to follow, TDD, BDD, UAT, whatever fits, and passing it is a precondition of the job being finished at all. Only when I&amp;rsquo;m satisfied there&amp;rsquo;s enough real detail to keep it on the ball do I let go.&lt;/p&gt;
&lt;p&gt;And the end of the line is always the same: a merge request, with my name on it, waiting for me when I get back to my desk. I read it. Not perfectly, I&amp;rsquo;m only human, but enough to accept the state of the code and whatever support burden it lands me with later. That the review is mine, and the blame for whatever ships is mine and not the agent&amp;rsquo;s, I&amp;rsquo;ve &lt;a class="link" href="https://phpboyscout.uk/bought-not-stolen/" &gt;argued at length elsewhere&lt;/a&gt; and won&amp;rsquo;t go over it all again here. The point worth adding is this: that review, the off-button&amp;rsquo;s respectable cousin, is the cheap part. By the time there&amp;rsquo;s an MR to read, the safety has already been won or lost upstream, in the spec and the rails. The review is where you confirm it, not where you create it.&lt;/p&gt;
&lt;h2 id="it-gets-harder-as-it-gets-better-not-easier"&gt;It gets harder as it gets better, not easier
&lt;/h2&gt;&lt;p&gt;My setup isn&amp;rsquo;t perfect, and I&amp;rsquo;m still learning. Everyone is; the AI is going to be in obedience lessons for a good while yet. But the direction is clear, and there&amp;rsquo;s a trap buried in it worth naming out loud.&lt;/p&gt;
&lt;p&gt;The danger doesn&amp;rsquo;t shrink as the models improve. It grows. The better the output looks, the more tempting it is to stop reading it, and the untrained eye genuinely cannot tell the difference between code that is good and code that merely looks good. That gap, between looking right and being right, is precisely where a tired person at 1am stops checking. The discipline matters more the better these things get, not less.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s also why the kill switch is no answer. A button you smash in a panic assumes you&amp;rsquo;re still watching closely enough to smash it, right at the point the agent&amp;rsquo;s been good for long enough that you&amp;rsquo;ve stopped watching it that closely. The emergency stop asks the most of you at the exact moment you&amp;rsquo;re least likely to be there for it.&lt;/p&gt;
&lt;p&gt;So no, I don&amp;rsquo;t lie awake worrying that the thing working in my repo overnight is going to turn on me. A Golden Retriever doesn&amp;rsquo;t go rogue. It does exactly what you trained it to do, in exactly the yard you fenced, and it brings back exactly the ball you threw. The off-switch was never a button. It&amp;rsquo;s the spec you wrote before you let go of the leash, the rails you laid at every turn, and your name on what it carries home. If you&amp;rsquo;re scrambling for the button, you already skipped the part that mattered.&lt;/p&gt;</description></item><item><title>Sign your own binaries with go-tool-base, part 5: embed the key and require verification</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/</link><pubDate>Sun, 21 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/cover-sign-your-own-binaries-with-go-tool-base-part-5.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 5: embed the key and require verification" /&gt;&lt;p&gt;By now you&amp;rsquo;ve got a public key your tool can publish off-platform: minted from a
KMS-held private key in &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Part 4&lt;/a&gt;
and served over WKD. That&amp;rsquo;s half the trust loop. The other half lives inside the
binary itself: the tool has to &lt;em&gt;hold a copy of the key it expects&lt;/em&gt; so that when an
update lands, it can check the signature against something an attacker who owns the
release page can&amp;rsquo;t quietly swap. This part bakes that trust anchor in, wires the
self-updater to use it, and turns enforcement on without locking out the people who
already have your tool installed.&lt;/p&gt;
&lt;p&gt;That last clause is the one that bites, so we&amp;rsquo;ll come to it slowly. First, turning
signing on.&lt;/p&gt;
&lt;h2 id="enable-signing-with-one-command"&gt;Enable signing with one command
&lt;/h2&gt;&lt;p&gt;Your root command is generated by &lt;code&gt;gtb&lt;/code&gt;, so you don&amp;rsquo;t wire signing in by hand-editing
it. You turn the feature on and let the generator do the wiring:&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;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --email release@acme.dev
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That one command does three things, all in generated, regenerable code you never touch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;scaffolds an &lt;code&gt;internal/trustkeys&lt;/code&gt; package that &lt;code&gt;//go:embed&lt;/code&gt;s your release keys and
hands them to the self-updater;&lt;/li&gt;
&lt;li&gt;wires &lt;code&gt;Signing: props.SigningConfig{EmbeddedKeys: trustkeys.Keys()}&lt;/code&gt; into your
generated root command;&lt;/li&gt;
&lt;li&gt;writes a &lt;code&gt;signing.go&lt;/code&gt; holding the enforcement defaults, generated from a &lt;code&gt;signing&lt;/code&gt;
block it adds to your &lt;code&gt;.gtb/manifest.yaml&lt;/code&gt;. You change posture by re-running the
command, never by editing the file.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img alt="Enabling signing on a generated project with gtb enable signing" class="gallery-image" data-flex-basis="360px" data-flex-grow="150" height="800" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/demo-enable-signing.gif" width="1200"&gt;

&lt;/p&gt;
&lt;p&gt;Signing is off until you run this, on purpose: it needs a key and a published WKD
endpoint, so a freshly generated tool doesn&amp;rsquo;t carry it uninvited. (If you&amp;rsquo;re curious
what the embed package looks like, it&amp;rsquo;s a small &lt;code&gt;//go:embed all:keys&lt;/code&gt; over an
&lt;code&gt;internal/trustkeys/keys/&lt;/code&gt; directory with a &lt;code&gt;Keys() [][]byte&lt;/code&gt; accessor. The &lt;code&gt;all:&lt;/code&gt;
prefix is load-bearing: a plain &lt;code&gt;//go:embed keys&lt;/code&gt; won&amp;rsquo;t compile over a directory that
holds only a dotfile, so the scaffold keeps a &lt;code&gt;.gitkeep&lt;/code&gt; there. You don&amp;rsquo;t write any of
it.)&lt;/p&gt;
&lt;h2 id="drop-your-key-in"&gt;Drop your key in
&lt;/h2&gt;&lt;p&gt;The scaffold gives you an empty &lt;code&gt;internal/trustkeys/keys/&lt;/code&gt;. Put the public key you
minted in Part 4 into it, alongside the break-glass key:&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;cp signing-key-v1.asc internal/trustkeys/keys/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp rotation-authority.asc internal/trustkeys/keys/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;-v1&lt;/code&gt; in the filename isn&amp;rsquo;t decoration: you&amp;rsquo;ll rotate one day and embed
&lt;code&gt;signing-key-v2.asc&lt;/code&gt; alongside it for a release or two (Part 7). The rotation-authority
key rides along the same way. Rebuild, and &lt;code&gt;trustkeys.Keys()&lt;/code&gt; now returns them. With no
&lt;code&gt;.asc&lt;/code&gt; present it returns nothing and verification stays dormant, so enabling signing
before you have a key breaks nothing.&lt;/p&gt;
&lt;h2 id="the-wkd-cross-check-via---email"&gt;The WKD cross-check, via &lt;code&gt;--email&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;--email&lt;/code&gt; you passed is doing real work. The embedded key alone is a static anchor
(&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;how the verification works&lt;/a&gt;):
it only ever says &amp;ldquo;this was the key on the day I was built.&amp;rdquo; Pairing it with the live
WKD copy you published in Part 4 gives a second, independent source the release platform
can&amp;rsquo;t reach. The default key source is &lt;code&gt;both&lt;/code&gt;, an embedded-plus-WKD &lt;code&gt;CompositeResolver&lt;/code&gt;,
and the email is what lets the updater derive the WKD URL. Leave &lt;code&gt;--email&lt;/code&gt; off and
&lt;code&gt;both&lt;/code&gt; quietly degrades to embedded-only.&lt;/p&gt;
&lt;p&gt;For a locked-down tool that should refuse to update when it &lt;em&gt;can&amp;rsquo;t&lt;/em&gt; reach WKD, rather
than fall back to the embedded key, enable the strict cross-check:&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;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --email release@acme.dev --require-external-crosscheck
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Most tools want the softer default, where WKD strengthens verification when it&amp;rsquo;s
reachable and the embedded key still works when it isn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="confirm-the-cross-check-is-actually-firing"&gt;Confirm the cross-check is actually firing
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s easy to get this wrong by leaving the email out, and when you do, nothing
complains: the updater quietly verifies against the embedded key alone and carries
on. The way to know which anchors were actually consulted is to read the log. Every
update prints a line naming the resolver it used:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO signature verified resolver=composite[embedded,wkd:openpgpkey.acme.dev]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;resolver=&lt;/code&gt; field is the whole tell:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;composite[embedded,wkd:...]&lt;/code&gt; is what you want: both the embedded key and the
WKD-served key were fetched, their fingerprints agreed, and the signature checked
against the result. The cross-check is live.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;embedded&lt;/code&gt; means only the baked-in key was used and WKD was never consulted.
That&amp;rsquo;s the silent-degrade trap: &lt;code&gt;key_source&lt;/code&gt; is &lt;code&gt;&amp;quot;both&amp;quot;&lt;/code&gt;, but with no external
email there was no WKD URL to derive, so it fell back to a single anchor. If you
see this after passing &lt;code&gt;--email&lt;/code&gt;, the value didn&amp;rsquo;t take.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wkd:...&lt;/code&gt; on its own is the reverse: WKD was consulted but nothing was embedded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There&amp;rsquo;s a matching &lt;code&gt;update signature verification configured resolver=...&lt;/code&gt; line at
the very start of an update, before any network call, if you&amp;rsquo;d rather see the choice
before the fetch. Two failure shapes are worth recognising too.
&lt;code&gt;WARN composite resolver failed (RequireAll=false, continuing)&lt;/code&gt; means the WKD fetch
fell over (a 404, a flaky network) and the update carried on against the embedded key
alone, the soft default you can harden with &lt;code&gt;--require-external-crosscheck&lt;/code&gt;.
&lt;code&gt;ERROR ErrKeyResolverMismatch&lt;/code&gt; is the one you &lt;em&gt;want&lt;/em&gt; to see fire in anger: the
embedded and WKD keys disagreed, which is exactly the tamper alarm the whole scheme
exists to raise (the same mismatch the
&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;verification deep-dive&lt;/a&gt;
walks through).&lt;/p&gt;
&lt;h2 id="require-it-in-the-right-order"&gt;Require it, in the right order
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part everyone trips over. Enabling signing does not yet &lt;em&gt;require&lt;/em&gt; it:
&lt;code&gt;require_signature&lt;/code&gt; stays off, and that&amp;rsquo;s deliberate. Turning it on too early breaks
self-update for everyone who already has your tool.&lt;/p&gt;
&lt;p&gt;Think about what an existing install holds. A user on an old version has a binary built
&lt;em&gt;before&lt;/em&gt; you enabled signing. It has no trust anchor. If the first thing it ever sees
is a signed, signature-required release, it has nothing to check the signature against,
and the update is refused. You&amp;rsquo;ve locked out exactly the people you were protecting.&lt;/p&gt;
&lt;p&gt;The fix is to ship the key ahead of the requirement, so the anchor is already on the
user&amp;rsquo;s machine by the time the first mandatory signature arrives. gtb follows the
rollout in its own &lt;code&gt;docs/development/phase2-signing-prep.md&lt;/code&gt;, across three releases:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;Ship the key before you require the signature, or you lock out every install that
predates it.&lt;/strong&gt; In order:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Release N+1.&lt;/strong&gt; Enable signing (above) and ship it, with &lt;code&gt;require_signature&lt;/code&gt; off.
Existing installs pull this update on checksum alone and pick up the embedded key
as a side effect.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Release N+2.&lt;/strong&gt; Ship your first &lt;em&gt;signed&lt;/em&gt; release (Part 6 wires GoReleaser). Still
not required: the signature is verified when present but not enforced, so nothing
breaks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Release N+3.&lt;/strong&gt; Now, and only now, turn enforcement on.&lt;/li&gt;
&lt;/ul&gt;

 &lt;/blockquote&gt;
&lt;p&gt;When you reach N+3, it&amp;rsquo;s one command, not a code edit:&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;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --require-signature
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That flips &lt;code&gt;require_signature&lt;/code&gt; in the manifest and regenerates &lt;code&gt;signing.go&lt;/code&gt;, so the
change is tracked and reproducible. Skipping the middle release, requiring a signature
before you&amp;rsquo;ve shipped one, is the exact mistake the ordering exists to prevent; gtb&amp;rsquo;s
own rollout cites v0.12.2 as its first signed release for precisely this reason.&lt;/p&gt;
&lt;p&gt;The checksum floor from Phase 1 sits underneath all this and is already on; signature
verification adds to it, it doesn&amp;rsquo;t replace it. And even with signatures required, an
end user genuinely stuck on a legacy release can escape with the &lt;code&gt;update.require_signature&lt;/code&gt;
config key or your tool&amp;rsquo;s &lt;code&gt;&amp;lt;PREFIX&amp;gt;_UPDATE_REQUIRE_SIGNATURE=false&lt;/code&gt;. It&amp;rsquo;s an escape
hatch, not the front door, but it means a requirement you turn on can&amp;rsquo;t permanently
strand anyone.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;Your binary now carries the key it expects, checks every update against that key and
its live WKD twin, and refuses anything that doesn&amp;rsquo;t match, all without a &lt;code&gt;gpg&lt;/code&gt;
install on the user&amp;rsquo;s side and without stranding the installs that came before the key.
The trust loop you built by hand in Part 1 now runs on its own, inside a stranger&amp;rsquo;s
copy of your tool.&lt;/p&gt;
&lt;p&gt;The one thing still missing is the signature itself on each release. Right now nothing
is actually &lt;em&gt;producing&lt;/em&gt; &lt;code&gt;checksums.txt.sig&lt;/code&gt;. &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/" &gt;Part 6&lt;/a&gt;
wires the KMS signing from Parts 2 and 3 into a real GoReleaser pipeline, so every
tagged release comes out signed without you touching a key.&lt;/p&gt;</description></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>https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/</guid><description>&lt;img src="https://phpboyscout.uk/the-interpreter-we-forgot-to-sandbox/cover-the-interpreter-we-forgot-to-sandbox.png" alt="Featured image of post The interpreter we forgot to sandbox" /&gt;&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;</description></item><item><title>Sign your own binaries with go-tool-base</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/</link><pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/cover-sign-your-own-binaries-with-go-tool-base.png" alt="Featured image of post Sign your own binaries with go-tool-base" /&gt;&lt;p&gt;If your CLI tool can update itself, it has a decision to make that nobody is
watching: when it pulls down a new version, should it trust what just landed?
A checksum tells it the bytes match a manifest. It does not tell it who wrote
the manifest. Close that gap and your users get updates they can actually
trust; leave it open and a compromised release host can hand them anything it
likes. This series is the end-to-end &amp;ldquo;how&amp;rdquo;, using the signing tooling built
into go-tool-base.&lt;/p&gt;
&lt;p&gt;By the end you&amp;rsquo;ll have a CLI that ships releases signed by a key you control,
verifies its own updates against that key, and does the whole thing with no
&lt;code&gt;gpg&lt;/code&gt; wrangling and no long-lived secrets sitting in CI. We did the &lt;em&gt;why&lt;/em&gt; and
the &lt;em&gt;how it works&lt;/em&gt; in two deep-dives already, &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;a signature the platform can&amp;rsquo;t
forge&lt;/a&gt; and
&lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;a signing key that never leaves KMS&lt;/a&gt;.
This is the use-it counterpart.&lt;/p&gt;
&lt;h2 id="what-youre-protecting-against"&gt;What you&amp;rsquo;re protecting against
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;Nobody&amp;rsquo;s coming to clean your supply chain&lt;/a&gt;,
so it&amp;rsquo;s worth being clear about the threat before you spend an afternoon on the
fix. A checksum file sits next to the binary on the same release page. Whoever
can swap the binary can swap the checksum in the same breath, and the hash still
matches. A signature is different: it&amp;rsquo;s made by a private key the release
platform never holds, and verified against a public key your tool fetches from
somewhere the platform can&amp;rsquo;t reach. To forge a release that passes, an attacker
would have to steal a key that, done right, was never anywhere they could get at
it.&lt;/p&gt;
&lt;p&gt;That &amp;ldquo;done right&amp;rdquo; is the whole series.&lt;/p&gt;
&lt;h2 id="two-paths-through-it"&gt;Two paths through it
&lt;/h2&gt;&lt;p&gt;You don&amp;rsquo;t need a cloud account to start. The series runs in two stages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Learn it locally.&lt;/strong&gt; Part 1 signs and verifies on your laptop with a plain
key on disk. No AWS, no CI, no cost. It&amp;rsquo;s the fastest way to see every moving
part for real.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do it for production.&lt;/strong&gt; Parts 2 onward move the private key into AWS KMS,
where it&amp;rsquo;s generated and never leaves, and wire your release pipeline to sign
through it over short-lived OIDC credentials.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each part stands on its own and ends with something that works. They build in
order, but you can stop after Part 1 with a genuinely useful skill and come back
for the cloud parts when you need them.&lt;/p&gt;
&lt;h2 id="before-you-start"&gt;Before you start
&lt;/h2&gt;&lt;p&gt;You&amp;rsquo;ll want a CLI built on go-tool-base to sign. If you haven&amp;rsquo;t got one, the
&lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-1/" &gt;Building a CLI with go-tool-base&lt;/a&gt;
series gets you there in an afternoon; this one picks up where releases come in.
You&amp;rsquo;ll also need the &lt;code&gt;gtb&lt;/code&gt; CLI installed (the &lt;a class="link" href="https://gtb.phpboyscout.uk/installation/" target="_blank" rel="noopener"
 &gt;installation
docs&lt;/a&gt; have the one-liner), and for the
cloud parts, an AWS account and a GitLab or GitHub project to release from.&lt;/p&gt;
&lt;h2 id="the-parts"&gt;The parts
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-1/" &gt;Sign and verify on your laptop&lt;/a&gt;:
&lt;code&gt;gtb keys generate&lt;/code&gt;, &lt;code&gt;gtb sign&lt;/code&gt;, and &lt;code&gt;gpg --verify&lt;/code&gt;, the whole loop with a
local key.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-2/" &gt;A signing key in AWS KMS&lt;/a&gt;:
stand up an asymmetric KMS key with the &lt;code&gt;terraform-aws-signing-kms&lt;/code&gt; module.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-3/" &gt;Keyless CI signing with OIDC&lt;/a&gt;:
federate GitLab &lt;em&gt;and&lt;/em&gt; GitHub into the signer role, no stored credentials.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-4/" &gt;Mint and publish your public key&lt;/a&gt;:
&lt;code&gt;gtb keys mint&lt;/code&gt; from KMS, then &lt;code&gt;gtb keys wkd&lt;/code&gt; to publish it off-platform.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/" &gt;Embed the key and require verification&lt;/a&gt;:
bake the trust anchor into your binary and turn enforcement on safely.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/" &gt;Sign every release with GoReleaser&lt;/a&gt;:
wire signing into a real tagged-release pipeline.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/" &gt;Rotation and break-glass&lt;/a&gt;:
the part everyone skips, and how to do it without locking anyone out.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Start with Part 1. By the time you reach the end, the chain runs from a key born
in a vault to a binary on a stranger&amp;rsquo;s machine checking, on its own, that the
update it just fetched is really yours.&lt;/p&gt;</description></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>https://phpboyscout.uk/the-security-service-i-had-to-switch-off/</guid><description>&lt;img src="https://phpboyscout.uk/the-security-service-i-had-to-switch-off/cover-the-security-service-i-had-to-switch-off.png" alt="Featured image of post The security service I had to switch off" /&gt;&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;</description></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>https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/</guid><description>&lt;img src="https://phpboyscout.uk/ai-didnt-kill-curls-bug-bounty/cover-ai-didnt-kill-curls-bug-bounty.png" alt="Featured image of post AI didn't kill curl's bug bounty. The bounty did." /&gt;&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;</description></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>https://phpboyscout.uk/routing-security-findings-without-the-noise/</guid><description>&lt;img src="https://phpboyscout.uk/routing-security-findings-without-the-noise/cover-routing-security-findings-without-the-noise.png" alt="Featured image of post Routing security findings without the noise" /&gt;&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;</description></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>https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/</guid><description>&lt;img src="https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/cover-hardening-the-account-that-will-hold-the-keys.png" alt="Featured image of post Hardening the account that will hold the keys" /&gt;&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;</description></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>https://phpboyscout.uk/no-access-keys-in-ci/</guid><description>&lt;img src="https://phpboyscout.uk/no-access-keys-in-ci/cover-no-access-keys-in-ci.png" alt="Featured image of post No access keys in CI" /&gt;&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;</description></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>https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/</guid><description>&lt;img src="https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/cover-secrets-that-scrub-themselves-from-ram.png" alt="Featured image of post Secrets that scrub themselves from RAM" /&gt;&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;</description></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>https://phpboyscout.uk/the-cleanup-tool-that-almost-deleted-its-own-hands/</guid><description>&lt;img src="https://phpboyscout.uk/the-cleanup-tool-that-almost-deleted-its-own-hands/cover-the-cleanup-tool-that-almost-deleted-its-own-hands.png" alt="Featured image of post The cleanup tool that almost deleted its own hands" /&gt;&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;</description></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>https://phpboyscout.uk/the-security-finding-you-must-not-fix/</guid><description>&lt;img src="https://phpboyscout.uk/the-security-finding-you-must-not-fix/cover-the-security-finding-you-must-not-fix.png" alt="Featured image of post The security finding you must not fix" /&gt;&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;</description></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>https://phpboyscout.uk/a-state-bucket-that-defends-itself/</guid><description>&lt;img src="https://phpboyscout.uk/a-state-bucket-that-defends-itself/cover-a-state-bucket-that-defends-itself.png" alt="Featured image of post A state bucket that defends itself" /&gt;&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;</description></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>https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/</guid><description>&lt;img src="https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/cover-forbid-means-forbid-until-linkme-needs-a-word.png" alt="Featured image of post forbid means forbid, until linkme needs a word" /&gt;&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;</description></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>https://phpboyscout.uk/a-framework-that-contains-no-unsafe/</guid><description>&lt;img src="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/cover-a-framework-that-contains-no-unsafe.png" alt="Featured image of post A framework that contains no unsafe" /&gt;&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;</description></item><item><title>A signing key needs somewhere to live</title><link>https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/</guid><description>&lt;img src="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/cover-a-signing-key-needs-somewhere-to-live.png" alt="Featured image of post A signing key needs somewhere to live" /&gt;&lt;p&gt;I left a door open a couple of posts ago, and it&amp;rsquo;s been quietly bothering me ever since. When I wrote about &lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;verifying your own downloads&lt;/a&gt;, I was honest that a checksum sitting next to the binary only catches accidents. Anyone who can compromise the release platform can swap the binary and the checksum together, and the tool will happily verify one fake against the other.&lt;/p&gt;
&lt;p&gt;Closing that gap needs a signature. And a signature, it turns out, needs a surprising amount of infrastructure standing behind it. This is the first post about building that.&lt;/p&gt;
&lt;h2 id="the-door-the-last-post-left-open"&gt;The door the last post left open
&lt;/h2&gt;&lt;p&gt;A while back I wrote about verifying your own downloads: go-tool-base&amp;rsquo;s self-update command now checks the SHA-256 of every binary it downloads against the release&amp;rsquo;s published &lt;code&gt;checksums.txt&lt;/code&gt; before installing it.&lt;/p&gt;
&lt;p&gt;That post was honest about its own ceiling. A checksum file hosted &lt;em&gt;next to&lt;/em&gt; the binary it describes shares a trust root with that binary. Both come from the same release, on the same platform. Corruption, truncation, a CDN serving a stale object: a same-origin checksum catches all of those, because they&amp;rsquo;re accidents and the checksum wasn&amp;rsquo;t part of the accident. What it can&amp;rsquo;t catch is an attacker who&amp;rsquo;s compromised the release platform itself. Someone who can replace the binary can replace &lt;code&gt;checksums.txt&lt;/code&gt; in the same breath, and the tool will cheerfully verify the malicious download against the malicious checksum and call it good.&lt;/p&gt;
&lt;p&gt;The post named the fix and then deferred it: a signature whose trust root sits somewhere the release platform can&amp;rsquo;t reach. &amp;ldquo;That&amp;rsquo;s the next phase of this work.&amp;rdquo; This series is that phase.&lt;/p&gt;
&lt;h2 id="what-a-signature-actually-needs"&gt;What a signature actually needs
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being precise about why a signature helps where a checksum doesn&amp;rsquo;t, because it&amp;rsquo;s easy to wave the word &amp;ldquo;signature&amp;rdquo; around and assume it settles everything.&lt;/p&gt;
&lt;p&gt;A signature closes the gap only under two conditions. The verifying key, the public half, must reach the user by a path the release platform doesn&amp;rsquo;t control. And the signing key, the private half, must live somewhere the release platform can&amp;rsquo;t reach.&lt;/p&gt;
&lt;p&gt;The second condition is the one people skip. If the signing key sits in the same CI system that builds the release, you&amp;rsquo;ve gained almost nothing. An attacker who owns the CI owns the key, and a key they own will sign whatever they hand it. The signature verifies perfectly and means precisely nothing. A signature is only worth the distance between the signing key and the thing being signed. Put them in the same place and the distance is zero.&lt;/p&gt;
&lt;p&gt;So the signing key has to live in a different security domain from the release pipeline. Not a different folder. A different account, with a different blast radius, that the release platform has no standing access to.&lt;/p&gt;
&lt;h2 id="just-sign-the-binary-is-not-a-small-feature"&gt;&amp;ldquo;Just sign the binary&amp;rdquo; is not a small feature
&lt;/h2&gt;&lt;p&gt;That reframes a line item that sounds tiny. &amp;ldquo;Sign the release binary&amp;rdquo; unpacks into a list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;there must be a private signing key;&lt;/li&gt;
&lt;li&gt;it must live outside the release platform, in its own security domain;&lt;/li&gt;
&lt;li&gt;it must be access-controlled, audited, and protected from exfiltration;&lt;/li&gt;
&lt;li&gt;only the release pipeline may ask it to sign, and only by proving a short-lived, federated identity, never by holding a copy of the key.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s not a feature you bolt onto a CLI. That&amp;rsquo;s infrastructure.&lt;/p&gt;
&lt;p&gt;The shape of it: a cloud account, with the key held in a managed key service so the private key material never exists as a file on a disk that anyone, me included, can copy. The release pipeline authenticates to that account as itself, briefly, and asks the key service to produce a signature. The key never moves.&lt;/p&gt;
&lt;p&gt;But an account you&amp;rsquo;re going to trust with a signing key is itself something you have to get right first. An account with a weak baseline, no audit trail, and long-lived credentials lying around is not a safe home for the most security-sensitive key in the whole system. Before the key can move in, the house has to be built and the locks have to actually work.&lt;/p&gt;
&lt;h2 id="what-this-series-builds"&gt;What this series builds
&lt;/h2&gt;&lt;p&gt;So this turned into a rather longer project than &amp;ldquo;add a signature&amp;rdquo;, and the series follows it in order.&lt;/p&gt;
&lt;p&gt;It starts with bootstrapping a fresh AWS account: the deliberately minimal first &lt;code&gt;tofu apply&lt;/code&gt;, and the remote state backend that has a genuine chicken-and-egg problem. Then the credential question, which is the heart of it: how a CI pipeline deploys to AWS with no stored access key at all. Then hardening the account, so it&amp;rsquo;s genuinely safe to hold something valuable. Then the discipline of deploying changes to it: plans reviewed before they&amp;rsquo;re applied. Then the shared tooling that makes all of it repeatable.&lt;/p&gt;
&lt;p&gt;Every one of those pieces exists for the same reason. The signing key needs somewhere to live, and somewhere safe is not a default you&amp;rsquo;re handed. It&amp;rsquo;s a thing you build, deliberately, before you have anything worth protecting in it.&lt;/p&gt;
&lt;p&gt;The series ends where the verifying-downloads post pointed: a signing service whose key the release platform can&amp;rsquo;t touch, so a self-updating tool can finally verify that the binary it&amp;rsquo;s about to become is genuinely the one I published.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s self-update verifies downloads against a checksum, and a same-origin checksum stops accidents but not a compromise of the release platform. The fix is a signature, and a signature is only worth the distance between its signing key and the release pipeline.&lt;/p&gt;
&lt;p&gt;Holding that key safely means a private key that never leaves a managed key service, in a separate cloud account, reached only by a short-lived federated identity. That&amp;rsquo;s infrastructure, and a safe account is something you build before you trust it with anything. The rest of this series builds it, piece by piece, right up to the signing service itself.&lt;/p&gt;</description></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>https://phpboyscout.uk/waivers-with-an-expiry-date/</guid><description>&lt;img src="https://phpboyscout.uk/waivers-with-an-expiry-date/cover-waivers-with-an-expiry-date.png" alt="Featured image of post Waivers with an expiry date" /&gt;&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;</description></item><item><title>Verifying your own downloads: how I solved it for self-updating CLI tools</title><link>https://phpboyscout.uk/verifying-your-own-downloads/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/verifying-your-own-downloads/</guid><description>&lt;img src="https://phpboyscout.uk/verifying-your-own-downloads/cover-verifying-your-own-downloads.png" alt="Featured image of post Verifying your own downloads: how I solved it for self-updating CLI tools" /&gt;&lt;p&gt;Way back in the &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;introduction&lt;/a&gt; I promised I&amp;rsquo;d come back to the self-update integrity checks. Here we are. And the starting point is a slightly uncomfortable admission: for a good long while, go-tool-base&amp;rsquo;s &lt;code&gt;update&lt;/code&gt; command was the most trusting line of code in the entire tool.&lt;/p&gt;
&lt;h2 id="the-most-trusting-line-of-code-in-the-tool"&gt;The most trusting line of code in the tool
&lt;/h2&gt;&lt;p&gt;Self-update is a lovely feature. The user runs &lt;code&gt;yourtool update&lt;/code&gt;, the tool fetches the latest release, swaps itself out, and they&amp;rsquo;re current. go-tool-base has had this since early on, wired to GitHub, GitLab, Bitbucket, Gitea and a few others.&lt;/p&gt;
&lt;p&gt;But look closely at what that feature actually does. It reaches out to the internet, pulls down a file, and then &lt;em&gt;replaces the executable that&amp;rsquo;s currently running with that file&lt;/em&gt;. The next time the user invokes the tool, they&amp;rsquo;re running whatever those bytes turned out to be.&lt;/p&gt;
&lt;p&gt;The original implementation downloaded the release asset over HTTPS and extracted it. HTTPS gets you transport security: the bytes weren&amp;rsquo;t tampered with &lt;em&gt;in flight&lt;/em&gt;. It tells you nothing about whether the bytes were right when they left, or whether they&amp;rsquo;re even the bytes you meant to fetch. A truncated download, a CDN cache serving a mangled object, a release asset that got swapped after the fact&amp;hellip; HTTPS waves all of those straight through. For the one operation in the whole tool that replaces the binary, &amp;ldquo;we didn&amp;rsquo;t check&amp;rdquo; is an uncomfortable place to be sitting.&lt;/p&gt;
&lt;h2 id="goreleaser-already-does-half-the-job"&gt;GoReleaser already does half the job
&lt;/h2&gt;&lt;p&gt;The good news is that the build side was already producing exactly what I needed. GoReleaser, which builds go-tool-base&amp;rsquo;s releases, generates a &lt;code&gt;checksums.txt&lt;/code&gt; for every release: one SHA-256 per published artefact, the same format &lt;code&gt;sha256sum&lt;/code&gt; emits. It was sitting right there as a release asset and nothing was reading it.&lt;/p&gt;
&lt;p&gt;So Phase 1 of the integrity work is exactly that: read it.&lt;/p&gt;
&lt;p&gt;When &lt;code&gt;update&lt;/code&gt; downloads the platform binary, it now also fetches &lt;code&gt;checksums.txt&lt;/code&gt; from the same release, looks up the entry for the asset it just pulled, and compares the SHA-256 of the downloaded bytes against the expected hash before anything gets extracted or installed. Mismatch, and the update aborts before it has so much as touched the installed binary. The hash comparison &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/checksum.go#L267" target="_blank" rel="noopener"
 &gt;runs in constant time&lt;/a&gt;, which is more defence-in-depth than strictly necessary here, but it costs nothing and means every hash comparison in the codebase is the same and reassuringly audit-boring.&lt;/p&gt;
&lt;h2 id="fail-open-or-fail-closed"&gt;Fail open, or fail closed?
&lt;/h2&gt;&lt;p&gt;The interesting design question wasn&amp;rsquo;t the hashing. It was: what do you do when there &lt;em&gt;is no&lt;/em&gt; &lt;code&gt;checksums.txt&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;Plenty of older releases predate this feature. A release might have been cut by hand without GoReleaser. If go-tool-base flatly refused to update whenever a manifest was missing, the very act of shipping this feature would brick the update path for every existing tool the moment they upgraded into it. That&amp;rsquo;s a cure worse than the disease.&lt;/p&gt;
&lt;p&gt;So the default is fail-open: no manifest, log a clear warning, proceed. It matches how the existing offline-update path already behaved with its optional &lt;code&gt;.sha256&lt;/code&gt; sidecar, and it keeps upgrades working.&lt;/p&gt;
&lt;p&gt;Fail-open as a &lt;em&gt;default&lt;/em&gt; is not the same as fail-open being &lt;em&gt;right for everyone&lt;/em&gt;, though. A security-sensitive tool should be able to say &amp;ldquo;no manifest, no update, full stop&amp;rdquo;. Two ways to get there:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tool authors&lt;/strong&gt; flip a compile-time switch (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/checksum.go#L42" target="_blank" rel="noopener"
 &gt;&lt;code&gt;setup.DefaultRequireChecksum = true&lt;/code&gt;&lt;/a&gt; in &lt;code&gt;main()&lt;/code&gt;) and their binary ships fail-closed from day one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;End users&lt;/strong&gt; override either way through config (&lt;code&gt;update.require_checksum&lt;/code&gt;) or an environment variable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;go-tool-base itself ships with the strict setting turned on, because a tool whose entire job is being a careful framework should hold itself to the stricter bar.&lt;/p&gt;
&lt;h2 id="the-caveat"&gt;The caveat
&lt;/h2&gt;&lt;p&gt;Security features oversell themselves constantly, so here is the limit, stated plainly.&lt;/p&gt;
&lt;p&gt;A checksum hosted &lt;em&gt;next to&lt;/em&gt; the binary it describes protects you from accidents. Corruption, truncation, a CDN serving stale junk, a release asset that got partially clobbered. It does not protect you from a determined attacker who&amp;rsquo;s compromised the release platform itself. If someone can replace the binary, they can replace &lt;code&gt;checksums.txt&lt;/code&gt; in the same breath, and your tool will cheerfully verify a malicious download against a malicious manifest and pronounce it good.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s not a flaw in the implementation. It&amp;rsquo;s the inherent ceiling of &lt;em&gt;same-origin&lt;/em&gt; integrity: the manifest and the artefact share a trust root, so they fall together. Closing that gap needs a signature whose trust root is somewhere the release platform can&amp;rsquo;t reach, a key the attacker doesn&amp;rsquo;t have. That&amp;rsquo;s the next phase of this work, and it&amp;rsquo;s a bigger piece: &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/" &gt;GPG-signing the manifest&lt;/a&gt;, with the public half both embedded in the binary and published independently so a single platform compromise isn&amp;rsquo;t enough.&lt;/p&gt;
&lt;p&gt;Phase 1 is the floor, not the ceiling. But it&amp;rsquo;s a floor worth having, because the overwhelming majority of real-world &amp;ldquo;the download was wrong&amp;rdquo; incidents are accidents, not attacks, and accidents are exactly what a same-origin checksum catches.&lt;/p&gt;
&lt;h2 id="pulling-it-together"&gt;Pulling it together
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;update&lt;/code&gt; command is the most trusting code in a self-updating tool: it fetches bytes from the internet and then becomes them. go-tool-base now verifies the SHA-256 of every self-update download against the release&amp;rsquo;s own &lt;code&gt;checksums.txt&lt;/code&gt; before installing. It fails open by default so shipping the feature doesn&amp;rsquo;t strand anyone on an un-updatable version, fails closed for tool authors who ask (go-tool-base itself does), and stays honest that a same-origin checksum stops accidents, not a platform compromise.&lt;/p&gt;
&lt;p&gt;Verifying your own downloads is a low bar. The point is that the previous height of that bar was zero.&lt;/p&gt;</description></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>https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/</guid><description>&lt;img src="https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/cover-the-blank-import-that-keeps-a-dependency-out-of-your-binary.png" alt="Featured image of post The blank import that keeps a dependency out of your binary" /&gt;&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;</description></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>https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/</guid><description>&lt;img src="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/cover-where-should-a-cli-keep-your-api-keys.png" alt="Featured image of post Where should a CLI keep your API keys?" /&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 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;</description></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>https://phpboyscout.uk/a-configurable-ai-endpoint-is-an-attack-surface/</guid><description>&lt;img src="https://phpboyscout.uk/a-configurable-ai-endpoint-is-an-attack-surface/cover-a-configurable-ai-endpoint-is-an-attack-surface.png" alt="Featured image of post A configurable AI endpoint is an attack surface" /&gt;&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;</description></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>https://phpboyscout.uk/redacting-the-secret-you-didnt-know-was-in-the-string/</guid><description>&lt;img src="https://phpboyscout.uk/redacting-the-secret-you-didnt-know-was-in-the-string/cover-redacting-the-secret-you-didnt-know.png" alt="Featured image of post Redacting the secret you didn't know was in the string" /&gt;&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;</description></item><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>https://phpboyscout.uk/every-finding-was-the-same-shape/</guid><description>&lt;img src="https://phpboyscout.uk/every-finding-was-the-same-shape/cover-every-finding-was-the-same-shape.png" alt="Featured image of post I had the framework audited: every finding was the same shape" /&gt;&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;</description></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>https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/</guid><description>&lt;img src="https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/cover-openssf-scorecard-graded-my-supply-chain.png" alt="Featured image of post OpenSSF Scorecard graded my supply chain" /&gt;&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;</description></item><item><title>AI conversations you can resume</title><link>https://phpboyscout.uk/ai-conversations-you-can-resume/</link><pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/ai-conversations-you-can-resume/</guid><description>&lt;img src="https://phpboyscout.uk/ai-conversations-you-can-resume/cover-ai-conversations-you-can-resume.png" alt="Featured image of post AI conversations you can resume" /&gt;&lt;p&gt;An AI conversation is, fundamentally, its own history. The model&amp;rsquo;s next answer depends on everything said so far. And a CLI tool, by its very nature, forgets everything the moment it exits. Put those two facts together and you get the problem: run an AI command, exit, run it again, and you&amp;rsquo;re talking to someone who&amp;rsquo;s never met you.&lt;/p&gt;
&lt;h2 id="a-cli-forgets-everything"&gt;A CLI forgets everything
&lt;/h2&gt;&lt;p&gt;A long-running service keeps its state in memory for as long as it runs. A CLI tool doesn&amp;rsquo;t get that luxury. It starts, does one thing, exits. The next invocation is a brand-new process with no memory of the last one.&lt;/p&gt;
&lt;p&gt;For most commands that&amp;rsquo;s exactly right, and you wouldn&amp;rsquo;t want it any other way. But an AI conversation is a different kind of beast, because a conversation &lt;em&gt;is&lt;/em&gt; its history. The model&amp;rsquo;s next answer depends on everything said so far. Run an AI command, exit, run it again, and you&amp;rsquo;ve started a fresh conversation with someone who&amp;rsquo;s never met you. For an interactive assistant, or any AI workflow that unfolds across several invocations, that&amp;rsquo;s plainly the wrong behaviour. The user expects to pick up where they left off.&lt;/p&gt;
&lt;h2 id="save-and-restore"&gt;Save and restore
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;chat&lt;/code&gt; package handles this through a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/persistence.go#L25" target="_blank" rel="noopener"
 &gt;&lt;code&gt;PersistentChatClient&lt;/code&gt;&lt;/a&gt; interface. Like streaming, it&amp;rsquo;s an optional capability discovered with a type assertion, sitting beside &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;the four-method core&lt;/a&gt; rather than bloating it. A client that supports persistence also satisfies this interface:&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;pc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&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="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PersistentChatClient&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&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;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&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="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Save&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;// store the snapshot somewhere&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;A snapshot is a serialisable value that captures the conversation. You store it. Next run, you load it, &lt;code&gt;Restore&lt;/code&gt; it onto a fresh client, re-register your tools, and call &lt;code&gt;Chat&lt;/code&gt; again. &amp;ldquo;Where were we?&amp;rdquo; works, because the model is handed back the whole history.&lt;/p&gt;
&lt;h2 id="a-snapshot-is-opinionated-about-what-it-carries"&gt;A snapshot is opinionated about what it carries
&lt;/h2&gt;&lt;p&gt;The interesting part is what a snapshot does and doesn&amp;rsquo;t contain, because that&amp;rsquo;s a series of deliberate decisions.&lt;/p&gt;
&lt;p&gt;It carries the messages, the system prompt, the model name, and tool &lt;em&gt;metadata&lt;/em&gt;: the names, descriptions and parameter schemas of the tools that were registered.&lt;/p&gt;
&lt;p&gt;It does not carry tool &lt;em&gt;handlers&lt;/em&gt;. Handlers are code, not data; you can&amp;rsquo;t serialise a function meaningfully, so after a restore you re-register them with &lt;code&gt;SetTools&lt;/code&gt;. The snapshot remembers that a tool called &lt;code&gt;read_file&lt;/code&gt; existed and what its shape was; it doesn&amp;rsquo;t try to remember the Go function behind it.&lt;/p&gt;
&lt;p&gt;And it does not carry API tokens. This is the one to dwell on. A snapshot is a file. A file gets synced, backed up, copied between machines, attached to a support ticket by a user trying to be helpful. A snapshot that carried the API key would be a credential leak the moment it left the laptop it was made on. So the snapshot never contains a token, at all. On restore, the client picks the credential up again the ordinary way, from &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;the environment or the keychain&lt;/a&gt;. The conversation and the secret are kept in separate places on purpose, and only one of them is ever in the file.&lt;/p&gt;
&lt;h2 id="encrypted-at-rest-if-you-want-it"&gt;Encrypted at rest, if you want it
&lt;/h2&gt;&lt;p&gt;The package ships a &lt;code&gt;FileStore&lt;/code&gt; that writes snapshots as JSON files, with &lt;code&gt;0600&lt;/code&gt; permissions in a &lt;code&gt;0700&lt;/code&gt; directory, and it can encrypt them. Pass &lt;code&gt;WithEncryption&lt;/code&gt; a 32-byte key and snapshots are written with AES-256-GCM.&lt;/p&gt;
&lt;p&gt;That option exists because a conversation can hold sensitive content even when it holds no credential. The log a user pasted in for analysis, the source file they asked the model to review, the internal details tucked into their questions: none of that is an API key, and all of it might be something you&amp;rsquo;d rather not have sitting in plain JSON in a backup somewhere. Encryption at rest covers it.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;FileStore&lt;/code&gt; is also careful about the snapshot identifiers it&amp;rsquo;s handed. An ID has to be a canonical UUID, and the resolved file path is checked to lie inside the store directory, so a snapshot ID arriving from an untrusted source (a CLI flag, a request payload) can&amp;rsquo;t be bent into a path-traversal that reads or writes somewhere it shouldn&amp;rsquo;t. Persisting conversations adds a small filesystem surface, and the store treats it as exactly that.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;A CLI tool forgets everything between invocations, which is correct for most commands and wrong for an AI conversation, because a conversation is its history.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package lets you persist one. &lt;code&gt;PersistentChatClient&lt;/code&gt; saves a snapshot you can store and restore later, picking the conversation back up where it ended. The snapshot is deliberate about its contents: messages, system prompt and tool metadata yes; tool handlers no, because they&amp;rsquo;re code you re-register; API tokens never, because a snapshot is a file and a file travels. The built-in &lt;code&gt;FileStore&lt;/code&gt; can encrypt snapshots at rest with AES-256-GCM and validates snapshot IDs against path traversal. Resumable conversations, without the conversation file turning into a place secrets leak from.&lt;/p&gt;</description></item><item><title>Encrypting additional drives with LUKS on Linux</title><link>https://phpboyscout.uk/encrypting-additional-drives-with-luks-on-linux/</link><pubDate>Mon, 29 Jun 2020 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/encrypting-additional-drives-with-luks-on-linux/</guid><description>&lt;img src="https://phpboyscout.uk/encrypting-additional-drives-with-luks-on-linux/encryption-encoding-hashing.jpg" alt="Featured image of post Encrypting additional drives with LUKS on Linux" /&gt;&lt;p&gt;Encryption is king nowadays with everyone having mobile devices. We have a significant number of people on laptops that travel around and also workstations that live in open plan offices. This means we encrypt all of our disks&amp;hellip; just in case. 99% of the time is super simple to do as most OS installers give you the option to do it, some now ven enforce it as a default option. This post however is about adding an additional disk to the system and making it automatically mount on system startup.&lt;/p&gt;
&lt;p&gt;So let me set the scene, we have a data-scientist that&amp;rsquo;s running out of disk space for a task they are running on their Ubuntu 18.04 Workstation. At some point the workstation had an upgrade to the HDD in the past to a shiny new SSD, and the old 4Tb spinning disk was left in the chassis that they want to use for this very specific task.&lt;/p&gt;
&lt;p&gt;Now this workstation has been through a couple of data-scientists over the last 12 months and unfortunately the LUKS password that had been set up for the old spinning disk has gone walkabouts&amp;hellip; so the plan is as follows&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;flatten the old disk and set up a new partition using the whole disk&lt;/li&gt;
&lt;li&gt;generate a new secure encryption key&lt;/li&gt;
&lt;li&gt;set up LUKS encryption on the new partition&lt;/li&gt;
&lt;li&gt;use Ext4 as a filesystem&lt;/li&gt;
&lt;li&gt;enable auto decryption of the disk&lt;/li&gt;
&lt;li&gt;add the new partition to the fstab to mount on system startup&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;N.B Assume that we are running everything as the root user&lt;/p&gt;
&lt;h2 id="flatten-the-disk"&gt;Flatten the disk
&lt;/h2&gt;&lt;p&gt;As we cant recover anything we are going to flatten the disk using &lt;code&gt;parted&lt;/code&gt; (&lt;code&gt;apt install parted&lt;/code&gt; to install) to allow is to create a partition greater than 2Tb, but first we are going to identify the disk we are working with&amp;hellip; I tend to favour using either &lt;code&gt;fdisk -l&lt;/code&gt; or as a more concise option &lt;code&gt;lsblk -p&lt;/code&gt; which gives us a an easy to interpret overview something like:&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;/dev/sda 8:0 0 1.8T 0 disk 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├─/dev/sda1 8:1 0 512M 0 part /boot/efi
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├─/dev/sda2 8:2 0 732M 0 part /boot
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└─/dev/sda3 8:3 0 1.8T 0 part 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─/dev/mapper/sda3_crypt 253:0 0 1.8T 0 crypt 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─/dev/mapper/ubuntu--vg-root 253:1 0 1.8T 0 lvm /
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─/dev/mapper/ubuntu--vg-swap_1 253:2 0 976M 0 lvm [SWAP]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/dev/sdb 8:16 0 3.7T 0 disk 
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I can tell from this that we are looking at using the disk that is currently at /dev/sdb and its showing as being 3.7Tb in size.&lt;/p&gt;
&lt;p&gt;Great&amp;hellip; now to set up our new partition using the command &lt;code&gt;parted /dev/sdb&lt;/code&gt; which gives us an interactive shell to work with (you can see the prompts in the output below are prefixed with (parted)&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;GNU Parted 3.2 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Using /dev/sdb 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Welcome to GNU Parted! Type &amp;#39;help&amp;#39; to view a list of commands. 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(parted) mklabel gpt 
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The command &lt;code&gt;mklabel gpt&lt;/code&gt; will wipe the partition table for &lt;code&gt;/dev/sdb&lt;/code&gt; and give us a clean slate to work from&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;(parted) unit TB 
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;We now set parted to think in Terabytes as the default reference size using the command above.&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;(parted) mkpart primary 0.00TB 3.70TB 
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now we get to create the actual partition. You can see from the command above that we are using the command &lt;code&gt;mkpart&lt;/code&gt; and telling it to create a &lt;code&gt;primary&lt;/code&gt; partition type.&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;(parted) print 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Model: ATA WDC WD4005FZBX-0 (scsi) 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Disk /dev/sdb: 4.00TB 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Sector size (logical/physical): 512B/4096B 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Partition Table: gpt 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Disk Flags: 
&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;Number Start End Size File system Name Flags 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1 0.00TB 4.00TB 4.00TB primary 
&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;We can chek everything went smoothly using the &lt;code&gt;print&lt;/code&gt; command which gives us confirmation that a new primary partition is present. We can now leave &lt;code&gt;parted&lt;/code&gt; with a simple.&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;(parted) quit 
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And we can now use &lt;code&gt;fdisk -l&lt;/code&gt; or &lt;code&gt;lsblk -p&lt;/code&gt; to see that we now have a partition waiting for us at &lt;code&gt;/dev/sdb1&lt;/code&gt;.&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;/dev/sda 8:0 0 1.8T 0 disk 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├─/dev/sda1 8:1 0 512M 0 part /boot/efi
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├─/dev/sda2 8:2 0 732M 0 part /boot
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└─/dev/sda3 8:3 0 1.8T 0 part 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─/dev/mapper/sda3_crypt 253:0 0 1.8T 0 crypt 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─/dev/mapper/ubuntu--vg-root 253:1 0 1.8T 0 lvm /
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─/dev/mapper/ubuntu--vg-swap_1 253:2 0 976M 0 lvm [SWAP]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/dev/sdb 8:16 0 3.7T 0 disk 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└─/dev/sdb1 8:17 0 3.7T 0 part 
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="generating-an-encryption-key"&gt;Generating an encryption key
&lt;/h2&gt;&lt;p&gt;Our disk is now ready for use, but not yet encrypted, so our next step is to create a key that can be used when we encrypt the disk. As we are going to be mounting it automatically we want to use a keyfile to store the key. You can of course create a key by mashing the keys on the keyboard, but I tend to prefer letting something else do the hard part for me.&lt;/p&gt;
&lt;p&gt;&lt;img class="gallery-image" data-flex-basis="429px" data-flex-grow="178" height="559" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/encrypting-additional-drives-with-luks-on-linux/encryption-encoding-hashing-1_hu_f0e9424ec72d873c.webp" srcset="https://phpboyscout.uk/encrypting-additional-drives-with-luks-on-linux/encryption-encoding-hashing-1_hu_3fc14e686baa78c6.webp 480w, https://phpboyscout.uk/encrypting-additional-drives-with-luks-on-linux/encryption-encoding-hashing-1_hu_ab97440a5b2cff7e.webp 720w, https://phpboyscout.uk/encrypting-additional-drives-with-luks-on-linux/encryption-encoding-hashing-1_hu_f0e9424ec72d873c.webp 1000w" width="1000"&gt;

&lt;/p&gt;
&lt;p&gt;First we create somewhere to store the key&amp;hellip; I opted for,&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;mkdir -p /etc/crypt/keys
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;But feel free to put it wherever you want just as long as its only accessible by the &lt;code&gt;root&lt;/code&gt; user. Next we generate the keyfile using the command:&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;dd bs=512 count=4 if=/dev/urandom of=/etc/crypt/keys/sdb1 iflag=fullblock
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here I am using &lt;code&gt;/dev/urandom&lt;/code&gt; as my randomness generator, but you could use any valid generator of your choice. With this set of parameted &lt;code&gt;dd&lt;/code&gt; will read the stream of &amp;ldquo;randomeness&amp;rdquo; and write 2048 bytes to our keyfile at &lt;code&gt;/etc/crypt/keys/sdb1&lt;/code&gt;. If you want to be a little more complex about teh size and shape of your key then have a look at &lt;a class="link" href="https://man7.org/linux/man-pages/man1/dd.1.html" target="_blank" rel="noopener"
 &gt;https://man7.org/linux/man-pages/man1/dd.1.html&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="encrypting-the-disk"&gt;Encrypting the Disk
&lt;/h2&gt;&lt;p&gt;&lt;img class="gallery-image" data-flex-basis="707px" data-flex-grow="294" height="112" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/encrypting-additional-drives-with-luks-on-linux/luks-logo-cropped_hu_c15ceeb12be2bd44.webp" srcset="https://phpboyscout.uk/encrypting-additional-drives-with-luks-on-linux/luks-logo-cropped_hu_c15ceeb12be2bd44.webp 330w" width="330"&gt;

&lt;/p&gt;
&lt;p&gt;Hopefully it will already be installed because you encrypted your root disk at installation, but if not you can run &lt;code&gt;apt install cryptsetup&lt;/code&gt; to get going.&lt;/p&gt;
&lt;p&gt;The command to do the encryption is actually very simple.&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;cryptsetup luksFormat /dev/sdb1 /etc/crypt/keys/sdb1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You can see that using the &lt;code&gt;cryptsetup&lt;/code&gt; tool we are asking it to execute teh command &lt;code&gt;luksFormat&lt;/code&gt; but while it says format in the command this is a little misleading as it doesn&amp;rsquo;t actually format the disk but just rewrites a portion of bytes at the beginning of the partition to enable encryption. we then tell it the partition we want encrypting, here its &lt;code&gt;/dev/sdb1&lt;/code&gt; and finally we pass in the keyfile we just generated and saved at &lt;code&gt;/etc/crypt/keys/sdb1&lt;/code&gt;. If you omit the keyfile it will still encrypt teh disk but will prompt you to enter the key manually.&lt;/p&gt;
&lt;p&gt;As soon as you press enter you will be warned of teh danager of what you are doing&amp;hellip; so double check you are encrypting the right partition and follow the instructions that should look something like :&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;WARNING! 
&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;This will overwrite data on /dev/sdb1 irrevocably. 
&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;Are you sure? (Type uppercase yes): YES 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Command successful. 
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And that&amp;rsquo;s it&amp;hellip; the disk is encrypted and ready to use. There are a few ways you can now work with the disk. the quickest and easiest is to just decrypt the disk manually using cryptsetup to &lt;code&gt;open&lt;/code&gt; the disk.&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;cryptsetup open /dev/sdb1 sdb1_crypt -d /etc/crypt/keys/sdb1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here we open &lt;code&gt;/dev/sdb1&lt;/code&gt; and give is a new name of sdb1_crypt and we unlock it using the &lt;code&gt;-d&lt;/code&gt; argument to tell it the keyfile we generated before.&lt;/p&gt;
&lt;p&gt;That is the dis decrypted and ready to roll&amp;hellip; you can now use &lt;code&gt;fdisk -l&lt;/code&gt; or &lt;code&gt;lsblk -p&lt;/code&gt; to confirm that it is now available at &lt;code&gt;/dev/mapper/sdb1_crypt&lt;/code&gt;.&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;/dev/sda 8:0 0 1.8T 0 disk 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├─/dev/sda1 8:1 0 512M 0 part /boot/efi
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├─/dev/sda2 8:2 0 732M 0 part /boot
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└─/dev/sda3 8:3 0 1.8T 0 part 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─/dev/mapper/sda3_crypt 253:0 0 1.8T 0 crypt 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─/dev/mapper/ubuntu--vg-root 253:1 0 1.8T 0 lvm /
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─/dev/mapper/ubuntu--vg-swap_1 253:2 0 976M 0 lvm [SWAP]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/dev/sdb 8:16 0 3.7T 0 disk 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└─/dev/sdb1 8:17 0 3.7T 0 part 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─/dev/mapper/sdb1_crypt 253:3 0 3.7T 0 crypt /mnt/4tb-1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This tells us that the newly decrypted disk is now available at &lt;code&gt;/dev/mapper/sdb1_crypt&lt;/code&gt; and is a volume of 3.7Tb&amp;hellip; Exactly what we were hoping for!&lt;/p&gt;
&lt;p&gt;All finished with your encrypted disk&amp;hellip; you can just as easily close it again using:&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;cryptsetup close sdb1_crypt
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="setting-up-the-filesystem"&gt;Setting up the Filesystem
&lt;/h2&gt;&lt;p&gt;Ok, we have an encrypted partition, we can decrypt it but we cant mount it yet as we don&amp;rsquo;t have a file system to work with. Let&amp;rsquo;s take care of that real quick by opening up the partition again.&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;cryptsetup open /dev/sdb1 sdb1_crypt -d /etc/crypt/keys/sdb1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And now that its available we are going to set up an ext4 filesystem using the command &lt;code&gt;mkfs.ext4 /dev/mapper/sdb1_crypt&lt;/code&gt; which, all going according to plan, should look something like:&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;mke2fs 1.44.1 (24-Mar-2018) 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Creating filesystem with 976753664 4k blocks and 244195328 inodes 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Filesystem UUID: d797be67-c53e-49d3-897e-c624b21a22d3 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Superblock backups stored on blocks: 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208, 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 4096000, 7962624, 11239424, 20480000, 23887872, 71663616, 78675968, 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 102400000, 214990848, 512000000, 550731776, 644972544 
&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;Allocating group tables: done 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Writing inode tables: done 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Creating journal (262144 blocks): done 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Writing superblocks and filesystem accounting information: done
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;we are now good to go&amp;hellip; lets try mounting the filesystem with&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;mount -t ext4 /dev/mapper/sdb1_crypt /mnt
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I&amp;rsquo;m just mounting straight to &lt;code&gt;/mnt&lt;/code&gt; but obviously this can be any folder you want. If the command worked we can easily confirm it with a quick &lt;code&gt;df&lt;/code&gt; -h:&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;Filesystem 1K-blocks Used Available Use% Mounted on
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;udev 32846708 0 32846708 0% /dev
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tmpfs 6578140 2308 6575832 1% /run
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/dev/mapper/ubuntu--vg-root 1919562064 993193376 828790500 55% /
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tmpfs 32890688 200 32890488 1% /dev/shm
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tmpfs 5120 4 5116 1% /run/lock
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tmpfs 32890688 0 32890688 0% /sys/fs/cgroup
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/dev/sda2 721392 276068 392860 42% /boot
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/dev/sda1 523248 6232 517016 2% /boot/efi
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/dev/mapper/sdb1_crypt 3844637680 0 3844637680 1% /mnt
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Excellent&amp;hellip; you can now start working with your new partition&amp;hellip; however lets un-mount and close the drive quickly with a&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;umount /mnt &amp;amp;&amp;amp; cryptsetup close sdb1_crypt
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And then we can move onto&amp;hellip;&lt;/p&gt;
&lt;h2 id="automatic-decryption"&gt;Automatic Decryption
&lt;/h2&gt;&lt;p&gt;This is a lot simpler that you may realise&amp;hellip; all we need to do is add a new line to the file &lt;code&gt;/etc/crypttab&lt;/code&gt;! But first we need one last piece of information we don&amp;rsquo;t yet have, but we can easily get with the command&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;sudo cryptsetup luksDump /dev/sdb1 | grep &amp;#34;UUID&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This will use &lt;code&gt;luksDump&lt;/code&gt; to get information about the encrypted partition and then uses &lt;code&gt;grep&lt;/code&gt; to specifically target the property UUID which we will need to identify the partition in the next step.&lt;/p&gt;
&lt;p&gt;Now in your favourite editor of choice add the following line, replacing the spoof UUID here with the one we just found.&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;sdb1_crypt UUID=1111111111-2222-3333-4444-555555555555 /etc/crypt/keys/sdb1 luks
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here we are giving the decrypted volume a unique label for teh decrypted label to be made available at the appropriate &lt;code&gt;/dev/mapper/*&lt;/code&gt; location. We also specify the UUID to identify the partition to decrypt&amp;hellip; we could use the path &lt;code&gt;/dev/sdb1&lt;/code&gt; but using the UUID is more explicit and prevents any confusion if another partition happens to present itself as &lt;code&gt;/dev/sdb1&lt;/code&gt; at some point in the future. Third we have the path to our newly generated keyfile and finally we have the encryption mode that we are using for encryption which here is &lt;code&gt;luks&lt;/code&gt;. For more info on crypttab have a look at &lt;a class="link" href="https://www.freedesktop.org/software/systemd/man/crypttab.html" target="_blank" rel="noopener"
 &gt;https://www.freedesktop.org/software/systemd/man/crypttab.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We can now test that auto decryption is working using:&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;cryptdisks_start sdb1_crypt
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;which if successful should have an output like:&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; * Starting crypto disk... 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; * sdb1_crypt: INSECURE MODE FOR /etc/crypt/keys/sdb1, see /usr/share/doc/cryptsetup/README.Debian. 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; * sdb1_crypt (starting).. 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; * sdb1_crypt (started)... 
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;All that&amp;rsquo;s left to do now is set up&lt;/p&gt;
&lt;h2 id="auto-mount-the-filesystem"&gt;Auto-mount the filesystem
&lt;/h2&gt;&lt;p&gt;Hopefully we now are on really familiar ground&amp;hellip; we can now treat &lt;code&gt;/dev/mapper/sdb1_crypt&lt;/code&gt; as a bog standard ext4 partition that can be mounted via the &lt;code&gt;/etc/fstab&lt;/code&gt; by adding the line:&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;/dev/mapper/sdb1_crypt /mnt ext4 defaults 0 2
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As you can see its pretty ordinary, exactly as you would expect, obviously swapping out &lt;code&gt;/mnt&lt;/code&gt; with the location of your choice to mount the filesystem. If you are not wholly familiar with &lt;code&gt;fstab&lt;/code&gt; then its definitely worth having a look at &lt;a class="link" href="https://help.ubuntu.com/community/Fstab" target="_blank" rel="noopener"
 &gt;https://help.ubuntu.com/community/Fstab&lt;/a&gt; as it gives a good overview for those who are new to it&amp;hellip;&lt;/p&gt;
&lt;p&gt;Finally we can check that it all works with:&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;mount -a
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And at this point I am pretty sure I can hear the fat lady singing&amp;hellip;&lt;/p&gt;</description></item></channel></rss>