Sample penetration testing report for a fictional app

A full sample penetration testing report for a fictional SaaS app: scope, method, nine findings with requests and evidence, controls that held, and a fix plan.

Short answer

This sample penetration testing report shows what Excession Labs delivers: project details, method, safety limits, evidence labels, an executive summary, nine findings with HTTP evidence and fixes, controls that held, a remediation plan and an evidence index. The target is a fictional app called Harbor Notes.

Demonstration

This is a demonstration report for a fictional application. No real customer system was tested, and the findings below were written to show our report format and evidence standard.

Harbor Notes, its hosts, accounts, identifiers and file hashes are invented. Request and response bodies are shortened for reading.

Project details#

The report covers a grey-box assessment of the Harbor Notes web application and JSON API.

Item Value
Client Harbor Notes (fictional)
Report title Web application and API security assessment
Assessment type Authorized grey-box web and API assessment with a final written report
Testing window 2026-10-19 to 2026-10-23
Report date and version 2026-11-09, version 1.0
Authorization Written scope and authorization signed by the system owner before testing
Test accounts Two tenants, three roles per tenant (owner, admin, member), created for this test and marked as test data

Scope and assets#

We tested only the assets marked Yes.

Asset Type In scope
https://app.harbor-notes.example React single-page app, Node.js backend Yes
https://api.harbor-notes.example/v1/ JSON API used by the app Yes
Client-side JavaScript bundles served by the app Static files Yes, read-only review
Admin panel at /admin on the app host Web UI and API routes Yes
Payment provider checkout pages Third-party No
Email delivery provider Third-party No
Denial-of-service and load testing Method No
Social engineering of staff Method No

Features reached through the in-scope hosts: workspaces, notes, sharing links, file attachments, invitations, billing plans, a search endpoint and the admin panel.

Severity scale#

We use a four-level scale and add no CVSS vectors by default. If your program needs CVSS, say so during scoping and we agree the method in writing.

Level Definition
Critical An attacker can read or change data across tenants, or take over accounts or the platform, with little effort and no special access. Fix before the next release.
High An attacker can cause serious harm to users or data, but needs a signed-in account, a user action, or a chain of two weaknesses.
Medium The weakness has a real effect, but the effect is limited by scope, by required conditions, or by other controls that work.
Low The weakness gives small information or hardening value. It does not give direct access to data or functions.

We set each level from the effect we proved. When a control changes our view, we change the level and say so in the finding.

Assessment method#

We tested the application as an authenticated user with known roles. We started from the client JavaScript bundle and the observed API traffic, then tested each object type and function against the roles.

We used these methods:

  1. Direct HTTP requests: we sent requests with an HTTP client, with and without credentials, and saved each request and response with a timestamp.
  2. Browser-observed tests: we checked cross-site request forgery and script injection in a real Chromium browser. A script that runs in a browser is stronger proof than a payload that appears in a response body.
  3. Client bundle review: we read the JavaScript bundles to list API routes, feature flags and client-side role checks that the server may not repeat.
  4. Differential comparison: we sent the same request as an anonymous caller, as each role in tenant A and as each role in tenant B, then compared status codes and bodies.
  5. Read-back from a second account: when a request changed data, we read the record back through a different session. A "200 OK" or {"success": true} is not proof of an effect.
  6. Controls: we ran control requests next to each exploit request: nonexistent identifiers, wrong record types, unsigned or wrongly signed cookies, another account's token, and anonymous against signed-in comparisons.

Safety constraints#

Destructive proofs used only test-owned accounts and records. Each record we created carried the marker EXC-TEST in its title, so the client can find and remove it.

Area Limit applied
Accounts and data Only the six test accounts and records they own. No real user data was read. Where a finding crossed tenants, tenant B was a test tenant we owned.
Uploads At most 12 files, each under 4 KB, text and SVG only. We deleted them after the test.
Password guessing We guessed no passwords on any account. The reset test used one test account and at most 60 reset requests.
Email Reset and invitation emails went only to test inboxes. The bounded reset test sent 60 emails.
Paid third-party APIs None called. Billing routes were read, not executed.
Scripts Reproduction scripts are read-only where possible. Run them only with authorization and with test-owned identifiers.

Evidence labels#

