$ cat attack-surface.md
What is attack surface?
Your attack surface is every place where someone outside can send something into your system, or learn something about it.
As a developer I thought of my apps as features. As a pentester I see them as doors. A login form is a door. So is a query parameter, an open port, a response header, a forgotten subdomain, and the 1,200 packages in node_modules.
Security doesn't start with making every door unbreakable. It starts with a simpler question: how many doors do you have - and do you know about all of them?
## known, unknown, forgotten
known
The production app, its API, the login page. You test these. Attackers expect them to be hardened.
forgotten
The v1 API, the staging server, the demo for a client in 2022. Still running, never patched. This is where most real findings are.
unknown
Things nobody on the team knows exist: a marketing tool on a subdomain, a bucket made by a contractor, a key in an old commit.
Attackers don't care which category a door is in. They find all three the same way - with OSINT - and they usually start with the ones you forgot.
## the doors in a typical web app
| layer | doors | leads to |
|---|---|---|
| Domains & subdomains | www, api, staging, old-admin, the marketing site an agency built in 2019 | A02 |
| Endpoints & parameters | Every route and every input - including v1 APIs nobody turned off | A01 |
| Ports & services | SSH, databases, admin panels, debug ports, metrics endpoints | A02 |
| What you reveal | Version headers, stack traces, Swagger pages, source maps, .git folders | A10 |
| Code you didn't write | npm / NuGet packages, base images, CI actions - and their version tags | A03 |
| Secrets & code | Public repos, old commits, JS bundles with API keys | A04 |
| People | Employee names, email formats, reused passwords, phishable inboxes | A07 |
## two kinds of door
things you can reach
Endpoints, ports, forms, uploads. Each one accepts input, and input is where injection, access-control bugs and RCE start.
things you reveal
Version headers, stack traces, API schemas, subdomains in certificates. They don't break anything alone - they tell an attacker where to look.
## how version tags grow your attack surface
This is the example that gets developers' attention most often, because it hits from two sides.
1 · showing your version
HTTP/1.1 200 OK
Server: nginx/1.18.0
X-Powered-By: ASP.NET
X-AspNet-Version: 4.0.30319Those lines don't break anything. But nginx 1.18.0 goes straight into a CVE search, and the attacker gets a list of known vulnerabilities for your exact version - without sending a single malicious request. Shodan has usually indexed it already.
| stack | hide the version |
|---|---|
| nginx | server_tokens off; |
| Express | app.disable('x-powered-by'); |
| ASP.NET Core (Kestrel) | options.AddServerHeader = false; |
| ASP.NET (IIS) | <httpRuntime enableVersionHeader="false" /> + remove X-Powered-By |
Hiding the version is not patching. It removes the free hint - you still need to update.
# Check your own app
$ curl -sI https://example.com | grep -iE '^(server|x-powered-by|x-aspnet-version|x-aspnetmvc-version):'2 · not pinning your version
// package.json
"express": "^4.18.0" // any 4.x ≥ 4.18.0
# Dockerfile
FROM node:latest
# GitHub Actions
- uses: some-org/some-action@v2Every highlighted value is a moving target. If a maintainer account is compromised and a malicious version is published, your next build pulls it in - no pull request, no review, nothing in your git diff. That code is now part of your attack surface, and you never chose it.
what I did as a developer
I set up Watchtower to automatically pull updated images and restart containers. It felt like great automation - always up to date, zero manual work.
Looking at it now as a pentester: any new image published under that tag went straight to production, with nobody reviewing what changed. Convenient for me - and just as convenient for an attacker.
| where | loose | pinned |
|---|---|---|
| npm | ^4.18.0 + npm install | committed lockfile + npm ci |
| NuGet | 13.* | exact versions + packages.lock.json |
| Docker | node:latest | node:22-alpine@sha256:… |
| GitHub Actions | @v2 | @<full commit SHA> # v2.3.1 |
Pinning doesn't mean never updating. It means updates arrive as a reviewed change - a Dependabot or Renovate pull request you can see, scan and approve. More in A03 Software Supply Chain Failures.
## shrinking it
- Inventory. List every domain, subdomain, app and API you own. Recon yourself to find the ones missing from the list.
- Delete. The safest door is one that doesn't exist. Turn off old APIs, staging servers and demo apps.
- Hide. Remove version banners, Swagger UI, source maps and stack traces from production.
- Pin. Lock dependencies, images and CI actions to exact versions.
- Repeat. Attack surface grows with every deploy. Re-run the recon regularly.