The Pental API: Connect Your Pentest Data to Your Own Tools
The Pental API reads and changes engagements, findings, clients and comments from your own scripts and systems, in the Portal and in our free reporting tool: keys, signing in, every function, and the limits.
The Pental API lets your team connect Pental to the rest of how they work: a tracker that should open a ticket for every high finding, a project board that should show this week's engagements, a dashboard, a script that moves findings into a spreadsheet. It reads engagements, findings, clients, people, comments, timelines and files, and with writing switched on it sets a finding's status, asks for a retest, adds comments, and makes and changes clients and engagements. It is the same in the Portal and in our free reporting tool, and it runs in your own database: Pental's servers are not in the path.
How a key works
A key belongs to one person and works as them. It reads what that person can read in the portal and changes what they can change there, through the API's own functions alone: never a table directly, never a file, never any other function, and never from a web page. A client of yours who could not see a draft finding in the portal cannot see it through a key either, because the same rules in your database decide both.
| What it means | |
|---|---|
| Off until turned on | An administrator turns the API on under Settings, API, decides whether keys may write, which roles may make keys (Admin, Senior and Standard to begin with) and the longest a key may live: 90 days to begin with, up to 365. |
| Made by its person | Each person makes their own keys on Profile, under API Keys, choosing a name, whether it reads or reads and writes, and how many days it lives. Making one asks for the second factor again if it was given more than ten minutes ago. Ten keys that work is the most a person can hold. |
| Shown once | The key is shown once, as it is made, with your API's address and the publishable key beside it. Only a one-way hash (SHA-256) of it is kept, so it cannot be shown again; if it is lost, make another and revoke the first. |
| Ends on its own | A key stops working when it expires, when its person or an administrator revokes it, and is revoked on its own when its person is switched off or deleted, changes role, organisation or sign-in, or has their second factor reset. An administrator taking a role off the list pauses its keys until the role is put back, and switching the API off stops every key at once. |
| Seen by an administrator | Settings, API lists every key with its person, what it can do, when it was made, when it expires, when and from where it was last used, how many writes it has made, and its state, with Revoke beside each and Revoke every key for a key that may have got out. The API log records every key made and revoked and every change to the settings, by whom. |
Sign in with three calls
A program signs in with the key, then calls the API with the session that gives it. Every call is a POST with a JSON body to your API's address, with the publishable key in the apikey header. All three values are in the dialog that showed the key: Your key, Your API address (your own database's address, or the address you open our free reporting tool at) and The publishable key.
1. rpc_api_key_exchange with p_key, using the publishable key alone, returns the key's person's email and a password made for this sign-in.
2. /auth/v1/token?grant_type=password with that email and password signs in, as every sign-in to the portal ends, and returns an access_token and a refresh_token.
3. rpc_api_key_bind with p_key, sent with the new access token as Authorization: Bearer, makes that session the key's. It has to come within a minute of the exchange, and only the session that exchange signed in can be bound. From then on the session calls the api_v1_ functions.
The access token lasts an hour unless your database's own settings say otherwise. A program that runs for longer renews it with the refresh token (/auth/v1/token?grant_type=refresh_token), which keeps the same session, rather than signing in again for every call: a key keeps its five newest sessions and ends older ones, a key exchanges ten times a minute at most, and on Pental's gateway and in our free reporting tool thirty sign-ins every five minutes is the most from one address. Keep the key out of your code and your command lines: the examples read it, the address and the publishable key from environment variables.
Python
import os
import requests
BASE = os.environ["PENTAL_API_URL"]
ANON = os.environ["PENTAL_PUBLISHABLE_KEY"]
KEY = os.environ["PENTAL_API_KEY"]
def post(path, body, token=None):
headers = {"apikey": ANON, "Authorization": "Bearer " + (token or ANON)}
r = requests.post(BASE + path, json=body, headers=headers, timeout=30)
if not r.ok:
raise SystemExit(f"{r.status_code} {r.text}")
return r.json() if r.content else None
# 1. The key for a password made for this sign-in
x = post("/rest/v1/rpc/rpc_api_key_exchange", {"p_key": KEY})
# 2. Sign in with it
session = post("/auth/v1/token?grant_type=password",
{"email": x["email"], "password": x["password"]})
token = session["access_token"]
# 3. Make the session the key's
post("/rest/v1/rpc/rpc_api_key_bind", {"p_key": KEY}, token)
def api(name, **args):
return post("/rest/v1/rpc/api_v1_" + name, args, token)
for f in api("findings", p_status="open"):
print(f["severity"], f["finding_code"] or "", f["title"])JavaScript
// Node 18 or later: save as findings.mjs and run with node findings.mjs
const BASE = process.env.PENTAL_API_URL;
const ANON = process.env.PENTAL_PUBLISHABLE_KEY;
const KEY = process.env.PENTAL_API_KEY;
async function post(path, body, token) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { apikey: ANON, Authorization: 'Bearer ' + (token || ANON), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await r.text();
if (!r.ok) throw new Error(r.status + ' ' + text);
return text ? JSON.parse(text) : null;
}
// 1. The key for a password made for this sign-in
const x = await post('/rest/v1/rpc/rpc_api_key_exchange', { p_key: KEY });
// 2. Sign in with it
const session = await post('/auth/v1/token?grant_type=password', { email: x.email, password: x.password });
// 3. Make the session the key's
await post('/rest/v1/rpc/rpc_api_key_bind', { p_key: KEY }, session.access_token);
const api = (name, args = {}) => post('/rest/v1/rpc/api_v1_' + name, args, session.access_token);
for (const f of await api('findings', { p_status: 'open' })) console.log(f.severity, f.finding_code || '', f.title);Curl
# Needs curl and jq
H=(-H "apikey: $PENTAL_PUBLISHABLE_KEY" -H "Content-Type: application/json")
KEYBODY=$(jq -n --arg k "$PENTAL_API_KEY" '{p_key: $k}')
# 1. The key for a password made for this sign-in
X=$(curl -sS --fail-with-body "${H[@]}" -H "Authorization: Bearer $PENTAL_PUBLISHABLE_KEY" \
-d "$KEYBODY" "$PENTAL_API_URL/rest/v1/rpc/rpc_api_key_exchange")
# 2. Sign in with it
TOKEN=$(curl -sS --fail-with-body "${H[@]}" -d "$(echo "$X" | jq '{email, password}')" \
"$PENTAL_API_URL/auth/v1/token?grant_type=password" | jq -r .access_token)
# 3. Make the session the key's
curl -sS --fail-with-body "${H[@]}" -H "Authorization: Bearer $TOKEN" \
-d "$KEYBODY" "$PENTAL_API_URL/rest/v1/rpc/rpc_api_key_bind" > /dev/null
curl -sS --fail-with-body "${H[@]}" -H "Authorization: Bearer $TOKEN" \
-d '{"p_status": "open"}' "$PENTAL_API_URL/rest/v1/rpc/api_v1_findings" | jq -r '.[] | "\(.severity) \(.title)"'Reading
Each read returns a list of rows with named columns. The full list of parameters and columns, with their types, is in the API's OpenAPI document. Engagements, findings and clients come with changed_at, when each was last written, and row_version, and comments with changed_at; p_changed_since returns only rows written since a moment, which is how to keep another system up to date without reading everything each time.
| Function | Parameters | Returns |
|---|---|---|
api_v1_me | None | |
api_v1_engagements | p_client_id, p_status, p_changed_since, p_include_archived | |
api_v1_findings | p_engagement_id, p_client_id, p_status, p_severity, p_changed_since, p_include_drafts, p_include_archived | |
api_v1_clients | p_search, p_changed_since | |
api_v1_people | p_include_inactive | |
api_v1_comments | p_engagement_id, p_changed_since | |
api_v1_timeline | p_engagement_id, p_changed_since | |
api_v1_files | p_engagement_id | |
api_v1_statuses | None |
Every parameter of a read can be left out. A list can be cut down the way PostgREST does it, by adding select, order and limit to the address: api_v1_findings?select=id,title,severity&order=severity_rank&limit=50.
Writing
Writing needs a key that reads and writes, and writing switched on under Settings, API. Each write is made as the key's person, under the same rules as the portal: a tester can set a finding's status and a client cannot, a client can ask for a retest of a finding shown to them, and nobody reaches an engagement that is not theirs. The administrator's list counts each key's writes, and a key makes sixty writes a minute at most. Changes made through the API send no email; the portal's own screens do that.
| Function | Parameters | What it does |
|---|---|---|
api_v1_finding_set_status | p_finding_id, p_status, p_row_version | |
api_v1_finding_request_retest | p_finding_id | |
api_v1_comment_add | p_engagement_id, p_content, p_parent_id, p_client_visible | |
api_v1_client_create | p_name, p_industry, p_contact_name, p_contact_email, p_contact_phone, p_address | |
api_v1_client_update | p_client_id, p_row_version, and any of the fields above | |
api_v1_engagement_create | p_client_id, p_name, p_start_date, p_end_date, p_type, p_lead_id, p_test_days, p_scope | |
api_v1_engagement_update | p_engagement_id, p_row_version, p_name, p_type, p_start_date, p_end_date, p_lead_id, p_clear_lead, p_test_days, p_scope |
When a call is refused
A refusal comes back with an HTTP status and a body whose details holds a short code a program can act on, and whose message says what to do in words.
| Status | Codes | Meaning |
|---|---|---|
| 401 | key_invalid, key_revoked, key_expired, key_suspended | |
| 401 | not_a_key_session, session_ended, no_session, no_exchange | |
| 403 | api_off, role_off, contractors_off, writes_off, read_only | |
| 403 | browser, not_allowed | |
| 404 | not_found | |
| 409 | changed | |
| 429 | rate_limited | |
| 400 | bad_request |
Keeping a key safe
Treat a key as you would the person's password. Keep it in your secrets manager or an environment variable, give each system its own key with a name that says what uses it, choose read-only wherever writing is not needed, and the shortest life that suits. Never put a key in a web page or an app that runs on somebody else's device: a request from a browser is refused, because every visitor to the page could read the key. When somebody leaves, switching them off revokes their keys; when a key may have got out, revoke it, and an administrator can revoke every key at once.
The rest of Pental's security is the same for a key as for a person: how Pental keeps your data in your own database, and setting up your team and access for what each role may do.
Connect Pental to the Way Your Team Works
Turn the API on in Settings, make a key on your profile, and have your tracker or project board read your engagements and findings in a few lines of code.
Only need the reporting? Our reporting tool is free and open source, on your own server.
Also Worth Reading
How to Write a Penetration Test Report, Step by Step
The process from first note to delivery: set the report up before testing, write findings as you confirm them, score severity with reasons, write the summary last, and QA everything before it leaves.
ReferencePenetration Testing Severity Levels and Risk Rating Matrix
Critical, high, medium, low and informational defined in one table and mapped to CVSS 4.0, with a likelihood and impact matrix for findings CVSS does not fit, and the habits that keep testers consistent.
ReferenceCVSS 4.0 Examples: 15 Common Pentest Findings Scored
Fifteen findings you raise in almost every test, each scored in CVSS 4.0 with a full vector and the reasoning behind it, set beside its 3.1 score so you can see exactly where the two versions part company.
Setup guide
Prefer to watch it?
The whole setup recorded, with chapters you can jump to: registering, your own domain, your own database in any of three places, your own mail server, branding, the first sign-in, updating the database on Supabase Cloud, and moving the database if you change your mind.
- 0:00 · Registering, signing in, and the free trial
- 0:44 · Your name and your firm’s name
- 0:50 · Custom domain
- +6 more