Every finding carries one label. The label tells you how strongly the evidence supports the claim.

Label Meaning
Observed We reproduced the effect with direct requests and confirmed it by read-back or a control.
Browser-observed We showed the effect in a real Chromium browser session.
Inferred We believe the path exists from code review or indirect signs, but we did not prove it. We label it separately and keep it out of confirmed impact.

Open questions go to the section "Limitations and open questions", never into confirmed impact.

Executive summary#

We found nine issues in Harbor Notes: one Critical, three High, three Medium and two Low. One issue allows any signed-in user to download another tenant's exported workspace. We proved this with two test tenants we own.

9Findings
1Critical
3High
3Medium
2Low
7Controls that held

Counts come from the findings table, as of the report date 2026-11-09. One finding (F-08) started as High and ended as Low after a control showed our first reading was wrong. We kept it in the report to show the change.

The root causes fall into three groups. Object access checks are missing on some routes (F-01, F-04). Browser protections are missing or incomplete (F-02, F-03, F-07). Abuse limits and lifecycle rules are missing (F-05, F-06). The two Low findings are hardening items.

Priority actions#

  1. Add a workspace ownership check to the export download route (F-01). Until it ships, disable the route or block cross-tenant reads at the API gateway.
  2. Encode note titles where they are rendered, and add a Content Security Policy (F-02).
  3. Bind the login flow to a CSRF token and set SameSite on the session cookie (F-03).
  4. Ignore the role field from the client on invitation requests (F-04).
  5. Add per-account and per-address limits to password reset (F-05).

Actions 1 to 4 close the High and Critical findings. Action 5 is the first Medium item and uses a standard control.

Findings at a glance#

The table lists every finding with its severity, current status and evidence label. Status is the state at report delivery. No fixes have been verified yet.

ID Title Severity Status Evidence label
F-01 Cross-tenant export download through predictable export IDs Critical Open Observed
F-02 Stored XSS in shared note titles High Open Browser-observed
F-03 Login CSRF on the sign-in endpoint High Open Browser-observed
F-04 Invitation endpoint accepts a client-chosen role High Open Observed
F-05 No rate limit on password reset Medium Open Observed
F-06 Share links survive when a note becomes private Medium Open Observed
F-07 Attachments served inline with the uploaded content type Medium Open Browser-observed
F-08 Audit log readable by the member role Low Open, downgraded from High Observed
F-09 Search errors disclose internal index names Low Open Observed

Reproduction environment#

All requests used these values, so you can reproduce each finding with the same setup.

Item Value
Tenant A Workspace ws_test_A, users owner-a, admin-a, member-a
Tenant B Workspace ws_test_B, users owner-b, admin-b, member-b
Test data marker Titles start with EXC-TEST
HTTP client curl 8.x and a scripted client that saves each exchange with a UTC timestamp
Browser Chromium, default profile, no extensions, fresh profile for each browser test
Authentication Bearer token in the Authorization header for API calls. Session cookie hn_session for the web app.
Token placeholders $TOKEN_OWNER_A, $TOKEN_MEMBER_B and so on. Real token values are not printed in this report.

F-01: Cross-tenant export through predictable export IDs#

Critical Evidence label: Observed

Description#

Harbor Notes lets a workspace owner export all notes and attachments as a ZIP archive. The app creates an export job and then downloads the result from GET /v1/exports/{export_id}/download.

Export IDs are sequential integers with a prefix, for example exp_000412, then exp_000413. The download route checks that the caller has a valid token. It does not check that the caller belongs to the workspace that owns the export.

The route that lists exports for a workspace (GET /v1/workspaces/{id}/exports) does check membership. The download route does not repeat the check.

Impact#

Any signed-in user, in any tenant, can download the full export of any other tenant. The attacker needs no special role, and a free-plan account is enough. The attacker can walk the ID range to collect exports across the platform.

An export holds every note in the workspace, the member list and all attachments. Together these make up the full content of a customer account. We rate this Critical because it needs no chain, no user action and no privilege.

Verified results#

We created an export in tenant A, then fetched it with a token for a member of tenant B. Tenant B received the archive that belongs to tenant A.

