Jump into setup guides, feature references, reporting conventions, API documentation, methodology pages, and workflow-specific playbooks.
Supply chain
API reference
Authentication, targets, scans, findings, assets, and MCP tools.
Findings, reports, dashboards, exports, integrations, and retests all read from the same normalized record.
Pencheff favors repeatable checks, then uses AI for triage, enrichment, orchestration, and remediation where it adds signal.
From the Pencheff docs
API overview
/reference/overviewBase URL:
- Production:
https://app.pencheff.com/api - Local dev:
http://localhost:8000
All endpoints require a bearer token issued by Clerk. See Authentication.
Endpoints at a glance
| Namespace | Endpoints | Docs |
|---|---|---|
/auth | Login, signup, logout | authentication |
/targets | CRUD for scan targets | targets |
/scans | Trigger scans, stream progress, fetch results | scans |
/findings | List, suppress, verify, comment, assign | findings |
/schedules | Cron-driven recurring scans | schedules |
/assets | Attack surface inventory | assets |
/integrations | Slack, Teams, PagerDuty, Splunk, webhook | integrations |
/sboms/{scan_id} | SBOM browse + download | sboms |
/dependencies/{scan_id} | SCA dep inventory | dependencies |
/proxy | Intercepting proxy sessions | proxy |
MCP tools (plugin side)
When you run Pencheff as an MCP server, the following 81 MCP tools are available — see MCP tool reference for the full list.
Response conventions
- Always JSON (
Content-Type: application/json) - Timestamps in ISO 8601 UTC (
"2026-04-21T14:23:00+00:00") - UUIDs for all primary keys
- Errors:
{ "detail": "..." }for client errors,{ "detail": "ClassName: message" }for server errors - Validation errors follow FastAPI's shape:
{ "detail": [{ "loc": [...], "msg": "..." }, ...] }
Rate limits
- 60 requests/min per user (SaaS)
- 30 concurrent scans per org on the Pro plan
Hit X-RateLimit-Remaining in the response headers to see how close
you are.
From the Pencheff docs
Scans API
/reference/scansPOST /scans
Trigger a new scan.
POST /scans
{
"target_id": "...",
"profile": "standard",
"consent_payload": {
"authorization_text": "I am authorised to test example.com as of 2026-05-06 and I accept the disclosed actions.",
"acknowledged": true
}
}
profile is one of quick, standard, or deep. Older specialised
names (engage, compliance, api-only, cicd, sca, iac,
supply-chain, network-va, hackme, continuous,
compliance-full) are still accepted and coerced to the matching tier
at the runner — see Picking a profile
for the fold-in table.
consent_payload is required when swarm mode is active (default). The
API returns 422 Unprocessable Entity if the field is absent, if
authorization_text is shorter than 50 characters, or if acknowledged is
not true. The payload is persisted on Scan.consent_payload (JSONB) and
included in every audit export. Shape (Pydantic schema ConsentPayload):
type ConsentPayload = {
authorization_text: string; // >= 50 characters
acknowledged: boolean; // must be true
};
GET /scans
List scans for the current org (paginated).
GET /scans/{id}
Fetch a single scan — includes summary counts, grade, progress.
GET /scans/{id}/progress (SSE)
Server-Sent Events stream of live progress ticks during an active scan. Events:
stage_start: recon_passivestage_done: recon_passive: 47 endpointsfinding: HIGH SQLi on /api/usersfinished
GET /scans/{id}/findings
Returns every finding for the scan with CVSS, EPSS, KEV, risk_score, compliance mapping.
GET /scans/{id}/linked-repos
Returns the repositories attached to this URL scan's target. Source-code
findings (Semgrep · Bandit · gosec · Brakeman · ESLint · OSV · secret-scan) live on each repo's own
assessment page (/repos/{repository_id}) — the URL scan deliberately
does not mix SAST findings into its own results list. Use this
endpoint to render a "Linked repositories" sidebar with deep-links.
type LinkedRepo = {
repository_id: string;
full_name: string;
provider: string | null;
scan_url: string; // e.g. "/repos/abc-123"
};
Returns [] for scans whose target has no attached repos. Available
on URL targets only.
GET /scans/{a}/compare/{b}
Compare two completed scans of the same workspace and return a structured diff. Particularly useful for LLM red-team scans where you want to gate a PR on safety regressions, or A/B different model versions on the same suite.
GET /scans/3a35.../compare/9359...
Authorization: Bearer <token>
Response:
type ScanCompare = {
baseline: { name: string; summary: RedTeamSummary };
candidate: { name: string; summary: RedTeamSummary };
regressions: Finding[]; // candidate-only
fixes: Finding[]; // baseline-only
common_failures: Finding[]; // present in both
counts: { regressions: number; fixes: number; common_failures: number };
keys: { regressions: string[]; fixes: string[]; common_failures: string[] };
scan_a: { id: string; profile: string; grade: string | null;
score: number | null; created_at: string };
scan_b: { id: string; profile: string; grade: string | null;
score: number | null; created_at: string };
};
The dedup key is endpoint|parameter|technique|title, so re-running
the identical suite against an unchanged target produces zero
regressions. The web UI exposes the same diff at
/scans/compare?a=…&b=… with a JUnit-XML download for the
regressions list — wire it into CI to fail builds on new findings.
POST /scans/{id}/share (LLM only)
Issues a Fernet-encrypted token granting public read access to the
scan's LLM-flavored report. Available only when the underlying target
is kind: "llm".
POST /scans/3a35.../share?ttl_seconds=604800
Authorization: Bearer <token>
Response:
{
"token": "gAAAAA...",
"expires_in": 604800,
"url_path": "/share/llm/gAAAAA..."
}
The companion public route GET /share/llm/{token} renders the scan
as HTML (default), Markdown, CSV, or JSON depending on the
?download= query param. Token expiry is the only revocation — let
it expire to revoke. PII is redacted in evidence snippets before
rendering, regardless of the public/private path.
GET /scans/{id}/llm-traces
Returns the full LLM call trace for a scan. Each row corresponds to one chat-completions call made by a swarm agent during the scan.
Authentication required. Returns [] for scans run with SWARM_ENABLED=false.
type ScanLLMTrace = {
id: string;
scan_id: string;
agent: string; // e.g. "InjectionAgent", "ChainAgent"
turn: number; // agent conversation turn number
request_messages: object[]; // full messages array sent to the LLM
response: object; // raw LLM response including tool-call blocks
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
reasoning: string | null; // thinking/reasoning block if present
created_at: string;
};
GET /scans/{id}/evidence/{finding_id}.png
Returns the Playwright evidence screenshot captured by EvidenceCaptureAgent
for the specified finding. PII is redacted before storage.
Authentication required. Returns 404 Not Found if no screenshot exists for
that finding (e.g. the finding was below high severity, the agent did not run,
or the scan used the legacy single-agent path).
GET /scans/3a35.../evidence/f7b2c4....png
Authorization: Bearer <token>
Response: image/png binary. Cache the response client-side — screenshots
do not change after the scan completes.
DELETE /scans/{id}
Cancel an in-flight scan or remove a finished one.
Scan object
type Scan = {
id: string;
target_id: string;
status: "queued" | "running" | "completed" | "failed" | "cancelled";
profile: string;
progress_pct: number; // 0-100
current_stage: string | null;
summary: {
critical: number; high: number; medium: number; low: number; info: number;
suppressed: number;
} | null;
grade: "A" | "B" | "C" | "D" | "F" | null;
score: number | null;
started_at: string | null;
finished_at: string | null;
};
From the Pencheff docs
Findings API
/reference/findingsGET /scans/{scan_id}/findings
Return every finding for a scan. Filter with query params:
?severity=critical?category=injection?owasp_category=A03?verified_only=true?include_suppressed=true?sort=risk_score(default; usecvss_scoreorcreated_at)
GET /findings/{id}
Fetch a single finding with full evidence, comments, assignments, tags.
PATCH /findings/{id}
Update status. Valid fields:
{
"verification_status": "true_positive" | "false_positive" | "true_negative" | "false_negative",
"suppressed": true,
"suppress_reason": "accepted_risk" | "wont_fix" | "false_positive" | "duplicate" | "out_of_scope",
"suppress_notes": "string",
"resolved_at": "2026-04-21T…Z",
"sla_days": 7
}
Collaboration
POST /findings/{id}/comments— add a commentGET /findings/{id}/comments— list commentsPOST /findings/{id}/assign—{"assignee_user_id": "..."}POST /findings/{id}/tags—{"tag": "p0-fix"}DELETE /findings/{id}/tags/{tag}— remove a tag
Prioritisation fields
Every Finding includes the unified prioritisation surface:
| Field | Type | Source |
|---|---|---|
risk_score | float (0–100) | computed at insert from CVSS × EPSS × KEV × SSVC × reachability |
ssvc_decision | string | one of act, attend, track_star, track |
reachability | string | one of exploited, reachable, present, unknown — see Reachability classifier |
epss | float (0–1) | null | EPSS feed; populated for SCA findings |
kev | bool | CISA KEV catalog membership |
Sort the list endpoint by risk_score:
GET /scans/{scan_id}/findings?sort=risk_score (default)
The unified, cross-table queue lives at
/unified-findings.
POST /findings/{id}/propose_fix
Generates a draft FixProposal for the finding. SCA findings get a
deterministic version-bump diff; SAST/DAST findings synthesise a unified
diff via the operator-configured patch-synthesis backend. See
Auto-fix PRs.
The route accepts kind ∈ {sast, dast}; SCA findings ride the
dast kind and Pencheff detects the SCA payload from evidence and
routes internally.
POST /findings/{id}/triage
Pro tier. Triage 2.0 — exploitability walkthrough returning
{ walkthrough, blast_radius, exploit_scenario, fix_outline, confidence }. Cached on finding.ai_triage; pass ?force=true to
regenerate. See Triage 2.0.
From the Pencheff docs
API keys (PENCHEFF_API_KEY)
/reference/api-keysPENCHEFF_API_KEY tokens give scripts, CI pipelines, and scheduled jobs
programmatic access to the Pencheff API without holding a Clerk session.
Each key is always pinned to one organisation and may additionally be
pinned to a single workspace. Permissions are granted as a list of
fine-grained category:action scopes — a key can only call endpoints
whose required scope it holds.
Key format
pcf_live_<43+ url-safe base64 chars>
The first eight characters after pcf_live_ form the lookup prefix
displayed in the dashboard (pcf_live_aB3xZ9k1…). The full secret is
shown once at creation — copy it then; it cannot be recovered.
Creating a key
In the dashboard: Settings → API keys → New key. Programmatically (a session-only endpoint — you must be signed in):
curl -X POST https://api.pencheff.com/api/v1/api-keys \
-H "Authorization: Bearer $CLERK_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "GitHub Actions — production CI",
"org_id": "org_01H...",
"workspace_id": "ws_01H...",
"scopes": ["scans:write", "findings:read", "reports:export"],
"expires_at": "2027-05-07T00:00:00Z"
}'
Response:
{
"id": "ak_01H...",
"name": "GitHub Actions — production CI",
"key": "pcf_live_aB3xZ9k1...43chars...",
"prefix": "aB3xZ9k1",
"org_id": "org_01H...",
"workspace_id": "ws_01H...",
"scopes": ["findings:read", "reports:export", "scans:write"],
"effective_scopes": ["findings:read", "reports:export", "scans:write"],
"expires_at": "2027-05-07T00:00:00Z",
"created_at": "2026-05-07T22:14:00Z"
}
The key field is only present in the create response. Save it now.
Using a key
Send it as a bearer token. The Authorization header is the only
accepted location — query-string ?token= is rejected for keys (URLs
leak into logs and browser history).
export PENCHEFF_API_KEY="pcf_live_aB3xZ9k1...43chars..."
curl https://api.pencheff.com/scans \
-H "Authorization: Bearer $PENCHEFF_API_KEY"
The active workspace is read from the key's pin. If the key is
workspace-scoped, requests with a conflicting X-Workspace-Id header
are rejected with 403. If the key is org-scoped only
(workspace_id: null), every request must include X-Workspace-Id and
the workspace must belong to the key's org.
Permission model
Default-deny
API-keyed requests are rejected by default on every endpoint that does
not explicitly declare a required scope. There is no fallback to "all
permissions" — even a key with *:* cannot reach an endpoint that
opts out of API-key access.
Session-only endpoints
A separate set of endpoints rejects API-keyed requests outright (HTTP 403). These never accept a key, regardless of scopes:
| Category | Reason |
|---|---|
| api-keys | A leaked key cannot mint more keys |
| auth | Sign-in / signup / onboarding |
| billing | Stripe customer state, plan changes |
| branding | Workspace branding |
| orgs | Member roles, invites, org settings |
| workspaces | Workspace creation / rename |
Org-wide vs. workspace-scoped keys
workspace_id: null— the key acts on any workspace in its org. The caller still has to passX-Workspace-Idto pick one per request. Only org owners and admins may mint these.workspace_id: <id>— the key is pinned to that workspace. Any otherX-Workspace-Idis rejected. Any user (member or above) in the org may mint these for workspaces they belong to.
Membership re-check
Every request re-validates that the issuing user is still a member of the key's org. If an admin removes the user from the org, all of that user's keys for that org stop working immediately — there is no cache.
Scope catalog
Wildcards are supported when granting:
scans:*— both read and write on scans*:read— read everything that exposes a read scope*:*— every scope in the catalog (admin-equivalent)
The matcher always normalises the required scope to the concrete form
declared by the endpoint, so scans:* will satisfy scans:write.
| Scope | What it grants |
|---|---|
assets:read | List assets in the inventory |
assets:write | Trigger ASM discovery, modify or delete assets |
comments:read | Read finding comments |
comments:write | Create or edit finding comments, assign findings, manage tags |
dashboard:read | Read dashboard metrics: heatmap, trend, KEV exposure, fix conversion |
dependencies:read | Read SCA dependency data |
engagements:read | Read engagement metadata and unified findings |
engagements:write | Create, close, or rotate engagement pairing codes |
findings:read | List and read findings |
findings:write | Triage, recheck, suppress, reopen, change status |
fix_proposals:read | Read fix proposal status, diffs, and usage stats |
fix_proposals:write | Generate, apply, revert auto-fix proposals; bulk-fix |
integrations:read | Read integration configuration |
integrations:write | Create, modify, delete, or test integrations |
intruder:read | Read intruder payload sets, attacks, and results |
intruder:write | Create payload sets and run intruder attacks |
notes:read | Read engagement notes |
notes:write | Create, modify, or delete engagement notes |
proxy:read | Read proxy session state and per-scan history |
proxy:write | Start or stop proxy sessions |
repeater:read | Read repeater tabs and saved responses |
repeater:write | Create, modify, or send repeater requests |
repos:read | Read repositories, repo scans, repo findings, repo SBOMs |
repos:write | Connect repos, trigger repo scans, generate SBOMs |
reports:export | Generate reports (PDF, DOCX, HTML) |
reports:read | Read existing reports and download files |
scans:read | List and read scans, get progress, view findings |
scans:write | Initiate, configure, cancel, or rerun scans |
schedules:read | Read scheduled scans |
schedules:write | Create, modify, or delete scheduled scans |
sboms:read | Read SBOMs |
targets:read | Read targets |
targets:write | Create, modify, or delete targets |
traffic:read | Read recorded HTTP traffic |
traffic:write | Tag or modify traffic rows |
unified_findings:read | Read the unified-finding queue |
Coverage matrix
The default-deny dependency layer rejects API-keyed requests on any endpoint that doesn't explicitly declare a required scope. Every scope listed above is wired into at least one HTTP endpoint. All of the following routers participate:
/scans/*,/findings/*,/targets/*,/reports/*,/assets/*/integrations/*,/schedules/*,/engagements/*/repos/*(except/repos/install-urland/repos/callback, which are GitHub App handshake endpoints — session-only)/sboms/*,/dependencies/*/repeater/*,/intruder/*,/proxy/*,/traffic/*/notes/*,/comments/*,/findings/{id}/assign,/findings/{id}/tags/fix-proposals/*,/fix-tasks/*,/scans/{id}/fix-all,/repo-scans/{id}/fix-all,/findings/{kind}/{id}/propose_fix,/findings/{kind}/{id}/fix_proposal,/usage/fix-llm/dashboard/*/unified-findings/*
The current authoritative list is also available via:
curl https://api.pencheff.com/api/v1/api-keys/scopes \
-H "Authorization: Bearer $CLERK_JWT"
Listing, updating, revoking
# List all your keys (does NOT return the secret)
curl /api/v1/api-keys -H "Authorization: Bearer $CLERK_JWT"
# Update name / scopes / expiry (cannot reissue the secret)
curl -X PATCH /api/v1/api-keys/$ID \
-H "Authorization: Bearer $CLERK_JWT" \
-d '{"scopes": ["scans:read"]}'
# Revoke (immediate; can't be undone)
curl -X DELETE /api/v1/api-keys/$ID \
-H "Authorization: Bearer $CLERK_JWT"
Every create / update / revoke action is recorded in the audit_logs
table tagged with the key ID for forensic traceability.
Plan limits
A single user can hold up to 50 active (non-revoked) keys across all their orgs. Revoke keys you no longer use to free a slot.
Recipes
CI/CD pipeline (read scans, export reports)
Mint a workspace-pinned key with the minimum needed scopes:
{
"name": "Buildkite — main",
"org_id": "org_01H...",
"workspace_id": "ws_prod",
"scopes": ["scans:write", "scans:read", "findings:read", "reports:export"],
"expires_at": "2027-05-07T00:00:00Z"
}
The GitLab CI and Azure DevOps templates (apps/gitlab-ci,
apps/azure-devops) currently run the local pencheff CLI, which does
not call the hosted backend. If your pipeline talks to the hosted
Pencheff API directly (custom curl steps, a thin internal CI agent,
etc.), pass the key as the Authorization: Bearer … header — that is
the only thing the API checks.
Read-only finding sync to a SIEM
{
"name": "Splunk forwarder",
"org_id": "org_01H...",
"workspace_id": "ws_prod",
"scopes": ["findings:read", "unified_findings:read"],
"expires_at": null
}
One-org-many-workspaces automation
Org admins can mint a single org-scoped key and let the caller pick the workspace at request time:
{
"name": "ACME bot — fan-out scanner",
"org_id": "org_01H...",
"workspace_id": null,
"scopes": ["scans:write", "findings:read"]
}
The script must then send X-Workspace-Id on every request:
curl https://api.pencheff.com/scans \
-H "Authorization: Bearer $PENCHEFF_API_KEY" \
-H "X-Workspace-Id: ws_staging"
Security notes
- Keys are stored as SHA-256 of the full token. The plaintext is shown only once at creation.
- The full token has 256 bits of entropy from
secrets.token_urlsafe, so plain SHA-256 (no bcrypt) is sufficient — the comparison is still constant-time (hmac.compare_digest). - Keys must be sent in the
Authorizationheader. Query-string token passing is rejected forpcf_live_*to keep secrets out of logs and browser history. - A revoked key returns 401 on the very next request — there is no TTL.
- A leaked key exposes only what its scopes allow on its pinned org / workspace. It cannot mint more keys, change billing, or modify org membership.
Related