<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Writing — szelcsanyi consulting</title><description>Field notes on building dependable systems, reading slow queries, and doing consulting that leaves a team stronger.</description><link>https://szelcsanyi.net</link><language>en</language><copyright>© 2026 Boris Szelcsányi</copyright><item><title>Pricing cyber risk in euros, not traffic lights</title><link>https://szelcsanyi.net/blog/pricing-cyber-risk-in-euros</link><guid isPermaLink="true">https://szelcsanyi.net/blog/pricing-cyber-risk-in-euros</guid><description>I spent this year building Nisura — a European cyber risk quantification engine. Here are the modelling decisions that mattered, including the one I argued about with myself for weeks.</description><pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate><content:encoded>Every board I have sat in front of has been shown the same artefact: a grid of red, amber and green squares. Someone walks through it, everyone nods, and the meeting moves on. Then the CFO asks the only question that actually matters — *how much money is this costing us, and how much would fixing it save?* — and the room goes quiet.

That silence is the reason [Nisura](https://nisura.eu) exists. It is a cyber risk quantification engine built for the European market: it maps your external attack surface, and resolves it into a euro figure your board can weigh against the cost of controls. This post is about the modelling decisions underneath that, including the one I got wrong first.

## Severity is not the same as danger

The instinct when you start is to rank by CVSS. It is the score everyone knows, it ships with every advisory, and it is right there in the feed. It is also close to useless for deciding what to fix on Monday.

CVSS answers *how bad would this be if someone exploited it.* It says nothing about whether anyone is actually doing so. The consequence is a queue sorted by theoretical worst case, where a 9.8 nobody has ever weaponised sits above a 7.5 that three ransomware crews are running today.

So the engine ranks on exploitation evidence instead — CISA&apos;s Known Exploited Vulnerabilities catalogue, EPSS probability scores from FIRST, and CISA-ADP&apos;s SSVC decision points (Act / Attend / Track). Combine those and the ordering inverts in a way that looks wrong until you sit with it:

&gt; A 2022 Zyxel command-injection flaw at 100% EPSS outranks a 2026 VMware path traversal at 46%. The old one is proven, packaged, and in active use. The new one is a press release.

Ransomware groups are not researchers. They reuse what already works against exposed edge devices — VPN concentrators, file-transfer appliances, mail servers. Age is not a proxy for safety, and novelty is not a proxy for danger. Rank by what is being used, not by what could theoretically be worst.

## The American model does not convert

This is the decision I spent the longest on, and my first instinct was wrong.

The mature loss models in this space are built on US insurance claims data. It is genuinely good data — decades of it, actuarially sound, far richer than anything comparable in Europe. My initial plan was to use it and convert the output to euros.

That does not work, and the reason is not the exchange rate.

A claims-based model inherits the shape of the policies behind it. It is bounded by what insurers agreed to pay, which means it silently caps your risk at the policy limit and excludes the parts that hurt most — deductibles, customer churn, reputational damage, IP loss, the eighteen months of deferred roadmap after an incident. You are not measuring your exposure. You are measuring someone&apos;s willingness to underwrite a slice of it.

On top of that, the inputs are wrong for European operations. Downtime cost is a function of labour cost, and labour cost in Austria is not labour cost in Texas. Business interruption depends on operating margin by sector, and European margins differ structurally. Regulatory exposure is not a rounding error here — NIS2 and GDPR have statutory caps that are computed differently, and DORA applies to an entirely separate population of firms.

So the engine is built the other way around: Eurostat labour indices, Damodaran-Europe margin data, the NACE sector taxonomy that NIS2 itself uses, and ENISA&apos;s EUVD as a first-class vulnerability source alongside the American feeds. The threat intelligence is global, because attackers are. The loss model is European, because the losses are.

This is also the part that is hardest to copy. Anyone can plug into the same public feeds — they are table stakes. Building the European cost model is the slow, unglamorous work that actually differentiates the output.

## Likelihood and impact are different levers

The second modelling decision was separating two things most tools blur together.

Multi-factor auth on external access changes the *probability* of a breach. Tested offline backups do not — they change what it *costs you* when it happens anyway. Those are different mathematical operations, and collapsing them into one &quot;security score&quot; destroys the information a decision-maker needs.

So controls and resilience are modelled as separate tiers:

- **Controls** — MFA, EDR, rapid patching, segmentation, no exposed RDP — reduce modelled breach likelihood.
- **Resilience** — immutable backups, a tested IR retainer, a rehearsed DR plan — reduce recoverable loss.

The practical payoff is that a CISO can see, before spending anything, which lever moves their number further. Sometimes the answer is a €40k control. Often it is a backup restore drill that costs a weekend. A single blended score can never tell you that.

The expected annual loss is then the obvious thing — the sum of event probability times financial loss across modelled scenarios — surfaced as a loss-exceedance curve rather than one number, because the tail is where the interesting decisions live. Expected annual loss tells you what to budget. Probable maximum loss tells you what could end the company. Those deserve separate lines.

## What NIS2 Article 21 actually asks for

There is a lot of noise about NIS2, most of it selling fear about the €10M cap and personal liability for executives. Both are real. Neither is the useful part.

The useful part is that Article 21 requires *ongoing* risk management and supply-chain assessment, and 21(2)(g) requires evidence of security-awareness training. &quot;Ongoing&quot; is the load-bearing word. An annual penetration test is obsolete the moment it is printed, and a vendor questionnaire is a self-assessment that nobody verifies. Neither survives contact with an auditor asking what your posture was in March.

Continuous external monitoring produces something a point-in-time audit structurally cannot: a timestamped, verifiable record of what your exposure was, when, and what you did about it. That is the compliance artefact — not a certificate, a history.

The same logic pushed human risk into the model. Authorised phishing simulations against your own staff produce a real click rate, which becomes a factor on the credential-access vector rather than a separate report nobody reads. Aggregate only, no credential ever captured, gated behind signed authorisation and a works-council sign-off in Austria and Germany. That last constraint is not a nice-to-have here — get it wrong and you have created a labour-law problem while trying to solve a security one.

## The part that keeps me honest

The obvious failure mode of this entire category is false precision. A figure like *1.250.400 €* reads as though someone counted it. Nobody counted it. It is the output of a probabilistic model with real uncertainty in every input, and presenting it as a hard number invites exactly the over-confidence that heatmaps are criticised for.

I have not fully solved that tension. The mitigations so far are showing the curve rather than the point estimate, keeping the assumptions inspectable, citing the sources for every cost baseline, and being explicit that this is a decision aid and not an audit or an underwriting opinion. The honest framing is that the number is not *correct* — it is *defensible*, and it is wrong in a direction you can inspect and argue with. That is a real improvement on amber, but it is not the same as truth.

The other constraint I set early was that the free assessment sends no packets at your infrastructure. Passive OSINT only, no login, no agents. Partly that is a trust question — asking permission to scan before you have earned any is a bad first interaction. Mostly it is that the alternative requires an authorisation conversation that the tool has not yet given anyone a reason to have.

## What I would tell someone starting this

Build the cost model before the scanner. The feeds are commodity and the scanning is solved; the thing that makes the output meaningful is the boring economic modelling underneath, and it takes far longer than you plan for.

And resist the score. Every conversation will push you toward one number, one grade, one colour, because that is what people are used to being handed. The entire value is in refusing — keeping likelihood separate from impact, uncertainty visible, and assumptions inspectable — so that when someone disagrees with the output, they can point at *which* input they think is wrong.

That argument is the product working. A heatmap never gave anyone something specific enough to argue with.</content:encoded><category>Security</category><category>Cyber Risk</category><category>NIS2</category><category>Product</category><author>boris@szelcsanyi.net (Boris Szelcsányi)</author></item><item><title>Six hours to mitigate a DoS — a connection-flood post-mortem</title><link>https://szelcsanyi.net/blog/anatomy-of-a-dos-attack</link><guid isPermaLink="true">https://szelcsanyi.net/blog/anatomy-of-a-dos-attack</guid><description>An unprotected origin server took a sustained flood on port 443 until its file descriptors ran out. Here is how we diagnosed it, stopped it in an afternoon, and why it was avoidable.</description><pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate><content:encoded>*This is a sanitised write-up of a real incident I handled. The client, IP addresses, hostnames, and other identifying details have been removed or generalised — it is shared purely for the technical lessons.*

One morning a client&apos;s website — a marketing site running on a single cloud VPS — went dark over HTTPS. Not slow. Gone. `curl` returned `HTTP 000`; the browser just spun. Plain HTTP on port 80 still answered instantly, SSH was fine, and the box responded to ping the whole time. That split — everything healthy except 443 — is the fingerprint of a specific kind of attack, and it tells you where to look.

By the end of the afternoon it was fully mitigated. Here&apos;s the anatomy.

## What actually happened

The server was hit with a **connection flood on port 443**. The attacker opened TLS connections as fast as it could and never sent a complete HTTP request — just enough to make nginx accept the socket and reserve a file descriptor for it. Multiply that by thousands of connections per second and you exhaust the one resource nobody thinks about until it&apos;s gone: **open file descriptors**.

Once the OS limit was hit, nginx could no longer accept *any* new connection — attacker or legitimate visitor alike. The site was effectively offline for HTTPS while the process sat there pinned, unable to make progress.

## The evidence

The story was written plainly in the logs — if you knew which log to read.

The nginx **error** log was full of the same two lines, tens of thousands of times:

```text
accept4() failed (24: Too many open files)
1024 worker_connections are not enough, reusing connections
```

The nginx **access** log, meanwhile, was **completely empty** for the same window. That&apos;s the giveaway: connections never reached the HTTP layer. They died at accept/TLS, so there was nothing to log as a request. If you only watch access logs and request-rate dashboards, an attack like this is invisible — the requests you&apos;d count never happen.

The host metrics corroborated it:

- **CPU:** pinned at 100–150% (spikes past 200%) for hours, versus ~5% at rest. TLS handshakes are the most expensive thing an HTTPS server does, and the attacker was forcing a firehose of them.
- **Packets in:** ~8,000–10,000 pps, at only ~1 MB/s. That ratio — high packet count, low bandwidth, ~100-byte average packet — screams *connection-establishment traffic*, not real payloads.
- **Port 80:** answered a 301 redirect in 0.08s throughout. **Port 443:** dead.

No sign of data exfiltration or compromise. This was pure resource exhaustion — a denial of service, nothing more, but nothing less.

## The root cause (it wasn&apos;t the attacker)

Attacks like this succeed or fail based on what you did *before* they arrive. Here, the origin server had been stood up by a third party without any of the basics:

- **The origin IP was directly reachable.** No reverse proxy, no CDN in front. The public IP was resolvable via DNS and even exposed through the host&apos;s reverse-DNS record, so the attacker could aim straight at the machine and bypass any name-based defence entirely.
- **No firewall to speak of.** Port 443 accepted connections from the entire internet.
- **nginx was tuned for a toy load.** `worker_connections` sat at the default `1024`, and `worker_rlimit_nofile` was never raised — so the file-descriptor ceiling was low and easy to hit.
- **No packet-level logging.** Which made forensics after the fact much harder than it needed to be.

The uncomfortable takeaway: the site was set up by a marketing agency, with no security review. That&apos;s the real vulnerability — **letting whoever builds the site also own its security posture, with no one accountable for hardening.**

## The fix

The mitigation was a layered move: hide the origin, then only let the shield talk to it.

1. **Put a reverse proxy in front (Cloudflare).** Every DNS record for the domain was switched to *proxied*, so all traffic now flows through the CDN&apos;s global network. TLS terminates on their edge, not on the origin — and, crucially, **the real server IP is no longer visible** to the outside world.
2. **Firewall the origin down to the CDN&apos;s IP ranges.** At the host firewall, ports 443 and 80 were restricted to *only* the proxy&apos;s published IP ranges (a handful of IPv4 and IPv6 CIDR blocks). Every other connection attempt to 443 is now dropped at the network edge before it ever reaches nginx. This single rule is what dropped attack traffic to near-zero.
3. **Move DNS authority to the proxy** to unlock its caching, WAF, and DDoS features end-to-end.
4. **Turn on &quot;Under Attack&quot; mode** — a JavaScript challenge that filters out automated scripts and bots while letting real visitors through after a brief interstitial.
5. **Add packet-level logging** (an `iptables` log rule on 443, persisted across reboots) so the next incident starts with evidence instead of guesswork.
6. **File abuse reports** with the hosting providers the traffic originated from.

Within minutes of the firewall rule landing, CPU fell back to idle and inbound packets dropped to essentially zero. The host graphs confirmed the mitigation cleanly: a wall of load, then flat.

## What I&apos;d tell anyone running an origin server

The specific attack matters less than the pattern. Five things would have prevented or blunted this entirely:

- **Never expose your origin IP.** Put a CDN/proxy in front from day one, and treat the origin IP as a secret. Most of these attacks evaporate the moment the attacker can&apos;t find the machine.
- **Firewall the origin to your proxy&apos;s ranges only.** A CDN in front does nothing if the attacker can still hit the naked IP directly. Lock 80/443 to the proxy&apos;s CIDRs — this is the rule that did the heavy lifting here.
- **Tune for the failure mode, not the demo.** Raise `worker_connections` (4096+) and `worker_rlimit_nofile` so file-descriptor exhaustion isn&apos;t a one-shot kill.
- **Watch the right signal.** Request-rate dashboards won&apos;t show a connection flood — the requests never complete. Watch connection counts, packets-per-second, CPU, and *error* logs.
- **Put security ownership somewhere accountable.** Whoever builds the site should not be the last word on how it&apos;s defended. A short hardening review before go-live would have closed every gap above.

The attack was resolved in about six hours, most of which was diagnosis and setting up the shield correctly. The mitigation itself — origin hidden, firewall scoped to the proxy — is now permanent. As long as the origin IP stays out of public view and the proxy stays on, the same attack simply has nowhere to land.

Boring, layered defence beats clever reaction. It usually does.</content:encoded><category>Security</category><category>Incident Response</category><category>DevOps</category><author>boris@szelcsanyi.net (Boris Szelcsányi)</author></item><item><title>Ship boring software on purpose</title><link>https://szelcsanyi.net/blog/ship-boring-software</link><guid isPermaLink="true">https://szelcsanyi.net/blog/ship-boring-software</guid><description>The most reliable systems I have built were also the least clever. Here is why I reach for boring first, and when I break my own rule.</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>Early in my career I mistook novelty for quality. A new datastore, an exotic queue, a framework three weeks old on Hacker News — each felt like progress. Most of them became the thing I was paged about at 3am.

These days I optimise for a different property: **how quickly can the next engineer understand this and change it safely?** That question tends to push me toward boring, well-understood technology, and it has been the single best predictor of a system aging well.

## Boring is a feature, not a compromise

Boring technology has a large surface area of prior art. When Postgres does something surprising, ten thousand people have already hit it and written it down. When your bespoke consensus layer does something surprising, you are the documentation.

That matters most exactly when things are on fire:

- The failure modes are known and searchable.
- Hiring is easier because the skills already exist.
- The tooling — backups, migrations, observability — is mature.

None of that is glamorous. All of it compounds.

## When I break the rule

Boring-by-default is not boring-always. I will spend novelty budget — what Dan McKinley called [innovation tokens](https://mcfunley.com/choose-boring-technology), of which you get about three — when three things line up:

1. The problem is genuinely core to the business, not incidental.
2. The boring option has a concrete, measured ceiling I have already hit.
3. I can contain the new thing behind an interface I could rip out in a week.

&gt; A good architecture makes the reversible decisions cheap and the irreversible ones rare.

If I cannot draw the line where the risky component ends, I am not ready to add it yet.

## The test I actually use

Before adding anything to a stack, I ask: *if this breaks at the worst possible time, do I know how to fix it, and can I explain that fix to someone who has never seen it?*

If the answer is no, the interesting choice is usually the wrong one. Ship the boring version, instrument it, and let the data — not the changelog — tell you when it is time for something sharper.</content:encoded><category>Architecture</category><category>Reliability</category><author>boris@szelcsanyi.net (Boris Szelcsányi)</author></item><item><title>How I read a slow query before touching it</title><link>https://szelcsanyi.net/blog/reading-a-slow-query</link><guid isPermaLink="true">https://szelcsanyi.net/blog/reading-a-slow-query</guid><description>A repeatable way to go from &quot;the database is slow&quot; to a specific, measured fix — without guessing or adding indexes at random.</description><pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&quot;The database is slow&quot; is not a bug report — it is a feeling. My job is to turn it into a number, then turn that number into a plan. Here is the loop I run, more or less unchanged, on every performance engagement.

## 1. Reproduce it with a real query

Guessing from a dashboard is how you end up optimising the wrong thing. I start by capturing the exact statement and parameters that are hurting, ideally from `pg_stat_statements`, sorted by total time rather than mean time. The query that is 8ms but runs a million times a day matters more than the 2-second report nobody looks at.

## 2. Ask the planner, do not assume

`EXPLAIN (ANALYZE, BUFFERS)` is the whole game. It tells you what the planner *actually did*, not what you hoped it would:

```sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders
WHERE tenant_id = $1 AND status = &apos;open&apos;
ORDER BY created_at DESC
LIMIT 50;
```

Two things I read first:

- **Estimated vs actual rows.** A large gap means the planner has bad statistics and is making bad choices downstream.
- **Buffers.** Heap reads that dwarf the rows returned mean I am fetching far more data than the answer needs.

## 3. Name the actual problem

Most slow queries I see are one of a short list:

1. A missing or wrong index — a sequential scan where a filter should have narrowed things.
2. A poor join order caused by stale statistics.
3. Fetching wide rows when only a few columns are used.
4. Doing in the database what should be a cache.

&gt; Naming the category is 80% of the fix. Only once I can say *which* of these it is do I let myself write any SQL.

## 4. Change one thing, measure again

Then — and only then — I make a single change: add the composite index, rewrite the join, add a covering column. I re-run the same `EXPLAIN ANALYZE` and compare. One variable at a time, every time, so I can attribute the improvement honestly.

The discipline is not in knowing clever tricks. It is in refusing to move to step four until steps one through three are boringly, provably done.</content:encoded><category>Postgres</category><category>Performance</category><author>boris@szelcsanyi.net (Boris Szelcsányi)</author></item><item><title>What I do in the first 90 days of an engagement</title><link>https://szelcsanyi.net/blog/freelance-first-90-days</link><guid isPermaLink="true">https://szelcsanyi.net/blog/freelance-first-90-days</guid><description>How I earn trust as an outside engineer — by shipping something small and real before I propose anything large.</description><pubDate>Sat, 02 May 2026 00:00:00 GMT</pubDate><content:encoded>The fastest way to lose a client is to spend three weeks writing a strategy deck. The fastest way to keep one is to ship something they can feel by Friday. Here is how I structure the opening of a new engagement so momentum arrives early and stays.

## Weeks 1–2: earn context, not opinions

I read the code, the incidents, and the calendar — in that order. The incidents tell me where the pain actually is; the calendar tells me how the team really works. I hold my architectural opinions loosely on purpose. Most systems that look wrong from the outside are a rational response to a constraint I cannot see yet.

The one thing I insist on early is access: repo, staging, logs, and a way to deploy. If I cannot get to production safely, I cannot help you safely.

## Weeks 3–6: ship one real thing

Before I propose anything big, I close one meaningful, visible piece of work end to end. A latency fix with a graph that moved. A flaky deploy made boring. Something the team has wanted and not had time for.

This does two things:

- It proves I can operate inside *your* system, not just talk about systems in general.
- It gives us a shared, concrete reference for every conversation that follows.

&gt; Trust is not granted by a title. It is repaid, in small increments, by work that lands.

## Weeks 7–12: make myself replaceable

The goal of good consulting is to leave the team stronger than a full-time hire would have. So the back half is about transfer, not heroics:

1. Write the runbook for the thing I just fixed.
2. Pair, so at least two people can maintain it without me.
3. Leave a short, honest map of what I would do next — and what I would not.

If I have done my job, the team does not need me by the end. That is the point, and it is also why they call me for the next one.</content:encoded><category>Consulting</category><category>Process</category><author>boris@szelcsanyi.net (Boris Szelcsányi)</author></item></channel></rss>