Step Account Result
Create a note EXC-TEST marker-7f3a in ws_test_A, then start an export owner-a exp_000412 created, status ready
Download exp_000412 member-b 200, ZIP of 2,318 bytes
Open the archive n/a Contains the note EXC-TEST marker-7f3a from tenant A
Download exp_000412 with no token anonymous 401

The marker note was written by tenant A and never shared with tenant B. Its presence in the archive that member-b downloaded is the read-back proof. Evidence: ev-01-export-create.json, ev-02-export-xtenant.http, ev-03-readback.json.

We tested only IDs we created, did not walk the ID range, and opened no export we did not create.

Reproduction#

Step 1. Tenant A creates an export.

POST /v1/workspaces/ws_test_A/exports HTTP/1.1
Host: api.harbor-notes.example
Authorization: Bearer $TOKEN_OWNER_A
Content-Type: application/json

{"include_attachments": true}
HTTP/1.1 202 Accepted
Content-Type: application/json

{"id": "exp_000412", "workspace_id": "ws_test_A", "status": "ready"}

Step 2. A member of tenant B requests the same export.

GET /v1/exports/exp_000412/download HTTP/1.1
Host: api.harbor-notes.example
Authorization: Bearer $TOKEN_MEMBER_B
HTTP/1.1 200 OK
Content-Type: application/zip
Content-Disposition: attachment; filename="ws_test_A-export.zip"
Content-Length: 2318

The filename value names tenant A's workspace, so the response itself shows the cross-tenant effect.

Controls#

Control request Result What it shows
Download exp_999999 (nonexistent) as member-b 404 The route does look up the record
Download exp_000412 with no token 401 Authentication works
GET /v1/workspaces/ws_test_A/exports as member-b 403 The list route checks membership
Download with a token whose signature we changed 401 Token signatures are verified
Download exp_000412 as member-a 200 Same-tenant access works, as designed

The control with the list route shows that a membership check exists in the codebase. The download route is missing it.

Remediation#

Check workspace membership in the download handler, after the lookup and before any file is returned. Return 404 for exports the caller may not read, so the response does not confirm that an ID exists.

// GET /v1/exports/:exportId/download
const exp = await db.exports.findById(req.params.exportId);
if (!exp) return res.status(404).json({ error: "not_found" });

const member = await db.memberships.find({
  workspaceId: exp.workspaceId,
  userId: req.user.id,
});
if (!member || !can(member.role, "workspace:export")) {
  return res.status(404).json({ error: "not_found" });
}
return streamExport(exp, res);

Also replace sequential IDs with random 128-bit identifiers. Random IDs make enumeration harder but do not replace the access check. Add an automated test that requests each object route with a token from another tenant and expects 404. Review other routes that take an object ID in the path for the same missing check.

F-02: Stored XSS in shared note titles#

High Evidence label: Browser-observed

Description#

When a user shares a note, the "Shared with me" list shows the note title to each recipient. The React app renders the title with dangerouslySetInnerHTML in the list item component. The API stores the title as typed and does not encode it.

A title that contains HTML runs in the recipient's browser when the recipient opens the "Shared with me" page.

Impact#

An attacker with any account can run script in the app origin as the recipient. The script can read data that the recipient's session can read, and it can call the API as the recipient. If the recipient is a workspace owner, the script can create invitations or exports in their name.

The attacker needs one user action: the recipient must open the list. The recipient does not need to open the note. We rate this High and not Critical because it needs a user action and a sharing relationship.

Verified results#

We shared a note from member-a to owner-b (both test accounts). The title contained a harmless marker script. When we opened "Shared with me" as owner-b in Chromium, the script ran in the origin https://app.harbor-notes.example and wrote a marker into the page.

Check Result
Script ran in Chromium as owner-b Yes, marker text EXC-TEST xss-fired added to the page
Script origin https://app.harbor-notes.example
Session cookie readable by script No, hn_session is HttpOnly
API call made by the script as owner-b Yes, GET /v1/me returned the profile of owner-b
Same title shown in the note editor Encoded as text, no script ran

The editor encodes the title and the list does not, so the flaw is in one component. Evidence: ev-04-xss-browser.png, ev-05-xss-console.json.

The payload used only the marker and the /v1/me read. We did not send data to any outside host.

Reproduction#

PATCH /v1/notes/note_test_0031 HTTP/1.1
Host: api.harbor-notes.example
Authorization: Bearer $TOKEN_MEMBER_A
Content-Type: application/json

