Reference

Posting Scan Results to Pental: the Ingestion API

Two public endpoints get scanner output into Pental without a browser: a webhook for anything that produces JSON, and the collector protocol for a scanner that Pental cannot reach. Tokens, payloads, field names and every error they return.

5 min read

There are three ways results reach Pental. Somebody uploads an export in Vulnerability Scans, Pental pulls them from a scanner you have connected, or something you run posts them. This page documents the third: two HTTP endpoints, no browser and no user session, because a CI job has nobody to sign in as.

Both live under one route and are selected by a query parameter. Both authenticate with a token you generate in the portal, and both require your tenant id, which is on the same screen. Neither uses a cookie, and neither will tell an unauthenticated caller whether a tenant exists.

Webhook: posting results you already have

Use this when something in your own pipeline produces findings: Nuclei in CI, a Greenbone export script, a scanner Pental has no adapter for, or your own tooling. The results land as findings on an engagement exactly as a pulled scan would, deduplicated against anything already there.

Create the token in Settings, Vulnerability Scanning, Connected scanners. It is shown once and stored as a SHA-256 hash, so if you lose it you generate another rather than recovering it.

curl -X POST 'https://portal.example.com/api/utils?fn=scan-webhook&client=<client uuid>' \
  -H 'x-tenant-id: <your tenant uuid>' \
  -H 'x-pental-scan-token: <your ingestion token>' \
  -H 'content-type: application/json' \
  -d '[
    {
      "title": "TLS 1.0 supported",
      "severity": "high",
      "asset": "portal.example.com:443",
      "description": "The server negotiates TLS 1.0.",
      "remediation": "Disable TLS 1.0 and 1.1 at the load balancer.",
      "cvss": 7.4,
      "cwe": "CWE-327"
    }
  ]'

Say where the results belong with either ?client=<uuid> or ?schedule=<uuid>. A client id opens a new run for that customer; a schedule id attaches to the run that schedule is waiting on. Send neither and the request is refused, because guessing which customer a finding belongs to is not a thing software should do.

The body may be a bare JSON array, or an object with a results, findings or vulnerabilities key, whichever your tool already emits. Nuclei JSONL is accepted as-is: post the file, one JSON object per line, and it is read line by line. One push carries at most 2000 results.

What a result may contain

Only title is required, and even that falls back to "Unnamed finding". Everything else is optional, and each field accepts the names the common scanners already use, so in most cases you post what your tool produced without renaming anything.

Field, and the names accepted for it
titletitle, name, vulnerability, issue, template-id, info.name
severityseverity, risk, risk_factor, info.severity. Anything unrecognised is derived from CVSS, and informational when there is none
cvsscvss, cvss_score, cvss3, score, info.classification.cvss-score
cve / cwecve, cves / cwe, cwes, and the Nuclei classification equivalents
assetasset, host, hostname, target, url, ip, matched-at, affects_url
descriptiondescription, desc, summary, info.description
impactimpact, synopsis, info.impact
remediationremediation, solution, fix, recommendation, info.remediation
evidenceevidence, proof, output, plugin_output, extracted-results, response
refsrefs, references, see_also, info.reference
port, protocol, servicethe same names
plugin_idplugin_id, pluginID, qid, template-id, signature, id
A field this list does not name is discarded before the results are written. If your tool emits something under a name that is not here, map it once on the scanner connection rather than reshaping every payload: the mapping is applied to pushed results and pulled ones alike.

Targets are optional and separate: send a targets array alongside the results and the run records what was in scope, which is what the vulnerability report prints.

What the webhook returns

{
  "ok": true,
  "run_id": "8f3c…",
  "imported": 42,
  "counts": {
    "created": 12,
    "updated": 30,
    "closed": 3,
    "below_threshold": 0,
    "hosts": 4,
    "critical": 1, "high": 4, "medium": 9, "low": 12, "info": 16
  }
}

imported is how many results were accepted. Inside counts, created and updated split those between new findings and ones already on the engagement; closed is how many previously reported issues the scanner no longer sees, which are marked remediated; below_threshold is how many were discarded by the minimum severity set for this customer; hosts is how many distinct assets appeared. The severity keys count the findings on the run as a whole.

Failures, and what each one means
400The body was not JSON, was not an array or a recognised wrapper, or neither ?client= nor ?schedule= was given
401The token is wrong, too short, or the tenant is not one this portal serves. Deliberately the same answer in every case
405Anything other than POST
409That scanner connection has been switched off in the portal
413More than 2000 results in one push
500The import failed after the results were accepted
503The portal database has not had the update that adds scanning applied yet

Collector: for a scanner Pental cannot reach

A scanner on a private network, or one your policy will not expose to the internet, is not going to accept a connection from a portal in a data centre. The collector solves it the other way round: a small Node program runs beside the scanner, connects outward, asks whether there is anything to do, and posts the results back. It is the same program a customer runs for scanning inside their own network, in which case the connection is owned by them rather than by you.

Download it from the same settings screen; it comes configured with its own token. It authenticates with x-pental-collector-token, and every action is a POST to one route.

POST /api/utils?fn=scan-agent&action=<action>
x-tenant-id:              <your tenant uuid>
x-pental-collector-token: <the collector's own token>
Action, and what it does
pollAsks for work. Returns a queued run and what to scan, or nothing. Also records the collector as alive, with its host and version
resultsPosts the findings for a run id it was given
failedReports that a run could not be completed, with a reason, so the run closes rather than hanging
testChecks the credentials for one scanner connection and reports what the scanner said
adapters / catalogueWhat this portal can talk to, and what the connected scanners offer
downloadThe collector program itself
There is one collector protocol and one set of results endpoints. A customer-owned agent for internal scanning is the same program with the connection marked as theirs, so nothing about the integration changes between the two cases.

Where results appear

Whichever route you use, findings are created on an engagement and stay in its vulnerability scanning section rather than joining the report. A scanner gives a title, a severity and an asset; it does not give the impact, evidence and write-up a report finding needs, so scan results are kept apart until somebody promotes one deliberately. The vulnerability report is the document that renders them, and any PDFs you want the customer to have can be attached to the run itself.

A result that matches one already on the engagement updates it rather than creating a second copy, keeps the worse of the two severities, adds the host to the list of affected ones, and reopens the finding if it had been marked remediated. Anything a tester has written by hand is left alone.


Try This on Your Own Database

Pental runs on a Postgres project you own, under your own brand, with the AI on your own key. The trial is the whole platform.


Also Worth Reading