Sample report · illustrative · ledgerly.io is fictional

See exactly what you get.

This is the report format ZerodayIQ delivers, filled in for a fictional SaaS. Open any finding to see the attack request and the control request side by side, the curl to replay it, and the exact fix.

4 findings · 41 vulnerability classes · 7 frameworks · about 11 minutes

01Penetration test report

Report ZD-R-a27297bd · Confidential

ledgerly.io

Target
ledgerly.io · app, api, staging-api, admin.staging
Scan type
External · authenticated (2 test tenants)
Date
4 Nov 2026 · 09:12 SGT
Duration
11 min 08 s
Surface
1,284 requests · 9 hosts
Overall risk
Critical

Executive summary

Anyone on the internet can take a live staging API key out of ledgerly.io’s public JavaScript, use it to open the staging admin console and list every tenant, then read any tenant’s invoices by ID. Together these reach every customer’s payroll data.

Each of the four findings below was re-fired against a control request and comes with a replay. One is already closed on retest. Fix 03 and 01 first: either fix alone breaks the chain.

4proven findings

  • Critical1
  • High2
  • Medium1
  • Low0
  1. 01Other tenants’ invoices readable by IDHightenant B’s invoice 200 · no session 401
  2. 02Live staging key shipped in the JS bundleHighvalid key 200 · random key 401
  3. 03Staging admin accepts the leaked keyCriticalleaked key 200 · no key 401
  4. 04No lockout after 500 sign-in attemptsMedium500 attempts · no 429

Business impact

Loss path
Secrets → Identity & access → Sensitive data
Data at risk
Payroll runs, employee names and bank details, invoices
Regulatory clock
GDPR Art. 33: notify the supervisory authority within 72 hours
Modeled exposure
$3.3M – $7.7M (technology-sector benchmark, ±40%)

Modeled from a public breach-cost benchmark (IBM Cost of a Data Breach) and the loss path the proven findings compose. It is an estimate, not a measurement of this target. ZerodayIQ never invents target-specific loss figures.

02Findings

Four findings, each proven against a control.

Endpoint
GET /api/v2/invoices/:id · api.ledgerly.io
Finding ID
ZD-c518efe09c
Detected by
bola · canary-confirmed
CVSS 4.0 vector
AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

What it means

The invoice API checks that you are signed in, but not that the invoice belongs to your company. Any customer can read any other customer’s invoice by changing the ID.

How an attacker uses it

Sign up for a free trial, then walk invoice IDs. The IDs are short and sequential per tenant, and the staging admin (finding 03) lists them for every tenant.

Business impact

Invoices include payroll runs: employee names, salaries and bank details for every ledgerly.io customer. Exposure of this data would start the GDPR 72-hour notification clock.

Proof · attack vs control

Attack · tenant A’s session

GET /api/v2/invoices/inv_B0042 HTTP/1.1
Host: api.ledgerly.io
Authorization: Bearer ‹tenant A token, redacted›

200OK · tenant B’s invoice

{"id":"inv_B0042","tenant_id":"tnt_8c1f",
 "type":"payroll_run","lines":14,
 "memo":"zdiq-canary-7f3a"}

differs from

Control · no session

GET /api/v2/invoices/inv_B0042 HTTP/1.1
Host: api.ledgerly.io
(no Authorization header)

401Unauthorized

{"error":"unauthorized"}
Replay · curl · secrets redacted
curl -i https://api.ledgerly.io/api/v2/invoices/inv_B0042 \
  -H "Authorization: Bearer $TENANT_A_TOKEN"

Re-fired 3×, identical result. Tenant B is our second test tenant: the scan read only the canary invoice it planted, never real customer data.

Proven · replay attached

The fix

src/api/invoices.ts