{"title": "EXC-TEST <img src=x onerror=\"document.body.dataset.exc='fired'\">"}
HTTP/1.1 200 OK
Content-Type: application/json

{"id": "note_test_0031", "title": "EXC-TEST <img src=x onerror=\"document.body.dataset.exc='fired'\">"}

Then share the note with owner-b and open /shared as owner-b in Chromium. The onerror handler sets document.body.dataset.exc, and the test reads it with the browser developer tools.

Controls#

Control Result
Title with plain text only Rendered as text
The same title in the note editor Encoded, no script
Content-Security-Policy response header Not present on the app host
HttpOnly flag on hn_session Set, so the script could not read the cookie
Title rendered on the public share page Encoded, no script (the public page uses a different component)

Remediation#

Remove dangerouslySetInnerHTML from the list component and render the title as a React text node.

// SharedNoteItem.jsx
- <span dangerouslySetInnerHTML={ { __html: note.title } } />
+ <span>{note.title}</span>

Add a Content Security Policy that blocks inline script, for example script-src 'self'; object-src 'none'; base-uri 'none'. A policy limits the effect of the next injection bug. Add a lint rule that fails the build on dangerouslySetInnerHTML without an approved sanitizer. Search the other list components for the same pattern.

F-03: Login CSRF on the sign-in endpoint#

High Evidence label: Browser-observed

Description#

POST /v1/auth/login accepts a form-encoded body and sets the session cookie. It has no CSRF token and no Origin check. The hn_session cookie is set with SameSite=None.

A page on another site can submit a login form that signs the victim's browser in to the attacker's account.

Impact#

The victim keeps using Harbor Notes without noticing that the session belongs to the attacker's account. Notes and attachments that the victim writes then go to the attacker. This is a data-loss path for users who do not notice the change of account.

We rate this High because the effect is a silent data transfer and users may paste sensitive text into notes. We did not prove a path to account takeover.

Verified results#

We hosted a test page on a separate origin we control. The page auto-submitted a login form with the credentials of member-b. We opened the page in a Chromium profile that held no session.

Check Result
Chromium profile after the test page loaded Signed in as member-b on app.harbor-notes.example
Request sent with Origin: https://csrf-test.example 302, Set-Cookie: hn_session=...; SameSite=None; Secure; HttpOnly
JSON login with Content-Type: application/json 415, so only the form route is exposed

Evidence: ev-06-csrf-login.html, ev-07-csrf-browser.png.

Reproduction#

POST /v1/auth/login HTTP/1.1
Host: api.harbor-notes.example
Origin: https://csrf-test.example
Content-Type: application/x-www-form-urlencoded

email=member-b%40test.harbor-notes.example&password=<test-owned password>
HTTP/1.1 302 Found
Location: https://app.harbor-notes.example/
Set-Cookie: hn_session=<redacted>; Path=/; Secure; HttpOnly; SameSite=None

Controls#

Control Result
Login with a wrong password 401, no cookie
Same request with JSON content type 415, so a cross-site JSON form post is not possible
CSRF on POST /v1/notes (state change after login) Blocked, because that route needs the bearer token from the app
Cookie flags Secure and HttpOnly set

The CSRF control on POST /v1/notes shows that state-changing API routes are protected. Only the sign-in route is open.

Remediation#

Set SameSite=Lax on the session cookie unless a cross-site flow needs None. Check the Origin header on the login route and reject any origin that is not on the allow list. Add a double-submit CSRF token to the login form.

const ALLOWED = new Set(["https://app.harbor-notes.example"]);

app.post("/v1/auth/login", (req, res, next) => {
  const origin = req.get("Origin");
  if (!origin || !ALLOWED.has(origin)) {
    return res.status(403).json({ error: "bad_origin" });
  }
  next();
});

After login, show the signed-in account name in the app header, so users can see which account they use.

F-04: Invitation endpoint accepts a client-chosen role#

High Evidence label: Observed

A workspace member can invite a user as admin. POST /v1/workspaces/{id}/invitations reads the role field from the request body and does not compare it to the caller's role. The member-a account invited a second test account as admin. After acceptance, GET /v1/workspaces/ws_test_A/members as that account showed role admin, and the account could change billing settings. Evidence: ev-08-invite-role.http.

