<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[helgafinn]]></title><description><![CDATA[helgafinn]]></description><link>https://helgafinn.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>helgafinn</title><link>https://helgafinn.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 08 Sep 2026 14:30:57 GMT</lastBuildDate><atom:link href="https://helgafinn.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The dead assets you can no longer grep for]]></title><description><![CDATA[Every app accumulates resources nothing uses. An image outlives the screen that displayed it. A colour survives the design system that named it. A localization key outlasts the copy it translated. Non]]></description><link>https://helgafinn.hashnode.dev/the-dead-assets-you-can-no-longer-grep-for</link><guid isPermaLink="true">https://helgafinn.hashnode.dev/the-dead-assets-you-can-no-longer-grep-for</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Xcode]]></category><category><![CDATA[localization]]></category><category><![CDATA[Developer Tools]]></category><dc:creator><![CDATA[crimsondhaks]]></dc:creator><pubDate>Thu, 03 Sep 2026 06:43:29 GMT</pubDate><content:encoded><![CDATA[<p>Every app accumulates resources nothing uses. An image outlives the screen that displayed it. A colour survives the design system that named it. A localization key outlasts the copy it translated. None of it is load-bearing, all of it ships, and Xcode says nothing.</p>
<p>The usual answer is to run a tool that finds them. That answer got quietly worse in June 2023, and the way it got worse is more interesting than the fact that it did.</p>
<h2>What a resource checker actually does</h2>
<p>The job looks simple. Read the asset catalogue to learn every declared name. Read the source to learn every referenced name. Subtract.</p>
<p>For a decade that second step was a text search. You had written <code>UIImage(named: "onboarding_illustration")</code>, so the asset's name existed verbatim in your source as a string literal. Grep for it. Found means used, absent means dead. Crude, but the string literal was a reliable anchor because the compiler gave you no other way to name an asset.</p>
<p>Xcode 15 removed the anchor.</p>
<h2>Asset symbols</h2>
<p>Xcode 15 <a href="https://developer.apple.com/videos/play/wwdc2023/10155/">generates a Swift symbol for every entry in your asset catalogue</a>, exposed as static properties on new <code>ImageResource</code> and <code>ColorResource</code> types. Idiomatic code became:</p>
<pre><code class="language-swift">Image(.onboardingIllustration)      // not Image("onboarding_illustration")
Color(.brandPrimary)                // not Color("brand_primary")
</code></pre>
<p>This is a genuine improvement. Typos become compile errors instead of blank rectangles at runtime. Rename an asset and the compiler finds every call site. There is no argument against adopting it.</p>
<p>But look at what happened to the checker. The asset is named <code>onboarding_illustration</code>. The source now contains <code>.onboardingIllustration</code>. The literal string <code>"onboarding_illustration"</code> appears nowhere in your project. A tool searching for it finds zero matches and concludes the asset is unused.</p>
<p>That is not a missed detection. It is the opposite — a confident report that a live asset is dead. Act on it and you ship a build with a missing image.</p>
<p>The name transformation is also not a straight camelCase. Xcode strips some suffixes when deriving the symbol: <a href="https://developer.apple.com/forums/thread/735357">a colour asset named <code>tealColor</code> becomes <code>.teal</code></a>, because <code>Color(.tealColor)</code> would read badly. So a checker cannot simply camelCase the declared name and search for that either. It has to model the generator's actual naming rules, including the parts that look like exceptions.</p>
<h2>The part that is still unchecked</h2>
<p>Asset symbols are at least a known problem. <a href="https://github.com/onevcat/FengNiao">FengNiao</a>, the most widely used tool in this space, <a href="https://github.com/onevcat/FengNiao/commits/master">fixed generated-symbol detection in April 2026</a> and handles it today. Credit where it is due — that repo is alive and the maintainer did the work.</p>
<p>Localization keys are a different story, and this is where the real gap sits.</p>
<p>Xcode 15 also introduced <a href="https://developer.apple.com/videos/play/wwdc2023/10155/">String Catalogs</a>, the <code>.xcstrings</code> format that supersedes <code>.strings</code> and <code>.stringsdict</code>. One JSON file per table, every language inside, structured and machine-readable. Apple's own framing is that it will replace the older formats over time, and it has been the default for new strings since Xcode 15.</p>
<p>From a tooling perspective this is a gift. The old <code>.strings</code> format was a property list dialect you had to parse carefully. A String Catalog is JSON with an explicit schema — trivial to read, trivial to enumerate.</p>
<p>And almost nothing reads it looking for dead keys. FengNiao is a resource cleaner; it does not do localization keys at all. The tools that did handle <code>.strings</code> were mostly written before <code>.xcstrings</code> existed. So the one resource type that became <em>easiest</em> to analyse is the one nobody analyses.</p>
<p>Unused localization keys are not free. They ship in every build, in every language. Worse, they cost human attention: they get sent to translators, paid for per word, and translated into forty languages for a string no screen displays. That is a recurring bill for text that renders nowhere.</p>
<h2>The tool that most people find first</h2>
<p>Search for a solution and you will likely land on <a href="https://github.com/tinymind/LSUnusedResources">LSUnusedResources</a> — 4,200 stars, a friendly Mac GUI, the top result for years.</p>
<p>Its last commit was August 2023.</p>
<p>Xcode 15 shipped that June. So the most-starred, most-discoverable tool in this category predates both asset symbols and String Catalogs, and its detection strategy is exactly the string-literal search that asset symbols invalidate. It is not abandoned in the sense of being broken and obvious. It runs, it produces a list, and on a modern codebase some entries on that list are assets you are actively using.</p>
<p>A tool that silently produces false positives is worse than no tool, because you act on it.</p>
<h2>Two things worth being honest about</h2>
<p><strong>No static check can be certain.</strong> Resource names get built at runtime:</p>
<pre><code class="language-swift">Image("mood_\(mood)")
</code></pre>
<p>No analysis can know what <code>mood</code> holds. Any tool claiming certainty here is lying to you. The correct behaviour is to surface the construction site and say plainly that it could not be resolved — not to stay quiet, and not to guess.</p>
<p><strong>A clean report is not permission to delete.</strong> It means nothing in the analysed surface referenced the name. Resources loaded from a server-driven config, referenced from a framework the analysis did not read, or named in a script the tool never saw will all look dead. Deleting resources is a change that wants a diff and a build, same as any other.</p>
<h2>The point</h2>
<p>Asset symbols and String Catalogs are both good changes. Type-safe asset references and structured localization are what you would ask for if you were designing this from scratch.</p>
<p>But they changed the shape of the problem, and the checking layer did not move with them. The result is a category of tooling where the most popular option is three years stale, the maintained option covers images but not strings, and the format that is easiest to analyse is the one nobody checks.</p>
<p>I wrote <a href="https://github.com/helgafinn/xcprune">xcprune</a> because I wanted the localization half to exist. It reads asset catalogues and string tables — <code>.strings</code>, <code>.stringsdict</code>, and <code>.xcstrings</code> — resolves references through both string literals and generated asset symbols, and reports runtime-constructed names separately as unresolved rather than folding them into either bucket. It is MIT licensed, has no dependencies, and runs as a CLI.</p>
<p>Use it, use FengNiao for the image side, or write your own. The tooling matters less than noticing that a whole class of resource stopped being checked when the format got better.</p>
]]></content:encoded></item><item><title><![CDATA[Pinning GitHub Actions to a SHA is not protection]]></title><description><![CDATA[In March 2025 an attacker compromised tj-actions/changed-files and rewrote its release tags to point at malicious code. The injected code dumped runner memory into workflow logs, which on a public rep]]></description><link>https://helgafinn.hashnode.dev/the-pin-you-stopped-reading</link><guid isPermaLink="true">https://helgafinn.hashnode.dev/the-pin-you-stopped-reading</guid><category><![CDATA[github-actions]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><category><![CDATA[cicd]]></category><category><![CDATA[supply chain]]></category><dc:creator><![CDATA[crimsondhaks]]></dc:creator><pubDate>Wed, 02 Sep 2026 12:31:06 GMT</pubDate><content:encoded><![CDATA[<p>In March 2025 an attacker compromised <code>tj-actions/changed-files</code> and rewrote its release tags to point at malicious code. The injected code dumped runner memory into workflow logs, which on a public repository means printing your secrets where anyone can read them. Around 23,000 repositories depended on that action, most of them through a mutable tag like <code>@v45</code>.</p>
<p>Repositories that had pinned the action to a commit SHA were unaffected. That is worth sitting with for a moment, because it is rare for a security practice to draw such a clean line through a real incident. The advice worked exactly as advertised.</p>
<p>So we all pinned. And then we quietly undid it.</p>
<h2>What pinning actually buys you</h2>
<p><code>uses: actions/checkout@v5</code> means "run whatever <code>v5</code> points at when this workflow starts". The tag is a pointer the upstream author can move at any time, and nothing about your repository changes when they do. You are trusting the tag to keep meaning what it meant when you wrote the line.</p>
<p><code>uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09</code> means "run this exact commit". Nobody can repoint it. If the upstream repository is compromised tomorrow, your workflow keeps running the code you reviewed.</p>
<p>That is the entire mechanism. It is not clever, and that is its strength.</p>
<h2>The part nobody mentions</h2>
<p>A pin is a snapshot, and snapshots go stale. The action you froze in March has since fixed bugs, gained features, and patched its own vulnerabilities, and you are running none of it. So you enable Dependabot, which is exactly the right call — an un-updated pin is its own risk.</p>
<p>Now look at what arrives in your pull request queue:</p>
<pre><code class="language-diff">- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
</code></pre>
<p>What changed? You cannot tell. Not because you are careless — because the diff contains no information. Two hex strings differ. That is the whole change.</p>
<p>GitHub's own engineering blog describes a repository where <a href="https://github.blog/security/supply-chain-security/tame-dependabot-group-your-updates-slow-the-cadence-keep-security-fast/">roughly one in six commits were Dependabot version bumps</a>, 61 of them in twelve months, sometimes several in a single day. Nobody meaningfully reviews an opaque hash 61 times a year. You approve it, because the alternative is manually diffing a third-party repository before every merge, and nobody has budgeted for that.</p>
<p>One maintainer put it plainly on her own blog, admitting she <a href="https://some-natalie.dev/blog/github-actions-changes/">blindly accepts Dependabot pull requests and noting this negates the benefit of pinning</a>. That is not a confession of bad practice. It is an accurate description of what the workflow makes people do.</p>
<p>So the pin is still in the file. The review it depends on is gone. You are running unreviewed third-party code with your credentials, with a line in your YAML that looks like diligence.</p>
<h2>It gets slightly worse</h2>
<p>Two details make this sharper than it first appears.</p>
<p><strong>Dependabot Alerts do not cover SHA-pinned actions.</strong> Users have been <a href="https://github.com/orgs/community/discussions/154189">asking GitHub to fix this asymmetry</a> for a while. You are told to pin, then told to enable version updates to combat staleness, and the alerting that would tell you a pinned action has a known vulnerability does not apply to the form you were told to use.</p>
<p><strong>The version comment is not verified by anything.</strong> Tooling writes bumps like this:</p>
<pre><code class="language-yaml">uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
</code></pre>
<p>That line makes two claims. First, run this commit — GitHub enforces it. Second, this commit is v5.1.0 — nothing enforces it at all. It is a comment. Your eyes read <code>v5.1.0</code> and move on, and the only thing GitHub cares about is the hash.</p>
<p>Those two claims agreeing is precisely what a retagged release breaks. In the tj-actions incident the tags were moved; the SHAs were not. If a pin's comment says <code>v5.1.0</code> and the <code>v5.1.0</code> tag now resolves to a different commit, something worth knowing has happened — either your pin is stale, or the tag moved under you. That check takes one API call, and almost nobody performs it.</p>
<h2>This is not a niche concern</h2>
<p>GitHub reported <a href="https://github.blog/news-insights/product-news/lets-talk-about-github-actions/">11.5 billion Actions minutes used in 2025, up 35% year over year</a>. Datadog's security research found that <a href="https://securitylabs.datadoghq.com/articles/case-for-github-actions-security/">two out of three organizations have at least one vulnerability in an Actions workflow</a>.</p>
<p>And the incidents kept coming after tj-actions. In May 2026 a threat group <a href="https://dev.to/unbearablelabs/two-supply-chain-attacks-in-one-week-heres-what-to-actually-fix-in-your-ci-2knc">compromised 5,561 public repositories in about six hours</a> by pushing workflows carrying dormant <code>workflow_dispatch</code> backdoors. TanStack's router was breached through <a href="https://www.copilotkit.ai/blog/tanstack-supply-chain-attack-and-how-to-lock-down-github-actions">cache poisoning that led to OIDC token theft and 84 malicious npm packages</a>.</p>
<p>Attackers moved to CI because CI holds credentials, runs on every push, and is reviewed less carefully than application code. Workflow files are code with production access that we treat as configuration.</p>
<p><em>Sources above were paraphrased; content was rephrased for compliance with licensing restrictions.</em></p>
<h2>What would actually help</h2>
<p>Not "review your Dependabot PRs more carefully." That advice has been available the whole time and it loses to arithmetic: 61 bumps a year against a finite attention budget.</p>
<p>What helps is making the diff contain information again. When a pin moves, the questions worth answering are mechanical, and mechanical questions can be answered mechanically:</p>
<ul>
<li><p>Does the new commit exist in any tag or branch upstream, or is it floating outside published history?</p>
</li>
<li><p>If the pin claims a version, does that tag still resolve to this commit?</p>
</li>
<li><p>Did the action's declared interface change — inputs, outputs, required flags?</p>
</li>
<li><p>Did its execution model change, for instance a JavaScript action becoming a Docker action that can pull an arbitrary image?</p>
</li>
<li><p>Did it gain network calls, secret references, or dynamic code execution that it did not have before?</p>
</li>
</ul>
<p>None of that requires judgment. It requires fetching two revisions and comparing them, which is a thing a computer should do for you before you click approve.</p>
<h2>Two honest caveats</h2>
<p><strong>No static check can be certain.</strong> An action that builds a resource name at runtime could reach anything, and a tool claiming certainty there is lying. The right behaviour is to say what could not be determined rather than stay quiet.</p>
<p><strong>A clean report is not a safety guarantee.</strong> It means the reviewable surface did not change in a way worth flagging. A sufficiently careful attacker can stay below that line. This raises the cost of an attack; it does not eliminate it.</p>
<h2>The point</h2>
<p>Pinning to a SHA is still correct. Keep doing it.</p>
<p>But recognise what it is: a mechanism that converts a trust problem into a review problem. It only pays out if the review happens, and we built a workflow that guarantees it does not. The pin in your YAML is not protection. It is a place where protection could go, if something were reading it.</p>
<hr />
<p>I wrote <a href="https://github.com/helgafinn/action-diff">action-diff</a> to do the comparison above — it reads both revisions of a bumped action and reports what changed, including whether the pin's claimed version still resolves to the commit it names. It is MIT licensed and runs as a GitHub Action or a CLI.</p>
<p>Use it, use something else, or write your own. The tooling matters much less than noticing that a practice we all adopted has quietly stopped working.</p>
]]></content:encoded></item></channel></rss>