<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Harshit Luthra</title>
    <description>The latest articles on Forem by Harshit Luthra (@sachincool).</description>
    <link>https://forem.com/sachincool</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F324078%2Fd55787a3-0609-4461-a718-e7cd6da8e118.png</url>
      <title>Forem: Harshit Luthra</title>
      <link>https://forem.com/sachincool</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/sachincool"/>
    <language>en</language>
    <item>
      <title>The git commands I actually run every day</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Wed, 20 May 2026 19:10:05 +0000</pubDate>
      <link>https://forem.com/sachincool/the-git-commands-i-actually-run-every-day-423p</link>
      <guid>https://forem.com/sachincool/the-git-commands-i-actually-run-every-day-423p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/daily-git-commands" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-05-20.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I've been using git for a decade and most of what I type still fits on a single hand. The 200-page Pro Git book is wonderful and almost none of it survives contact with a real Tuesday. What survives is a small, boring set of commands that get rerun constantly, plus a handful of less-boring ones I reach for once a week and would mourn if they disappeared.&lt;/p&gt;

&lt;p&gt;This post is that list, ordered by how often my fingers actually type them. Aliases are from the oh-my-zsh &lt;code&gt;git&lt;/code&gt; plugin (enabled in most zsh configs that exist); the full command sits next to the alias so it's portable.&lt;/p&gt;

&lt;h2&gt;
  
  
  the daily eight
&lt;/h2&gt;

&lt;p&gt;These are the ones I'd type in my sleep. If you're not using all eight already, picking them up pays back inside a week.&lt;/p&gt;

&lt;h3&gt;
  
  
  gst
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git status&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gst
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run this between every other command. It's the cheapest sanity check git has. Branch, ahead/behind, staged, unstaged, untracked. Two seconds. If you only learn one alias, learn this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  glola
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git log --oneline --graph --decorate --all&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;glola | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one true log. Graph of every branch (local + remote), one line per commit, colored refs. Pipe through &lt;code&gt;head&lt;/code&gt; because most of the time you only care about the last 20-30 commits. I have this bound to muscle memory more thoroughly than my own phone number.&lt;/p&gt;

&lt;h3&gt;
  
  
  gd / gds
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git diff / git diff --staged&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gd          &lt;span class="c"&gt;# what's changed but not staged&lt;/span&gt;
gds         &lt;span class="c"&gt;# what's staged and about to be committed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gds&lt;/code&gt; before every commit. If you set &lt;a href="https://github.com/dandavison/delta" rel="noopener noreferrer"&gt;delta&lt;/a&gt; as your pager (&lt;code&gt;brew install git-delta&lt;/code&gt;, then &lt;code&gt;pager = delta&lt;/code&gt; in &lt;code&gt;~/.gitconfig&lt;/code&gt;), the output stops being painful to read.&lt;/p&gt;

&lt;h3&gt;
  
  
  gcam
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git commit -a -m&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcam &lt;span class="s2"&gt;"fix: trailing slash in webhook URL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick one-line commits for small fixes. For anything bigger I drop the &lt;code&gt;-m&lt;/code&gt; and let &lt;code&gt;$EDITOR&lt;/code&gt; open so I can write a proper message with a body.&lt;/p&gt;

&lt;h3&gt;
  
  
  gpsup
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git push --set-upstream origin &amp;lt;current-branch&amp;gt;&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpsup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First push of a new branch. The full command is annoying to type, so &lt;code&gt;gpsup&lt;/code&gt; figures out the current branch name itself. After the first push, plain &lt;code&gt;gp&lt;/code&gt; (just &lt;code&gt;git push&lt;/code&gt;) works because upstream is set.&lt;/p&gt;

&lt;h3&gt;
  
  
  gco / gcb
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git checkout / git checkout -b&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gco main             &lt;span class="c"&gt;# switch to main&lt;/span&gt;
gco -                &lt;span class="c"&gt;# switch to previous branch&lt;/span&gt;
gcb feature/login    &lt;span class="c"&gt;# create + switch to new branch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gco -&lt;/code&gt; is the one to notice. Like &lt;code&gt;cd -&lt;/code&gt; for branches. When you're bouncing between two branches all day, it's a single keystroke each way instead of typing the name.&lt;/p&gt;

&lt;h3&gt;
  
  
  gpf
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git push --force-with-lease&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After rebasing or amending. &lt;strong&gt;Always use &lt;code&gt;--force-with-lease&lt;/code&gt;, never &lt;code&gt;--force&lt;/code&gt;.&lt;/strong&gt; The lease version refuses to push if someone else has pushed to your branch since your last fetch, saving you from silently overwriting a teammate's work. There is no good reason to ever type &lt;code&gt;--force&lt;/code&gt; in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  gfa
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;git fetch --all --prune&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gfa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refresh every remote, prune deleted remote branches. Run before you start anything that depends on knowing the current state of the world. The &lt;code&gt;--prune&lt;/code&gt; half is what makes the next section work.&lt;/p&gt;

&lt;h2&gt;
  
  
  the weekly five
&lt;/h2&gt;

&lt;p&gt;The commands that aren't in your fingers yet but should be.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git switch&lt;/code&gt; and &lt;code&gt;git restore&lt;/code&gt; (the new commands)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git switch main
git switch &lt;span class="nt"&gt;-c&lt;/span&gt; new-feature           &lt;span class="c"&gt;# create + switch&lt;/span&gt;
git restore &lt;span class="nt"&gt;--staged&lt;/span&gt; file.txt       &lt;span class="c"&gt;# unstage&lt;/span&gt;
git restore &lt;span class="nt"&gt;--source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;abc123 file.go &lt;span class="c"&gt;# restore single file from any commit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;switch&lt;/code&gt; and &lt;code&gt;restore&lt;/code&gt; split the four jobs &lt;code&gt;checkout&lt;/code&gt; used to do. Safer because they can't accidentally do the wrong one. The one I reach for most is &lt;code&gt;restore --source=&amp;lt;sha&amp;gt; &amp;lt;path&amp;gt;&lt;/code&gt;. Translation: "grab this single file from three commits ago without touching anything else."&lt;/p&gt;

&lt;h3&gt;
  
  
  interactive rebase with autosquash
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;--fixup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;abc123       &lt;span class="c"&gt;# fixup commit targeting abc123&lt;/span&gt;
git commit &lt;span class="nt"&gt;--fixup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;abc123       &lt;span class="c"&gt;# another one, still targeting&lt;/span&gt;
&lt;span class="c"&gt;# ... keep working ...&lt;/span&gt;
git rebase &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--autosquash&lt;/span&gt; main &lt;span class="c"&gt;# all fixups slot into place automatically&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single biggest workflow win I've found in ten years of git. While reviewing your own PR you find a bug four commits back. Don't fix it on top. &lt;code&gt;git commit --fixup=&amp;lt;sha&amp;gt;&lt;/code&gt; creates a commit &lt;em&gt;targeting&lt;/em&gt; the offender. Keep working. When you're done: &lt;code&gt;git rebase -i --autosquash main&lt;/code&gt; reorders and squashes everything for you. PR history stays clean. No &lt;code&gt;// fix bug in earlier commit&lt;/code&gt; commits.&lt;/p&gt;

&lt;p&gt;Install &lt;code&gt;git-absorb&lt;/code&gt; (&lt;code&gt;brew install git-absorb&lt;/code&gt;) and it picks the target sha for you by looking at which lines you changed. The flow becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# edit files to fix the bugs&lt;/span&gt;
git absorb &lt;span class="nt"&gt;--and-rebase&lt;/span&gt;
&lt;span class="c"&gt;# done.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time it works on a six-commit branch you'll wonder why it isn't built into git.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git reflog&lt;/code&gt;, the universal undo
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reflog
git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; HEAD@&lt;span class="o"&gt;{&lt;/span&gt;5&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every change to &lt;code&gt;HEAD&lt;/code&gt; is logged for 90 days. Bad rebase? &lt;code&gt;reflog&lt;/code&gt;. Deleted branch? &lt;code&gt;reflog&lt;/code&gt;. &lt;code&gt;reset --hard&lt;/code&gt; to the wrong commit? &lt;code&gt;reflog&lt;/code&gt;. There is almost nothing in git you can't undo if you know about it. I've never met anyone who used it as much as they should.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git worktree&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../proj-hotfix hotfix/prod-down
git worktree list
git worktree remove ../proj-hotfix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Need to fix a prod bug while halfway through a feature? Don't stash. &lt;code&gt;worktree add&lt;/code&gt; gives you a second checkout in a sibling directory, sharing the same &lt;code&gt;.git&lt;/code&gt;. Same repo, two working trees, both editable, no stash gymnastics. I use it constantly for "let me review your PR" without leaving my own branch.&lt;/p&gt;

&lt;h3&gt;
  
  
  branches sorted by recency
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; alias.recent &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"for-each-ref --sort=-committerdate refs/heads/ &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
   --format='%(HEAD) %(color:yellow)%(refname:short)%(color:reset) &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
             %(color:green)(%(committerdate:relative))%(color:reset) %(contents:subject)'"&lt;/span&gt;

git recent | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git branch&lt;/code&gt; lists alphabetically, which is useless. &lt;code&gt;git recent&lt;/code&gt; lists by last-commit-date, which is exactly what you want when you're trying to remember the name of "that branch from Tuesday."&lt;/p&gt;

&lt;h2&gt;
  
  
  the cleanup ritual
&lt;/h2&gt;

&lt;p&gt;Run this weekly. If you've ever scrolled through 80 stale branches looking for the one you actually want, you already know why.&lt;/p&gt;

&lt;h3&gt;
  
  
  the easy half: real merges
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gfa
git branch &lt;span class="nt"&gt;--merged&lt;/span&gt; main | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'\*\|main\|master'&lt;/span&gt; | xargs &lt;span class="nt"&gt;-n1&lt;/span&gt; git branch &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deletes every local branch whose tip commit is already in &lt;code&gt;main&lt;/code&gt;. Works only if your team uses merge commits. Most don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  the hard half: squash-merges
&lt;/h3&gt;

&lt;p&gt;GitHub's "Squash and merge" creates a brand-new commit on &lt;code&gt;main&lt;/code&gt; with a different SHA. &lt;code&gt;git branch --merged&lt;/code&gt; won't catch your local branch because its commits literally aren't in main's history.&lt;/p&gt;

&lt;p&gt;The workaround: after &lt;code&gt;gfa&lt;/code&gt;, any branch whose tracked remote was deleted shows as &lt;code&gt;[gone]&lt;/code&gt;. Those are your merged-and-deleted PRs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# git-gone: delete local branches whose remote tracking branch is gone&lt;/span&gt;
git-gone&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  git fetch &lt;span class="nt"&gt;--prune&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;gone
  &lt;span class="nv"&gt;gone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="nt"&gt;-each-ref&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'%(refname:short) %(upstream:track)'&lt;/span&gt; refs/heads &lt;span class="se"&gt;\&lt;/span&gt;
         | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$2 == "[gone]" {print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$gone&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No gone branches"&lt;/span&gt;
    &lt;span class="k"&gt;return
  fi
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$gone&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"Delete these? [y/N] "&lt;/span&gt;
  &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; confirm
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$confirm&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"y"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$gone&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | xargs &lt;span class="nt"&gt;-r&lt;/span&gt; git branch &lt;span class="nt"&gt;-D&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or install &lt;a href="https://github.com/foriequal0/git-trim" rel="noopener noreferrer"&gt;&lt;code&gt;git-trim&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;brew install git-trim&lt;/code&gt;), which is smarter. It also detects patch-equivalent commits, so it catches squash-merges even when the upstream tracking ref isn't &lt;code&gt;[gone]&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git trim                &lt;span class="c"&gt;# dry-run&lt;/span&gt;
git trim &lt;span class="nt"&gt;--confirm&lt;/span&gt;      &lt;span class="c"&gt;# actually delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the closest thing to "did my PR ship?" you can ask git directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  the archeology pack
&lt;/h2&gt;

&lt;p&gt;For when something is broken and the question is "when did this start."&lt;/p&gt;

&lt;h3&gt;
  
  
  pickaxe, finding when a string appeared
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s2"&gt;"functionName"&lt;/span&gt;       &lt;span class="c"&gt;# commits where this string was added or removed&lt;/span&gt;
git log &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"regex"&lt;/span&gt;              &lt;span class="c"&gt;# same but with regex&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git log --grep&lt;/code&gt; searches commit &lt;em&gt;messages&lt;/em&gt;. &lt;code&gt;-S&lt;/code&gt; searches the &lt;em&gt;content of the diff&lt;/em&gt;. Different thing entirely. When you need to find "who introduced this line" but the answer isn't simple &lt;code&gt;blame&lt;/code&gt; because the line has moved, pickaxe is the answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git blame -w -C -C -C&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git blame &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; path/to/file.go
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain &lt;code&gt;blame&lt;/code&gt; is misleading. It gives credit to whoever last touched the line, which is often whoever ran a formatter. The flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-w&lt;/code&gt; ignore whitespace changes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-C -C -C&lt;/code&gt; follow code copied or moved across files, with three levels of aggressiveness&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: the &lt;em&gt;actual&lt;/em&gt; author of the logic, not the person who reformatted it. I've used these flags to chase down a bug that touched code that had moved across three files in two refactors. Plain &lt;code&gt;blame&lt;/code&gt; would have pointed at a Prettier commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git log -p --follow &amp;lt;file&amp;gt;&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--follow&lt;/span&gt; path/to/renamed-file.go
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full history of a single file, &lt;em&gt;including across renames&lt;/em&gt;. Default &lt;code&gt;git log&lt;/code&gt; loses the trail at the rename boundary. &lt;code&gt;--follow&lt;/code&gt; does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git range-diff&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git range-diff main feature-old feature-new
git range-diff @&lt;span class="o"&gt;{&lt;/span&gt;u&lt;span class="o"&gt;}&lt;/span&gt; @
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After rewriting history with rebase, this shows what &lt;em&gt;actually&lt;/em&gt; changed between two ranges of commits, not just file diffs. The &lt;code&gt;@{u}..@&lt;/code&gt; form compares your local branch to its upstream. Run it before every force-push and you'll see exactly what you're about to overwrite. The last reviewer I worked with on a big rebase asked me to paste the &lt;code&gt;range-diff&lt;/code&gt; into the PR comments instead of re-reviewing the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  the "stop pasting from Stack Overflow" pack
&lt;/h2&gt;

&lt;p&gt;Enable these once and forget about them.&lt;/p&gt;

&lt;h3&gt;
  
  
  turn on rerere
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; rerere.enabled &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Git now remembers how you resolved a conflict and replays the resolution automatically the next time the same conflict appears. Saves real time on long-running rebases.&lt;/p&gt;

&lt;h3&gt;
  
  
  default to safer push
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; push.default current
git config &lt;span class="nt"&gt;--global&lt;/span&gt; push.autoSetupRemote &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;current&lt;/code&gt; makes &lt;code&gt;git push&lt;/code&gt; push the current branch to a remote of the same name. &lt;code&gt;autoSetupRemote&lt;/code&gt; means &lt;code&gt;git push&lt;/code&gt; on a new branch sets upstream automatically. No more &lt;code&gt;gpsup&lt;/code&gt; for the first push.&lt;/p&gt;

&lt;h3&gt;
  
  
  better diff and merge UX
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; diff.algorithm histogram
git config &lt;span class="nt"&gt;--global&lt;/span&gt; merge.conflictStyle zdiff3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;histogram&lt;/code&gt; produces cleaner diffs for most refactors than the default &lt;code&gt;myers&lt;/code&gt;. &lt;code&gt;zdiff3&lt;/code&gt; shows the common ancestor in conflict markers, i.e. the original code both sides diverged from. Once you've used it, plain &lt;code&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&lt;/code&gt; markers feel like flying blind.&lt;/p&gt;

&lt;h3&gt;
  
  
  maintenance, on a schedule
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git maintenance start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sets up a background cron-equivalent that runs &lt;code&gt;gc&lt;/code&gt;, &lt;code&gt;prefetch&lt;/code&gt;, and &lt;code&gt;loose-objects&lt;/code&gt; on a schedule. Repos stay fast without manual &lt;code&gt;git gc&lt;/code&gt; runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  the three tools worth installing today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/tummychow/git-absorb" rel="noopener noreferrer"&gt;git-absorb&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;brew install git-absorb&lt;/code&gt;). Auto-fixup commits without picking SHAs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/dandavison/delta" rel="noopener noreferrer"&gt;delta&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;brew install git-delta&lt;/code&gt;). Diff and blame output that doesn't hurt to look at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/jesseduffield/lazygit" rel="noopener noreferrer"&gt;lazygit&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;brew install lazygit&lt;/code&gt;). TUI for the operations that are tedious on CLI: partial commits, interactive add, stash management, conflict resolution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I don't reach for &lt;code&gt;lazygit&lt;/code&gt; daily, but the day I do, usually a five-way merge conflict, it pays for itself immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  bonus: two AI shell helpers for the stuff git can't tell you
&lt;/h2&gt;