Controls: the same request with role: "owner" returned 400, a request for a nonexistent workspace returned 404, and the web UI hides the role selector from members. The UI check is client-side only. Fix: derive the allowed roles from the caller's role on the server, and reject any role that is higher than the caller's own role.

F-05: No rate limit on password reset#

Medium Evidence label: Observed

Description#

POST /v1/auth/password-reset sends a reset email to the address in the request. The route has no limit by address, by IP or by account. It returns the same response for known and unknown addresses.

Impact#

An attacker can send many reset emails to one user. This floods the user's inbox and costs Harbor Notes email sending fees. It can also hide a real security email among many others. The route does not reveal whether an account exists.

We did not find a way to guess or bypass the reset token. We rate this Medium because the effect is nuisance and cost, and it gives no access.

Verified results#

We ran a bounded test against one test account (member-a) with a test inbox. We sent 60 requests in 90 seconds from one IP address and stopped there.

Measure Result
Requests sent 60
Responses with 202 60
Responses with 429 or any block 0
Reset emails received in the test inbox 60
Cost of the test 60 emails to a test inbox, no paid API called

All 60 emails arrived, so the server applied no limit in that window. Evidence: ev-09-reset-burst.json, ev-10-reset-inbox.json.

We chose 60 because it is large enough to show that no short-window limit exists and small enough to stay within the safety limit in this report. We did not test higher counts.

Reproduction#

POST /v1/auth/password-reset HTTP/1.1
Host: api.harbor-notes.example
Content-Type: application/json

{"email": "member-a@test.harbor-notes.example"}
HTTP/1.1 202 Accepted
Content-Type: application/json

{"status": "if_account_exists_email_sent"}

Repeat the request 60 times with a test-owned address and count the 202 responses.

Controls#

Control Result
Request for an unknown address Same 202 body, no account enumeration
Use of an old reset token 400, tokens are single-use
Reset token length 32 random bytes, no pattern seen in 5 samples
Reuse of a token after a password change 400

Remediation#

Limit requests by address and by IP. A common starting point is 3 requests per address per hour and 20 per IP per hour. Return the same 202 response when a limit applies, so the limit does not leak account state.

const resetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,
  limit: 3,
  keyGenerator: (req) => req.body.email?.toLowerCase() ?? req.ip,
  handler: (req, res) =>
    res.status(202).json({ status: "if_account_exists_email_sent" }),
});
app.post("/v1/auth/password-reset", resetLimiter, resetHandler);

Add an alert for a high rate of reset requests, and consider a CAPTCHA after repeated requests.

Medium Evidence label: Observed

When an owner sets a note to private, the public share link https://app.harbor-notes.example/s/{token} still returns the note. We created a link for a test note, switched the note to private in the UI (the UI showed "Private"), and requested the link as an anonymous caller. The response was 200 with the note body. The link stayed valid after we also removed the sharing record in the UI. A fresh link for the same note had a different token, so the old one was never revoked. Evidence: ev-11-share-after-private.http.

Controls: a link with one character changed returned 404, tokens were 22 random characters, and deleting the note made the link return 404. Fix: when a note changes to private, delete or revoke its share tokens in the same database transaction. Add an optional expiry date for share links, and show the owner a list of active links.

F-07: Attachments served inline with the uploaded content type#

Medium Evidence label: Browser-observed

The download route GET /v1/files/{id} returns the file with the content type chosen at upload and with Content-Disposition: inline. It sets no X-Content-Type-Options: nosniff header. An SVG file that contained a marker script ran in Chromium when we opened the file URL directly.

The script ran on the API origin api.harbor-notes.example, not on the app origin. The session cookie is not sent to the API host, and the API needs a bearer token that lives in app memory. So the script could not read the token. This control is why we rate the finding Medium and not High. If the two hosts ever share a registrable cookie scope or a token store, the finding becomes High.

The upload test used 3 files under 4 KB, which we deleted. Evidence: ev-12-svg-inline.png. Fix: serve user files from a separate sandbox domain, send Content-Disposition: attachment for any type other than a short allow list of images, set X-Content-Type-Options: nosniff, and add Content-Security-Policy: sandbox on file responses.

