Run web, API, code, dependency, cloud, AI, and internal-network assessments from one queue with unified findings, evidence, remediation, and audit output.
Risk, reporting, and compliance
Re-examination
Verify any fix on demand with targeted re-test probes against the same finding.
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
Auto-fix PRs
/features/auto-fixPencheff turns SAST, DAST, and SCA findings into ready-to-merge GitHub pull requests. Click Propose fix on any finding and Pencheff:
- Materialises a working tree of the connected repo.
- Generates a unified diff via the appropriate strategy:
- SCA — deterministic version-bump in the offending manifest.
- SAST — scanner-native autofix from Semgrep when present; LLM patch otherwise.
- DAST — provenance-rank candidate handlers, then patch the most likely one.
- Opens a branch, commits the diff, pushes, and opens a PR via the GitHub App. The PR body cites the finding, evidence, and remediation guidance.
SCA: deterministic, free, no LLM
The SCA path is the simplest and the cheapest: Pencheff parses the manifest, finds the line, replaces the version, and writes the diff — no LLM call, no per-fix cost.
Supported manifests (9 ecosystems)
| Ecosystem | Manifest |
|---|---|
| Python | requirements.txt, pyproject.toml, Pipfile |
| Node.js | package.json |
| Go | go.mod |
| Rust | Cargo.toml |
| Ruby | Gemfile |
| PHP | composer.json |
| Java | pom.xml |
Lockfiles are deliberately not edited
Editing package-lock.json, poetry.lock, Cargo.lock, etc. in place
would break integrity hashes for most ecosystems. The PR body instructs
the developer to run the right installer (npm install, poetry lock,
go mod tidy, …) — the lockfile regenerates correctly that way.
SAST + DAST: synthesised patches
When no scanner-native autofix exists, Pencheff calls an
operator-configured chat-completions backend to produce a unified diff.
Token usage and PAYG cost are recorded in fix_llm_usage per call.
Configuration
Add to .env (or set as env vars):
# Operator-supplied credentials for the patch-synthesis backend.
FIX_LLM_API_KEY=sk-...
# Optional overrides
# FIX_LLM_BASE_URL=
# FIX_LLM_MODEL=
# FIX_LLM_REQUEST_TIMEOUT=60.0
API
POST /findings/{kind}/{finding_id}/propose_fix— generate a draft proposal.kindissastordast; SCA findings ride thedastkind (Pencheff detects the SCA payload from evidence and routes internally).POST /fix-proposals/{id}/apply— open the PR.POST /fix-proposals/{id}/revert— close the PR + delete the branch.
See Findings reference for the full API.
What's tested
cd apps/api && uv run pytest tests/test_sca_patcher.py
19 unit tests cover all 9 supported manifest formats plus the "lockfile rejected" contract.
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;
};
FAQ
Common questions
- What is a re-examination in Pencheff?
- A re-examination re-runs the specific tests that produced previously reported findings against your updated application. It confirms whether remediation was successful and produces a formal closure certificate that can be submitted as evidence in compliance audits.
- Can Pencheff automatically open pull requests to fix vulnerabilities?
- Yes. For dependency vulnerabilities and some SAST findings, Pencheff can open auto-fix pull requests that bump the affected package to a patched version or apply a known secure code pattern — reviewed and merged by your team.
- How long does a re-examination take?
- A targeted re-examination that re-tests only previously open findings typically completes in 2–10 minutes, depending on the number of findings and the depth of the original test.
Related