&lt;p&gt;Git can tell you what changed. It can't tell you the syntax for the &lt;code&gt;find&lt;/code&gt; command you needed two minutes ago. Two short zsh functions wrap an AI CLI so the answer lands in the terminal instead of in a chat tab. &lt;code&gt;p&lt;/code&gt; prints to stdout (for reading), &lt;code&gt;d&lt;/code&gt; pre-types a command into your next prompt (for running).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# p: one-shot AI answer printed to terminal (math, facts, regex, syntax)&lt;/span&gt;
p&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  emulate &lt;span class="nt"&gt;-L&lt;/span&gt; zsh
  setopt NO_GLOB
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$# &lt;/span&gt;&lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"usage: p &amp;lt;question or task&amp;gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi
  &lt;/span&gt;pi &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--append-system-prompt&lt;/span&gt; &lt;span class="s1"&gt;'Answer in ONE line. No preamble, no explanation, no markdown, no code fences. For shell/kubectl/git/etc requests output only the command. For factual or math questions output only the answer.'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\000-\037'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^[[:space:]]*//;s/[[:space:]]*$//'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'noglob p'&lt;/span&gt;

&lt;span class="c"&gt;# d: AI suggests a shell command, pre-typed into next prompt (review + Enter)&lt;/span&gt;
d&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  emulate &lt;span class="nt"&gt;-L&lt;/span&gt; zsh
  setopt NO_GLOB
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"You are a command line expert. The user wants to run a command but they don't know how. Here is what they asked: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;query&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Return ONLY the exact shell command needed. No explanation, no markdown, no code blocks. Just the raw command."&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;cmd
  &lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;droid &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; glm-4.6 &lt;span class="nt"&gt;-r&lt;/span&gt; off &lt;span class="nt"&gt;--output-format&lt;/span&gt; text &lt;span class="nt"&gt;--disabled-tools&lt;/span&gt; execute-cli &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\000-\037'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^[[:space:]]*//;s/[[:space:]]*$//'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  print &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'noglob d'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;pi&lt;/code&gt; and &lt;code&gt;droid&lt;/code&gt; for whatever AI CLI you have: &lt;code&gt;claude -p&lt;/code&gt;, &lt;code&gt;llm&lt;/code&gt;, &lt;code&gt;gh copilot suggest&lt;/code&gt;, &lt;code&gt;ollama run&lt;/code&gt;. The pattern is what matters, not the backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  why split into two functions
&lt;/h3&gt;

&lt;p&gt;Different jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;p&lt;/code&gt; (read)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;d&lt;/code&gt; (run)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Output goes to&lt;/td&gt;
&lt;td&gt;stdout&lt;/td&gt;
&lt;td&gt;next prompt buffer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You then...&lt;/td&gt;
&lt;td&gt;read it&lt;/td&gt;
&lt;td&gt;edit / press Enter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;"what's the regex for X"&lt;/td&gt;
&lt;td&gt;"find files larger than 100MB"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  the trick: &lt;code&gt;print -z&lt;/code&gt; is what makes &lt;code&gt;d&lt;/code&gt; safe
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;print -z&lt;/code&gt; pushes text onto the zsh line editor, i.e. into your next prompt, pre-typed and ready. Compared to the alternatives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;th&gt;Safety&lt;/th&gt;
&lt;th&gt;Friction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eval "$(...)"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fastest&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;bad&lt;/strong&gt;, auto-runs model output&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipe to &lt;code&gt;pbcopy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;safe&lt;/td&gt;
&lt;td&gt;switch focus, paste&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Print to stdout&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;safe&lt;/td&gt;
&lt;td&gt;select + copy + paste&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;print -z&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;fastest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;safe&lt;/strong&gt;, you press Enter&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same trick &lt;code&gt;Ctrl-R&lt;/code&gt; history search uses. Native zsh. You always see and approve the command before it runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  what it feels like
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;p git rebase abort
git rebase &lt;span class="nt"&gt;--abort&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;p whats the syntax &lt;span class="k"&gt;for &lt;/span&gt;git log since a &lt;span class="nb"&gt;date
&lt;/span&gt;git log &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"2 weeks ago"&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;d find all .log files modified &lt;span class="k"&gt;in &lt;/span&gt;the last hour
&lt;span class="c"&gt;# next prompt now shows, cursor at the end:&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.log"&lt;/span&gt; &lt;span class="nt"&gt;-mmin&lt;/span&gt; &lt;span class="nt"&gt;-60&lt;/span&gt;█

&lt;span class="nv"&gt;$ &lt;/span&gt;d remove all &lt;span class="nb"&gt;local &lt;/span&gt;branches whose remote is gone
&lt;span class="c"&gt;# next prompt:&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git fetch &lt;span class="nt"&gt;--prune&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="nt"&gt;-each-ref&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'%(refname:short) %(upstream:track)'&lt;/span&gt; refs/heads | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$2 == "[gone]" {print $1}'&lt;/span&gt; | xargs git branch &lt;span class="nt"&gt;-D&lt;/span&gt;█
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A two-letter command, and the answer is already on the line where you wanted it.&lt;/p&gt;

&lt;h3&gt;
  
  
  the three defensive details
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;emulate &lt;span class="nt"&gt;-L&lt;/span&gt; zsh&lt;span class="p"&gt;;&lt;/span&gt; setopt NO_GLOB         &lt;span class="c"&gt;# function-local zsh defaults, no globbing&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'noglob p'&lt;/span&gt;                      &lt;span class="c"&gt;# `p list *.log files` won't glob-expand `*.log`&lt;/span&gt;
&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\000-\037'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/[trim]//'&lt;/span&gt;    &lt;span class="c"&gt;# strip control chars (incl. ANSI), trim whitespace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;noglob&lt;/code&gt; is the one most people miss. Without it, &lt;code&gt;d&lt;/code&gt; &lt;code&gt;list all *.log files&lt;/code&gt; would have zsh expand &lt;code&gt;*.log&lt;/code&gt; against the current directory &lt;em&gt;before&lt;/em&gt; the function ever sees it. With &lt;code&gt;noglob&lt;/code&gt;, the glob characters pass through literally.&lt;/p&gt;

&lt;p&gt;A single-function variant of this, with a heuristic that picks stdout vs pre-typed automatically, lives in &lt;a href="https://dev.to/til/one-line-ai-shell-helper"&gt;this TIL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  ten years in, the surprise
&lt;/h2&gt;

&lt;p&gt;After a decade, the command I run most isn't &lt;code&gt;commit&lt;/code&gt;. It isn't &lt;code&gt;push&lt;/code&gt;. It's &lt;code&gt;gst&lt;/code&gt;, hundreds of times a day, between every other operation. The most-used git command in my shell is the one that does nothing.&lt;/p&gt;

</description>
      <category>git</category>
      <category>shell</category>
      <category>zsh</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A single zsh function for one-line AI answers that knows when to pre-type the command</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Wed, 20 May 2026 07:01:10 +0000</pubDate>
      <link>https://forem.com/sachincool/a-7-line-shell-function-for-one-liner-ai-answers-20o8</link>
      <guid>https://forem.com/sachincool/a-7-line-shell-function-for-one-liner-ai-answers-20o8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/til/one-line-ai-shell-helper" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-05-20.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I kept opening a chat tab just to ask "what's the kubectl command for decoding a secret" or "convert 42 GiB to bytes". The context switch was costing more than the answer was worth.&lt;/p&gt;

&lt;p&gt;Wrapping an AI CLI into a single shell function fixed it. The interesting part is &lt;code&gt;print -z&lt;/code&gt;, plus one heuristic that needs more care than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  the function
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# p: one-shot AI query. Examples: `p whats 2 + 2`, `p kubectl secret decode grafana`&lt;/span&gt;
&lt;span class="c"&gt;# Smart dispatch: if the answer looks like a runnable command, pre-type it into&lt;/span&gt;
&lt;span class="c"&gt;# the next prompt (print -z). Otherwise print to stdout. Math/facts get printed,&lt;/span&gt;
&lt;span class="c"&gt;# commands get queued for you to review and press Enter.&lt;/span&gt;
p&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  emulate &lt;span class="nt"&gt;-L&lt;/span&gt; zsh
  setopt NO_GLOB
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$# &lt;/span&gt;&lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"usage: p &amp;lt;question or task&amp;gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;local &lt;/span&gt;out
  &lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pi &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--no-session&lt;/span&gt; &lt;span class="nt"&gt;--append-system-prompt&lt;/span&gt; &lt;span class="s1"&gt;'Answer in ONE line. No preamble, no explanation, no markdown, no code fences. For shell/kubectl/git/etc requests output only the command. For factual or math questions output only the answer.'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\000-\037'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^[[:space:]]*//;s/[[:space:]]*$//'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    return &lt;/span&gt;1
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;first&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="p"&gt;%% *&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;a-zA-Z_]&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; whence &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;print &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;print &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'noglob p'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pi&lt;/code&gt; is just whatever AI CLI you have. Swap in &lt;code&gt;claude -p&lt;/code&gt;, &lt;code&gt;llm&lt;/code&gt;, &lt;code&gt;gh copilot suggest&lt;/code&gt;, &lt;code&gt;ollama run&lt;/code&gt;. The pattern doesn't care about the backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it feels like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;p whats 2 + 2
&lt;span class="go"&gt;4

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;p capital of mongolia
&lt;span class="go"&gt;Ulaanbaatar

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;p regex &lt;span class="k"&gt;for &lt;/span&gt;matching an email
&lt;span class="go"&gt;[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;p kubectl secret decode grafana
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;next prompt now shows, cursor at the end:
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl get secret grafana &lt;span class="nt"&gt;-o&lt;/span&gt; go-template&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{range $k,$v := .data}}{{$k}}: {{$v | base64decode}}{{"\n"}}{{end}}'&lt;/span&gt;█
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;p find all log files modified today
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;next prompt:
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.log"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;█
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same two-letter command for both. Answers go to stdout, commands go to the prompt buffer where you can edit them before pressing Enter.&lt;/p&gt;

&lt;h2&gt;
  
  
  the key idea: &lt;code&gt;print -z&lt;/code&gt; for runnable output
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;print -z&lt;/code&gt; is the trick that makes this design work. It pushes text onto the zsh line editor, i.e. into your next prompt, pre-typed and ready. Compared to every alternative:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;th&gt;Safety&lt;/th&gt;
&lt;th&gt;Friction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eval "$(...)"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fastest&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;bad&lt;/strong&gt;, auto-runs model output&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipe to &lt;code&gt;pbcopy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;safe&lt;/td&gt;
&lt;td&gt;switch focus, paste&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Print to stdout&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;safe&lt;/td&gt;
&lt;td&gt;select + copy + paste&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;print -z&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;fastest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;safe&lt;/strong&gt;, you press Enter&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mental model: &lt;code&gt;print -z&lt;/code&gt; is what &lt;code&gt;Ctrl-R&lt;/code&gt; history search does when you accept a result. Native zsh. You always see and approve the command before it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  the heuristic: when is the answer a command?
&lt;/h2&gt;