F-08: Audit log readable by the member role#

Low Evidence label: Observed. Downgraded from High.

Description#

GET /v1/admin/audit-log returned 200 for member-a. The route sits under the admin prefix, so we first recorded it as High: an admin function open to the lowest role.

What changed our view#

We ran the controls after the first observation. They showed that our first reading was wrong.

Check Result Effect on severity
Entries in the response, tenant A caller Only ws_test_A entries No cross-tenant exposure
Compare with the "Activity" page in the app, as member-a Same events The UI already shows these events to members
POST /v1/admin/users/{id}/disable as member-a 403 Admin write functions are protected
GET /v1/admin/users as member-a 403 Admin read functions are protected
Extra fields in the audit log compared with the Activity page ip_address of each actor The only real difference

The route is a read-only view of events that members can already see. The only extra data is the IP address of other members in the same workspace. We changed the level from High to Low. Evidence: ev-13-auditlog-member.http, ev-14-auditlog-controls.http.

Remediation#

Remove ip_address from the member view, or restrict the route to admin and owner roles. Name the route outside the /admin prefix if it is meant for all members, so route names match their access rules.

F-09: Search errors disclose internal index names#

Low Evidence label: Observed

GET /v1/search?q= with a malformed filter value returns a 500 response whose body includes the internal index name (notes_v7_tenant_shard_3) and the search library version. The text helps an attacker plan later tests. It gives no access to data. We tested with three malformed values on test data, and a valid query returned normal results with tenant scoping intact. A cross-tenant query with a workspace_id filter for tenant B returned only tenant A results. Evidence: ev-15-search-error.http. Fix: catch search errors, log the detail on the server, and return a generic error with a request ID.

Controls that held#

These protections worked under test. Fixes must not remove them.

Control What we tried Result
Workspace list routes check membership member-b requested lists of tenant A notes, members and exports 403 on each route
Note read, edit and delete by ID member-b used note IDs from tenant A 404, no data returned
Token signature checks Tokens with a changed signature or algorithm none 401
Session cookie flags Inspected hn_session Secure and HttpOnly set
Admin write routes member-a called user disable and plan change routes 403
Search tenant scoping Query with workspace_id of tenant B from tenant A Only tenant A results
Reset tokens Reuse, and use after a password change 400, single-use

We list these because the fixes for F-01 and F-04 touch the same authorization code. A regression test for each control keeps the fix from weakening it.

Remediation plan#

The plan orders actions by the risk they remove. Owners are placeholders. Effort is S (under one day of work), M (a few days) or L (more than a week).

Action Findings Owner Effort
Add a workspace membership check to the export download route and switch to random IDs F-01 Backend team S
Add cross-tenant tests for every route that takes an object ID F-01, F-04 Backend team M
Render note titles as text and add a Content Security Policy F-02 Frontend team M
Check Origin on login, set SameSite=Lax, add a CSRF token F-03 Backend team S
Validate invitation roles against the caller's role F-04 Backend team S
Add rate limits and alerts to password reset F-05 Backend team S
Revoke share tokens when a note becomes private, add expiry F-06 Backend team M
Move user files to a sandbox domain, add nosniff and attachment headers F-07 Platform team L
Restrict the audit log route and remove actor IP from the member view F-08 Backend team S
Return generic search errors with a request ID F-09 Backend team S

After the fixes ship, ask for a retest of F-01 to F-04. A retest uses the same requests and the same controls.

Limitations and open questions#

The points here are limits of this assessment, not confirmed findings.

  • Time-boxed: Testing ran for five days against a fixed scope. A lack of findings in an area does not show that the area has no issues.
  • Grey-box accounts: We tested with the roles we were given. Roles that the client adds later were not tested.
  • Unproven chain (inferred): F-02 and F-04 might combine: script in an owner's session could send an invitation with a chosen role. We did not run this chain, so we do not count it as impact.
  • Open question on export history. We do not know whether other tenants' exports were downloaded before this test. The client should check access logs for GET /v1/exports/*/download where the caller's workspace differs from the export's workspace.
  • Open question on F-07 and cookie scope. The client should confirm that the app and API hosts will never share a cookie scope.
  • Not tested: Third-party checkout pages, the email provider, load behavior, and the mobile apps.

