1. Summary
- Product
- Acme Invoicing web application and API (fictional)
- Client
- Client name redacted
- Version
- Commit 4f2a9c1, main branch
- Review period
- 14–18 September 2026 (5 business days)
- Report date
- 18 September 2026
- Status statement
- (2) At the issue date, no Critical findings remained open within the stated scope. 2 High severity findings remained open; they are listed in the Findings Report with remediation scheduled by 18 October 2026. This attestation will be reissued after retest.
Acme Invoicing is in better shape than most outsourced builds of its age. Authentication is sound, there are no secrets in the git history, and the infrastructure is simple. One Critical issue let any logged-in customer read every other customer's invoices. It was fixed during the review and verified on Day 5. Two High issues remain open, both small fixes. They are why status statement (2) applies rather than (1), and the attestation is reissued once their retest passes.
2. Scope and method
- In scope
- One repository (TypeScript API, React frontend), about 38k lines of code, full git history; staging environment with two test organisations; deployment configuration and CI pipeline.
- Out of scope
- Production systems, the mobile app, third-party services beyond how they are integrated.
- Method
- Static analysis, dependency audit and secrets scan of the full history; manual review of the ten review areas; each Critical and High finding confirmed with a single, non-destructive proof-of-concept request on staging, using test data. Ship-Ready includes no dynamic testing beyond that.
3. Findings overview
| ID | Severity | Finding | Area | Status |
|---|---|---|---|---|
| C-01 | CRITICAL | Any customer can read any other customer's invoices | Authorization & tenant isolation | Fixed on Day 4, verified Day 5 |
| H-01 | HIGH | Payment webhook accepts unsigned requests | API & business logic | Open, due 18 October 2026 |
| H-02 | HIGH | Password reset tokens never expire and can be reused | Authentication & sessions | Open, due 18 October 2026 |
| M-01 | MEDIUM | Invoice amounts validated in the UI only; API accepts negative values | Input handling | Open, 30 days |
| M-02 | MEDIUM | Former contractor's deploy key still active | Operational readiness | Open, 30 days |
| M-03 | MEDIUM | Unhandled errors return stack traces and SQL to the browser | Secrets & configuration | Backlog |
| M-04 | MEDIUM | PDF rendering dependency two major versions behind, with published advisories | Dependencies & supply chain | Backlog |
| L-01 | LOW | No rate limit on login and password reset | Authentication & sessions | Backlog |
| L-02 | LOW | No Content-Security-Policy or Referrer-Policy header | Secrets & configuration | Backlog |
| L-03 | LOW | Sessions last 30 days with no idle timeout | Authentication & sessions | Backlog |
4. Selected findings in detail
Three of the ten findings are shown in full: the Critical, and the two open Highs behind status statement (2). Every finding in a real report has the same five parts.
What's wrong
The invoice endpoint loads an invoice by its ID and returns it without checking that it belongs to the caller's organisation. Invoice IDs are sequential, so they are easy to enumerate.
Evidence
// src/api/invoices/[id].ts:14
export async function getInvoice(req: Request, res: Response) {
const invoice = await db.invoice.findUnique({
where: { id: req.params.id }, // <- no organisation filter
});
if (!invoice) return res.status(404).end();
return res.json(invoice);
} On staging, a user in test organisation B retrieved invoice 10412, which belongs to organisation A, with a single request. The same pattern appears in 4 of the 23 tenant-scoped endpoints (listed in the appendix).
Impact
Any paying customer could download every other customer's invoices: names, addresses, amounts and bank details. That is a reportable personal-data breach under GDPR.
Fix
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, orgId: req.user.orgId },
}); Then move tenant scoping into one shared data-access helper so a new endpoint can't forget it, and add a test that calls each endpoint as the wrong tenant. Effort: half a day for the four endpoints, one day for the helper and tests.
Status
Fixed in commit 7c1e0b3 on Day 4. Verified on staging on Day 5: all 23 endpoints return 404 for another organisation's records.
What's wrong
The payment provider signs every webhook. The handler never checks that signature, so it trusts any request sent to the URL.
Evidence
// src/webhooks/payments.ts:9
app.post('/webhooks/payments', express.json(), async (req, res) => {
const event = req.body; // <- signature header never checked
if (event.type === 'invoice.paid') {
await markPaid(event.data.object.metadata.invoiceId);
}
res.sendStatus(200);
}); Impact
Anyone who knows the webhook URL, which appears in the frontend bundle's error-reporting config, can mark any invoice as paid and unlock paid features without paying. The preconditions are knowing the URL and an invoice ID, which is why this is High rather than Critical.
Fix
Verify the signature using the raw request body and the signing secret, using the provider's SDK. Reject events older than five minutes, and store processed event IDs so a replayed event is ignored. Effort: about two hours.
Status
Open. Agreed fix date 18 October 2026; included in the re-test.
What's wrong
A password reset link stays valid forever and keeps working after it has been used. The token is stored in plain text, and changing the password doesn't end existing sessions.
Evidence
// src/auth/reset.ts:22
export async function requestReset(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) return;
const token = crypto.randomBytes(32).toString('hex');
await db.user.update({
where: { id: user.id },
data: { resetToken: token }, // <- stored in plain text, no expiry
});
await mailer.sendPasswordReset(user.email, token);
}
// src/auth/reset.ts:41
export async function resetPassword(token: string, password: string) {
const user = await db.user.findFirst({ where: { resetToken: token } });
if (!user) throw new BadRequestError();
await db.user.update({
where: { id: user.id },
data: { passwordHash: await hash(password) }, // <- token not cleared, sessions kept
});
} On staging, a reset link requested on Day 1 still worked on Day 4. The same link worked a second time after the password had already been changed, and the session from before the reset stayed logged in throughout.
Impact
Anyone who ever gets hold of an old reset email can take over that account at any time: a forwarded or shared mailbox, a former employee, or a leak of the email provider's logs. The real user changing their password doesn't lock them out, and nothing alerts the user. The precondition is access to a past reset email, which is why this is High rather than Critical. Because the tokens are stored in plain text, a database leak would expose every pending reset as well.
Fix
Store only a hash of the token, with an expiry of 30 to 60 minutes. Clear it the moment it's used, and invalidate any other outstanding tokens for that user. End all existing sessions when the password changes, and email the user to say it was changed. Add tests for an expired token, a reused token and session invalidation. Effort: half a day including tests.
Status
Open. Agreed fix date 18 October 2026; included in the re-test.
5. Fix list
Before go-live
- C-01 (done)
Within 30 days
- H-01, H-02: retested before the attestation is reissued
- M-01, M-02
Backlog
- M-03, M-04
- L-01, L-02, L-03
6. Compliance signals
What these findings mean for the frameworks your customers and auditors ask about. These are signals, not an assessment.
| Framework | Signal | Related |
|---|---|---|
| GDPR Art. 32–33 | Personal data was exposed across tenants until the fix. Check the access logs for signs it was exploited before then, to decide whether the 72-hour breach notification applies. | C-01 |
| NIS2 Art. 21 | If you're in scope: access control, supplier access and vulnerability handling need documented controls. A former contractor still had access, and dependencies aren't tracked. | M-02, M-04 |
| Cyber Resilience Act | If you sell a product with digital elements in the EU: there's no SBOM, so nobody can tell when a dependency needs a security update. | M-04 |
| SOC 2 · ISO 27001 | The questions customers ask most often about logical access and account recovery would currently be answered "no" or "partly". | C-01, H-02, M-02 |
Turning these signals into the evidence an auditor or a customer's security questionnaire accepts is a separate engagement: the Compliance Readiness Pack.
A real report continues with every remaining finding in full and an appendix of affected files and tool output.