Skip to content
juniordev4life
Go back

What a Penetration Test Taught Us About "Done"

Your feature works. The tests are green, the PR is approved, the demo went well. Done, right?

Now imagine handing that same feature to someone whose entire job is to break it - methodically, for a week, with no interest in your happy path. That is a penetration test, and the first time you sit through the readout, “done” stops meaning “it works” and starts meaning something far more uncomfortable: it works, it fails safely, and it leaks nothing.

The most humbling part is not the exotic exploits. It is how many findings are boring - code that works perfectly and is quietly, dangerously wrong. Here are the ones that recur across almost every assessment, with the why behind each, so you can fold them into your own Definition of Done before a stranger does it for you.

First, what a pentest actually tests

A pentest does not test your features. It tests your assumptions - the ones so deep you forgot you made them. That the input will look roughly like the form intended. That nobody will call the endpoint directly. That the error page nobody reads is harmless. Industry data year after year puts the critical findings in the same three buckets - injection, broken access control, and authentication weaknesses - and APIs are now the single most exploited surface. But underneath those categories sit small, concrete habits. Fix the habits and most of the categories never materialize.

The findings that show up everywhere

1. Containers running as root

The finding: your app runs as root inside its container. If an attacker gets any code execution, they own the whole container instead of a powerless user.

The fix is almost embarrassingly small - run as a non-root user and bind only to non-privileged ports:

# Most official images already ship an unprivileged user
USER node
# ...and listen on a high port so a non-root process can bind it
EXPOSE 8080

No chown gymnastics needed when the app only reads its own code and writes nothing to disk. It is one line, and it turns “container compromise” into “annoyed attacker”.

2. Debug and diagnostic endpoints in production

The /debug, /health/detailed, /test-auth route you added to troubleshoot one bad afternoon - and never removed. Pentesters love these. They leak backend URLs, database and table names, dependency versions, and “is this credential present” flags, and they are almost always left unauthenticated because “it’s just for us”.

Rule: no diagnostic endpoints in production. Diagnose from logs. If one is genuinely unavoidable, put it behind real authentication with least privilege - never open.

3. Auth responses that let attackers count your users

Log in with a wrong password and read the message. “Unknown email”? You just told an attacker that address is not a user. “Wrong password”? You confirmed it is. Repeat a few million times and your login form becomes a machine that lists every customer you have. Registration and password-reset flows leak the same way.

The fix is to make the responses identical whether or not the account exists - same message, same timing, same status - so the endpoint answers no questions it was not asked.

4. Pagination that silently resets to page one

A subtle one. A client sends a cursor or offset your backend does not recognize, and the endpoint helpfully returns the first page instead of an error. Feels friendly. Except an offset-based client that keeps asking “give me the next page” can now loop forever, and an attacker can hammer expensive first-page queries on purpose. Silent fallback is a denial-of-service vector wearing a smile.

Treat a pagination token as opaque and validate it. An unknown one is a 400, not a do-over.

5. Error messages that give away the blueprint

The stack trace rendered on the 500 page. The message that says which internal service timed out, at which hostname, running which framework version. Every one of those is a free map for the attacker - reconnaissance you volunteered.

Split the audience: the client gets a generic, boring error; the full detail goes to your server logs with a correlation id. The person debugging gets everything; the person attacking gets “something went wrong”.

6. Secrets in the URL

An API key or token in a query string - ?token=.... It feels harmless because it works. But URLs are logged everywhere: your access logs, the load balancer, the proxy, the CDN, the browser history, the referrer header sent to third parties. A secret in a URL is a secret written on a postcard.

Secrets travel in headers or the request body, never the URL. And if one ever did land in a log, treat it as compromised and rotate it.

The real lesson: “done” includes failure

Look at that list again. Not one of those findings is a bug in the normal sense. Every single feature worked. They passed tests, shipped green, demoed fine. They were “done” by every definition most teams use.

That is the shift a pentest forces. Working is table stakes. The questions that actually define done are the ones we never ask in review:

The good news: these do not require a security team. They require a slightly bigger Definition of Done - and most of them can be pushed into CI and defaults so nobody has to remember them under deadline. A non-root base image. A lint rule against ?token=. A generic error handler wired in once. An enumeration-safe auth response written correctly the first time. Baked in, they cost nothing per feature. Discovered in a pentest, they cost a remediation sprint and an uncomfortable meeting.

The occupancy permit

A house can look finished. Walls painted, lights on, keys in the door - you would happily move in. Then the building inspector arrives and does something the tour never did: opens the panel, checks the wiring behind the drywall, tests whether the outlets are grounded, confirms the fire exits actually open. None of that is visible in the finished house. All of it decides whether the house is safe to live in - and only after it passes does the house earn its occupancy permit.

“It works” is the house looking finished. A pentest is the inspection behind the walls. And “done” - real done - is the occupancy permit: not just that the lights turn on, but that the wiring behind them will not burn the place down. Build to pass the inspection before it is scheduled, and the readout meeting becomes the most boring hour of your quarter. That is exactly what you want it to be.


Share this post:

Previous Post
No TypeScript, On Purpose - Plain JavaScript with JSDoc in 2026
Next Post
How This Blog Writes Itself - And Why I Still Review Every Word