router.get('/invoices/:id', requireSession, async (req, res) => {
-  const invoice = await Invoice.findById(req.params.id);
+  const invoice = await Invoice.findOne({
+    id: req.params.id,
+    tenantId: req.session.tenantId,   // scope every lookup to the caller
+  });
   if (!invoice) return res.sendStatus(404);

Return 404, not 403, so IDs from other tenants can’t be confirmed. Apply the same scope to /exports and /payslips, which share the lookup.

Mapped to

  • OWASPA01:2021 Broken Access Control
  • SOC 2CC6.1, CC6.3
  • ISO 27001A.5.15, A.8.3
  • PCI DSS 4.07.2.1
  • All 7 frameworks

Retest

Open

Ship the fix, then retest from the report. The same attack and control are re-fired.

Location
/assets/app.4f9c1e.js · app.ledgerly.io
Finding ID
ZD-a3527f341a
Detected by
js · key validated live
CVSS 4.0 vector
AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:H/SI:N/SA:N

What it means

The production JavaScript bundle contains a working API key for ledgerly.io’s staging environment. Anyone who opens the site can read it in their browser.

How an attacker uses it

Search the bundle for key-shaped strings, then try them against the hosts named next to them. This key is accepted by staging-api, and it is the first step of the attack chain.

Business impact

On its own, access to a staging API. Combined with finding 03, it unlocks the staging admin console, which is connected to production tenant data.

Proof · attack vs control

Attack · key from the bundle

GET /v1/me HTTP/1.1
Host: staging-api.ledgerly.io
X-Api-Key: lk_stg_‹redacted›3f9a

200OK · key is live

{"key":"staging-ci","env":"staging",
 "scopes":["admin:read","admin:write"]}

differs from

Control · random key, same shape

GET /v1/me HTTP/1.1
Host: staging-api.ledgerly.io
X-Api-Key: lk_stg_‹random›e21c

401Unauthorized

{"error":"invalid_api_key"}
Replay · curl · secrets redacted
curl -i https://staging-api.ledgerly.io/v1/me \
  -H "X-Api-Key: $LEAKED_STAGING_KEY"

Read-only call. The key is shown only by its last four characters; the full value is never written into the report.

Proven · replay attached

The fix

webpack.config.js

  plugins: [
-    new webpack.DefinePlugin({
-      STAGING_API_KEY: JSON.stringify(process.env.STAGING_API_KEY),
-    }),
+    // Server-side secrets never go through DefinePlugin.
+    // The browser calls /api with the user's session instead.
    new HtmlWebpackPlugin({ template: 'src/index.html' }),

Then revoke the key and issue a new one. Removing it from the bundle does not un-leak it.

Mapped to

  • OWASPA07:2021 Identification & Authentication Failures
  • SOC 2CC6.1, CC6.7
  • ISO 27001A.5.17
  • PCI DSS 4.08.6.2
  • All 7 frameworks

Retest

Closed · 6 Nov 2026

The old key now returns 401, and app.9b03d2.js no longer contains it. The control was unchanged.

Endpoint
GET /api/admin/tenants · admin.staging.ledgerly.io
Finding ID
ZD-20777760f8
Detected by
bola · exposure
CVSS 4.0 vector
AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N

What it means

The staging admin console is on the public internet and authenticates with a single shared API key. It accepts the key leaked in finding 02, with full admin scope.

How an attacker uses it

Send the leaked key to the admin API, list every tenant and their invoice ranges, then feed those IDs into finding 01. No account and no password are needed.

Business impact

Staging is wired to production tenant records. Admin write scope also allows changes to tenant settings, so the damage could go beyond reading data.

Proof · attack vs control

Attack · leaked key

GET /api/admin/tenants?limit=1 HTTP/1.1
Host: admin.staging.ledgerly.io
X-Api-Key: lk_stg_‹redacted›3f9a

200OK · tenant list

{"total":2318,"tenants":[{"id":"tnt_…",
 "name":"‹redacted›","plan":"payroll"}]}

differs from

Control · no key

GET /api/admin/tenants?limit=1 HTTP/1.1
Host: admin.staging.ledgerly.io
(no X-Api-Key header)

401Unauthorized

{"error":"missing_api_key"}
Replay · curl · secrets redacted
curl -i "https://admin.staging.ledgerly.io/api/admin/tenants?limit=1" \
  -H "X-Api-Key: $LEAKED_STAGING_KEY"

limit=1 and read-only by design: write scope was observed in the key’s metadata, never exercised. Tenant names are redacted in the report.

Proven · replay attached

The fix

admin/src/middleware/auth.ts

export function requireStaff(req, res, next) {
-  if (req.get('X-Api-Key') === process.env.ADMIN_KEY) return next();
-  return res.sendStatus(401);
+  // Staff SSO only. Shared keys are never accepted on admin routes.
+  const user = req.session?.user;
+  if (!user) return res.sendStatus(401);
+  if (!user.roles.includes('staff')) return res.sendStatus(403);
+  return next();
}

Also take admin.staging off the public internet (VPN or IP allow-list), and point staging at seeded data instead of production tenants.

Mapped to

  • OWASPA01:2021 Broken Access Control
  • SOC 2CC6.1, CC6.2
  • ISO 27001A.5.15, A.8.5
  • PCI DSS 4.07.2.1, 8.2.2
  • All 7 frameworks

Retest

Open

Revoking the key (02) does not close this. Any future shared key would reopen it.

Endpoint
POST /api/v2/auth/sign-in · app.ledgerly.io
Finding ID
ZD-6e2d871c8f
Detected by
vulns · own test account only
CVSS 4.0 vector
AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N

What it means

The sign-in endpoint never slows down, locks the account or asks for a CAPTCHA, however many wrong passwords it receives.

How an attacker uses it

Credential stuffing: replay passwords leaked from other breaches against ledgerly.io accounts, at full speed, with nothing to stop them.

Business impact

Account takeover of finance users who reuse passwords. Each takeover is a tenant’s payroll, through the front door.

Proof · attempt #500 vs attempt #1

Attack · attempt #500, wrong password

POST /api/v2/auth/sign-in HTTP/1.1
Host: app.ledgerly.io
Content-Type: application/json

{"email":"zdiq-test-a@…","password":"‹wrong #500›"}

401no 429 · no Retry-After

{"error":"invalid_credentials"}

identical to

Control · attempt #1, wrong password

POST /api/v2/auth/sign-in HTTP/1.1
Host: app.ledgerly.io
Content-Type: application/json

{"email":"zdiq-test-a@…","password":"‹wrong #1›"}

401same body, same timing

{"error":"invalid_credentials"}

Thenattempt #501 with the correct password returned 200: the account was never locked.

Replay · curl · secrets redacted
for i in $(seq 1 500); do
  curl -s -o /dev/null -w "%{http_code}\n" https://app.ledgerly.io/api/v2/auth/sign-in \
    -H "Content-Type: application/json" \
    -d "{\"email\":\"$TEST_EMAIL\",\"password\":\"wrong-$i\"}"
done | sort | uniq -c

Here the proof is the absence of change: 500 attempts in 3 min 50 s, no throttling. Run only against our own test account, under the scan’s rate guard.

Proven · replay attached

The fix

src/api/auth.ts

+import rateLimit from 'express-rate-limit';
+
+const signInLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000,
+  limit: 10,                                  // per account + IP
+  keyGenerator: (req) => `${req.body.email}:${req.ip}`,
+});
-router.post('/auth/sign-in', signIn);
+router.post('/auth/sign-in', signInLimiter, signIn);

Pair it with MFA for finance roles and a breached-password check at sign-up.

Mapped to

  • OWASPA07:2021 Identification & Authentication Failures
  • SOC 2CC6.1, CC6.6
  • ISO 27001A.8.5
  • PCI DSS 4.08.3.4
  • All 7 frameworks

Retest

Open

Retest passes when attempt #11 returns 429 and the control is unchanged.

03Attack chain

Three findings, one path to every tenant’s payroll.

Chains are built only from reported findings. Each link is graded by its proof: verified means reproduced end to end, evidence-linked means backed by evidence but not exercised.

Chain status: evidence-linked 3 of 3 finding links verified · outcome not exercised
  1. 02

    Take the key from the bundle

    Valid key 200 vs random key 401 on staging-api. Verified

  2. 03

    Open the staging admin and list tenants

    Leaked key 200 vs no key 401; returns all 2,318 tenant IDs. Verified

  3. 01

    Read any tenant’s invoices by ID

    Tenant B’s invoice 200 vs no session 401, using a free-trial session. Verified

  4. →

    Every tenant’s payroll

    Payroll runs are invoices of type payroll_run, served by the same unscoped lookup. Not exercised: the scan never reads real customer data. Evidence-linked

Since retest: 02 is closed, so this path is broken at its first link today. 03 and 01 stay open, and any new leaked key would rebuild it.

04Coverage ledger · Proof of absence

What we tested, and what we found nothing in.

A clean result only means something if you can see what was tested. The ledger lists every vulnerability class with its outcome, so absence is auditable instead of assumed.

38of 41 classes tested · 3 inconclusive

  • 4 proven
  • 29 tested clean
  • 5 not applicable
  • 3 inconclusive

Coverage ledger: 41 vulnerability classes and their result
Vulnerability classResultBasisDetectors
Injection
SQL injectionTested clean46 parameterised endpoints; sqlmap confirmed nonesqlmap, vulns
NoSQL injectionTested clean31 JSON endpoints, operator payloads rejectedendpoint_test
OS command injectionTested clean46 endpoints, time and echo probes vs controlvulns, endpoint_test
Server-side template injectionTested cleanArithmetic probes never evaluatedssti
XML external entityNot applicableNo endpoint accepts XMLendpoint_test
LDAP injectionNot applicableNo directory-backed input foundendpoint_test
Cross-site scriptingTested clean112 reflected inputs; 1 candidate disproved in a browservulns, endpoint_test
DOM-based XSSTested clean38 routes rendered in real Chromiumdom_xss
Access & identity
Broken object-level authorizationProven · 01Tenant A read tenant B’s invoicebola
Broken function-level authorizationTested clean22 admin routes; tenant session got 403bola
Authentication bypass / exposed adminProven · 03Leaked key opened admin.stagingbola, exposure
Brute force / missing lockoutProven · 04500 attempts, no 429vulns
JWT weaknessesTested cleanalg:none, alg confusion, weak secret all rejectedjwt
OAuth / OIDCTested cleanredirect_uri and state checks holdoauth
Session managementTested cleanSecure, HttpOnly, SameSite; rotated on sign-inweb
Cross-site request forgeryTested clean14 state-changing forms carry tokenscsrf
Mass assignmentTested cleanrole and tenant_id ignored on PATCHapi_fuzz
Server-side
Server-side request forgeryTested clean6 URL parameters; no out-of-band callbackssrf
Path traversal / file inclusionTested cleanEncoded and null-byte variants vs controlvulns, endpoint_test
Insecure deserializationInconclusiveWAF blocked payloads (403). Re-test requireddeser
Unrestricted file uploadTested cleanReceipt upload: extension, MIME and execution checks holdupload
HTTP request smugglingInconclusiveCDN rejected malformed framing before origin. Re-test requiredsmuggling
Web cache poisoningTested cleanUnkeyed headers never reflectedcache
Race conditionsInconclusiveBurst was rate-limited (429). Re-test requiredrace
Client-side
CORS misconfigurationTested cleanArbitrary origins not trusted with credentialscors
Open redirectTested clean1 candidate disproved by controlredirect
Prototype pollutionTested clean__proto__ and constructor payloads inertproto_poll
Exposure & secrets
Secrets in client codeProven · 02Live staging key in app.4f9c1e.jsjs
Exposed files & backupsTested clean1 candidate disproved (soft-404)exposure, ffuf
Information disclosureTested cleanNo stack traces, versions or internal hosts leakedinfo
Cloud storage exposureTested clean12 bucket names checked; none publiccloud
Debug endpoints & source mapsTested cleanNo .map files or debug routes servedjs, ffuf
Components
Known CVEsTested cleanVersions and templates, medium and abovecve, nuclei
Vulnerable JS librariesTested clean14 libraries versioned, none in a vulnerable rangejslib
API & emerging
GraphQL abuseNot applicableNo GraphQL endpointgraphql
WebSocket securityNot applicableNo WebSocket upgrade observedwebsocket
AI feature abuse (LLM Top 10)Not applicableNo AI feature exposedai_feature
Infrastructure
TLS / certificateTested cleanTLS 1.2+ only; certificate valid 71 daysssl
Security headers & cookiesTested clean1 candidate disproved (HSTS on a 304)web
Email authenticationTested cleanSPF, DKIM, DMARC p=rejectemail
Subdomain takeoverTested clean9 hosts; every CNAME points at an owned resourcetakeover

Ledger digest

sha256:fe14010b4fe83303852f0467c919ef9a7ca089b91e96e3aad7d426dd87079297

Signature
HMAC-SHA256, signed by ZerodayIQ (DSSE envelope)
Predicate
zerodayiq.dev/attestations/coverage-ledger/v1
Signed
4 Nov 2026 · 09:23 SGT

What the ledger attests

  • 29 classes were exercised by a live detector against the reachable surface and produced no finding.
  • 3 classes are inconclusive. They are not certified clean; re-test them.
  • Absence covers only the surface this scan discovered: 1,284 requests across 9 hosts.
  • The digest is computed over the ledger’s canonical form, and ZerodayIQ signs it. Change any row and verification fails, so the ledger is tamper-evident. It is our attestation, not a third party’s.

05Cross-lab review

A second opinion, from a different lab.

Serious findings can be independently reviewed by models from two different AI labs. If they disagree, we flag the finding for verification instead of stating it as fact.

Illustrative example

Cross-lab review outcome per finding (illustrative)
FindingLab ALab BOutcome
01 · HighUpheldUpheldBoth labs agree
02 · HighUpheldUpheldBoth labs agree
03 · CriticalUpheldUpheldBoth labs agree
04 · Medium——Below review threshold

Had the labs disagreed on any of these, it would appear under “Needs verification” below, not in your findings.

06Compliance map

Mapped to 7 frameworks.

Every finding is mapped to the controls it touches, so you can hand your auditor one table. “Mapped to” is not a statement of compliance or certification.

Findings mapped to controls in seven frameworks
FindingPCI DSS 4.0HIPAASOC 2GDPRISO 27001:2022NIST CSF 2.0OWASP Top 10
01Invoices by ID7.2.1§164.312(a)(1)CC6.1, CC6.3Art. 25, Art. 32A.5.15, A.8.3PR.AA-05A01:2021
02Key in JS bundle8.6.2§164.312(d)CC6.1, CC6.7Art. 32A.5.17PR.AA-01A07:2021
03Staging admin7.2.1, 8.2.2§164.312(a)(1), (d)CC6.1, CC6.2Art. 32A.5.15, A.8.5PR.AA-03, PR.AA-05A01:2021
04No lockout8.3.4§164.312(d)CC6.1, CC6.6Art. 32A.8.5PR.AA-03A07:2021

07Not reported · needs verification

What we didn’t report, and why.

Most scanners stop at the list of maybes. We re-fire each one against a control request and drop anything the control explains.

317candidates

4proven and reported

313disproved by control requests

Examples of what was disproved

Examples of candidates that were disproved and not reported
CandidateWhereWhy it was not reported
Reflected XSS/search?q=Payload came back HTML-encoded; nothing executed in a real browser.
Time-based SQL injection/api/v2/reports?sort=The 2.1 s delay did not reproduce; 3 re-fires stayed within baseline jitter.
IDOR/api/v2/users/:idTenant B’s user ID returned 404 to tenant A, exactly like a random ID.
Open redirect/login?next=Off-site values fell back to /dashboard, same as the control.
Exposed .git/.git/HEAD200, but the body was the app shell, identical to a random path (soft-404).
Missing HSTScdn.ledgerly.ioPresent on every 200; absent only on a 304, which browsers ignore.

Needs verification · 0

Nothing in this scan. When a finding can’t be proven or disproved, or two labs disagree on it, it is listed here unscored, never mixed into your findings.

08Your report

Get this report for your own domain.

Verify your domain. That’s all an external scan needs. First results in about 10 minutes; authenticated testing uses test accounts you provide.

Only domains you verify · Non-destructive by default

PDF version available in your report.