EST. 2021  •  OSLO, NORWAY

Skui.io

Homelab & Self-Hosting

← Back
homelab

The Dependencies You Didn't Add

Last time I wrote about inheriting other people's CVEs. This is the post about the one service I built the other way — no framework, three dependencies, a stack I can hold in my head.

The Dependencies You Didn't Add
Contents

Overview
#

You can’t code your way out of other people’s CVEs. But you can be aware of them. You can know which ones are actually yours. And you can choose your dependencies understanding that you’re not only picking a feature set — you’re picking whose security debt you’re willing to carry.

That’s how I ended the last post.

It’s easy to write a line like that and feel clever. It’s harder to actually live by it, because the whole industry is built the other way — you reach for a framework, it reaches for forty packages, those reach for four hundred, and a year later a scanner tells you your little side project depends on something you’ve never heard of, written by someone you’ll never meet, with a CVE you can’t fix.

So this is the post where I take my own advice and look at the one service I built deliberately, stubbornly, the other way.

What it is
#

ethica.no is a small business-operations suite I run myself — a marketing and docs site out front, and behind a login, the boring-but-real machinery: a CRM, a service desk with SLA tracking, invoicing, a newsletter, a document portal. Two languages, white-label branding, the works. The kind of thing you’d normally assemble out of Django plus a dozen plugins, or pay three SaaS subscriptions for.

I built it as one Python process and a pile of HTML.

No framework
#

Here’s the part that makes people wince: there’s no web framework in it. No Flask, no Django, no FastAPI. The server is Python’s own http.server, with urllib, hmac, html, email — standard library, the stuff that ships in the box. Routing, sessions, auth, CSRF, the admin layer: all hand-written, all mine.

The database layer is the same story. There’s no ORM. db.py is a thin shim — about a hundred lines — that lets code written against plain sqlite3 run on top of Postgres in production. The queries are just SQL, with ? placeholders, that I typed out myself. SQLAlchemy never enters the building.

The front end? No npm. No React, no build step full of packages that rot the moment you stop looking at them. The public site is Hugo — one static-site binary — and the dynamic parts are server-rendered HTML from that same Python process.

So what does it depend on?
#

This is the honest part, because “zero dependencies” would be a lie, and the last post was all about not lying to yourself with numbers.

The entire third-party dependency list is one line in the Dockerfile:

RUN pip install --no-cache-dir "psycopg[binary]" "fpdf2" "pywebpush"

Three packages. That’s the whole tree I added on purpose:

  • psycopg — because talking to Postgres is the one thing the standard library genuinely doesn’t do.
  • fpdf2 — because generating an invoice PDF by hand is a special kind of misery I chose to skip.
  • pywebpush — because the encryption dance for browser push notifications is not something you want to reinvent.

Each one is there because the standard library has a real gap, and each one I can explain in a sentence. I can hold the entire dependency list of this service in my head. Try doing that with the average node_modules.

That’s the actual point — not zero, but small and deliberate. Every name on the list is a decision I made and can defend, instead of a number that crept in behind forty other numbers I never agreed to.

What the scanner found
#

Remember the wall of red from last time? When I pointed the same scanner at this service, it was quiet. The few things it flagged were exactly where you’d expect — in those three named libraries, or in the base image — never in some transitive package I’d never read a line of.

And the image itself is built to give an attacker as little as possible. It’s a three-stage build: Hugo compiles the site and then the entire toolchain is thrown away; Python installs into a slim layer; and the thing that actually ships is a distroless runtime — no shell, no package manager, no perl, no tar, running as a non-root user. The build notes record the trade plainly: the CVE surface drops from around fifty-six on a normal slim Python base to about fourteen, none of them critical or high — and there’s nothing in there to pivot with even if you got in.

Then there’s the database — the one piece I didn’t write. It’s postgres:16-alpine: the exact image I spent half the last post complaining about, the one carrying sixteen Go-standard-library CVEs I can’t patch because I don’t build it. They’re still in there. I acknowledged them and moved on.

But the database is air-gapped. It lives on an internal-only Docker network with no route to the internet and no published port — nothing on the box can reach it except the app’s own processes. So those sixteen unfixable CVEs sit in an image that nothing can connect to from the outside, that can’t phone home, that doesn’t exist as far as the network is concerned. You can’t always fix a dependency’s bug. But you can make sure there’s no path to it. That’s the move the last post was missing: acknowledged doesn’t have to mean exposed.

None of this is the standard library being magic. It’s the standard library, plus refusing to pack things I don’t need, plus putting the things I can’t fix where nothing can touch them.

The bill for doing it this way
#

I’m not going to pretend this is free, because it isn’t, and the trade is the whole story.

No framework means I wrote my own routing, my own session handling, my own auth, my own CSRF. That’s more code I own — and worse, it’s the security-sensitive code, the exact stuff most people are right to want a battle-tested library for. A framework has thousands of eyes on its login flow. Mine has one. When I get sessions wrong, there’s no dependency to blame and no upstream patch to wait for. It’s my bug, my CVE, my Saturday.

So I didn’t escape risk. I traded it. I swapped a large pile of inherited dependency risk for a smaller pile of self-authored bug risk — and I did it with eyes open, because for this service, on infrastructure I run alone, I’d rather own the bugs I can read than the ones I can’t.

The standard library is a dependency too, of course. But it’s a dependency with a security team, a stability promise measured in decades, and an update cadence that doesn’t break my build every Tuesday. If I’m going to carry someone’s security debt, I’ll carry Python’s.

The point
#

The last post was about all the code in my stack that I didn’t write and can’t fix. This one is about the one corner where I went the other way and wrote almost all of it myself.

The dependency list fits in a single sentence.

Every name on it I can defend.

And the security debt I’m carrying, for once, is mostly my own.