Discovery & inventory — find every certificate, key, and secret you already have
What it is
Before you can manage credentials, you have to know they exist. Discovery is how trstctl finds the credentials already scattered across your infrastructure — certificates, SSH keys, and secrets — and the inventory is the single, tenant-scoped list it keeps them in.
Think of it like a building's master key register: someone has to walk every floor, write down every lock and every key, and keep that register current as locks change. trstctl is that walker and that register, for machines.
In the console, open Discover. The page starts with the credentials that need a decision. Choose Run scan to use an existing discovery source or add your first one.
trstctl discovers credentials five ways, and each suits a different corner of your estate: scanning the network from outside, asking a trstctl Edge runtime what a host can see from inside, pulling inventory straight from cloud provider APIs, reading SSH key files and trust config, and connecting to external secret stores. Everything they find lands in one inventory.
Why it exists
Almost every organization has more machine credentials than it can name, and the ones nobody remembers are the ones that cause outages and breaches: the certificate that expires on a forgotten load balancer at 3 a.m., the SSH key a contractor left behind, the API token hard-coded in a script five years ago. You cannot rotate, revoke, or risk-score a credential you do not know about.
Discovery turns "we think we have a few hundred certs" into a precise, queryable list — the foundation every other trstctl feature builds on. Risk scoring, drift detection, the credential graph, and lifecycle automation all read the inventory.
How it works
The inventory (F1) — the source of truth that is actually a projection
The inventory is a PostgreSQL table of certificate metadata — subject, SANs, issuer, serial, SHA-256 fingerprint, key algorithm, validity window, where it's deployed, and lifecycle status. It never stores a private key.
Nothing writes to that table directly — a core trstctl design rule
(event sourcing). When a certificate is discovered or issued, the
orchestrator appends a certificate.recorded event to the append-only, tamper-evident
log, and a projector reads that event and builds the table row. The table is a
projection — a derived view you could delete and rebuild from the log — which is
why trstctl can survive a database loss: the truth is the event log, and the inventory
is a fast index into it.
Ingestion is idempotent: the row key is (tenant_id, fingerprint), so seeing the same
certificate twice refreshes one row instead of creating a duplicate, and the ingest API
requires an Idempotency-Key so a retried request can't double-record.
Certificate parsing routes through the single isolated cryptography path, so the
inventory code itself never touches the low-level X.509 libraries directly.
GET /api/v1/certificates/health serves a tenant-wide expiry dashboard from that same
projection: total and lifecycle-status inventory counts, expired and 7/30/90-day
expiry bands, source breakdown, and the soonest-expiring certificates. Total and
source counts preserve revoked and superseded history. Expiry action counts, bands,
and the soonest-expiring list include only active certificates, so a retired
predecessor does not raise a new expiry warning. The expiry bands sum to the active
count. A short-lived certificate issued through the approved JIT workflow remains in
inventory and the separate expected_ephemeral band, even after a later discovery
observation changes its source label. Its planned expiry is excluded from renewal
alarms, the expiry worklist and alert delivery; request a fresh attested credential
through Workloads instead. The active count is the retained lifecycle status,
so it includes a JIT record until revocation or retirement even after its validity
window closes. Issued, manually imported, and discovery-fed active certificates all
count, including one found on a load balancer after a different CA issued it.
Find a certificate across the inventory
Open Certificates and use Search certificates to find a known credential without loading unrelated pages first. Search checks the authenticated tenant's subject, SANs, issuer, serial, fingerprint, lifecycle status, and deployment location. The list stays paginated; its loaded count is not a total search-result count.
The console waits briefly after typing, cancels obsolete reads, and keeps the search field available while a request runs or returns no matches. Changing the search, page size, or expiry window starts a new page sequence. The issuer, profile, team, and environment controls still filter the matching rows loaded in the console.
API clients use GET /api/v1/certificates?q=<text>&limit=20. Matching is a
case-insensitive literal substring; a complete certificate UUID also matches.
Percent signs and underscores are literal characters, not wildcards. The trimmed
query must be valid UTF-8, contain no NUL, and have at most 256 characters; invalid
queries return 400. Omit q or pass whitespace to use the ordinary inventory query.
For subsequent pages, send next_cursor as cursor with the same q and
expiring_before. The existing page-size limit is 1–100. Search runs before both
pagination and the expiry filter's result limit; it does not retrieve private keys,
issuance responses, or custody receipts. Broad substring searches can scan the
tenant's metadata; this endpoint does not establish a large-estate latency guarantee.
When expiring_before is set, the result contains active certificates only. Omit it
to search historical revoked and superseded rows as well.
Recover the certificate behind a scanned row
Some paths record what a certificate says without the certificate itself: a network
scan without agent_channel.probe_scanned_certificates, a cloud listing, an older
import. The certificate detail says so (certificate_bytes_held: false), and such a
row cannot take part in authority agreement checks, which
identify a certificate by the issuer and serial inside its bytes. Certificate
bytes → Look up in this tenant's CA records (certificates recover-der <id> with
{"method": "ca_ledger"}) finds a mint of the tenant's CAs that retained the
certificate by the row's serial; Read from host:port ({"method": "endpoint"})
reads the certificate that address serves from the control plane — for a private
address the console sends that one address as the allowed CIDR, which needs
egress:private. Either way the bytes are attached only when they hash to the row's
fingerprint and the row held none; the answer says recovered, already_held,
not_found, mismatch (the endpoint serves another certificate) or unreachable,
and the detail then shows where the bytes came from.
Network discovery (F2) — scanning from an enrolled network relay
Network discovery connects from an explicitly enrolled agent with the network
role to IP/port ranges you define, performs a normal
TLS handshake, captures the certificate each host presents, and
records its metadata. No software is installed on the targets — it sees exactly what
any client on the network would see.
The scanner runs in its own bounded lane: a bounded pool of workers (default 16, queue 256). When the queue fills, it slows the producer instead of dropping targets or exhausting the pool the API needs, so a big scan can never starve the rest of the system. The handshake and certificate parsing both go through the single isolated cryptography path, and the scanner applies the shared SSRF guard and a reserved-IP denylist before dialing expanded CIDRs, so a scan cannot be turned into a loopback, RFC1918, link-local, or cloud-metadata probe.
Operators can safely save a network, ssh, or adcs source before its relay is
enrolled. Saving records configuration; it does not scan. Plan preview and
GET /api/v1/discovery/sources/{id}/preflight return ready, the exact connection
origin, and blocked_reasons from the same server-owned readiness rule. If no
non-offboarded network-role relay has sent an authenticated heartbeat within two
configured heartbeat intervals, manual and scheduled run admission both refuse
the run before a run event or outbox row is created. A stale relay may have an
expired channel certificate; re-enroll that exact agent and wait for a fresh
heartbeat. The console explains the fix instead of leaving an impossible run
silently queued.
Readiness also checks the server's agent dispatch allowlist. A healthy relay is not
enough when discovery.run (or the source’s exact adcs.inventory /
adcs.inventory.enrollment_groups / adcs.inventory.endpoint_probe kind) is absent from
agent_channel.claimable_job_kinds: preview and saved-source preflight name that
exact configuration gap, and run admission returns a conflict before creating an
event or outbox row. The console therefore cannot report “ready” for work the
server is configured never to hand out.
Once at least one eligible relay is online, the run executes from the outbox worker. External probes are journaled first and delivered at-least-once, so a temporary relay disconnect after admission is durable and retryable instead of being done inline by the request handler. Readiness proves that an eligible relay exists; it does not claim that a particular relay is connected at every millisecond.
What a run tells you, target by target
A completed run carries one outcome per assigned target in target_results
(succeeded, failed, blocked, or rejected, with the reason for anything that
did not succeed). A partial run's error names the targets that failed or were
blocked, so Discover → Runs shows which listener is the problem instead of a bare
count. Listeners that need an application-level upgrade to TLS are handled without a
protocol hint: when a reachable target rejects a bare TLS ClientHello, the scanner
retries with the PostgreSQL SSLRequest negotiation and inventories the certificate the
server then presents.
Running the same source again refreshes its findings instead of duplicating them: a
listener observed by a later run keeps its one finding (and its triage decision) with
first_seen_at, last_seen_at, seen_count, and run_id moved to the latest run,
so the console and the open-finding counts describe the estate once.
Recover a failed or partial run without erasing the failure
Open Discover → Runs and choose Retry on a failed or partial run. The
console first rechecks the source's current relay and scope prerequisites. If the
source is ready, POST /api/v1/discovery/runs/{id}/retry creates a new queued run
with the same source, schedule binding, and dry-run mode. The original terminal run
is never changed or deleted. The replacement stores retry_of_run_id, so the table,
API, event replay, and a page reload still answer “which failure did this replace?”
The console shows newest runs first; Load older runs follows the bounded
history. API clients can request GET /api/v1/discovery/runs?order=recent&limit=50
and pass each returned next_cursor with the same order to page by creation
time and run ID. The CLI exposes the same mode with
trstctl discovery runs list --order recent --limit 50 and accepts the returned
cursor with --cursor. Omitting order keeps the original ID-order contract.
This is intentionally not available for queued, running, or succeeded runs. An
active run must finish; a successful run can be repeated from its source as a fresh
scan. Every retry requires an Idempotency-Key, so a browser, proxy, or human sending
the same request again receives the first replacement instead of queueing duplicates.
Headless operators use trstctl-cli discovery runs retry <failed-run-id> and get the
same server rule. If recovery is blocked, restore or enroll the required network-role
relay, then retry with a new idempotency key; never rewrite the old run or its outbox
row.
If a relay's signed result says it discovered more credentials than it actually included, the control plane fails that run and imports no findings from it. It retains the signed receipt, marks the exact agent job failed, and acknowledges the terminal report so one bad sealed result cannot block every later job after a restart. Discover → Runs and the API show the count mismatch and the need to retry. Use the same Retry action above after the relay is updated; the old failed run remains in the audit trail.
Continuous monitoring rollup
GET /api/v1/discovery/monitoring (CLI: trstctl discovery monitoring) is a single
read-side rollup over the tenant's discovery sources, enabled schedules, last runs,
findings, and certificate inventory — it creates no new state and joins the same
discovery.* and certificate.recorded projections other endpoints already read. Each
source row shows whether it's scheduled, the monitoring interval, the latest run
status, current execution readiness, the exact blocker and connection origin,
finding counts, and pointers to /api/v1/certificates and
/api/v1/discovery/findings. The console keeps this exact rollup under
Monitoring and exact scan evidence. The credentials needing a decision appear
first; operators can still expand the underlying CT, drift, coverage, source,
schedule, run, and repository-path proof when they need to verify or troubleshoot it.
Agent-based discovery (F3) — what each host can see from the inside
A network scan only sees what a host presents on a port. Plenty of credentials never appear on the wire: a certificate sitting in a file, in a PKCS#11 token, in the Windows certificate store, or in a Kubernetes Secret. The trstctl agent runs on the host and enumerates those local sources, then reconciles what it finds into the inventory over its mutually-authenticated (mTLS) channel. Each source is independent: if one token errors, the agent records the error and keeps going, so one broken source can't hide the rest.
The agent enrolls into the control plane with a one-time bootstrap token
(POST /enroll/bootstrap); GET /api/v1/agents then lists it, and
POST /api/v1/agents/{id}/offboard emits an agent.offboarded event, keeps the row
visible as a tombstone with actor/reason evidence, and causes the agent's mTLS
certificate to be rejected on future heartbeat, renewal, and inventory RPCs.
POST /api/v1/agents/{id}/cert-revocations takes the exact client-certificate
serial, SHA-256 fingerprint, or both. The control plane commits an independent
tenant-scoped denial before publishing either removal event, so an already-open
mTLS connection is refused on its next RPC even if the event projection fails.
The pending command retains the exact event for restart recovery; the projected
tombstone and revocation row remain rebuildable from the event log. This agent
channel checks its own denial on every RPC and renewal; it does not wait for
CRL or OCSP propagation to terminate agent access.
In Agents → Fleet status and safe actions → Revoke certificate, identify the
exact client certificate with its serial or SHA-256 fingerprint. On the agent host,
openssl x509 -in agent.crt -noout -serial -fingerprint -sha256 reads those values
without exposing the private key. The console requires at least one selector and
explains that the next agent request is refused, including a request on a connection
opened before revocation. If the certificate cannot be inspected, Offboard agent
denies the whole agent identity. Operators can use the same selector with the API or
CLI; an empty selector does not mean “current certificate.”
Enrollment is deliberately two steps in Agents → Add agent:
- Review exact enrollment plan asks
POST /api/v1/agents/enrollment-tokens/previewwhat the token would allow. The server returns the trimmed agent identity, signed certificate roles, public agent address, TLS name the new agent must verify, required permissions, and any blockers. This call creates no token, event, job, or agent connection. - Mint one-time token appears only while that exact plan is ready and unchanged. Editing the identity or either role hides the plan and requires a new review. Minting then calls the separate idempotent token route, and the token is shown once; the browser does not save it to local or session storage.
Leaving the identity blank means any agent identity in the caller's tenant may use the
token. Selecting Network relay also requires agents:relay.grant; ordinary
agents:write authority cannot silently turn a host agent into a relay. If the public
agent address or TLS name is missing, preview says exactly what must be configured and
minting stays unavailable. Headless operators use trstctl agents enroll-token-preview
with the same JSON body before trstctl agents enroll-token.
Changing an enrolled agent's roles. The roles live in signed SANs on the
agent's certificate, so they change at a renewal, never by editing a row, and no
re-enrollment is needed. An operator holding agents:relay.grant records the
complete role set with Agents → Fleet status and safe actions → Change roles,
POST /api/v1/agents/{id}/roles with {"roles": ["host", "network"], "reason": "…"},
or trstctl agents roles grant <agent-id>. The grant is an agent.roles.granted
event; agents:write alone cannot record one. The agent's next heartbeat asks it
to renew, the renewal over the agent channel signs exactly the granted roles and
records which grant it signed (agent.cert.renewed, schema 2, carries the new
certificate's roles), and the agent reconnects with the new certificate. The agent
list shows the change as pending ("Next renewal: …") until a renewal signs it and
applied, with the new certificate's serial, after; the roles column keeps showing
what the presented certificate carries. An agent built before this release ignores
the request and takes the roles at its scheduled renewal, with about a third of its
24-hour certificate left. Removing the network role works the same way; the old
certificate stays valid until it expires, so revoke it as well when the change is
urgent. An offboarded agent cannot take new roles.
A renewal never takes roles from the CSR. A CSR that asks for a role the renewal is
not signing is refused with the role named, on the agent channel and on the HTTP
renewal endpoint (/enroll/renewal), which carries the presented certificate's roles
over unchanged and does not apply grants.
The agent's own discovery sources are filesystem, pkcs11, windows-store,
k8s-secret, trust-store, and private-key — the read has to happen on the host,
since only it can see its own files, tokens, Windows store, trust stores, browser
profiles, or in-cluster Secrets. An enrolled agent sends metadata-only ReportInventory
batches over the same mTLS channel it uses for heartbeat and renewal. Its first
authenticated heartbeat must register the agent before an inventory batch; otherwise
the RPC asks it to heartbeat first. The server rejects
inline secret-looking metadata keys, caps batch size, and ingests in the bounded agent
lane so a noisy fleet cannot starve the API. GET /api/v1/agents advertises the
accepted source kinds for each enrolled endpoint, and the Agents console shows the same
capability list.
Only a completed inventory run whose recorded executing agent ID matches the
receiver-stamped finding can create graph trust-store or service-dependency edges.
Manual imports remain visible as discovery findings but cannot claim agent authority.
Generic POST /api/v1/discovery/sources declarations cannot create the reserved
agent kind or an agent: source name; enrolled reporters create these records
through the authenticated inventory channel. The CT monitoring setup also refuses
an agent: name, so a watchlist cannot reserve or replace a host's source. CT
updates return a conflict if their requested name belongs to another source kind
or to a different CT source ID; operators must choose an unclaimed name. A generic
source declaration cannot change the kind bound to an existing name either; its
preview reports that ownership conflict before the operator starts the mutation.
The secretscan:, secret-repo:, and secret-third-party: source names are also
reserved for their product workflows. Configure those scans through the Secrets
tool or its dedicated API, not by creating a generic discovery source or CT monitor
under a product-owned name. The weak-key-audit: name belongs to the inventory
weak-key audit, whose runs appear in run history (see
Weak keys).
Repository and third-party scan source names end with their stable source ID. That
keeps otherwise identical labels separate when a repository/ref contains a colon
or when two third-party artifacts share a provider, source, and event.
The selected-agent panel links to View discovery runs and View discovery findings, so collector claims can be checked against the server's actual evidence. An offline or stale agent cannot provide current evidence. First check its service and network path. If the endpoint has the wrong certificate or capability role, revoke that certificate or offboard the agent, then enroll it again with a newly reviewed one-time token. Offboarding never deletes the old row: the actor, reason, and time remain as a tombstone, so recovery cannot erase the failure that caused it.
For Linux certificate files, the shipped agent can inventory public certificate roots at
startup with --inventory-cert-roots, reporting references, fingerprints, and
certificate metadata only — never private keys or secret values.
Kubernetes TLS Secrets. A cluster is frequently the largest single population of
certificates an organization holds, and the one nobody has an inventory of. Run the agent
in-cluster with --inventory-k8s-secrets and it enumerates the kubernetes.io/tls
Secrets in its own namespace through the in-cluster service account, reporting each
Secret's certificate metadata over the same mTLS inventory path as every other source.
The exact contract: it reads tls.crt — public certificate material — and never
tls.key; no key bytes cross the agent channel. It needs list access to Secrets in that
namespace. A Secret whose certificate does not parse is skipped rather than failing the
pass, so one malformed Secret does not cost you the inventory of the rest; an
unreachable API server is an error rather than an empty result, because reporting "no
certificates" for a cluster you could not reach is worse than reporting nothing.
One agent can cover more than its own namespace. --inventory-k8s-namespaces all
lists every namespace the service account may list and inventories each one;
--inventory-k8s-namespaces payments,edge names the namespaces to read. Each finding
still carries its namespace and Secret name, and only tls.crt is read. The RBAC is
the operator's choice: deploy/kubernetes/rbac-inventory-namespaces.yaml is the
opt-in, read-only cluster grant (list namespaces, list Secrets) for all; for a named
list, bind a namespaced Role with list on Secrets in each namespace. A namespace the
service account cannot list is reported as an error naming that namespace, never as
an empty inventory, and the readable namespaces are reported first, so a missing
binding shows up as a gap in the run rather than as a clean cluster.
PKCS#11 tokens. HSMs and smart cards hold the certificates an organization cares
most about and inventories least. The default agent image is statically linked and
cannot load a PKCS#11 module, and it says so: --inventory-pkcs11-module there fails
with "built without cgo" instead of reporting an empty token, and the shipped census
the agent sends on heartbeat does not list pkcs11. The release publishes a second
agent image, ghcr.io/ctlplne/trstctl-agent-pkcs11 (built from
deploy/docker/Dockerfile.agent-pkcs11, make agent-pkcs11-image locally), with cgo,
glibc, SoftHSM and OpenSC's opensc-pkcs11.so on board; vendor modules such as
libykcs11.so or an nShield/Luna Cryptoki are mounted in. Run that image with
--inventory-pkcs11-module /usr/lib/softhsm/libsofthsm2.so (or the vendor path),
optionally --inventory-pkcs11-token <label> to pick one token and
--inventory-pkcs11-pin-file for tokens whose certificate objects are not public.
The contract matches the other collectors: a read-only session, objects of class
CKO_CERTIFICATE only, CKA_VALUE and CKA_LABEL read, never a private-key search and
never C_Sign or an export; a token or PIN that cannot be opened is an error, never an
empty inventory. Findings carry the token and object label as their location (not the
module path, which differs per host for the same physical token). The repository's
SoftHSM container test loads the real module, writes a certificate and a key pair onto
a token with pkcs11-tool, and checks that the agent reports the certificate and
nothing about the key.
CA, trust-store & private-key discovery
GET /api/v1/ca/discovery rolls up CA estates: configured public upstream CAs,
configured private upstream CAs, and imported private CA hierarchy authorities, all in
one tenant-scoped response with source id, source kind, public/private scope, status,
and pointers such as /api/v1/external-cas/{id}/issue or
/api/v1/ca/authorities/{id}/issue. It intentionally omits certificate PEM and
private-key bytes — operators use it to see which CAs are connected, then follow the
referenced route for issuance or import.
Trust-store discovery is a separate agent collector, because a trusted CA is not the
same thing as a deployed service certificate. The agent reads public trust anchors from
OS trust directories, Java cacerts/JKS files, NSS profile exports, browser profile
exports, and Windows trust-store enumerators, tagging each finding with
trust_store_kind (os, java, nss, browser, or windows) and
private_key_present=false. It also reports the certificate SHA-256 fingerprint
and SPKI SHA-256 public-key identity. The graph counts a store as trusting a managed
issuer only when one of those exact identities matches; a subject-name-only match
is a separately visible unverified candidate, not an authoritative edge. This lets
the control plane answer "what does this host trust?" without ever moving a key or
mistaking two same-name roots for the same authority. The control plane stamps the
host from the reporting agent's verified mTLS identity, so several stores or anchor
paths on one machine remain several stores across one host.
Finding exposure. GET /api/v1/discovery/findings?distrust_entry=<entry name> lists
the findings whose reader matched a distrust-list entry, and ?key_strength=weak those
whose key was rated weak (CLI trstctl-cli discovery findings list --distrust_entry "<entry name>" or
--key_strength weak). The MCP distrust_exposure and weak_key_exposure tools answer
from these and the rated certificate inventory.
Removing a distrusted root. When the control plane's distrust list names a
trust-store anchor itself (for example emSign ECC Root CA - G3, by key), the finding
can be removed from the host through trust distribution:
POST /api/v1/discovery/findings/{id}/remove-distrusted-root with the finding's
fingerprint and anchor_path as reviewed (connectors:write; CLI
trstctl-cli discovery findings remove-distrusted-root --force). The removal is queued
for the exact host agent that found the anchor, which must run with --relay-claim
and a --host-exec-profile whose allowed_roots include the anchor's directory. The
agent reads the file itself (the control plane never holds its bytes), and removes it
only when it holds exactly that one certificate and the agent's own distrust list names
it: the shipped entries plus any operator list given with --distrust-file. The
receipt ends as trust_anchor_removed, trust_anchor_absent,
trust_anchor_removal_refused (a bundle, another certificate, a root the agent's list
does not name, or a path outside the profile or behind a symlink) or
trust_anchor_removal_failed. A bundle such as /etc/ssl/certs/ca-certificates.crt is
never edited: remove that root with the host's trust tool (update-ca-certificates,
update-ca-trust or the platform equivalent). Only OS trust-store files are
removable this way; Java keystores, NSS databases, browser exports and the Windows
store need their own tools. A removed source file reaches bundles built from it the
next time the host's trust tool runs.
Private-key-material discovery answers the companion question: "what sensitive key
files exist here?" Point the agent at canary directories with
--inventory-private-key-roots. It reads each regular file locally, classifies PKCS#8,
PKCS#1 RSA, SEC1 EC, OpenSSH, and encrypted private-key containers through the isolated
cryptography boundary, wipes the file buffer after inspection, and reports only the
path, format, algorithm, file-mode metadata, and — when the key is parseable — a
fingerprint derived from the public key. Encrypted keys are located and tagged as
encrypted, but no passphrase is requested and no private bytes ever reach the control
plane.
SSH credential discovery (F42) — keys and standing access
SSH is where forgotten access hides. trstctl inventories SSH credentials two ways: a
network-side SSH handshake captures each host's host key, and the on-host agent
reads host keys, user keys, authorized_keys grants, known_hosts trust anchors, and
each configured sshd_config's trust posture (A115): the fingerprints of the CAs in its
TrustedUserCAKeys file, its RevokedKeys list (KRL version and digest, a plain key
list, or a missing file), each HostCertificate it presents (signing CA, CA signature
verified or not, key ID, principals, validity), and AuthorizedKeysFile. It reads the
global section the way sshd resolves it (first value wins, HostCertificate
accumulates, Include files in place) and counts Match blocks rather than applying
them. The posture is reported when the agent connects, so restart the agent after
changing sshd_config. The same agent path locates and classifies SSH and TLS
private-key files as metadata-only findings instead of copying key bytes into the
control plane.
On-host SSH collection is explicit, not a surprise filesystem crawl. Configure only
the paths the agent may read with --inventory-ssh-host-key-globs,
--inventory-ssh-user-key-globs, --inventory-ssh-authorized-keys,
--inventory-ssh-known-hosts, and --inventory-ssh-sshd-configs. Every flag is empty
by default. For example:
trstctl-agent ... \
--inventory-ssh-host-key-globs '/etc/ssh/ssh_host_*_key.pub' \
--inventory-ssh-authorized-keys '/home/*/.ssh/authorized_keys' \
--inventory-ssh-known-hosts '/etc/ssh/ssh_known_hosts,/home/*/.ssh/known_hosts' \
--inventory-ssh-sshd-configs /etc/ssh/sshd_config
The agent reports the result over its existing mTLS inventory RPC. The control plane
derives the tenant from the verified agent certificate, appends discovery events, and
projects the metadata into GET /api/v1/ssh/fleet. Operators can read the same view
with trstctl ssh fleet or in Machine & Workload Trust → SSH trust.
Two flags make the result actionable. StandingAccess marks an entry that grants
persistent login (an authorized_keys line). Orphaned marks a standing-access grant
whose comment field is blank — meaning nobody can say whose key it is. An orphaned
standing-access key is exactly the thing a security team wants surfaced. Only the
fingerprint is ever stored, never private key material (held in wipeable memory and
zeroed after use).
The control plane serves ssh discovery source/run/finding records and executes
non-invasive SSH host-key scans from the discovery outbox worker. Source configs accept
explicit targets (host:port) or CIDRs plus ports, run on a bounded worker lane, and
use reserved-address filtering unless the operator explicitly allows private or loopback
diagnostic targets.
Discovery source kinds, one shape
Eight more source kinds follow an identical pattern: create a source with a kind and
a config, queue a run, read back metadata-only findings. Rather than walk through
that shape eight times, here is what each one actually finds:
| Source kind | What it finds | Config essentials |
|---|---|---|
cloud_certificate (F49) |
Certificates the cloud provider already knows about: AWS ACM, Azure Key Vault, GCP Certificate Manager | providers[] (region/vault/project plus access_key_id_ref, secret_access_key_ref, or token_ref); inline credentials are rejected before storage |
nhi_cross_surface |
Non-certificate machine identities across six surfaces: IdP, cloud, SaaS, on-prem, code, and CI | observations[]: surface, system, external_id, principal, owner, credential_kind, scopes; needs at least one observation per surface |
service_account (CAP-NHI-03) |
AD/on-prem and cloud service accounts | accounts[]: surface (active_directory or cloud), provider, account_id, principal, credential_refs; needs at least one of each surface |
oauth_grant |
Third-party OAuth apps, grants, and scopes; a second pass flags abused or malicious grants | grants[]: provider, app_id, principal, resource, scopes, consent_type, third_party, owner; no client-secret or token field exists |
nhi_behavior |
Behavior anomalies (unfamiliar IP, geo, or user-agent; usage spikes; off-hours activity) against a learned per-principal baseline | events[]: principal, occurred_at, ip, geo, user_agent, action, usage_count, baseline; optional business_hours window |
credential_compromise (CAP-ITDR-02) |
Leaked, replayed, or honeytoken-flagged credentials (OWASP NHI2 signals) | signals[]: principal, credential_ref, credential_kind, provider, detector, observed_at, reason, confidence, evidence_refs |
k8s_ingress_gateway (CAP-K8S-03) |
Kubernetes Ingress/Gateway API TLS needs; with a cluster agent bound, hands each TLS block to that agent, which generates the key in the cluster and writes the Secret; without one, reports each resource not delivered |
resources[]: kind (Ingress or Gateway), namespace, name, tls_secret_name, hosts, auto_issue; optional cluster_agent_id, owner_id (required with it), verify_address |
secret_store / api_key (F35, F36) |
Secrets, API keys, tokens, and PATs by reference only — path, name, or ARN plus a masked fingerprint, never a value; also covers cloud secret-manager import from AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, and HashiCorp Vault KV for certificate material stored as secrets | path/name/ARN, masked_fingerprint, scope, expiry, rotation_age_days; secret-shaped fields (token_value, secret, password, private_key) are rejected before a source is stored |
All eight run through the discovery outbox worker, normalize their input into
metadata-only findings carrying a stable provenance string (<kind>:<key-parts>), and
append the standard discovery.* audit events. Several also stamp a capability tag
used for compliance/ITDR mapping (for example CAP-ITDR-02, CAP-ITDR-03) — see
Observability & risk for how findings feed risk scoring and
posture. Every finding also becomes a node in the credential graph
with its provenance and risk score; a related bridge ingests leaked-credential findings
from scanners (gitleaks, trufflehog) into the same graph, again excluding the secret
value structurally.
One worked example — the shape is identical for the other seven kinds; change kind
and config (the create → run → findings CLI flow is the same one shown for network
under Use it below):
{
"kind": "oauth_grant",
"name": "quarterly-oauth-consent",
"config": {
"grants": [
{
"provider": "okta",
"app_id": "0oa-payments",
"principal": "payments-bi-export",
"resource": "google-workspace",
"scopes": ["drive.readonly"],
"consent_type": "admin",
"third_party": true,
"owner": "finance-platform"
}
]
}
}
For oauth_grant specifically: a grant with provider threat signals, a dangerous or
.default scope, offline_access combined with high-privilege admin consent, an
unverified publisher, a missing owner on a privileged grant, or a suspicious redirect
URI additionally emits an oauth_grant_abuse finding tagged capability=CAP-ITDR-03.
Ordinary high-risk-but-owned grant inventory stays oauth_grant only, so trstctl does
not count inventory as detection.
Unified NHI inventory — every credential kind, one denominator
GET /api/v1/nhi/inventory (nhi:read) is a read-side projection, not a new state
store, over identities, certificate inventory rows, API-token metadata, enrolled
agents, and discovery findings, normalized into one item shape spanning certificates,
SSH keys, secrets, API keys, OAuth apps, tokens/PATs, service accounts, IAM roles,
webhooks, workload IDs, and agents. Each item preserves its provenance (identity,
certificate_inventory, access_api_token, agent_fleet, or discovery_finding) plus
public metadata — owner, status, fingerprint, risk score, source system, external
reference — and never returns secret values, private keys, or raw API tokens. The
dashboard's NHI inventory summary consumes this API directly, so the first screen
counts all eleven kinds instead of only the managed identities table.
Managed NHI decommissioning rides the same denominator: POST /api/v1/nhi/decommission
selects tenant-local managed identities by departure, vendor-term, or inactivity signal
and drives the normal event-sourced revoke/retire lifecycle transitions.
Ownership attribution — the canonical treatment
Every non-human identity needs an accountable owner, not a discovery note that says
"owner: unknown." GET /api/v1/ownership/attribution (nhi:read) resolves NHIs to an
owner two ways: managed identities resolve their stored owner_id directly, while
discovery-fed rows resolve the metadata-only owner, team, or vendor name captured at
discovery time against registered owners. Anything that doesn't resolve stays marked
orphaned rather than silently counted as accountable — that feeds tenant
governance reporting and the orphaned-owner signal risk scoring weighs. Each resolution
carries an evidence ref (such as discovery.finding:<id> or inventory:<id>) back to
the record that produced it, so an operator can trace an attribution to its source. The
same data is available via trstctl-cli owners attribution and the Owners console;
Policy & governance is the main consumer that turns this
into compliance posture.
Discovery triage
Findings are immutable evidence, but their operator triage state is mutable and
event-sourced. triage_status starts as unmanaged; the state model also includes
investigating, managed, dismissed, and (A106) remediated and
accepted_risk. POST /api/v1/discovery/findings/{id}/claim marks a finding managed, and POST /api/v1/discovery/findings/{id}/dismiss dismisses it with a reason. Both are
tenant-scoped, idempotent mutations guarded by discovery:write.
POST /api/v1/discovery/findings/{id}/resolution (A106, discovery:write)
records a resolution with a reason and an action: investigate (an
assignee and an optional future due_at; repeating it reassigns),
remediated (an evidence_ref for the fix), accept_risk (an expires_at
within one year; the leader's lifecycle sweep reopens the finding when the
acceptance expires, as system:risk-acceptance-expiry), or reopen (from
remediated or accepted risk). Each action refuses the other actions' fields.
A finding reports overdue when its investigation is past due and regressed
when it was seen again after its fix was recorded. The NHI shadow and stale
posture leave a fix that held and an unexpired acceptance out of the open
posture and count them (remediated, accepted_risk, resolved); the CT and
drift dashboard summaries count every status (triage_counts), plus overdue
and regressed findings. On the Posture page each CT-log and drift row has a
Resolve action with these choices, limited to the moves its status allows.
GET /api/v1/discovery/findings filters on the server by run_id,
source_kind (the kind of the source a finding was recorded under, for
example ct_log or drift), kind, and triage_status (unmanaged
includes findings never triaged), and pages by cursor. The CT-log and drift
dashboards (GET /api/v1/discovery/ct-monitoring and
GET /api/v1/discovery/drift-remediation) take triage_status, cursor, and
limit (1-100, default 50): their findings are one page of their own source
kind with a next_cursor, and their summary counts every finding of that
kind on the server.
A finding produced without an explicit ID gets one deterministic ID from its exact tenant, run, kind, reference, and fingerprint. This matters when the outbox retries a partly completed scan: the retry records the same observation identity instead of a new UUID that collides only after restart replay. Older histories can contain both payload IDs for one natural observation. The projection keeps those IDs as aliases, chooses the earliest immutable observation (then lexical ID for an exact-time tie) as the canonical row, and resolves later triage events through either alias. A duplicate whose source, provenance, risk, or metadata differs is not merged: replay fails with the tenant, natural key, both IDs, and differing fields rather than silently changing the evidence.
GET /api/v1/nhi/posture/shadow is the shadow posture view: it pages
through tenant-scoped discovery findings, excludes anything already claimed as managed
or dismissed, counts unmanaged and unregistered external NHIs by kind and surface, and
returns recommendation/evidence refs only — never credential values.
trstctl-cli nhi posture shadow reads the same view.
In the console
The /discovery screen is named Find unmanaged credentials because that is the
operator's job, not the database feature underneath it. Its default reading order is:
- What needs attention — unmanaged count, high-risk count, and credential types.
- Credentials to review — one plain-language row action opens the finding.
- Claim — the main next step turns a reviewed finding into managed inventory.
When creating an identity from a TLS finding with one distinct observed subject
alternative name, the console suggests that name rather than the certificate's
full distinguished name such as CN=web.example.com. This lets endpoint enrollment
reuse the requested identity for the same hostname and owner. The name remains
editable; when a certificate has multiple names, review which endpoint this
identity represents before creating it. Creation and claiming do not issue or
deploy a certificate.
Less-common rotate, revoke, decommission, remediate, and dismiss actions are grouped under More actions. Exact fingerprints, internal finding/source/run IDs, provenance, evidence references, raw kinds, the shadow-NHI projection, CT monitoring, and drift details remain available through explicit evidence disclosures. This keeps the first decision understandable while preserving the technical proof needed for an audit or investigation. See The web console.
Use it
The certificate inventory (F1) is live today. Drive it from the CLI:
# list certificates, newest first, paginated
trstctl-cli certificates list --limit 50
# list only certificates expiring within a window
trstctl-cli certificates list --expiring-before 720h
# show estate-wide expiry/source health, including imported and discovered certs
trstctl-cli certificates health
# ingest a certificate you already have (idempotent)
trstctl-cli certificates ingest -f ./server.pem
Those map to the served REST routes GET /api/v1/certificates,
GET /api/v1/certificates/health, and POST /api/v1/certificates (the latter
requires an Idempotency-Key header). For certificates ingest, -f accepts a
PEM certificate directly as shown above. To set an owner, deployment location,
or source during import, pass a JSON file with pem, owner_id,
deployment_location, and source fields instead. An import keeps the signed
certificate, recorded as an observation, so revocation health watches the CRL
and OCSP endpoints it names (including an AD CS LDAP distribution point covered
by an AD CS discovery source); importing a certificate trstctl issued updates its
provenance and never replaces the public chain trstctl retained. The CLI supplies a fresh
idempotency key for either form; repeat an exact request with the same explicit
key when recovering from an uncertain response.
Network discovery is live too:
cat > segment.json <<'JSON'
{"name":"edge","ranges":["10.0.0.0/24"],"staleness_hours":24,"excluded":false}
JSON
trstctl-cli discovery segments create -f segment.json
cat > source.json <<'JSON'
{"kind":"network","name":"edge-tls","config":{"segment":"edge","targets":["10.0.0.10:443"],"allow_rfc1918":true}}
JSON
trstctl-cli discovery sources create -f source.json
trstctl-cli discovery sources list
trstctl-cli discovery sources preflight <source-id>
cat > run.json <<'JSON'
{"source_id":"<source-id>"}
JSON
trstctl-cli discovery runs start -f run.json
trstctl-cli discovery runs get <run-id>
Copy the run ID from the start response. Read that exact run until its status is
succeeded. A queued or running response is still pending work; a failed run
requires inspecting its error and target results before retrying. Then read the
findings for that completed run:
trstctl-cli discovery findings list --run_id <run-id>
trstctl-cli nhi posture shadow
This example deliberately permits an owned RFC1918 target inside the declared
segment. Without allow_rfc1918, private IPv4 targets remain blocked. In the
console, select Allow approved RFC1918 targets for that same explicit scope.
This permission does not allow metadata, link-local, multicast, or other prohibited
addresses. A separate allow_loopback admits loopback targets (127.0.0.0/8, ::1)
for an authorized local diagnostic, such as a relay checking a listener on its own
host; it widens nothing else. Hostnames must be declared by their exact name; an IP-only segment does
not implicitly authorize a hostname that resolves into it. Preflight checks known
literal-address blockers without dialing; DNS and connectivity are checked by the
relay during the run.
The segment command maps to POST /api/v1/discovery/segments; declare that
bounded estate scope before creating any network or SSH source. The remaining
commands map to POST|GET /api/v1/discovery/sources,
GET /api/v1/discovery/sources/{id}/preflight,
POST|GET /api/v1/discovery/schedules, POST|GET /api/v1/discovery/runs,
GET /api/v1/discovery/runs/{id}, POST /api/v1/discovery/runs/{id}/retry,
GET /api/v1/discovery/findings,
POST /api/v1/discovery/findings/{id}/claim, and
POST /api/v1/discovery/findings/{id}/dismiss. The shadow posture command maps to
GET /api/v1/nhi/posture/shadow.
A discovery schedule name belongs to one schedule within a tenant. Retry a
lost create response with the same Idempotency-Key to recover that creation;
a separate create with the same name returns 409 before another event is
recorded. Use GET /api/v1/discovery/schedules to find the existing schedule.
Open Discover → Schedules to pause future scans, resume a paused schedule, or
retire it permanently after reviewing the exact schedule name. Pausing and retiring
leave prior runs and findings available. A stale scheduler decision is checked
again at run admission, so it cannot queue a new scheduled run after pause or
retirement. Retirement keeps the schedule visible as a tombstone and refuses
resume; create a new schedule with a new name if monitoring should start again.
The same controls are available through
POST /api/v1/discovery/schedules/{id}/pause, /resume, and /retire and the
trstctl-cli discovery schedules commands. GET on the schedule ID reads the
current state, including retired_at. Each mutation needs an Idempotency-Key;
retry the same key after a lost response to recover the original result.
To see enrolled agents that perform local discovery:
trstctl-cli agents list
# On an enrolled host, report public certificate files the agent can see.
trstctl-agent --enroll-url https://localhost:8443 \
--bootstrap-token-file ./trstctl-bootstrap-token \
--server localhost:19443 \
--server-name localhost \
--name edge-agent-1 \
--ca-bundle ./trstctl-ca.pem \
--inventory-cert-roots /etc/ssl,/etc/pki/tls/certs \
--inventory-os-trust-roots /etc/ssl/certs,/etc/pki/ca-trust/source/anchors \
--inventory-java-trust-stores "$JAVA_HOME/lib/security/cacerts" \
--inventory-nss-trust-roots "$HOME/.pki/nssdb/exported-roots" \
--inventory-browser-trust-roots "$HOME/.config/chromium/Default/exported-roots" \
--inventory-private-key-roots /etc/ssl/private,/etc/ssh
# Then read the projected discovery inventory and graph from the control plane.
trstctl-cli discovery findings list
trstctl-cli graph nodes
--enroll-url is the control-plane base URL, not /enroll/bootstrap; the agent
adds the endpoint. The console uses this same contract on Agents and in the setup
wizard. When the console itself is intentionally served from loopback HTTP for
local evaluation, its generated command adds
--allow-insecure-loopback-enrollment. The binary rechecks that the resolved
destination is loopback before every dial. Non-loopback HTTP remains refused.
When you find a credential you didn't expect, follow it into the credential graph to see what it can reach, or into risk scoring to see why it matters.
Pitfalls & limits
Be precise about what runs in the server today versus what ships as tested library code awaiting control-plane wiring (this matters for an honest evaluation — see also Current limitations):
| Capability | Status today |
|---|---|
| Certificate inventory (F1) | Served — REST + CLI, event-sourced, with the /api/v1/certificates/health expiry/source dashboard |
| Network discovery (F2) | Served — source/preview/preflight/schedule/run/finding APIs + CLI/UI; relay readiness is checked before admission, TLS scans execute through the outbox with reserved-IP SSRF filtering; runs report per-target outcomes, PostgreSQL listeners are negotiated (SSLRequest) before TLS, and repeat runs refresh findings instead of duplicating them |
| Agent-based discovery (F3) | Served — enrollment (/enroll/bootstrap, /api/v1/agents) and the mTLS ReportInventory path record source/run/finding rows and graph nodes |
| SSH discovery (F42) | Served — source/schedule/run/finding APIs + CLI/UI; host-key scans execute through the outbox, and on-host SSH/private-key inventory reports through the agent mTLS path |
| Agentless cloud discovery (F49) | Served — AWS ACM, Azure Key Vault, and GCP Certificate Manager provider execution runs from the outbox with credential references; the shipped demo lab proves the AWS provider against its LocalStack emulator, and a run names each provider's outcome |
| Secret-store & API-key discovery (F35, F36) | Served for cloud secret managers — AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, and HashiCorp Vault KV imports; metadata-only references and fingerprints, never values; the demo lab seeds a tagged certificate secret in LocalStack so the AWS collector is proven locally, and a partial run names the provider that failed |
| Cross-surface NHI discovery | Served — six-surface (IdP/cloud/SaaS/on-prem/code/CI) metadata-only findings |
| Unified NHI inventory | Served — /api/v1/nhi/inventory normalizes identities, certificates, tokens, agents, and findings across eleven kinds |
| Service-account discovery (CAP-NHI-03) | Served — AD/on-prem and cloud service-account findings |
| OAuth grant discovery + abuse detection (CAP-ITDR-03) | Served — consent metadata findings, plus oauth_grant_abuse detections |
| NHI behavior analytics | Served — baseline and anomaly findings for IP, geo, user-agent, usage-spike, and off-hours signals |
| Compromised-credential detection (CAP-ITDR-02) | Served — honeytoken/leak/replay signals normalized to findings tagged to OWASP NHI2 |
| Shadow/unmanaged NHI posture | Served — /api/v1/nhi/posture/shadow, trstctl-cli nhi posture shadow, and the Discovery console |
| Kubernetes Ingress/Gateway TLS auto-issuance (CAP-K8S-03) | Served through an in-cluster agent — bind a k8s_ingress_gateway source to the host-role agent running in the cluster (cluster_agent_id, owner_id, optional verify_address). Each run records every Ingress/Gateway TLS resource as a finding, keeps one k8s_secret destination and one certificate identity per TLS block, and queues the first issuance to that agent: it generates the key inside the cluster, sends only a CSR, writes the TLS Secret, reads it back and returns a signed receipt, so the control plane never generates the key. A resource reports delivery_queued until the agent's signed deploy receipt is recorded and delivered after; when the agent's latest attempt failed it reports not_delivered in the receipt's words while the job is retried. Renewals go to the same agent, and a TLS block with several hosts gets one certificate naming them all. A source without an agent, or with one that cannot deliver, reports each resource not_delivered with the reason and makes nothing (Key custody, Deliver into a Kubernetes Secret) |
NHI policy compliance lives in Policy & governance; CT-log monitoring and drift detection live in Observability & risk — all three read the same discovery projections, but they aren't this page's features.
Other gotchas: a network scan only sees what a host presents on a port at scan time — pair it with agent-based discovery for the full picture. Cloud discovery needs read-only credentials with list/get permission on the relevant service. Secret-store discovery records references, so a finding tells you a secret exists and where, not what it is.
Reference
- CLI groups: the discovery-relevant commands live under
discovery(sources,schedules,runs,findings,ct-monitoring,drift-remediation,monitoring), pluscertificates,agents, andnhi. trstctl-cli has many more command groups outside discovery;trstctl-cli <group> --helplists the current subcommands for any of them. - Served routes:
GET|POST /api/v1/certificates,GET /api/v1/certificates/{id},GET|POST /api/v1/discovery/sources,GET /api/v1/discovery/sources/{id}/preflight,GET|POST /api/v1/discovery/schedules,GET /api/v1/discovery/schedules/{id},POST /api/v1/discovery/schedules/{id}/pause|resume|retire,GET|POST /api/v1/discovery/runs,GET /api/v1/discovery/runs/{id},POST /api/v1/discovery/runs/{id}/retry,GET /api/v1/discovery/findings,POST /api/v1/discovery/findings/{id}/claim,POST /api/v1/discovery/findings/{id}/dismiss,GET /api/v1/agents,GET /api/v1/nhi/posture/shadow,GET /api/v1/ownership/attribution,POST /api/v1/agents/enrollment-tokens,POST /api/v1/agents/{id}/offboard,GET /api/v1/graph,POST /enroll/bootstrap. - Agent channel:
AgentService.ReportInventoryover the mTLS agent gRPC listener whenagent_channel.enabledis true. - Config:
TRSTCTL_LIFECYCLE_RENEW_BEFORE(default720h) sets the expiry window the inventory and lifecycle treat as "renew soon". - Served discovery source kinds:
network,ssh_trust(explicit agentless SSH trust through a pinned network relay; setup and limits),cloud_certificate,cloud_secret,nhi_cross_surface,oauth_grant,service_account,nhi_behavior,credential_compromise,k8s_ingress_gateway,ct_log,drift,manual, plus metadata-onlyssh,secret_store,api_key, andagent. - Discovery source kinds (agent):
filesystem,pkcs11,windows-store,k8s-secret,trust-store,private-key. - Agent inventory flags:
--inventory-cert-roots,--inventory-os-trust-roots,--inventory-java-trust-stores,--inventory-java-trust-store-password,--inventory-nss-trust-roots,--inventory-browser-trust-roots,--inventory-private-key-roots,--inventory-ssh-host-key-globs,--inventory-ssh-user-key-globs,--inventory-ssh-authorized-keys,--inventory-ssh-known-hosts,--inventory-ssh-sshd-configs. - Audit events:
certificate.recorded,discovery.source.upserted,discovery.schedule.upserted,discovery.schedule.paused,discovery.schedule.resumed,discovery.schedule.retired,discovery.run.queued,discovery.run.started,discovery.finding.recorded,discovery.run.completed,secretscan.finding.
See also
Policy & governance (ownership attribution's main consumer) · Observability & risk (scoring what you discover) · Graph, query & AI (what a credential can reach) · Secrets · Current limitations · glossary: certificate, fingerprint, bulkhead, event sourcing
Covers: F1, F2, F3, F42, F49, F35, F36
Nested AD CS enrollment links
Discovery’s AD CS source wizard can opt into read-only nested group reads. Select an upgraded network relay, enable Read nested enrollment groups, and provide the explicit membership domain naming context. Preview checks both the server’s job allowlist and that relay’s fresh enabled executor census. Posture displays the observed users/computers, canonical SIDs, security-group paths, trustee, observation time and completeness. An incomplete or refused read gives a recovery action and no verified member list. This does not evaluate a full Windows token or authorize issuance. The default direct-ACL reader stays available.
The nested enrollment runbook includes exact source JSON, CLI operations, bounds, matching-rule verification and the Windows forest proof boundary. The focused served journey exercises real Samba AD, vaulted single-attempt credentials, mTLS signed results, immutable projections, CLI readback, tenant isolation and replay. The final image/browser/restart qualification follows the release replay.
AD CS enrollment endpoint candidates
The AD CS source wizard can opt into fixed-path probes derived from directory hostnames. Enable Probe enrollment paths from directory hostnames, select an upgraded network relay and enter explicit DNS suffixes. Preview checks the relay capability without contacting LDAP. Candidate counts become known during the queued directory read; private destinations need an additional approved CIDR grant.
Posture shows configured, LDAP-derived and deduplicated combined origins, the latest page's total and offsets, per-service coverage and unattempted outcomes. The next run advances the retained cursor. Empty endpoints on a different page mean unassessed, and unreachable endpoints remain unverified. Findings and compliance evidence carry those limits; HTTP reachability does not prove IIS Extended Protection or successful SCEP enrollment. The service-view ceiling is visible when reached. The endpoint probing runbook explains exact configuration, bounds and recovery. Windows IIS/NDES qualification requires the Windows environment; a private Samba/HTTP/TLS fixture is separate proof.