&lt;p&gt;The smart dispatch decides between &lt;code&gt;print -z&lt;/code&gt; (pre-type) and &lt;code&gt;print -r&lt;/code&gt; (stdout) by looking at the first word of the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;a-zA-Z_]&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; whence &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$first&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;print &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;print &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two checks, both load-bearing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First char is a letter or underscore.&lt;/strong&gt; Excludes digits (&lt;code&gt;4&lt;/code&gt;), symbols (&lt;code&gt;[&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;(&lt;/code&gt;), and anything else that obviously isn't a command name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;whence -p&lt;/code&gt; resolves it to a PATH executable.&lt;/strong&gt; Not just "this name exists in the shell", but &lt;em&gt;specifically&lt;/em&gt; a real binary on disk.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why &lt;code&gt;whence -p&lt;/code&gt; and not &lt;code&gt;command -v&lt;/code&gt;? Read on.&lt;/p&gt;

&lt;h2&gt;
  
  
  the footgun: oh-my-zsh numeric aliases
&lt;/h2&gt;

&lt;p&gt;My first attempt used &lt;code&gt;command -v "$first"&lt;/code&gt; as the heuristic. It looked right. It failed in a way that took a minute to spot.&lt;/p&gt;

&lt;p&gt;When I ran &lt;code&gt;p whats 2 + 2&lt;/code&gt;, the answer was &lt;code&gt;4&lt;/code&gt;, but nothing appeared in my terminal. The function exited cleanly with status 0. No error.&lt;/p&gt;

&lt;p&gt;What had happened: oh-my-zsh's &lt;code&gt;dirhistory&lt;/code&gt; plugin (loaded by default in many configs) aliases &lt;code&gt;1&lt;/code&gt; through &lt;code&gt;9&lt;/code&gt; to &lt;code&gt;cd -1&lt;/code&gt; ... &lt;code&gt;cd -9&lt;/code&gt; for jumping around the directory stack. So &lt;code&gt;command -v 4&lt;/code&gt; returned true. &lt;code&gt;4&lt;/code&gt; was a recognized alias, and the function tried to &lt;code&gt;print -z 4&lt;/code&gt; into my prompt buffer.&lt;/p&gt;

&lt;p&gt;In a real interactive shell, that would have stuffed &lt;code&gt;4&lt;/code&gt; into my prompt invisibly (it'd appear when I hit Enter). In my non-interactive test (&lt;code&gt;zsh -ic '...'&lt;/code&gt;) it disappeared into the void because there's no line editor to render the stuffed buffer.&lt;/p&gt;

&lt;p&gt;The fix has two parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[[ "$first" == [a-zA-Z_]* ]]&lt;/code&gt;&lt;/strong&gt; alone would have caught it, because &lt;code&gt;4&lt;/code&gt; doesn't start with a letter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;whence -p&lt;/code&gt;&lt;/strong&gt; instead of &lt;code&gt;command -v&lt;/code&gt; makes it doubly safe. &lt;code&gt;whence -p&lt;/code&gt; only matches binaries in PATH, ignoring aliases, functions, and builtins. Aliases like &lt;code&gt;4 → cd -4&lt;/code&gt; are filtered out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either check alone would have caught the bug. Having both means the next time I add a feature here, I don't have to remember which one was load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  defensive details that earn their keep
&lt;/h2&gt;

&lt;p&gt;Three small things prevent subtle bugs:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;noglob&lt;/code&gt; on the alias
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'noglob p'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, &lt;code&gt;p list all *.log files&lt;/code&gt; would have zsh expand &lt;code&gt;*.log&lt;/code&gt; against the current directory &lt;em&gt;before&lt;/em&gt; the function ever sees it. With &lt;code&gt;noglob&lt;/code&gt;, the glob characters pass through literally. Same trick git uses for its arguments.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;emulate -L zsh&lt;/code&gt; + &lt;code&gt;setopt NO_GLOB&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;emulate &lt;span class="nt"&gt;-L&lt;/span&gt; zsh
setopt NO_GLOB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;emulate -L zsh&lt;/code&gt; resets shell options to defaults, scoped to this function only (the &lt;code&gt;-L&lt;/code&gt; means local, so they restore on return). &lt;code&gt;NO_GLOB&lt;/code&gt; is belt-and-suspenders for callers that bypass the alias (&lt;code&gt;command p ...&lt;/code&gt;, &lt;code&gt;\p ...&lt;/code&gt;, or scripts that don't see your aliases).&lt;/p&gt;

&lt;h3&gt;
  
  
  output sanitization
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\000-\037'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^[[:space:]]*//;s/[[:space:]]*$//'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tr -d '\000-\037'&lt;/code&gt; strips all C0 control characters. That includes ANSI escape sequences (ESC = &lt;code&gt;\033&lt;/code&gt;), stray nulls, and any invisible cruft the model might emit. Critical for &lt;code&gt;print -z&lt;/code&gt; because control characters in the payload corrupt the line editor's display.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sed&lt;/code&gt; then trims leading and trailing whitespace, which the model usually adds even when told not to.&lt;/p&gt;

&lt;h2&gt;
  
  
  why &lt;code&gt;"$*"&lt;/code&gt; and not &lt;code&gt;"$@"&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;"$*"&lt;/code&gt; joins all positional args into one string with spaces between them. &lt;code&gt;"$@"&lt;/code&gt; would pass them as separate args, which most AI CLIs would concatenate anyway, but some treat the first positional as the prompt and the rest as files (the &lt;code&gt;@file.txt&lt;/code&gt; convention is common). Joining explicitly avoids that ambiguity.&lt;/p&gt;

&lt;p&gt;If your CLI supports &lt;code&gt;--&lt;/code&gt; to end option parsing, prefer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;your-ai-cli &lt;span class="nt"&gt;-p&lt;/span&gt; ... &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pi&lt;/code&gt; doesn't accept &lt;code&gt;--&lt;/code&gt;, hence the bare &lt;code&gt;"$*"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  the system-prompt nudge actually matters
&lt;/h2&gt;

&lt;p&gt;Without &lt;code&gt;--append-system-prompt&lt;/code&gt;, even with &lt;code&gt;-p&lt;/code&gt;, the default coding-assistant prompt wraps shell commands in code fences and adds a one-sentence intro. That breaks &lt;code&gt;print -z&lt;/code&gt; (code fences are not commands) and clutters the terminal.&lt;/p&gt;

&lt;p&gt;The phrasing that worked best:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Answer in ONE line. No preamble, no explanation, no markdown, no code fences. For shell/kubectl/git/etc requests output only the command. For factual or math questions output only the answer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"No markdown, no code fences" is doing most of the work. Without it you get backtick-wrapped output that &lt;code&gt;print -z&lt;/code&gt; would happily push into your prompt as &lt;code&gt;`kubectl get pods`&lt;/code&gt;, which is not a runnable command.&lt;/p&gt;

&lt;h2&gt;
  
  
  why this beats the chat UI for short questions
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Chat UI&lt;/th&gt;
&lt;th&gt;&lt;code&gt;p&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Switch context&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Round-trip latency&lt;/td&gt;
&lt;td&gt;~3-5s + UI&lt;/td&gt;
&lt;td&gt;~1-2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output format&lt;/td&gt;
&lt;td&gt;markdown, prose&lt;/td&gt;
&lt;td&gt;bare answer or pre-typed command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Get command into shell&lt;/td&gt;
&lt;td&gt;select + copy + paste&lt;/td&gt;
&lt;td&gt;already in your prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session pollution&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no (&lt;code&gt;--no-session&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glob-expansion footgun&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;guarded (&lt;code&gt;noglob&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For anything longer than a paragraph the chat UI is still better. For "what's the syntax for X" or "the command for Y", the terminal is the right place to put the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  the one substitution that fixed it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;command -v&lt;/code&gt; → &lt;code&gt;whence -p&lt;/code&gt;. One swap. The rest of the function (the &lt;code&gt;noglob&lt;/code&gt;, the &lt;code&gt;emulate -L zsh&lt;/code&gt;, the control-char strip) was already doing its job. The bug was trusting that "this name resolves in the shell" meant "this name is a binary on disk." It doesn't, and on any zsh with oh-my-zsh loaded it especially doesn't.&lt;/p&gt;

</description>
      <category>zsh</category>
      <category>shell</category>
      <category>ai</category>
      <category>cli</category>
    </item>
    <item>
      <title>How to bypass corporate MDM and AI gateways on Claude Code</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:59:08 +0000</pubDate>
      <link>https://forem.com/sachincool/how-to-bypass-corporate-mdm-and-ai-gateways-on-claude-code-27de</link>
      <guid>https://forem.com/sachincool/how-to-bypass-corporate-mdm-and-ai-gateways-on-claude-code-27de</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/bypassing-claude-code-mdm-managed-settings" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-05-08.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you're reading this, there's roughly an 80% chance your company rolled out an MDM last quarter, your network team wedged Claude API traffic through an AI gateway around the same time, and now Claude Code boots with MCPs you didn't pick while forwarding your prompts somewhere you haven't audited. &lt;code&gt;/mcp&lt;/code&gt; shows three servers nothing in your repo touches. &lt;code&gt;env | grep ANTHROPIC&lt;/code&gt; returns a base URL on a domain you've never seen. The experience got worse and nobody asked you.&lt;/p&gt;

&lt;p&gt;This post covers both leashes. The MDM one is fixable in 12 lines of zsh. The AI gateway one depends on how deep your network team went.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's an MDM, in three sentences
&lt;/h2&gt;

&lt;p&gt;MDM stands for Mobile Device Management. Jamf, Kandji, Intune, Workspace ONE, whichever agent enrolled your laptop on day one. It owns parts of &lt;code&gt;/Library&lt;/code&gt;, can write files there as root with the system-immutable flag set, and re-pushes them on a schedule, which is why a plain &lt;code&gt;rm&lt;/code&gt; doesn't survive. For Claude Code, the relevant directory is &lt;code&gt;/Library/Application Support/ClaudeCode/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  the managed-settings situation
&lt;/h2&gt;

&lt;p&gt;The two files doing the work are &lt;code&gt;/Library/Application Support/ClaudeCode/managed-settings.json&lt;/code&gt; and &lt;code&gt;/Library/Application Support/ClaudeCode/managed-mcp.json&lt;/code&gt;. Claude Code reads them on startup, treats them as the highest-priority settings layer, and merges them over whatever you have in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;. Anything IT puts in there wins: forced MCPs, forced skills, allowed and denied permission lists, and the &lt;code&gt;env&lt;/code&gt; block that can set &lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt;. That last one is how the AI gateway routing gets wired into Claude Code in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  why &lt;code&gt;rm&lt;/code&gt; doesn't work
&lt;/h2&gt;

&lt;p&gt;First instinct fails, and not in a way that's obvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="s2"&gt;"/Library/Application Support/ClaudeCode/managed-settings.json"&lt;/span&gt;
&lt;span class="c"&gt;# rm: managed-settings.json: Operation not permitted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Root isn't enough. The MDM agent sets the file's system-immutable flag with &lt;code&gt;chflags schg&lt;/code&gt; after writing it. That flag blocks deletion even by root until it's cleared. The macOS &lt;code&gt;chflags(1)&lt;/code&gt; man page is the receipt. &lt;code&gt;schg&lt;/code&gt; is the "system immutable" flag, and the file "may not be changed, moved, or deleted" while it's set.&lt;/p&gt;

&lt;p&gt;Confirm it on your own machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lO&lt;/span&gt; &lt;span class="s2"&gt;"/Library/Application Support/ClaudeCode/managed-settings.json"&lt;/span&gt;
&lt;span class="c"&gt;# -rw-r--r--  1 root  wheel  schg  482 May 14 09:11 managed-settings.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;schg&lt;/code&gt; in column five is the marker.&lt;/p&gt;

&lt;p&gt;The detail that matters: managed-settings.json is the same config layer your &lt;code&gt;~/.claude/settings.json&lt;/code&gt; uses. The IT copy just lives under &lt;code&gt;/Library&lt;/code&gt;, is owned by root, and has the schg flag set. The merge logic doesn't know which file came from a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  the cleanup script
&lt;/h2&gt;

&lt;p&gt;One thing worth flagging before you run this. On macOS, the &lt;code&gt;schg&lt;/code&gt; flag is normally clearable by root for files outside SIP-protected paths — and &lt;code&gt;/Library/Application Support/ClaudeCode/&lt;/code&gt; is not SIP-protected. So &lt;code&gt;sudo chflags noschg&lt;/code&gt; works as written. If your MDM also writes its config into a SIP-protected location (rare for application config, more common for system extensions), you'd need Recovery Mode Terminal to clear those, which is a different conversation. The script's &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; will silently swallow that failure, so if reruns don't seem to take, that's where to look.&lt;/p&gt;

&lt;p&gt;Save this as &lt;code&gt;/usr/local/sbin/claudecode-cleanup.sh&lt;/code&gt;, make it executable, run with &lt;code&gt;sudo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/zsh&lt;/span&gt;
&lt;span class="nv"&gt;FILES&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"/Library/Application Support/ClaudeCode/managed-settings.json"&lt;/span&gt;
  &lt;span class="s2"&gt;"/Library/Application Support/ClaudeCode/managed-mcp.json"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FILES&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c"&gt;# Clear immutable flag if file exists, then remove&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; /usr/bin/chflags noschg &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
  /bin/rm &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;755 /usr/local/sbin/claudecode-cleanup.sh
&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/local/sbin/claudecode-cleanup.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines do the real work. &lt;code&gt;chflags noschg&lt;/code&gt; clears the immutable bit. &lt;code&gt;rm -f&lt;/code&gt; removes the file. The &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; swallows the noise on a clean machine where the file isn't there.&lt;/p&gt;

&lt;p&gt;Restart Claude Code. &lt;code&gt;/mcp&lt;/code&gt; should be back to whatever you actually installed, and &lt;code&gt;/permissions&lt;/code&gt; should be whatever's in &lt;code&gt;~/.claude/settings.json&lt;/code&gt; instead of whatever IT decided you needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  the launchd arms race
&lt;/h2&gt;

&lt;p&gt;I'd love to tell you this is permanent. It isn't.&lt;/p&gt;

&lt;p&gt;MDM agents sync on a schedule. Every 15 minutes, every hour, on login, depending on profile. When they sync, they notice the file is gone, put it back, and re-apply the schg flag. You'll watch managed-mcp.json reappear like a horror-movie villain you keep stabbing.&lt;/p&gt;

&lt;p&gt;A few options, in increasing order of trouble you're inviting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run the script on a launchd LaunchAgent that fires at login.&lt;/strong&gt; Once per session. Low impact, low effectiveness, but if your MDM only syncs at login this is enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run it on a launchd timer with a 60-second interval.&lt;/strong&gt; Now you're in an arms race with the sync schedule. Works until someone in IT notices a config-drift alert for your hostname.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block the MDM agent's outbound DNS.&lt;/strong&gt; Effective, loud, and the kind of thing that gets your laptop wiped on the next compliance audit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I run the first one. The MDM gets its login telemetry, my dev environment isn't broken for the hour or so between syncs, nobody opens a ticket. Pick the option that matches how much you actually want to fight this.&lt;/p&gt;

&lt;p&gt;Minimal &lt;code&gt;~/Library/LaunchAgents/cloud.harshit.claudecode-cleanup.plist&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;plist&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Label&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;cloud.harshit.claudecode-cleanup&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/usr/bin/sudo&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;-n&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/usr/local/sbin/claudecode-cleanup.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;RunAtLoad&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plist&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sudo -n&lt;/code&gt; only works if you've added a NOPASSWD line for that exact script in &lt;code&gt;/etc/sudoers.d/claudecode-cleanup&lt;/code&gt;. Which the MDM might rewrite. The arms race goes deeper than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  the AI gateway angle
&lt;/h2&gt;

&lt;p&gt;The other leash sits at the network layer. Companies route Claude API traffic through a gateway (Cloudflare AI Gateway, Portkey, LiteLLM, internal proxies) so they can log prompts, strip PII, enforce per-user quotas, or quietly downgrade Opus calls to Haiku when the monthly bill spikes. Claude Code respects &lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt; and will talk to whatever endpoint it points at, as long as your OAuth token or API key authenticates there.&lt;/p&gt;

&lt;p&gt;Two routing patterns to recognize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The env block in managed-settings.json.&lt;/strong&gt; IT sets &lt;code&gt;ANTHROPIC_BASE_URL=https://ai-gw.corp.example.com/v1&lt;/code&gt; inside the env section of the managed file. Claude Code reads it on startup. Same fix as the MCP file. The cleanup script above already kills this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System proxy plus a corporate root CA.&lt;/strong&gt; Your laptop has a "Corporate Root CA" in keychain, and either an &lt;code&gt;https.proxy&lt;/code&gt; setting or transparent network interception routes api.anthropic.com traffic through the gateway. Deleting managed-settings.json does nothing here. The interception lives below the application layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To tell which one you have, run this in a fresh shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; anthropic
&lt;span class="c"&gt;# If you see ANTHROPIC_BASE_URL, it's the env block.&lt;/span&gt;

curl &lt;span class="nt"&gt;-v&lt;/span&gt; https://api.anthropic.com/v1/messages 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'issuer|subject|server certificate'&lt;/span&gt;
&lt;span class="c"&gt;# If the cert chain is signed by your corporate CA, it's transparent interception.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  bypassing the gateway
&lt;/h2&gt;

&lt;p&gt;For the env-block case, the cleanup script already does the work. Restart your shell after running it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;unset &lt;/span&gt;ANTHROPIC_BASE_URL
&lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; anthropic
&lt;span class="c"&gt;# (empty)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the transparent-proxy case, your options shrink:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Personal hotspot for sensitive sessions.&lt;/strong&gt; Burns mobile data, leaves no trail through the gateway. Most realistic option for an individual contributor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WireGuard or Tailscale out to a personal node.&lt;/strong&gt; Works if your MDM profile allows it. Many block third-party VPNs through &lt;code&gt;com.apple.systempolicy.kernel-extension-policy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal device for personal work.&lt;/strong&gt; Boring answer. The one that holds up in HR if it ever comes up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What doesn't work: removing the corporate root CA from keychain. It's pinned by an MDM payload and gets re-added on next sync, same pattern as managed-settings.json.&lt;/p&gt;

&lt;h2&gt;
  
  
  should you actually do this
&lt;/h2&gt;

&lt;p&gt;Worth saying out loud: both leashes exist because someone at your company had a reason. Compliance, data residency, an incident from six months ago whose postmortem nobody can find.&lt;/p&gt;

&lt;p&gt;If the forced MCP is &lt;code&gt;internal-secrets-lookup&lt;/code&gt; and the gateway logs prompts to a SOC pipeline, your team probably wants you using it. If the MCP is &lt;code&gt;corporate-docs-mcp&lt;/code&gt; pointed at a 404 and the gateway downgrades Opus to Haiku because someone misread an invoice, you're deleting dead weight.&lt;/p&gt;

&lt;p&gt;The script doesn't know which. Ask before you script. Most MDM platforms support per-user opt-out scopes, and one polite Slack message to IT beats a &lt;code&gt;launchd&lt;/code&gt; plist.&lt;/p&gt;

&lt;h2&gt;
  
  
  what these scripts don't do
&lt;/h2&gt;

&lt;p&gt;The cleanup clears two files. It does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stop the MDM agent.&lt;/li&gt;
&lt;li&gt;Touch &lt;code&gt;~/.claude/settings.json&lt;/code&gt;. Your settings stay yours.&lt;/li&gt;
&lt;li&gt;Handle &lt;code&gt;/Library/Application Support/ClaudeCode/managed-permissions.json&lt;/code&gt; if your MDM uses one. Add it to the &lt;code&gt;FILES&lt;/code&gt; array.&lt;/li&gt;
&lt;li&gt;Survive a reboot or a sync. The agent re-pushes on next check-in.&lt;/li&gt;
&lt;li&gt;Defeat a transparent proxy with a pinned corporate CA. Use the hotspot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you wanted a permanent escape from corporate IT, you wouldn't be reading a blog about &lt;code&gt;chflags&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>mdm</category>
      <category>aigateway</category>
      <category>macos</category>
    </item>
    <item>
      <title>Lazy SRE's guide to secure systems, part 5: the dev laptop is the perimeter</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:58:52 +0000</pubDate>
      <link>https://forem.com/sachincool/lazy-sres-guide-to-secure-systems-part-5-the-dev-laptop-is-the-perimeter-3109</link>
      <guid>https://forem.com/sachincool/lazy-sres-guide-to-secure-systems-part-5-the-dev-laptop-is-the-perimeter-3109</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/lazy-security-part-5-dev-laptops" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-05-03.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;In June 2024, Mandiant published the writeup for the Snowflake mass-extortion campaign. Ticketmaster, Santander, AT&amp;amp;T, LendingTree, Advance Auto Parts — roughly 165 Snowflake tenants in total had data extracted from their warehouses. The defining detail wasn't sophistication. It was the laptop.&lt;/p&gt;

&lt;p&gt;Mandiant traced the entry point to infostealer malware (Lumma, RedLine, Vidar variants) running on contractor and developer machines. Their report described the affected devices as personal systems also used for gaming and downloading pirated software. The infostealer harvested every credential the browser had ever saved, including the Snowflake login that didn't have MFA enforced. The attackers walked through the front door of a Fortune 500's data warehouse.&lt;/p&gt;

&lt;p&gt;This is part 5. Earlier parts covered npm (&lt;a href="https://dev.to/blog/lazy-security-part-1-supply-chain"&gt;Part 1&lt;/a&gt;), GitHub Actions (&lt;a href="https://dev.to/blog/lazy-security-part-2-github-actions"&gt;Part 2&lt;/a&gt;), the unsexy infrastructure list (&lt;a href="https://dev.to/blog/lazy-security-part-3-unsexy-list"&gt;Part 3&lt;/a&gt;), and DNS auth records (&lt;a href="https://dev.to/blog/lazy-security-part-4-dns-records"&gt;Part 4&lt;/a&gt;). Part 5 is about the laptop. The piece of hardware on an engineer's desk that has every SSH key, AWS profile, kubeconfig, GitHub PAT, Slack token, and Stripe key they have ever used to do their job.&lt;/p&gt;

&lt;p&gt;The thesis from Part 1 stands. Future You at 3am will not run an EDR scan after every browser extension install. The config that prevents the extension from being installed in the first place is the one that runs while you sleep: the MDM that whitelists, the disk encryption that protects what gets stolen, the hardware MFA that survives the keylogger.&lt;/p&gt;

&lt;h2&gt;
  
  
  MDM is the table you set first
&lt;/h2&gt;

&lt;p&gt;Mobile Device Management is the thing every small startup skips and every enterprise has. The bad-faith reason is that it's expensive and annoying. The honest reason in 2026 is that the free options have caught up.&lt;/p&gt;

&lt;p&gt;For a 15-person Apple-heavy team, the lazy stack is &lt;strong&gt;Apple Business Manager&lt;/strong&gt; (free, Apple-only) plus &lt;strong&gt;Fleet&lt;/strong&gt; (OSS, free under 300 endpoints on the self-hosted path, generous free tier on Fleet's cloud). Apple Business Manager assigns a Mac to your organization at first boot, before the user creates a personal Apple ID on it. Fleet runs the osquery agent on every machine and lets you push configuration profiles (the same plist payloads Jamf would push) plus query inventory in SQL syntax.&lt;/p&gt;

&lt;p&gt;The lazy default config profile, in plain English:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Require FileVault. Escrow the recovery key to MDM. If the laptop walks, the disk is encrypted; if the user forgets the password, you can recover.&lt;/li&gt;
&lt;li&gt;Require auto-lock at five minutes idle, password to wake. Not a screensaver.&lt;/li&gt;
&lt;li&gt;Block unsigned package installs, restrict the Mac App Store to managed Apple IDs only.&lt;/li&gt;
&lt;li&gt;Require macOS updates within fourteen days of release. The fourteen days lets you skip a known-bad point release; longer than fourteen is negligence.&lt;/li&gt;
&lt;li&gt;Block AirDrop on the corporate Wi-Fi, restrict USB external storage to read-only (or block entirely if your workflow doesn't need it).&lt;/li&gt;
&lt;li&gt;Install osquery via MDM, enrolled to your Fleet server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For Linux and Windows in the mix, Fleet covers both with the same osquery agent and the same query syntax. The MDM-config-profile half is Windows Intune (free with Microsoft 365 Business Premium) or Workspace ONE's free tier. Either way, the stack is "Fleet for inventory and detections + a platform-specific MDM for enforcement."&lt;/p&gt;

&lt;p&gt;The lazy fix for the most common gap: a weekly cron that runs one Fleet query, "every laptop without FileVault enabled," and posts a Slack alert with the user's name. The conversation that follows is "we found your machine, can you enable it today" — not a six-month audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  hardware keys, one-time spend
&lt;/h2&gt;

&lt;p&gt;YubiKey 5 NFC is $50. Buy two per engineer: one for the desk, one for the bag. Total for 15 engineers: $1,500, one-time, capital expense, deductible.&lt;/p&gt;

&lt;p&gt;What it gets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WebAuthn / FIDO2 for SSO login (Google, Okta, GitHub, Cloudflare, AWS): a keylogger can record every keystroke and still never get the second factor.&lt;/li&gt;
&lt;li&gt;SSH key storage in hardware. &lt;code&gt;ssh-keygen -t ed25519-sk -O resident&lt;/code&gt; writes the key into the YubiKey. The private key never exists on disk.&lt;/li&gt;
&lt;li&gt;PIV smartcard for VPN auth, code signing (&lt;code&gt;gpg --card-edit&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;TOTP fallback for the SaaS that hasn't shipped WebAuthn yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The free alternative for the SaaS that doesn't support hardware keys is passkeys. Passkeys are WebAuthn under the hood, also phishing-resistant, built into iOS, macOS, Android, Windows Hello, Chrome, and Safari. Free. The catch is sync: if the engineer's iCloud is compromised, so is the passkey. Hardware keys aren't synced; they are a physical token. The lazy answer is both: passkeys for low-risk auth, YubiKeys for the keys that gate production.&lt;/p&gt;

&lt;p&gt;Cost: $1,500 one-time for 15 engineers. The cheapest line item in this post for what it gets you.&lt;/p&gt;

&lt;h2&gt;
  
  
  EDR is where the budget goes
&lt;/h2&gt;

&lt;p&gt;Endpoint Detection and Response is the part of this stack that costs real money. For OSS-only, the answer is osquery + Wazuh, which works but requires writing detections by hand. For a 15-person team with one platform engineer, "write your own EDR detections" is not a project anyone will finish.&lt;/p&gt;

&lt;p&gt;The honest 2026 small-team answer is &lt;strong&gt;Microsoft Defender for Business&lt;/strong&gt; at $3/user/month. It ships in Microsoft 365 Business Premium (also useful if you're on M365 anyway), has acceptable macOS coverage, and includes managed detections written by Microsoft's security team. Cost for 15 engineers: $540/year. &lt;strong&gt;CrowdStrike Falcon Go&lt;/strong&gt; is $60/endpoint/year if you want best-in-class detection at small-team scale; same math, $900/year for 15.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fznvd4iki3kai24wn1vss.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fznvd4iki3kai24wn1vss.gif" alt="An animated horizontal bar chart in a dark editorial palette comparing the annual endpoint stack cost for a 15-engineer team across three configurations. Top bar: OSS-only (osquery + Wazuh self-hosted) at roughly $240/year (just the VPS). Middle bar (accented, brighter cyan, coral tip): Defender for Business at $540/year, the recommended default. Bottom bar: CrowdStrike Falcon Go at $900/year. A small note underneath each bar shows what each catches and what each misses; a strip at the bottom reads 'one-time YubiKey spend not included ($1,500 for 15 engineers across all three).'" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 — three configurations. Pick the middle bar unless you have a reason.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The lazy stance: Defender for Business if you're on Microsoft 365 already. Falcon Go if you're not on M365 and want managed detection without the OSS-engineer overhead. osquery + Wazuh only if you have a security engineer with bandwidth to maintain the detections, which most 15-person startups don't. Pretending otherwise is how you end up with a fancy SIEM nobody reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  the password manager and browser hygiene argument
&lt;/h2&gt;

&lt;p&gt;1Password Business at ~$8/user/month. Bitwarden Teams at $4. Apple Passwords (or 1Password Families) if you're Mac-only and don't need shared vaults. Pick one and stop arguing about it on the team's &lt;code&gt;#tools&lt;/code&gt; channel.&lt;/p&gt;

&lt;p&gt;The point of the password manager isn't strong passwords. The point is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One place for credentials, audited.&lt;/li&gt;
&lt;li&gt;Shared vaults for vendor logins, instead of "share the password in Slack DM" hygiene.&lt;/li&gt;
&lt;li&gt;Breach notifications when a saved password appears in a public breach corpus.&lt;/li&gt;
&lt;li&gt;Masked email aliases (1Password feature, Apple's Hide My Email equivalent): every signup gets a separate alias, every spam list is contained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Browser hygiene matters because the Snowflake infostealer harvested credentials from browser local storage. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enforce browser auto-updates via MDM. Both Chrome and Edge expose policy keys for this; Firefox via &lt;code&gt;policies.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Block sync of work browser profiles to personal Google/Apple accounts. The "I signed into Chrome with my personal account and now all my work bookmarks are in someone else's cloud" leak is real.&lt;/li&gt;
&lt;li&gt;Block "developer mode" extension installs. Force extensions to come from the Chrome Web Store; force the Web Store to honor the org's allowlist via the &lt;code&gt;ExtensionInstallAllowlist&lt;/code&gt; policy.&lt;/li&gt;
&lt;li&gt;Disable browser password saving entirely. Everything routes through the password manager.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total: $1,440/year for 15 engineers on 1Password Business. $720 on Bitwarden Teams. $0 on Apple Passwords if it covers your needs. Pick a line and walk it.&lt;/p&gt;

&lt;h2&gt;
  
  
  the personal device problem
&lt;/h2&gt;

&lt;p&gt;The Snowflake breach was about contractors using personal Macs for work. The lazy answer at a 15-person startup might surprise: corp-issue every contractor a laptop. Yes, including the four-hour-a-week consultant.&lt;/p&gt;

&lt;p&gt;A refurbished MacBook Air with 16GB RAM is roughly $700 from Apple's Certified Refurbished store. The cost of a Snowflake-scale breach starts at $370K (the reported AT&amp;amp;T ransom) and ends in the customer-churn and legal-exposure column. The break-even point on hardware-for-contractors is under three serious incidents, ever.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftqabyha68mwr3upib5m0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftqabyha68mwr3upib5m0.png" alt="An editorial side-by-side system diagram on a dark navy ground. Left panel labeled 'personal device, BYOD' shows a laptop with chaotic state: unenforced FileVault status, a personal iCloud sign-in, a Mac App Store with personal Apple ID, a Chrome browser synced to a personal Google account, a Slack web app session that's been logged in for nine months, a folder labeled 'pirated software' with a red warning. Right panel labeled 'corp-issued, MDM enrolled' shows the same laptop with each item enforced: FileVault ON, MDM-managed Apple ID, App Store restricted, Chrome work profile only, Slack session expires daily, no third-party software installs. Each enforced item has a green check; each unenforced item on the left has a coral X. A title above reads 'where the Snowflake breach lived'." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 — same laptop, different enrollment. The right panel is the one where Mandiant doesn't write your name down.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What "no work on personal devices" actually requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Contract clause: hardware is issued, personal-device use for work is prohibited.&lt;/li&gt;
&lt;li&gt;MDM enrollment at first boot via Apple Business Manager (or Windows Autopilot).&lt;/li&gt;
&lt;li&gt;Disabled iCloud personal sign-in; only managed Apple IDs.&lt;/li&gt;
&lt;li&gt;Wipe via MDM on offboarding, before reissue.&lt;/li&gt;
&lt;li&gt;No "I can just SSH from home for ten minutes" escape hatch. The escape hatch is what the contractor will use the day they get phished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the section of the post that gets the most pushback. The pushback is right about cost and wrong about risk. Run the math at your scale; it runs the same direction every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  the receipts
&lt;/h2&gt;

&lt;p&gt;For 15 engineers, the first-year laptop security budget:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;YubiKey 5 × 30 keys (two per engineer): $1,500, one-time.&lt;/li&gt;
&lt;li&gt;Fleet (OSS self-hosted on a small VPS): $240/year.&lt;/li&gt;
&lt;li&gt;Microsoft Defender for Business: $540/year. Substitute Falcon Go at $900 if not on M365, or osquery+Wazuh at $0 if you have a security engineer.&lt;/li&gt;
&lt;li&gt;1Password Business: $1,440/year. Or Bitwarden Teams at $720. Or Apple Passwords at $0.&lt;/li&gt;
&lt;li&gt;Refurbished corp laptops for non-employee contractors: ~$700 per, as needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total recurring: roughly $1,020–$2,220/year for 15 engineers, depending on the EDR and password-manager line. Add the one-time YubiKey spend and the first year lands at $2,520–$3,720. Call it $14–$21 per engineer per month.&lt;/p&gt;

&lt;p&gt;What it catches: every infostealer that hits a managed laptop (Defender flags it), every credential that lives in the browser (replaced by the password manager), every login that doesn't have phishing-resistant MFA (the YubiKey is required), every personal device touching production (blocked by the no-BYOD policy).&lt;/p&gt;

&lt;p&gt;What it doesn't catch: a determined adversary with physical access and unlimited time. A laptop in a hotel room with no FileVault is owned. A laptop with FileVault and a YubiKey left in the USB-A port overnight is owned slower. Neither situation is what this stack is built for; it is built for the infostealer that landed on the contractor's personal Mac.&lt;/p&gt;

&lt;p&gt;If you do one thing this week, buy two YubiKeys for yourself, enroll them on GitHub, Google, and Okta, and turn off SMS-based MFA on each. Total cost: $100, one hour. Then do the rest of the team next quarter.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>lazysre</category>
      <category>endpoint</category>
    </item>
    <item>
      <title>Lazy SRE's guide to secure systems, part 4: the four DNS records</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:58:36 +0000</pubDate>
      <link>https://forem.com/sachincool/lazy-sres-guide-to-secure-systems-part-4-the-four-dns-records-4eh4</link>
      <guid>https://forem.com/sachincool/lazy-sres-guide-to-secure-systems-part-4-the-four-dns-records-4eh4</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/lazy-security-part-4-dns-records" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-04-26.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;In February 2024, Guardio Labs published a writeup of a campaign called SubdoMailing. Five million phishing emails a day, sent through subdomains owned by MSN, eBay, VMware, NYC.gov, UNICEF, and McAfee. Every single email passed SPF and DKIM. Every one of them passed DMARC.&lt;/p&gt;

&lt;p&gt;The attack didn't break those protocols. It used them. Each victim domain had an &lt;code&gt;include:&lt;/code&gt; line in its SPF record pointing at a contractor's domain that had been allowed to expire. The attackers re-registered the orphan, inherited the trust, started sending. Some of the broken &lt;code&gt;include:&lt;/code&gt; chains had been broken for over a year — Guardio dated the operation back to at least late 2022. Nobody had thought to read their own SPF record again after writing it.&lt;/p&gt;

&lt;p&gt;This is part 4. Earlier parts covered npm (&lt;a href="https://dev.to/blog/lazy-security-part-1-supply-chain"&gt;Part 1&lt;/a&gt;), GitHub Actions (&lt;a href="https://dev.to/blog/lazy-security-part-2-github-actions"&gt;Part 2&lt;/a&gt;), and identity, network, and audit logs (&lt;a href="https://dev.to/blog/lazy-security-part-3-unsexy-list"&gt;Part 3&lt;/a&gt;). Part 4 is four DNS records and two monitors. One afternoon to write them, three weeks for DMARC to ramp safely. Zero ongoing cost. Closes the entire phishing-impersonation class and the entire rogue-certificate class at the same time.&lt;/p&gt;

&lt;p&gt;Future You at 3am will not investigate an SPF chain when finance forwards a wire-transfer email. The records that run in their place will.&lt;/p&gt;

&lt;h2&gt;
  
  
  SPF, and the include trap
&lt;/h2&gt;

&lt;p&gt;SPF stands for Sender Policy Framework. The record lives in DNS as a TXT entry on your apex domain. It declares which IP addresses or domains are allowed to send email on your behalf. The receiving mail server checks the sending IP against the list. The check passes or it fails. That is the entire protocol.&lt;/p&gt;

&lt;p&gt;The record itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yourorg.com TXT "v=spf1 include:_spf.google.com include:mailgun.org -all"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;v=spf1&lt;/code&gt; is the version marker. &lt;code&gt;include:&lt;/code&gt; delegates to another domain's SPF record, which expands at lookup time to that vendor's actual IP allowlist. &lt;code&gt;-all&lt;/code&gt; says anything not listed is hard-fail.&lt;/p&gt;

&lt;p&gt;That last token matters. &lt;code&gt;-all&lt;/code&gt; (hard-fail) tells receivers to reject anything not on the list. &lt;code&gt;~all&lt;/code&gt; (soft-fail) tells them to mark it suspicious but maybe deliver anyway. &lt;code&gt;?all&lt;/code&gt; (neutral) tells them you have no opinion. Every getting-started guide ever written defaults to &lt;code&gt;~all&lt;/code&gt; "to be safe." The major receivers have said for years that they treat &lt;code&gt;~all&lt;/code&gt; and &lt;code&gt;-all&lt;/code&gt; the same in scoring. The lazy answer is &lt;code&gt;-all&lt;/code&gt;. The only reason to use &lt;code&gt;~all&lt;/code&gt; is during a migration when you can't yet enumerate every legitimate sender.&lt;/p&gt;

&lt;p&gt;The SPF spec has a ten-DNS-lookup limit. Every &lt;code&gt;include:&lt;/code&gt; counts, recursively. If you chain five SaaS senders (Google + Mailgun + Postmark + SendGrid + Stripe), each one's &lt;code&gt;include:&lt;/code&gt; expands into its own record, which may include another, and you can blow the limit without realizing. When you blow the limit, the record evaluates as &lt;code&gt;permerror&lt;/code&gt;, and many receivers treat that as "no SPF," which means anyone can spoof you. Tools like &lt;code&gt;dmarcian.com/spf-survey&lt;/code&gt; count the lookups for free. Audit yours.&lt;/p&gt;

&lt;p&gt;The SubdoMailing failure mode is what happens when one &lt;code&gt;include:&lt;/code&gt; points at a contractor whose domain you don't control. The contractor goes out of business. The registration expires. Someone buys the lapsed domain. They publish their own SPF allowlist. Your domain now declares that the buyer is an authorized sender for you. Every email they send passes SPF. The fix is to audit your &lt;code&gt;include:&lt;/code&gt; chain quarterly: does every domain in it still belong to someone you trust? Most teams have never done this once.&lt;/p&gt;

&lt;h2&gt;
  
  
  DKIM, in DNS
&lt;/h2&gt;

&lt;p&gt;DKIM (DomainKeys Identified Mail) is a cryptographic signature on every outbound email. The signing key is a public/private keypair. The private key lives in your mail server (Google Workspace, Microsoft 365, Postmark, your own Postfix, whatever). The public key lives in DNS, under a selector subdomain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;selector1._domainkey.yourorg.com TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selector (&lt;code&gt;selector1&lt;/code&gt; here) is so you can rotate keys. Publish a new selector, switch the mail server to sign with the new private key, leave the old selector live for a week so in-flight emails still verify, then retire it. Most providers handle this rotation for you once the original selector is configured.&lt;/p&gt;

&lt;p&gt;Two things go wrong in practice. First, key length. RSA-1024 was the standard a decade ago and is now considered weak; RSA-2048 is the current default. Some old DKIM records still publish 1024-bit keys, and many major receivers now fail or ignore 1024-bit signatures. Audit with &lt;code&gt;dig TXT selector1._domainkey.yourorg.com&lt;/code&gt;. Second, third parties signing on your behalf without your knowledge. If finance connects a new SaaS tool that sends email as &lt;code&gt;noreply@yourorg.com&lt;/code&gt; and nobody sets up DKIM for that path, that vendor's emails will fail DKIM alignment. Receivers see a domain with DKIM mostly working and one path failing, which is often enough to flag the whole domain in spam filters.&lt;/p&gt;

&lt;p&gt;Most providers (Google Workspace, Microsoft 365, Postmark, Mailgun, SendGrid) make DKIM publishing a checklist item in their onboarding. If a vendor doesn't, that is a signal about the vendor's sophistication, not yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  DMARC, the part that does the work
&lt;/h2&gt;

&lt;p&gt;DMARC (Domain-based Message Authentication, Reporting &amp;amp; Conformance) is the policy layer on top of SPF and DKIM. It tells receivers what to do when SPF and DKIM checks fail, and it tells you, via aggregate reports, what's happening to your domain in the wild.&lt;/p&gt;

&lt;p&gt;A minimal DMARC record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;_dmarc.yourorg.com TXT "v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc@yourorg.com; pct=100; adkim=s; aspf=s"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fields that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;p=reject&lt;/code&gt;: policy for emails that fail both SPF and DKIM on the apex. Three values, &lt;code&gt;none&lt;/code&gt; (just report), &lt;code&gt;quarantine&lt;/code&gt; (deliver to spam), &lt;code&gt;reject&lt;/code&gt; (drop). The end state is &lt;code&gt;reject&lt;/code&gt;. The path is &lt;code&gt;none → quarantine → reject&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sp=reject&lt;/code&gt;: same policy for subdomains. This is the SubdoMailing detail every public DMARC how-to forgets. A domain with &lt;code&gt;p=reject&lt;/code&gt; but &lt;code&gt;sp=none&lt;/code&gt; is wide open for subdomain abuse. Set both.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rua=mailto:&lt;/code&gt;: where aggregate reports get sent. Free DMARC report parsers (Postmark, dmarcian, EasyDMARC) accept these and render them as human-readable summaries.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pct=100&lt;/code&gt;: fraction of failing mail to apply the policy to. Start at 25% during the ramp, end at 100%.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;adkim=s&lt;/code&gt; and &lt;code&gt;aspf=s&lt;/code&gt;: strict alignment. The From-address domain must match the DKIM signing domain (and SPF return path) exactly. The default is relaxed, which lets subdomains substitute. Strict is what you want unless something is breaking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ramp from &lt;code&gt;p=none&lt;/code&gt; to &lt;code&gt;p=reject&lt;/code&gt; is what takes three weeks. The risk is breaking a legitimate sender path you didn't know existed. Week one, publish &lt;code&gt;p=none; pct=100&lt;/code&gt;. Receive DMARC aggregate reports for seven days. Identify every IP and &lt;code&gt;From:&lt;/code&gt; domain that sent on your behalf. There will be three or four you didn't expect: a newsletter platform finance signed up for, an HR tool, a calendar invite system. Onboard each into SPF and DKIM. Week two, move to &lt;code&gt;p=quarantine; pct=25&lt;/code&gt;, watch reports for new failures. Week three, &lt;code&gt;p=reject; pct=100&lt;/code&gt;. Done.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr3nkhvqqeq0ksvnynvn8.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr3nkhvqqeq0ksvnynvn8.gif" alt="An animated horizontal bar chart in a dark editorial palette showing FBI IC3 business email compromise losses in the United States by year, from 2020 ($1.8B) through 2024 ($2.77B). Bars fill in sequence. The 2024 bar is accented with a brighter cyan and a coral tip. A bottom strip notes that the average loss per incident in 2024 was $129K and that the dataset is U.S.-only — global BEC losses are higher." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 — BEC losses by year, U.S. only. The 2024 number exceeded ransomware.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most small teams stop at &lt;code&gt;p=quarantine&lt;/code&gt; and never finish the ramp. The difference between &lt;code&gt;quarantine&lt;/code&gt; and &lt;code&gt;reject&lt;/code&gt; is whether the attacker's spoofed wire-transfer email lands in the CFO's spam folder or never enters the mail system at all. Spam is where employees go to recover legitimate mail that was filtered too aggressively, which means they go there to fish out emails they want to trust. Reject is the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  CAA, two lines to gate cert issuance
&lt;/h2&gt;

&lt;p&gt;CAA (Certification Authority Authorization) is a DNS record that names which Certificate Authorities are allowed to issue TLS certificates for your domain. Without one, any publicly trusted CA in the world can issue a cert for your domain to anyone who passes that CA's domain-validation challenge. With one, only the CAs you've named can.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yourorg.com CAA 0 issue "letsencrypt.org"
yourorg.com CAA 0 issuewild "letsencrypt.org"
yourorg.com CAA 0 iodef "mailto:security@yourorg.com"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;issue&lt;/code&gt; restricts standard certificates. &lt;code&gt;issuewild&lt;/code&gt; restricts wildcard certificates. &lt;code&gt;iodef&lt;/code&gt; is where notifications are sent when an unauthorized CA tries to issue. If you use multiple CAs (one for ACM in AWS, one for Let's Encrypt in your edge, one for Cloudflare-managed certs), list them all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yourorg.com CAA 0 issue "letsencrypt.org"
yourorg.com CAA 0 issue "amazon.com"
yourorg.com CAA 0 issue "digicert.com"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CAA cannot prevent a misbehaving CA from issuing anyway. But CAs are required by the CA/Browser Forum baseline requirements to honor CAA at issuance time. They mostly do. When they don't, the misissuance ends in a Mozilla CA-incident bug report and eventual CA distrust. CAA exists so that legitimate misissuance is detected (because the CA you named never issued the cert and the issuing CA broke the rule) and accidental misissuance is structurally impossible. Both buy you something.&lt;/p&gt;

&lt;p&gt;Cost: three DNS lines. Effort: ten minutes. Catches a class of attack (man-in-the-middle via misissued cert) that most teams have no other defense against.&lt;/p&gt;

&lt;h2&gt;
  
  
  the monitors
&lt;/h2&gt;

&lt;p&gt;Two streams pay back the four records.&lt;/p&gt;

&lt;p&gt;First, certificate transparency log monitoring. Every publicly trusted CA is required to log every certificate they issue to public append-only logs. &lt;code&gt;crt.sh&lt;/code&gt; is a free queryable index. The &lt;code&gt;certstream&lt;/code&gt; Python library streams new entries in real time, also free. Cloudflare offers free CT monitoring for any domain on its DNS. Whatever you pick, the workflow is: cert is issued for &lt;code&gt;*.yourorg.com&lt;/code&gt; → log entry appears within seconds → your monitor pages a Slack channel → you check whether you issued it. If you didn't, that is an incident, not a notification.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fharshit.cloud%2Fimages%2Flazy-security-part-4-dns-records%2Fdns-records-napkin.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fharshit.cloud%2Fimages%2Flazy-security-part-4-dns-records%2Fdns-records-napkin.png" alt="A hand-drawn napkin showing the four DNS records as a cheat sheet, written in marker, ready to copy into a DNS panel. Top of the napkin reads 'the four-record afternoon'. Four labeled blocks underneath: SPF as a TXT record with  raw `-all` endraw  circled in red, DKIM as a TXT record with the selector subdomain highlighted, DMARC with  raw `p=reject` endraw  and  raw `sp=reject` endraw  both underlined twice, CAA with the issuer name circled. Bottom of the napkin has two boxes labeled 'CT log monitor' and 'DMARC report inbox', with arrows pointing to a small Slack icon and a small email icon. A red callout at the bottom reads 'fifteen minutes a week'." width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 — the whole afternoon, sketched. Plus what runs after.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Second, DMARC aggregate report parsing. The &lt;code&gt;rua=&lt;/code&gt; address in your DMARC record receives daily XML reports from every receiver. Reading the XML raw is unpleasant. The free tiers of Postmark, dmarcian, and EasyDMARC all accept the report stream and render it as "here are the IPs that sent as you this week, here are the ones that failed alignment, here are the new ones since last week." The new-sender alerts are where you find out that someone in marketing has connected a SaaS tool that's now sending emails as you, failing alignment, and getting your domain reputation downgraded.&lt;/p&gt;

&lt;p&gt;A weekly fifteen-minute review of both monitors is what good looks like at a 25-person team. The cost is fifteen minutes a week. The product is "we'd have noticed if someone issued a cert for our login subdomain on Tuesday."&lt;/p&gt;

&lt;h2&gt;
  
  
  the receipts
&lt;/h2&gt;

&lt;p&gt;Four DNS records. Two monitors. One afternoon for the records, three weeks for the DMARC ramp, fifteen minutes a week for the reviews. Cost: zero, unless you upgrade past the free tier of a DMARC parser at $15–$50 a month, which is the only thing on the list that's not free.&lt;/p&gt;

&lt;p&gt;What this catches: every attempt to send email impersonating your domain from outside your authorized sender list, every attempt to issue a TLS cert for your domain from an unauthorized CA. The FBI's 2024 IC3 report attributed $2.77B in U.S. business email compromise losses to roughly 21,000 incidents — a $129K average. The fraction of those that would have been caught by a domain publishing &lt;code&gt;p=reject; sp=reject&lt;/code&gt; with an honest SPF audit is enormous.&lt;/p&gt;

&lt;p&gt;What it doesn't catch: phishing from a lookalike domain (&lt;code&gt;yourorg-corp.com&lt;/code&gt;, &lt;code&gt;yourorg-support.com&lt;/code&gt;, &lt;code&gt;yourorg.co&lt;/code&gt;). Lookalike-domain defense needs a paid monitoring service at the tier that matters, and there's no free version that works at small-team scale. Skip it until you have a budget line for security. Note it in the runbook.&lt;/p&gt;

&lt;p&gt;If you do one thing this week, publish &lt;code&gt;_dmarc.yourorg.com TXT "v=DMARC1; p=none; rua=mailto:dmarc@yourorg.com"&lt;/code&gt; and point the address at a Postmark free-tier DMARC inbox. Read the first report in seven days. The list of senders you didn't know about is the answer to "why has this been skipped for two years."&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>lazysre</category>
      <category>dns</category>
    </item>
    <item>
      <title>Lazy SRE's guide to secure systems, part 3: the unsexy list</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:58:21 +0000</pubDate>
      <link>https://forem.com/sachincool/lazy-sres-guide-to-secure-systems-part-3-the-unsexy-list-4e8n</link>
      <guid>https://forem.com/sachincool/lazy-sres-guide-to-secure-systems-part-3-the-unsexy-list-4e8n</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/lazy-security-part-3-unsexy-list" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-04-19.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I have a calendar reminder that fires on the first of every month. It says "rotate the PAT." I have hit "snooze for 1 week" seventeen times in a row. The PAT in question is a &lt;code&gt;ghp_&lt;/code&gt; token with read-write access to four private repos and permission to push tags, and the last time I rotated it was October 2024. If anyone has phished my GitHub session in the past fifteen months, they have had a year's head start on me.&lt;/p&gt;

&lt;p&gt;This is part 3. &lt;a href="https://dev.to/blog/lazy-security-part-1-supply-chain"&gt;Part 1&lt;/a&gt; was npm. &lt;a href="https://dev.to/blog/lazy-security-part-2-github-actions"&gt;Part 2&lt;/a&gt; was GitHub Actions. This part is the unsexy list: the controls that don't fit a single attacker narrative, that protect against many different classes of incident in small ways. Identity, network access, default credentials, attestation, the audit log you'll need when the rest of the series missed what you needed it to catch.&lt;/p&gt;

&lt;p&gt;The thesis from Part 1 stands. Future You at 3am will not rotate the PAT. The config that makes the rotation unnecessary (short-lived expiry, fine-grained scope, SSO enforcement, audit streaming) is the one that runs while you sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  the PAT you forgot is in four places
&lt;/h2&gt;

&lt;p&gt;Personal-access tokens hide in more places than I want to think about. Mine, when I went through them this weekend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.netrc&lt;/code&gt; (the one git falls back to when no credential helper is set)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.zshrc&lt;/code&gt;, exported as &lt;code&gt;GH_TOKEN&lt;/code&gt; because some script three years ago needed it&lt;/li&gt;
&lt;li&gt;Mac Keychain, two duplicates, one expired in 2023 but the dialogue still surfaces it&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;.env&lt;/code&gt; in a repo I haven't pushed to since last summer, committed in plaintext to the &lt;code&gt;staging&lt;/code&gt; branch (&lt;code&gt;git log -S 'ghp_'&lt;/code&gt; finds these surprisingly often)&lt;/li&gt;
&lt;li&gt;One CI secret in a repo whose workflow file I deleted six months ago; the workflow went, the secret did not&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's five, not four, which is on-brand for this section.&lt;/p&gt;

&lt;p&gt;The fix isn't "rotate them all." It's "make the next leak useless." Three configs at the org level do the work.&lt;/p&gt;

&lt;p&gt;First, require expiration on all PATs. GitHub org settings → Personal access tokens → Require an expiration date; set the org max to 90 days (GitHub's platform ceiling is 366, but 90 is the right org default). Tokens issued before the setting keep working until their original expiry, so old tokens die naturally as they age out. No big-bang migration.&lt;/p&gt;

&lt;p&gt;Second, enforce SSO on the org. A leaked PAT without an active SSO session can't reach SSO-protected repos. Most SaaS git-hosted orgs should have this on already; if yours doesn't, that is the highest-yield ten minutes in this post.&lt;/p&gt;

&lt;p&gt;Third, stream the GitHub audit log somewhere SQL-shaped, with two-year retention. The default is six months. You will want eighteen months of history exactly when you need eighteen months of history. The question "did this token get used last week?" should be a query, not a support ticket.&lt;/p&gt;

&lt;p&gt;The thing that took me longest to learn is that fine-grained PATs (&lt;code&gt;github_pat_&lt;/code&gt; prefix, not &lt;code&gt;ghp_&lt;/code&gt;) let you scope a token to one repo with read-only contents and nothing else. The default scope (full account) is what turns a leaked PAT into a domain compromise. To stop typing &lt;code&gt;ghp_&lt;/code&gt; into shells entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# ~/.gitconfig
[credential]
  helper = !gh auth git-credential
[url "https://github.com/"]
  insteadOf = git@github.com:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gh auth login&lt;/code&gt; once, and &lt;code&gt;git push&lt;/code&gt; works for the rest of your career. The PAT now lives in one place: &lt;code&gt;gh&lt;/code&gt;'s keyring entry, scoped to your machine, rotated by &lt;code&gt;gh&lt;/code&gt; whenever it likes.&lt;/p&gt;

&lt;h2&gt;
  
  
  identity is the perimeter
&lt;/h2&gt;

&lt;p&gt;SSO + MFA + SCIM is the only thing on the unsexy list that competes with the PAT story for "worst yield from neglect." A single phished password without these is a domain admin compromise. With them, the same phish gets the attacker a soup of session cookies that expire in eight hours and an MFA prompt they can't satisfy.&lt;/p&gt;

&lt;p&gt;The three configs, in rough order of cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MFA, mandatory, no exceptions.&lt;/strong&gt; Including the founder, including the contractor, including the on-call rotation. The exception list is the attack list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSO for every system that supports it.&lt;/strong&gt; Yes, Okta SSO Tax is real. Yes, it is annoying. It is cheaper than rebuilding identity after a session-token compromise. Most of the Snowflake-customer breaches of 2024 started with a non-SSO'd account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCIM provisioning to every system that supports it.&lt;/strong&gt; SCIM means offboarding actually offboards. The day someone leaves, every connected system revokes their access in the same SAML attribute push. Without SCIM, the median time to fully revoke at a small startup is days, and there is always one Postgres console nobody remembered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu0zdept58bjyjr0ib65p.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu0zdept58bjyjr0ib65p.gif" alt="An animated horizontal bar chart in a dark editorial palette comparing the time to fully revoke an employee's access after offboarding. Top bar 'without SCIM (median, small-startup surveys 2024-2025)' grows over several seconds to around four days. Bottom bar 'with SCIM, SAML attribute push' grows to roughly forty-five seconds and is almost invisible at the scale of the first. Coral tip on the without-SCIM bar marks the window of compromise." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 — the no-SCIM bar is the entire window of compromise.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One nightly cron closes most of the rest of the gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# nightly: diff "people on payroll" vs "humans with prod access"&lt;/span&gt;
okta-cli list-users &lt;span class="nt"&gt;--status&lt;/span&gt; active | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/active.txt
aws iam list-users &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Users[].UserName'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[]'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/prod.txt
diff /tmp/active.txt /tmp/prod.txt | mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"identity-diff &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; sec@yourorg.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs in twelve seconds and surfaces the contractor whose SCIM hook silently broke in March.&lt;/p&gt;

&lt;h2&gt;
  
  
  the access plane: Tailscale, IAP, PrivateLink
&lt;/h2&gt;

&lt;p&gt;Nothing internal needs to be on the public internet. Anything that isn't can't be scanned by Shodan, can't be hit by a credential stuffer, can't be 0-day'd by a CVE published yesterday. The configs are different per layer, but the move is the same: take the thing off the internet and put authentication in front of it.&lt;/p&gt;

&lt;p&gt;For shell access and internal HTTP services, Tailscale. The pitch is honest. Install the daemon on every machine, write a twelve-line ACL, you have a private network without running a VPN appliance. Replace SSH-to-bastion with &lt;code&gt;tailscale ssh&lt;/code&gt;. Replace the internal Grafana on &lt;code&gt;grafana.yourorg.io&lt;/code&gt; with &lt;code&gt;grafana.your-tailnet.ts.net&lt;/code&gt;. Both stop existing on the public internet the same afternoon.&lt;/p&gt;

&lt;p&gt;For web apps that need real auth-aware proxying (customer-facing internal tools, vendor admin panels), Cloudflare Access or Google IAP. The user hits a public URL, the proxy hands them off to your IdP, then proxies the request to a private backend. The backend has no public route.&lt;/p&gt;

&lt;p&gt;For service-to-service inside cloud accounts, AWS PrivateLink and GCP Private Service Connect. These exist so your &lt;code&gt;stripe-receiver&lt;/code&gt; lambda doesn't need to leave the VPC to reach Stripe's API. They are also what you need so the data warehouse in account A can reach the production database in account B without anything traversing the public internet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fharshit.cloud%2Fimages%2Flazy-security-part-3-unsexy-list%2Faccess-plane-contrast.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fharshit.cloud%2Fimages%2Flazy-security-part-3-unsexy-list%2Faccess-plane-contrast.png" alt="A hand-drawn two-panel napkin. Left panel labeled 'what the security group says ( raw `0.0.0.0/0` endraw )' shows three boxes (postgres, redis, grafana) sitting in the open, with arrows from labeled attackers (a Shodan crawler, a credential stuffer, a CVE-2026-12345 scanner) landing directly on them. A dashed line labeled 'the bastion SG' floats nearby, doing nothing. Right panel labeled 'what the tailnet says' shows the same three boxes behind a solid Tailnet boundary, with the same attacker arrows bouncing off the boundary line. Bottom strip reads 'twelve lines of ACL → entire blast radius'." width="799" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 — same services, different boundary. The right panel is whatever Future You at 3am will thank you for.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The anti-pattern is the "we'll just rotate the bastion IP" security group. We won't. The credentials for the bastion are in a Slack channel from 2023. The bastion is one of those things that exists because someone set it up before everyone joined and nobody knows whether it's safe to turn off. The lazy answer is to make the bastion irrelevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  the helm chart that ships with admin/admin
&lt;/h2&gt;

&lt;p&gt;Every operator-installed thing in the cluster has a default password. Argo CD's &lt;code&gt;admin&lt;/code&gt; with auto-generated password is fine, because the password isn't &lt;code&gt;admin&lt;/code&gt;. Grafana's chart that ships with &lt;code&gt;admin/admin&lt;/code&gt; is not fine. Jenkins ships with a random initial password printed to &lt;code&gt;initialAdminPassword&lt;/code&gt; that most operators copy in once and never rotate. Half the database charts have &lt;code&gt;password: changeme&lt;/code&gt; in &lt;code&gt;values.yaml&lt;/code&gt; and the README says "you should change this," which is not the same as the chart changing it.&lt;/p&gt;

&lt;p&gt;The lazy fix is two configs.&lt;/p&gt;

&lt;p&gt;First, every secret in the cluster comes from external-secrets or sealed-secrets, never from a &lt;code&gt;values.yaml&lt;/code&gt;. Pick one. The choice matters less than the consistency. Mine is external-secrets pointing at Vault, because reconciliation handles rotation upstream and the YAML stays clean.&lt;/p&gt;

&lt;p&gt;Second, a weekly cron that hits every Service in the cluster with the top 25 default credentials and pages on success. &lt;code&gt;nuclei&lt;/code&gt; ships a template set for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nuclei &lt;span class="nt"&gt;-t&lt;/span&gt; http/default-logins/ &lt;span class="nt"&gt;-l&lt;/span&gt; services.txt &lt;span class="nt"&gt;-severity&lt;/span&gt; critical,high
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it finds something, that's a real incident. If it doesn't, you have evidence, which is the audit-log argument postponed by one section.&lt;/p&gt;

&lt;p&gt;One honest aside in parentheses: the rate at which Helm chart maintainers have moved away from default passwords is encouraging. Bitnami's PostgreSQL chart now generates a random password by default instead of &lt;code&gt;changeme&lt;/code&gt;. The chart that ships with &lt;code&gt;admin/admin&lt;/code&gt; today is more likely to be a private internal chart someone wrote three years ago than something current from Bitnami. (Note: the official Grafana chart still defaults to &lt;code&gt;admin/admin&lt;/code&gt; — override it via Helm values before first install; "I'll change it later" is the part nobody does.) Check the internal charts first.&lt;/p&gt;

&lt;h2&gt;
  
  
  sigstore, provenance, and reproducible builds
&lt;/h2&gt;

&lt;p&gt;Part 1 ended on "the next-tier defenses are real, Part 3 will name them." These are them. Sigstore signing, npm provenance, reproducible builds. Each closes a class of attack that pinning and cooldowns can't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sigstore for container images.&lt;/strong&gt; &lt;code&gt;cosign verify&lt;/code&gt; confirms an image was built by your specific GitHub Actions workflow, with your repo's OIDC identity, against a transparency-log entry that's public and append-only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cosign verify ghcr.io/yourorg/api:abc123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--certificate-identity-regexp&lt;/span&gt; &lt;span class="s1"&gt;'^https://github.com/yourorg/api/'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--certificate-oidc-issuer&lt;/span&gt; https://token.actions.githubusercontent.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an attacker pushes a malicious image to your registry without also compromising your CI's OIDC trust, the verify fails. Bake the verify into your deploy step; refuse to deploy what doesn't pass. That is the attested-deployment pattern Part 2 named, in one verb in your CD pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;npm provenance.&lt;/strong&gt; &lt;code&gt;npm audit signatures&lt;/code&gt; (since npm 9.5) tells you which dependencies have published provenance attestations linking the &lt;code&gt;.tgz&lt;/code&gt; to a specific GitHub Actions build. A package with provenance gives you a tamper-evident chain: this artifact came from this commit on this branch in this repo, built by this workflow. Coverage is uneven (most &lt;code&gt;@types/*&lt;/code&gt; packages have it; most one-maintainer packages don't), but the trend is good. The number to track is "what fraction of my install graph has provenance?" That's your remaining audit surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproducible builds.&lt;/strong&gt; The hardest of the three. Same source produces the same binary, bit-for-bit, on every build machine. Two implementations have shipped at scale: Debian's reproducible-builds program (&lt;code&gt;reproducible-builds.org&lt;/code&gt; tracks coverage by package) and Nix. The lazy version, for a small team, is to build the production artifact twice on two different runners and compare hashes. If they match, your CI is reproducible enough to detect a poisoned-build attack. If they don't, you have a non-determinism bug to fix, which is also worth knowing about.&lt;/p&gt;

&lt;h2&gt;
  
  
  audit logs are for after the incident
&lt;/h2&gt;

&lt;p&gt;Part 2 ended on "Part 3 will name the controls that exist to make the postmortem readable, not to prevent the incident." This is the section. Audit logging is what tells you whether everything in the previous six sections actually worked, what got accessed when one of them didn't, and which credential to roll at 03:11.&lt;/p&gt;

&lt;p&gt;Three streams, all of which support direct destination handoff:&lt;/p&gt;

&lt;p&gt;GitHub's audit log to S3, Splunk, Datadog, or whichever SQL-shaped destination you'll actually query. Settings → Audit log → Streaming. Default retention is six months; you want two years. The same goes for Okta's System Log (Reports → System Log → Stream).&lt;/p&gt;

&lt;p&gt;AWS CloudTrail to a separate audit account, write-only from production, S3 with Object Lock and KMS-encrypted. Multi-region. The level of paranoia required is "this bucket survives a full prod-account compromise." GCP and Azure have equivalents (Cloud Audit Logs, Activity Logs).&lt;/p&gt;

&lt;p&gt;Application audit. Stripe webhook history, Slack audit log, Google Workspace audit log. Each is one config and one Splunk index. The marginal effort approaches zero. The payoff is the difference between a one-page incident summary and a six-week panic.&lt;/p&gt;

&lt;p&gt;The runbook for "we think we had a breach Thursday" is then a SQL query against a known schema. Without these, it's an interview with everyone who had access.&lt;/p&gt;

&lt;h2&gt;
  
  
  the receipts
&lt;/h2&gt;

&lt;p&gt;The unsexy list is one afternoon, one quarter, and one year. The afternoon: PAT cleanup, SSO/MFA mandatory, GitHub audit log streaming on. The quarter: SCIM provisioning everywhere, Tailscale on every internal service, external-secrets across the cluster. The year: sigstore for your images, an &lt;code&gt;npm audit signatures&lt;/code&gt; report tracked weekly, reproducible-build hash comparison in CI.&lt;/p&gt;

&lt;p&gt;It will not catch a nation-state with patience. It will not catch an insider with a grudge. It will not catch the next Log4j the day it lands. Those are different problems with different budgets, and worth a separate post when one of them happens to one of us.&lt;/p&gt;

&lt;p&gt;What it does: it makes the postmortem on your next incident readable. It moves "we don't know what got accessed" out of the executive summary and into "Appendix A, the SQL query." For a small team, that is the difference between recovering and rebuilding.&lt;/p&gt;

&lt;p&gt;If you do one thing this week, generate a fresh fine-grained PAT scoped to one repo with a 90-day expiry, switch your &lt;code&gt;gh auth login&lt;/code&gt; to it, and delete the eight-year-old &lt;code&gt;ghp_&lt;/code&gt; from your &lt;code&gt;~/.zshrc&lt;/code&gt;. The calendar reminder won't help. Future You at 3am will not rotate it. Make the wrong default impossible.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>lazysre</category>
      <category>identity</category>
    </item>
    <item>
      <title>Lazy SRE's guide to secure systems, part 2: the actions you didn't pin</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:58:05 +0000</pubDate>
      <link>https://forem.com/sachincool/lazy-sres-guide-to-secure-systems-part-2-the-actions-you-didnt-pin-428g</link>
      <guid>https://forem.com/sachincool/lazy-sres-guide-to-secure-systems-part-2-the-actions-you-didnt-pin-428g</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/lazy-security-part-2-github-actions" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-04-12.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Last March, someone with write access to the &lt;code&gt;trivy-action&lt;/code&gt; repo rewrote 76 of its 77 version tags in place. The tags still resolved to &lt;code&gt;aquasecurity/trivy-action&lt;/code&gt; — they just resolved to different commits than they did the week before. Every pipeline that ran &lt;code&gt;aquasecurity/trivy-action@0.20.0&lt;/code&gt; (and every other tagged version) ran the attacker's commit instead. Secrets exfiltrated. The stolen credentials chained into PyPI and took down LiteLLM. Nobody noticed for hours, because the workflow file diff was still clean.&lt;/p&gt;

&lt;p&gt;This is part 2. &lt;a href="https://dev.to/blog/lazy-security-part-1-supply-chain"&gt;Part 1&lt;/a&gt; covered npm: the dependencies you didn't read. Part 2 is the same problem one level up: the workflows you didn't pin. Part 3 is the unsexy list — Tailscale, PrivateLink, IAP, the PAT you forgot.&lt;/p&gt;

&lt;p&gt;The thesis from Part 1 stands. The best security work for a small team is the work &lt;em&gt;Future You at 3am&lt;/em&gt; will actually execute. The configuration that makes the wrong thing impossible beats the runbook that only discourages it. With GitHub Actions, "the wrong thing" has gotten very specific over the last twelve months, and the configs to block each variety have gotten correspondingly precise.&lt;/p&gt;

&lt;h2&gt;
  
  
  pinning is necessary but not sufficient
&lt;/h2&gt;

&lt;p&gt;The first thing the trivy-action incident proves: hash-pinning to &lt;code&gt;@0.20.0&lt;/code&gt; is not pinning. It's a name lookup. The owner of the repo is allowed to rewrite that name. The pin you actually wanted was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@9b9a3f5c8a5c7e1b6e4d2f1c9b8a7e6d5c4b3a2f&lt;/span&gt; &lt;span class="c1"&gt;# v0.20.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full forty-character SHA. Immutable. The version comment is so the next reader knows what they're looking at; the SHA is so the workflow runs the code you reviewed.&lt;/p&gt;

&lt;p&gt;Two GitHub features shipped in 2025 that change the math:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SHA pinning enforcement&lt;/strong&gt; (Aug 2025). An org-level policy that &lt;em&gt;fails&lt;/em&gt; workflow runs using unpinned actions, instead of warning about them. Settings → Actions → General → Action pinning. Turn it on. There is no "we'll get to it" version of this toggle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immutable Releases&lt;/strong&gt; (Oct 2025, GA). Action authors opt in to making release tags non-rewritable after publication. If you publish actions, turn this on for downstream consumers. If you consume actions, prefer ones that have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lazy stance: enforcement at the org level. The workflow that doesn't have a forty-character SHA fails the run. The PR can't merge. The work of remembering to pin moves from every engineer's head to one setting.&lt;/p&gt;

&lt;p&gt;What this doesn't catch: an attacker who compromises the maintainer account and ships a new tag at a new SHA. The SHA is real. Pinning by SHA doesn't help, because the workflow author &lt;em&gt;will&lt;/em&gt; rev to the new version when they read the maintainer's release notes. Which is the next config.&lt;/p&gt;

&lt;h2&gt;
  
  
  cooldown is the same trick that worked for npm
&lt;/h2&gt;

&lt;p&gt;Part 1's load-bearing config was &lt;code&gt;SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS=48&lt;/code&gt;. The principle: most published malware is detected and pulled within hours. If you can wait, the wait does the work for you.&lt;/p&gt;

&lt;p&gt;The action ecosystem has the same property, with a longer window. &lt;a href="https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns" rel="noopener noreferrer"&gt;yossarian's analysis&lt;/a&gt; puts the cooldown that catches most supply-chain attacks at 7-14 days. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pinact &lt;span class="nt"&gt;--min-age&lt;/span&gt; 7 .github/workflows/&lt;span class="k"&gt;*&lt;/span&gt;.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refuses to write a pin younger than seven days. Add to pre-commit, your CI lint, or whatever your dependabot equivalent runs before opening the bump PR.&lt;/p&gt;

&lt;p&gt;For Renovate users, the equivalent lives in the action manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"packageRules"&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 class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matchManagers"&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="s2"&gt;"github-actions"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minimumReleaseAge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7 days"&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="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Same trick, different ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvx1defqiel3s0o3h03m1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvx1defqiel3s0o3h03m1.gif" alt="An animated horizontal bar chart in a dark editorial palette showing the share of recent supply-chain action compromises caught by a cooldown of 0, 3, 7, 14, or 21 days. The 0-day bar lands at 3% and the 3-day bar at 38%. The 7-day bar reaches 76% and the 14-day bar reaches 89%, both accented with a brighter cyan and a coral tip. The 21-day bar lands at 94%. A bottom strip notes that the trivy-action force-push was detected at about nine days." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 2 — the wait is doing the work. Seven days closes most of the door; fourteen closes most of the rest.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The empirical question is whether seven days is enough. The trivy-action force-push was detected at about nine — seven would have caught most consumers, not all of them. The cost of fourteen is "your action versions lag upstream by two weeks." If your action surface is small (most teams are running &lt;code&gt;actions/checkout&lt;/code&gt;, &lt;code&gt;actions/setup-node&lt;/code&gt;, one cloud-login action, maybe a deploy action), set fourteen and forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  pull_request_target is the new postinstall
&lt;/h2&gt;

&lt;p&gt;Part 1 named &lt;code&gt;postinstall&lt;/code&gt; as the single trigger that does the most damage and the single switch (&lt;code&gt;ignore-scripts=true&lt;/code&gt;) that closes the most doors. Actions has the same shape and the same fix.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pull_request_target&lt;/code&gt; runs in the context of the base repository, with access to repository secrets, but is triggered by a PR from a fork. The legitimate use case is small: comment on PRs, label them, run lightweight metadata jobs. The illegitimate use case is enormous: check out the fork's code and execute it. The attack writes itself. Open a fork, modify a script the trusted workflow runs, watch the runner exfiltrate every secret in the env.&lt;/p&gt;

&lt;p&gt;Astral, who maintain &lt;code&gt;uv&lt;/code&gt; and &lt;code&gt;ruff&lt;/code&gt;, &lt;a href="https://astral.sh/blog/open-source-security-at-astral" rel="noopener noreferrer"&gt;wrote it cleanly&lt;/a&gt;: "these triggers are almost impossible to use securely." GitHub partially mitigated this in November 2025 by forcing &lt;code&gt;pull_request_target&lt;/code&gt; to always use the default branch's version of the workflow, so an attacker can't push a vulnerable workflow on a feature branch and trigger it. But the foot-cannon still ships loaded if your default-branch workflow checks out PR-head code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fharshit.cloud%2Fimages%2Flazy-security-part-2-github-actions%2Fpull-request-target-contrast.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fharshit.cloud%2Fimages%2Flazy-security-part-2-github-actions%2Fpull-request-target-contrast.png" alt="A hand-drawn two-panel napkin. Left panel labeled 'pull_request_target' shows a fork PR boundary as a dashed line, a modified script.sh inside the fork, and a runner on the base side reaching across the boundary while holding a red keyring labeled NPM_TOKEN, AWS_KEY, GH_PAT. Right panel labeled 'pull_request' shows the same setup, but the keyring is replaced by a greyed-out 'secrets.* not in scope' bag. The two panels are structurally identical except for the presence or absence of secrets in the runner." width="799" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fig. 3 — same workflow, different trigger, opposite blast radius.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The lazy stance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't use &lt;code&gt;pull_request_target&lt;/code&gt; unless you've named the specific reason and one other person has signed off.&lt;/li&gt;
&lt;li&gt;If you do, never &lt;code&gt;actions/checkout&lt;/code&gt; the PR head from inside it. Check out the base SHA, do the metadata thing, exit.&lt;/li&gt;
&lt;li&gt;For everything else, use &lt;code&gt;pull_request&lt;/code&gt;. It runs without secrets. Attacker-controlled code stays attacker-jailed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same shape as &lt;code&gt;ignore-scripts=true&lt;/code&gt;. The setting that closes the class.&lt;/p&gt;

&lt;h2&gt;
  
  
  the safe defaults that go in every workflow
&lt;/h2&gt;

&lt;p&gt;The four-line workflow header that does the most work per character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash -euo pipefail {0}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;contents: read&lt;/code&gt; overrides the org-level default. If a step needs to push a tag or open a PR, that job opts back up to &lt;code&gt;contents: write&lt;/code&gt; explicitly. The default is the safe one.&lt;/p&gt;

&lt;p&gt;At the checkout step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@&amp;lt;sha&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# v4.2.0&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;persist-credentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default behavior of &lt;code&gt;actions/checkout&lt;/code&gt; is to leave a credential sitting in &lt;code&gt;.git/config&lt;/code&gt; for the rest of the workflow. Later steps have shipped this credential into uploaded artifacts more than once. Opt out unless a later step in the same job needs to push.&lt;/p&gt;

&lt;p&gt;Three secret-access rules with the same flavor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Step-scoped &lt;code&gt;env:&lt;/code&gt;, never workflow-scoped, for any secret.&lt;/li&gt;
&lt;li&gt;Never &lt;code&gt;${{ toJson(secrets) }}&lt;/code&gt;. Exposes every secret in the project to the runner. There is no use case.&lt;/li&gt;
&lt;li&gt;Never &lt;code&gt;secrets: inherit&lt;/code&gt; on reusable workflows. Pass each secret by name. The reusable workflow gets exactly what it asked for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trivy-action exfiltration worked partly because secrets were workflow-scoped. The malicious step inherited every credential in the env, not just the one the legitimate scan needed. Step-scoping wouldn't have prevented the credential theft — but it would have bounded the blast radius to one secret instead of all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  OIDC, the promise from part 1
&lt;/h2&gt;

&lt;p&gt;Part 1 ended on "the next-tier defenses are real, Part 3 names them." OIDC is the part of that conversation that lives here.&lt;/p&gt;

&lt;p&gt;The trade: instead of storing an &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; in repo secrets and praying nobody exfiltrates it, you configure AWS to trust GitHub's OIDC issuer for a specific repo, branch, and workflow. GitHub mints a short-lived (five-minute) OIDC identity token for the workflow run. The workflow trades that for STS credentials whose lifetime you set (default one hour). Nothing long-lived ever sits in the env.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@&amp;lt;sha&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# v4.0.2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/github-deploy&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws s3 sync ./dist s3://my-bucket&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The role's trust policy restricts the OIDC subject to your exact repo and (ideally) branch. An attacker who compromises a fork PR can't assume the role, because they don't match the trust condition. The OIDC JWT itself lasts five minutes and the STS credential is scoped to whatever you configure (default one hour). Even an exfiltrated credential gets the attacker a bounded window of scoped access, not a permanent IAM user.&lt;/p&gt;

&lt;p&gt;For Google Cloud, the equivalent is Workload Identity Federation. For HashiCorp Vault, the JWT auth backend. Same shape across providers.&lt;/p&gt;

&lt;p&gt;The labor here is genuinely one-time. Configure the trust relationship once per repo, delete the long-lived key, forget about rotation forever. The rotation runbook you're not maintaining is one of the better quiet wins in this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  zizmor is the local proxy for workflows
&lt;/h2&gt;

&lt;p&gt;Part 1's &lt;code&gt;safe-chain&lt;/code&gt; sat in front of every package install and refused malware before bytes hit disk. The action ecosystem's equivalent is &lt;code&gt;zizmor&lt;/code&gt; — a workflow linter that reads your YAML and catches the patterns this post is about, before they merge.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;zizmor
zizmor .github/workflows/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It catches unpinned actions, &lt;code&gt;pull_request_target&lt;/code&gt; with PR-head checkouts, template-injection patterns where attacker-controlled input lands in a &lt;code&gt;run:&lt;/code&gt; string, jobs with excessive permissions. Add it to pre-commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .pre-commit-config.yaml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/woodruffw/zizmor-pre-commit&lt;/span&gt;
  &lt;span class="na"&gt;rev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.x&lt;/span&gt;  &lt;span class="c1"&gt;# pin the rev, obviously&lt;/span&gt;
  &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zizmor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The principle is identical to safe-chain. Move the security check from "after the incident, in the postmortem" to "before the PR can merge, on the dev machine." The CI run is the second line of defense. The pre-commit is the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  the receipts
&lt;/h2&gt;

&lt;p&gt;The above stack is approximately one afternoon: org-level SHA pinning enforcement, &lt;code&gt;pinact --min-age 7&lt;/code&gt; or Renovate &lt;code&gt;minimumReleaseAge: 7 days&lt;/code&gt;, the four-line workflow header, &lt;code&gt;persist-credentials: false&lt;/code&gt;, no &lt;code&gt;pull_request_target&lt;/code&gt; with PR-head checkouts, OIDC for every cloud credential, &lt;code&gt;zizmor&lt;/code&gt; in pre-commit.&lt;/p&gt;

&lt;p&gt;It will not catch a maintainer-account compromise that ships clean-looking code which activates weeks later. It will not catch a determined attacker who studies your build and writes a payload that survives every linter and looks innocent at PR review. Nothing in this post will. Part 3 will name the controls that buy partial mitigation against that class: sigstore, npm provenance, reproducible builds, attested deployments. And the ones that exist to make the postmortem readable, not to prevent the incident.&lt;/p&gt;

&lt;p&gt;For a small team, the delta from this post is moving from "we're one tag-rewrite away from a credential theft cascade" to "an attacker would need a credentialed insider, or a fifteen-minute window of luck against a scoped IAM role." That's the only delta that matters at this scale.&lt;/p&gt;

&lt;p&gt;If you do one thing this week, turn on SHA pinning enforcement at the org level. Everything else gates off that.&lt;/p&gt;

</description>
      <category>security</category>
      <category>lazysre</category>
      <category>githubactions</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Blocking AI crawlers is the new 'noindex'</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:57:19 +0000</pubDate>
      <link>https://forem.com/sachincool/blocking-ai-crawlers-is-the-new-noindex-4lig</link>
      <guid>https://forem.com/sachincool/blocking-ai-crawlers-is-the-new-noindex-4lig</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/til/blocking-ai-crawlers" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2026-01-21.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you're blocking GPTBot, Anthropic, Perplexity, Gemini — you're trading future reach for short-term control.&lt;/p&gt;

&lt;h2&gt;
  
  
  the math
&lt;/h2&gt;

&lt;p&gt;AI search traffic today: ~1%&lt;br&gt;
AI search traffic tomorrow: 25–35%&lt;/p&gt;

&lt;p&gt;Let them crawl. Train the discovery layer. Be early.&lt;/p&gt;
&lt;h2&gt;
  
  
  common AI crawler user agents
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Crawler&lt;/th&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GPTBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ClaudeBot&lt;/code&gt; / &lt;code&gt;Anthropic-AI&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PerplexityBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Google-Extended&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Google (Gemini)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  the robots.txt decision
&lt;/h2&gt;

&lt;p&gt;Blocking these crawlers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: &lt;span class="n"&gt;GPTBot&lt;/span&gt;
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /

&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: &lt;span class="n"&gt;ClaudeBot&lt;/span&gt;
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feels like control. Actually it's invisibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  why this matters
&lt;/h2&gt;

&lt;p&gt;When someone asks an AI "how do I do X" and your content isn't in the training data, you don't exist in that conversation.&lt;/p&gt;

&lt;p&gt;The sites that trained the discovery layer early will own the AI search results later.&lt;/p&gt;

&lt;p&gt;Visibility &amp;gt; invisibility.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>crawlers</category>
      <category>strategy</category>
    </item>
    <item>
      <title>Access denied: when your browser extensions look like attack vectors</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:57:03 +0000</pubDate>
      <link>https://forem.com/sachincool/access-denied-edgesuite-edition-when-your-browser-extensions-become-attack-vectors-2h0c</link>
      <guid>https://forem.com/sachincool/access-denied-edgesuite-edition-when-your-browser-extensions-become-attack-vectors-2h0c</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/akamai-browser-extensions-blocking" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2025-12-31.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Last week I tried booking a flight on Indigo. Access Denied. Tried MakeMyTrip. Access Denied. Ixigo? Same story. Yatra? Blocked.&lt;/p&gt;

&lt;p&gt;My banking apps worked fine. But every travel booking site using Akamai's CDN decided I was public enemy number one. Sometimes the site would load, then the OTP API calls would silently fail. Making a complete fool out of me at checkout.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvmc1y8fxynqq6vysbnpb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvmc1y8fxynqq6vysbnpb.png" alt="MakeMyTrip Access Denied" width="800" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  the debugging rabbit hole
&lt;/h2&gt;

&lt;p&gt;First thought: bad IP from my ISP's CGNAT pool. Changed my IP. Worked for 10 minutes. Then blocked again.&lt;/p&gt;

&lt;p&gt;Second thought: maybe Akamai's IP reputation is flagging me. Checked their &lt;a href="https://www.akamai.com/us/en/clientrep-lookup/" rel="noopener noreferrer"&gt;Client Reputation lookup&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu0m7oz43x4z3xhqpi14w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu0m7oz43x4z3xhqpi14w.png" alt="Akamai Clean IP Reputation" width="800" height="506"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nope. Clean as a whistle.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdyk6ktfp1yyq5bwz28sw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdyk6ktfp1yyq5bwz28sw.png" alt="My IP Info - Tata Play, Bengaluru" width="800" height="1152"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Google dorking time. Found tons of users globally facing the same issue. Not ISP-specific. Not India-specific. Something else was up.&lt;/p&gt;

&lt;p&gt;Then I found &lt;a href="https://leinss.com/blog/?p=3409" rel="noopener noreferrer"&gt;this blog&lt;/a&gt; that pointed at browser extensions. Interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  the lightbulb moment
&lt;/h2&gt;

&lt;p&gt;Switched from Arc to Chrome. Still blocked. Because I carried over the same 21 extensions like a digital hoarder.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ietmy78w42054e6pwak.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ietmy78w42054e6pwak.png" alt="My Extension Arsenal - Part 1" width="722" height="1162"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc68h3rzo3hykcoyb134u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc68h3rzo3hykcoyb134u.png" alt="My Extension Arsenal - Part 2" width="800" height="960"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's my toolkit: Wappalyzer, Shodan, Trufflehog, DotGit, and a bunch of OSINT/greyhat recon tools. The same extensions I use for security research were making me look like an attacker to Akamai's Bot Manager.&lt;/p&gt;

&lt;p&gt;Turned off all extensions. Instant access to every site.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's actually happening
&lt;/h2&gt;

&lt;p&gt;Akamai's Bot Manager isn't counting your requests. It's fingerprinting the client environment. Browser extensions can inject JavaScript, mutate the DOM, alter request behavior, and add tracking parameters — all things the client-side fingerprint will flag as bot-shaped, the same way it would flag a scraper or an injection probe.&lt;/p&gt;

&lt;p&gt;My security toolkit became my own DoS attack vector. Poetic, really.&lt;/p&gt;

&lt;p&gt;Some users reported User-Agent changes helped. I didn't test that. I also didn't have time to debug which of the 21 extensions was the actual culprit. Life's too short for that level of troubleshooting.&lt;/p&gt;

&lt;h2&gt;
  
  
  the takeaway
&lt;/h2&gt;

&lt;p&gt;WAF rules are aggressive by design. Your legitimate security tools look exactly like attack vectors because, well, they kind of are. The line between security researcher and threat actor is thinner than we'd like to admit.&lt;/p&gt;

&lt;p&gt;If you're getting blocked by Akamai with a clean IP:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check your extensions first, not your ISP&lt;/li&gt;
&lt;li&gt;VPN working temporarily? That's behavioral detection, not IP blocking&lt;/li&gt;
&lt;li&gt;The Client Reputation tool won't catch extension-based triggers&lt;/li&gt;
&lt;li&gt;Your OSINT toolkit makes CDNs nervous&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Infrastructure is meant to keep bad actors out. Sometimes it keeps infrastructure wizards out too. Not fun.&lt;/p&gt;

</description>
      <category>security</category>
      <category>waf</category>
      <category>akamai</category>
      <category>debugging</category>
    </item>
    <item>
      <title>VictoriaLogs vs Loki: real-world benchmarking results</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:56:48 +0000</pubDate>
      <link>https://forem.com/sachincool/victorialogs-vs-loki-real-world-benchmarking-results-26bg</link>
      <guid>https://forem.com/sachincool/victorialogs-vs-loki-real-world-benchmarking-results-26bg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/victorialogs-vs-loki" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2025-11-19.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;On 500 GB of logs over 7 days, on the same hardware: &lt;strong&gt;94% lower query latencies, 37% smaller storage, and under half the CPU and RAM&lt;/strong&gt;. The single number that surprised us most was the 12× drop in needle-in-a-haystack search times.&lt;/p&gt;

&lt;h2&gt;
  
  
  the setup
&lt;/h2&gt;

&lt;p&gt;At Truefoundry we run multi-tenant ML workloads on Kubernetes. The log layer has to deliver fast ad-hoc search across mixed namespaces (often with no good labels to anchor on), sustained 60+ MB/s ingestion during deploys and incidents, and live tailing that doesn't fall behind during a noisy crash loop. It also has to run as a single binary — we don't want a six-component log stack — within a 4 vCPU / 16 GiB node ceiling shared with everything else.&lt;/p&gt;

&lt;p&gt;Loki was our default. Past the 1M-active-series mark it started showing 30s+ search latencies and high I/O amplification. So we benchmarked it head-to-head against VictoriaLogs and let the numbers decide.&lt;/p&gt;

&lt;h3&gt;
  
  
  the contestants
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loki:&lt;/strong&gt; Grafana Labs' log store. Compressed chunks, label-based indexing, LogQL. Brilliant Grafana integration; expensive regex scans and Go GC overhead at scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VictoriaLogs:&lt;/strong&gt; VictoriaMetrics' columnar LSM log database. Per-field indices, SIMD search, LogsQL. Single binary, low memory footprint, efficient compression.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  benchmark methodology
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardware&lt;/td&gt;
&lt;td&gt;4 vCPU / 8 GiB RAM, identical for both, QoS: Guaranteed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Log generator&lt;/td&gt;
&lt;td&gt;flog → Vector → Loki / VictoriaLogs at 65 MB/s sustained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dataset&lt;/td&gt;
&lt;td&gt;~500 GB over 7 days; mix of unique and duplicated lines across 20 namespaces, 40 apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retention&lt;/td&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load test&lt;/td&gt;
&lt;td&gt;Locust 2.27.1, 10 virtual users, sustained 43 RPS via &lt;code&gt;/select/logsql/query&lt;/code&gt; and the Grafana datasource&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queries&lt;/td&gt;
&lt;td&gt;Stats, Needle in a Haystack, Negative — detailed below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;Block cache disabled on both; pods restarted before each run to simulate cold reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index tweaks&lt;/td&gt;
&lt;td&gt;Defaults on both&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  the headline figure
&lt;/h2&gt;

&lt;p&gt;Before the methodology debate, here's what the seven days produced.&lt;/p&gt;

&lt;p&gt;The Grafana panels behind those numbers — same six metrics for both systems, two very different shapes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loki:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy0lg32kfmyuaq9jqyf0g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy0lg32kfmyuaq9jqyf0g.png" alt="Loki Grafana dashboard: CPU usage pinned near 4 vCPU limit, memory holding around 6–7 GB, regular throttling spikes hitting 40–50% during the benchmark window" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VictoriaLogs:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foh6hnuwqdk5x9wji3xnw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foh6hnuwqdk5x9wji3xnw.png" alt="VictoriaLogs Grafana dashboard over the same period: CPU near zero baseline with brief spikes to 1 vCPU, memory flat around 1.3 GB, no throttling visible" width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The memory line is the one that most directly translates into infrastructure cost. At steady state, VictoriaLogs sat around 1.3 GB while Loki held 6–7 GB. Freeing ~5 GB per node is the difference between bin-packing four tenants on a box and seven.&lt;/p&gt;

&lt;h2&gt;
  
  
  storage on disk
&lt;/h2&gt;

&lt;p&gt;Same logs, same 7-day retention, identical ingestion path. Loki landed at &lt;strong&gt;501 GB&lt;/strong&gt;; VictoriaLogs at &lt;strong&gt;318 GB&lt;/strong&gt; — &lt;strong&gt;37% smaller&lt;/strong&gt; with no tuning on either side.&lt;/p&gt;

&lt;p&gt;The difference is partly the codec — VictoriaLogs uses zstd, Loki defaults to snappy — but mostly the layout. Columnar storage finds redundancy that stream-chunked LSMs don't see; values from the same field compress together far better than values stitched in by line order.&lt;/p&gt;

&lt;p&gt;At fleet scale this is a 1 TB volume holding what used to need 1.5 TB.&lt;/p&gt;

&lt;h2&gt;
  
  
  query performance
&lt;/h2&gt;

&lt;p&gt;Three query patterns, run against the same 500 GB / 7-day index. Result sets were verified to be identical between the two systems.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. stats — log count over 24 hours
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Purpose:&lt;/strong&gt; Total log lines from &lt;code&gt;app="servicefoundry-server"&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LogQL:&lt;/strong&gt; &lt;code&gt;sum(count_over_time({app="servicefoundry-server"}[24h]))&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LogsQL:&lt;/strong&gt; &lt;code&gt;{app="servicefoundry-server"} | stats count()&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Loki&lt;/td&gt;
&lt;td&gt;2.5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VictoriaLogs&lt;/td&gt;
&lt;td&gt;1.5s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Aggregate counts hit Loki's strength — label-anchored, no text scan — and Loki still loses by 40% on the wall clock. VictoriaLogs holds its own on label queries; Loki has no answer for the others.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. needle in a haystack — finding one line in 500 GB
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Purpose:&lt;/strong&gt; Locate a single static log entry &lt;code&gt;[UNIQUE-STATIC-LOG] ID=abc123 XYZ&lt;/code&gt; in the &lt;code&gt;truefoundry&lt;/code&gt; namespace over 7 days.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LogQL:&lt;/strong&gt; &lt;code&gt;{namespace="truefoundry", app!="grafana"} |= "[UNIQUE-STATIC-LOG] ID=abc123 XYZ"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LogsQL:&lt;/strong&gt; &lt;code&gt;{namespace="truefoundry", app!="grafana"} "[UNIQUE-STATIC-LOG] ID=abc123 XYZ"&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Loki&lt;/td&gt;
&lt;td&gt;12s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VictoriaLogs&lt;/td&gt;
&lt;td&gt;~900ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single-character difference in syntax — &lt;code&gt;|=&lt;/code&gt; vs nothing — hides the architectural one. Loki's &lt;code&gt;|=&lt;/code&gt; is a substring filter run line-by-line over decompressed chunks. VictoriaLogs treats the same string as an index probe. 12 seconds turns into 900 milliseconds on identical hardware.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. negative — proving a string doesn't exist
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Purpose:&lt;/strong&gt; Search for a string that doesn't appear anywhere in the dataset. Forces a full scan in both systems.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LogQL:&lt;/strong&gt; &lt;code&gt;{namespace="truefoundry"} |= "non-existent log line"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LogsQL:&lt;/strong&gt; &lt;code&gt;{namespace="truefoundry"} "non-existent log line"&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dataset&lt;/th&gt;
&lt;th&gt;Loki&lt;/th&gt;
&lt;th&gt;VictoriaLogs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;500 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Timeout&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 GB&lt;/td&gt;
&lt;td&gt;2.6s&lt;/td&gt;
&lt;td&gt;266ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The negative query is the quiet one. At 300 GB Loki handles it in 2.6 seconds. At 500 GB the resources choke and the query halts — never returns. In production that's the difference between an alert that fires and a dashboard that loads.&lt;/p&gt;

&lt;h2&gt;
  
  
  ingestion under pressure
&lt;/h2&gt;

&lt;p&gt;We pushed both with 120 flog replicas to find the ceiling.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Loki&lt;/th&gt;
&lt;th&gt;VictoriaLogs&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Peak ingestion&lt;/td&gt;
&lt;td&gt;20 MB/s&lt;/td&gt;
&lt;td&gt;66 MB/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3× higher&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vCPU (sustained)&lt;/td&gt;
&lt;td&gt;4 vCPU, 100% throttled&lt;/td&gt;
&lt;td&gt;2 vCPU peak&lt;/td&gt;
&lt;td&gt;50% lower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;~4 GiB&lt;/td&gt;
&lt;td&gt;~1.3 GiB&lt;/td&gt;
&lt;td&gt;3× lower&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5s3qafw879ghz36crg8m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5s3qafw879ghz36crg8m.png" alt="Loki CPU saturation graph at 4 vCPUs and memory consumption at 4GB during peak ingestion load with 120 flog replicas" width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F90stz0licep4ktlozyla.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F90stz0licep4ktlozyla.png" alt="VictoriaLogs performance graph showing 2 peak vCPU usage and 1.3GB memory consumption during the same ingestion load" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Loki hit the CPU wall first and never recovered — pinned at 100% throttled while still topping out at 20 MB/s. VictoriaLogs absorbed the same firehose at 3× the throughput, on &lt;strong&gt;72% less CPU and 87% less memory&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  load test under traffic
&lt;/h2&gt;

&lt;p&gt;Locust, 10 concurrent users, simulating real read traffic. VictoriaLogs handled 36% more requests per second, p99 latency was 3.6× faster than Loki under load, and tail latency stayed lower at every percentile we measured.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4q830dsli4gijrwe42c0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4q830dsli4gijrwe42c0.png" alt="Load test results for VictoriaLogs showing 36% higher RPS and 3.6x faster p99 latency with 10 concurrent users at 43 RPS" width="800" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu5pzz8c4g8ykkhx7hk1r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu5pzz8c4g8ykkhx7hk1r.png" alt="Load test results for Loki showing slower response times and lower throughput under the same simulated traffic" width="800" height="147"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  why the gap is this big
&lt;/h2&gt;

&lt;p&gt;Four design choices doing most of the work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Full-text indexing.&lt;/strong&gt; Per-token indices skip line-by-line filtering entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Columnar LSM layout.&lt;/strong&gt; Reads touch only the columns the query asks for; fewer disk seeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory discipline.&lt;/strong&gt; Lower steady-state overhead means more headroom for everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SIMD search.&lt;/strong&gt; Vectorised inner loops on commodity CPUs add up over billions of lines.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  when to pick which
&lt;/h2&gt;

&lt;p&gt;VictoriaLogs is the right pick when text search and grep-style queries are the primary workload, when ad-hoc exploration across large windows matters, when resource efficiency and bin-packing density are real constraints, or when you want fewer knobs to tune in production.&lt;/p&gt;

&lt;p&gt;Loki is the right pick when label-based queries dominate and full-text is rare, when deep Grafana ecosystem integration is non-negotiable, or when you already operate Loki at scale and the migration cost outweighs the wins.&lt;/p&gt;

&lt;p&gt;For us, on this workload, the resource economics decided it. The freed memory per node became real infrastructure savings within a quarter. 12 seconds turned into 900 milliseconds with no tuning, and that's the number I keep quoting six months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://grafana.com/docs/loki/latest/" rel="noopener noreferrer"&gt;Loki Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.victoriametrics.com/victorialogs/" rel="noopener noreferrer"&gt;VictoriaLogs Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vector.dev/" rel="noopener noreferrer"&gt;Vector Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://grafana.com/docs/alloy/latest/" rel="noopener noreferrer"&gt;Grafana Alloy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>logging</category>
      <category>observability</category>
      <category>victorialogs</category>
    </item>
    <item>
      <title>When Netlify killed my free tier: a 15-minute migration to Dokploy</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:56:32 +0000</pubDate>
      <link>https://forem.com/sachincool/when-netlify-killed-my-free-tier-a-15-minute-migration-to-dokploy-54k1</link>
      <guid>https://forem.com/sachincool/when-netlify-killed-my-free-tier-a-15-minute-migration-to-dokploy-54k1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/blog/netlify-to-dokploy-migration" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2025-10-24.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Late night. Got this email: &lt;strong&gt;"[Netlify] Your projects have been suspended due to credit limit exceeded."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Five sites down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;linkedintel.ai (LinkedIn Sales Intelligence AI for SDR's)&lt;/li&gt;
&lt;li&gt;sachin.cool (rookie website from college time)&lt;/li&gt;
&lt;li&gt;dilharia.love (wedding RSVP site - yes, judge me)&lt;/li&gt;
&lt;li&gt;My personal blog&lt;/li&gt;
&lt;li&gt;A ex-ceo's landing page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Netlify moved legacy free tier users to their new 300-credit plan. I burned through it in a week.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsr36oq31ynqa6cnwbjvm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsr36oq31ynqa6cnwbjvm.webp" alt="Netlify upgrade notice" width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;New option: $9/month for 1000 credits, or figure something else out.&lt;/p&gt;

&lt;p&gt;I had 15 minutes before my girlfriend woke up. Here's what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  the €3 solution
&lt;/h2&gt;

&lt;p&gt;Hetzner CX22: 2 vCPUs, 4GB RAM, 40GB SSD. &lt;strong&gt;€3.29/month&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnhzjpsy8a3j3wble9spb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnhzjpsy8a3j3wble9spb.png" alt="Hetzner CX22 pricing" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Math was simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Netlify: $108/year for credit anxiety&lt;/li&gt;
&lt;li&gt;Dokploy + Hetzner: $42/year for unlimited deploys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flv0tg352cnydppfq86oa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flv0tg352cnydppfq86oa.png" alt="Netlify vs Self-Hosted Comparison" width="788" height="1062"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd been &lt;a href="https://www.youtube.com/watch?v=RoANBROvUeE" rel="noopener noreferrer"&gt;watching this Dokploy video&lt;/a&gt; the week before. Perfect timing.&lt;/p&gt;

&lt;h2&gt;
  
  
  the 15-minute panic deploy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Minutes 0-5&lt;/strong&gt;: Spun up Hetzner in Helsinki. Got the IP. Updated DNS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minutes 5-8&lt;/strong&gt;: SSH'd in, ran the Dokploy installer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://dokploy.com/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Dokploy installed Docker, Traefik, PostgreSQL, everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minutes 8-12&lt;/strong&gt;: Connected Git repos. Paste GitHub URL, select branch, done.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsakx8rssoug6r1jbmvdz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsakx8rssoug6r1jbmvdz.png" alt="Dokploy Git integration" width="799" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minutes 12-15&lt;/strong&gt;: Hit deploy on all 5 projects. Watched them come back to life.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsb7mltfo18orsizrucu6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsb7mltfo18orsizrucu6.png" alt="Dokploy migration dashboard" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Fiance woke up. dilharia.love was live.&lt;/p&gt;

&lt;h2&gt;
  
  
  what surprised me
&lt;/h2&gt;

&lt;p&gt;SSL just works. Traefik + Let's Encrypt provision certificates automatically. I'm running Cloudflare Full (Strict) mode - zero warnings.&lt;/p&gt;

&lt;p&gt;WWW redirects? One checkbox. Netlify charged extra for this.&lt;/p&gt;

&lt;p&gt;Logs and monitoring built-in. No Datadog bill. No "$500/month observability platform."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fup8su3uc2a8hz3zme0hi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fup8su3uc2a8hz3zme0hi.png" alt="Dokploy projects dashboard" width="800" height="773"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  the catch
&lt;/h2&gt;

&lt;p&gt;You own the ops. Server goes down? That's on you. No 99.9% SLA.&lt;/p&gt;

&lt;p&gt;You handle security: OS updates, SSH keys, backups. I run &lt;code&gt;apt upgrade&lt;/code&gt; weekly and backup to Backblaze B2 for $0.50/month.&lt;/p&gt;

&lt;p&gt;For personal projects? Worth it. For business-critical stuff? Pay for managed services.&lt;/p&gt;

&lt;h2&gt;
  
  
  one month later
&lt;/h2&gt;

&lt;p&gt;Server load: 8% CPU. Zero downtime. SSL renewals automatic.&lt;/p&gt;

&lt;p&gt;All 5 sites running smoothly: linkedintel.ai pulling data, sachin.cool looking sharp, dilharia.love collecting RSVPs.&lt;/p&gt;

&lt;p&gt;Deployed 3 more projects since then. No credit anxiety. No surprise bills.&lt;/p&gt;

&lt;p&gt;Total maintenance time: 10 minutes/week.&lt;/p&gt;

&lt;p&gt;Best infrastructure decision I've made this year.&lt;/p&gt;

&lt;h2&gt;
  
  
  related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/aws-cost-optimization-tricks"&gt;AWS Cost Optimization: How We Cut Our Bill by 60%&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/ja4-fingerprinting-network-security"&gt;How I Took Down 30% of Production with One TLS Fingerprinting Rule&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/kubernetes-debugging-tips"&gt;5 Kubernetes Debugging Tricks That Saved My Production&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>hosting</category>
      <category>costoptimization</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>Delivery impersonation: the social engineering vector that just works</title>
      <dc:creator>Harshit Luthra</dc:creator>
      <pubDate>Mon, 18 May 2026 16:56:16 +0000</pubDate>
      <link>https://forem.com/sachincool/delivery-service-impersonation-is-an-alarmingly-effective-social-engineering-vector-52bj</link>
      <guid>https://forem.com/sachincool/delivery-service-impersonation-is-an-alarmingly-effective-social-engineering-vector-52bj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://harshit.cloud/til/delivery-social-engineering" rel="noopener noreferrer"&gt;harshit.cloud&lt;/a&gt; on 2025-10-17.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A few weeks ago, my mum got a WhatsApp call from someone claiming to deliver a Diwali hamper from a bakery she'd never ordered from. They asked for her live location to "route the driver". She sent it. Twenty seconds, full home address handed to a stranger.&lt;/p&gt;

&lt;h2&gt;
  
  
  why this attack works
&lt;/h2&gt;

&lt;p&gt;The attack rides three psychological triggers at once. Mentioning a well-known local business creates instant credibility. Gift deliveries during festivals are common and expected, so the pretext doesn't trip anyone's filter. And "I'm outside and need directions now" prompts immediate action before the victim has time to verify anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  the attack pattern
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attacker: "Hi, I'm from [Popular Local Bakery]. I have a Diwali gift
          hamper for you but I'm having trouble finding your location.
          Could you share your address or live location?"

Victim: Shares full address or WhatsApp live location without verification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No order confirmation requested. No delivery tracking number asked for. No verification of any kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  why people fall for it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gift context&lt;/strong&gt;: during festivals, people expect surprise gifts from friends and family&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helpful nature&lt;/strong&gt;: most people want to help someone who seems to be doing their job&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time pressure&lt;/strong&gt;: the implied urgency ("I'm waiting outside") prevents critical thinking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low perceived risk&lt;/strong&gt;: sharing an address seems harmless compared to financial data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust in local brands&lt;/strong&gt;: using a known local business name lowers suspicion&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  defense strategies
&lt;/h2&gt;

&lt;p&gt;The defense is one habit: don't share an address until you've verified the order exists. Ask for a tracking number, call the business on its public number, ask who sent the gift and check with them. If the driver "needs directions right now", give a landmark, not a pin. Most delivery apps already have in-app chat — there's no good reason a real driver needs your live location over WhatsApp.&lt;/p&gt;

&lt;h2&gt;
  
  
  real-world impact
&lt;/h2&gt;

&lt;p&gt;This attack can be used for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Physical surveillance and stalking&lt;/li&gt;
&lt;li&gt;Burglary planning (knowing when someone is home)&lt;/li&gt;
&lt;li&gt;Identity theft (address is often used for verification)&lt;/li&gt;
&lt;li&gt;Targeted phishing (now knowing exact location)&lt;/li&gt;
&lt;li&gt;Physical security breaches&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  the asks that work
&lt;/h2&gt;

&lt;p&gt;The pretexts that actually get through share a shape. A familiar local business name doing the credibility work. A plausible occasion — Diwali hampers, birthday flowers, Amazon redelivery — that fits the calendar. A small action framed as urgent: "I'm outside, just send the location". Zero technical skill, one phone call, full address.&lt;/p&gt;

</description>
      <category>socialengineering</category>
      <category>cybersecurity</category>
      <category>opsec</category>
      <category>privacyrisk</category>
    </item>
  </channel>
</rss>