Evidence index#

Each item is a saved request, response, screenshot or script output with a UTC timestamp. The hashes below are placeholders for this demonstration. In a real report, the SHA-256 manifest lets you check that a file has not changed.

File Finding SHA-256 (placeholder)
ev-01-export-create.json F-01 3b1f9c07a2de45e18c60b7f4d29a51ce8e3f0a6b7d4c92e15f8a30bd6c7e2194
ev-02-export-xtenant.http F-01 a94c5e2d7b01f386c4d9e0a7152b8f63de4a19c07b5e3f82a6d1c940e7b3582f
ev-03-readback.json F-01 7e20d4b9c15a83f6e02b7d94a1c5f3086b9e4d27a0c18f5e3b6d92a4c07e1f58
ev-04-xss-browser.png F-02 c58a1e93f2b04d76a9e1c3058d7f2b64e0a93c1d58b7f4e26a0d9c315b8e7a42
ev-05-xss-console.json F-02 1d93b7e5a08c42f6d1b9e3705a4c8f26b0e7d15a93c4f8e2061b7d5a39c0e4f8
ev-06-csrf-login.html F-03 e4072b9d1c85a3f6b0d7e2941a5c8f3d60b9e72a14c5d8f3e0a6b19c7d2f4e85
ev-07-csrf-browser.png F-03 5f1a8c3e90b7d24a6e1c05f9b83d7a42c0e6f19b5a3d8e7c24f0b61a9d3c5e78
ev-08-invite-role.http F-04 92c6e0a4b81d37f5a9c2e60d4b7f18a3e5c09d26b4f7a81e3c0d59b2a6f4e170
ev-09-reset-burst.json F-05 b30d7a15e9c4f862a0d3b7e51c9f4a68d2e0b73c5a1f8e94d6b0a27c3e5f1980
ev-10-reset-inbox.json F-05 46e9b2c0d71a58f3e4b6a09d2c7f1583b0e8a4d69c2f5e170a3b8d4c96e1f2a7
ev-11-share-after-private.http F-06 d85a3f0c6e1b94a27d5c0e8f3b6a1942c7e0d5b83a9f4e2160c7b5d1a8e3f904
ev-12-svg-inline.png F-07 0a7c4e9b2d6f1583e0a9c7b4d21f6e85a3c0b97d4e2f1a68c5b0d3e7f9a41c26
ev-13-auditlog-member.http F-08 f2b81d6a4c0e9573b1d8f2a6e04c7b93d5a0e1f84c6b2a9d73e5f0c1b8a4d627
ev-14-auditlog-controls.http F-08 6c0e3a9d5b71f248e8a0c4d6b2f93e17a5d0c8b4e6f1a39d2c7b05e4f8a1d3c9
ev-15-search-error.http F-09 89d4b1f7e0a3c652d9b8e4a07f1c5d3e2b60a9c8d4f7e15a3b0c6d92e8f4a1b5

Appendix: reproduction scripts#

The proof-of-concept scripts ship with the report as files, one per finding. They are read-only where possible and take identifiers as arguments, for example repro/f01_readback.sh ws_test_A exp_000412. Run them only with authorization from the system owner and with test-owned identifiers.

This page is a demonstration. The methodology page describes how we work. To ask about a test of your own application, contact us. For a small, scoped test at no cost, see the free pentest pilot.

Questions

Is this a real customer report?

No. Harbor Notes is a fictional application and no real system was tested. We wrote the findings to show our report format and evidence standard. The hosts use the reserved .example domain, and the hashes in the evidence index are placeholders.

What format do you deliver?

We deliver the report in HTML, PDF and Markdown, plus proof-of-concept scripts. The scripts are read-only where possible. You must run them only with authorization and with test-owned identifiers.

Do you use CVSS?

Not by default. We use a four-level scale (Critical, High, Medium, Low) with a written definition for each level. If your program needs CVSS vectors, tell us during scoping and we agree the method in writing.

Does every finding have proof?

Each finding carries an evidence label. A finding is confirmed only when we show a real effect, for example a change that a second account reads back. Inferred paths are labeled separately, and open questions stay out of confirmed impact.

Need a pentest you can act on?

Tell us what you need tested and why. We reply to agree scope, and testing starts only after written authorization.