Skip to content

Post-Processors

Post-processors compute composite edges and risk scores from raw graph state after the batch write phase. They run in server/internal/analysis/processors/ and are orchestrated by analysis.RunPostProcessors().

All composite edges carry: scan_id, last_seen, confidence (0.0-1.0), risk_weight, is_composite=true, source_collector. source_collector is detector provenance, not lifecycle ownership.

Dependency DAG

auth_strength ──┬── can_reach ─────┬── cross_service_credential_chain
                 ├── cross_protocol      └── can_exfiltrate
                 └── confused_deputy
has_access_to ───┬── can_reach
                 └── cross_protocol
can_execute
shadows
poisoned_description
poisoned_instructions
can_impersonate
                            ALL ───── risk_score

Execution Order

# Processor Dependencies
1 auth_strength none (pre-pass)
2 has_access_to none
3 can_execute none
4 shadows (+ POISONS_CONTEXT) none
5 poisoned_description none
6 poisoned_instructions none
7 taints none (reads INGESTS_UNTRUSTED + schema_keys)
8 can_reach auth_strength, has_access_to
9 cross_service_credential_chain has_access_to, can_reach
10 ifc_violation has_access_to (reads INGESTS_UNTRUSTED)
11 can_exfiltrate can_reach
12 can_impersonate none
13 confused_deputy auth_strength, can_reach
14 cross_protocol auth_strength, has_access_to
15 risk_score all of 1-14

Scope compatibility policy

The ingest v1 model permits evidence from many collection points in one graph. Any processor that compares otherwise unrelated observations must use the shared exact predicate below; a prose judgment at each call site is not sufficient.

  • Artifact-local evidence composes only with the same weak artifact.
  • Two network-context observations compose only when both the collection point and network context match.
  • Collection-point evidence composes with collection-point or network-context evidence only when the collection point matches.
  • No other cross-scope join is permitted.
Processor family Scope policy
shadows / POISONS_CONTEXT, taints, credential-derived can_reach, can_exfiltrate, can_impersonate, confused_deputy Shared compatible-scope predicate required
auth_strength, has_access_to, can_execute, poisoned self-edges, ifc_violation, cross_protocol, risk_score Bound to one node, server, resource, host, or already-scoped graph path
cross_service_credential_chain The sole approved global join: observed credential material with merge_key=value_hash and identical value_hash

Positive and negative tests enforce this matrix. Endpoint-derived service observations are network-context scoped; that separates vantages but does not claim an immutable service identity across endpoint replacement or SaaS tenants.

Processor Interface

type PostProcessor interface {
    Name() string
    Dependencies() []string
    Process(ctx context.Context, db graph.GraphDB, scanID string) (ProcessingStats, error)
}

ProcessingStats returns: ProcessorName, EdgesCreated, NodesUpdated, Duration, Error.

The Execution Order table above is the canonical sequence. The numbered section headings below predate later additions; new processors (auth_strength, taints, ifc_violation, confused_deputy) and the POISONS_CONTEXT pass are documented under their own headings.


auth_strength (pre-pass)

Computes: a paired effective authentication tuple and numeric auth_strength on MCPServer / A2AAgent, plus derived effective assessment properties on incoming TRUSTS_SERVER edges.

Configured auth_method / auth_assurance / auth_evidence and protocol runtime observed_auth_* remain immutable provenance. For MCP, a reachable HTTP, internally valid observed tuple with a method other than unknown becomes effective_auth_method, effective_auth_assurance, and effective_auth_evidence, with effective_auth_source=observed. An unknown, unavailable, partial, or contradictory observation falls back atomically to the configured tuple (effective_auth_source=configured); fields are never coalesced independently. A2A runtime evidence wins only for the exact bounded read-only observation auth_probe_method=get_task_nonexistent, auth_probe_status=anonymous_protocol_access, and observed none/unauthenticated/anonymous_probe_succeeded, with bounded detail task_not_found_v1 or task_not_found_v0_3. Card retrieval, protected or inconclusive probe diagnostics, missing metadata, and every other A2A observed tuple retain configured-derived behavior.

Both raw provenance channels are optional and atomic. A valid known observed tuple can therefore materialize effective authentication without a configured tuple. If neither a valid known observed tuple nor a complete valid configured tuple remains, the pass sets every derived effective_auth_* property and auth_strength to null so retired owners cannot leave stale posture behind.

The evidence-aware policy is none=100/unauthenticated only for an effective_auth_source=observed tuple carrying anonymous_probe_succeeded, basic=85/weak, apiKey=70/weak, bearer=50/moderate, oauth=25/strong, oidc=20/strong, and mtls=10/strong. Unknown/custom and configured/unconfirmed-none methods receive effective_auth_assurance=unknown and a null numeric property.

For each TRUSTS_SERVER, the pass writes effective_risk_weight, effective_auth_assessment_complete, and effective_auth_source. Exact reachable observed none/unauthenticated/anonymous_probe_succeeded sets every incoming trust edge to 0.1, complete, source observed: anonymous runtime access bypasses every configured client credential. Every other runtime posture preserves each relationship's own configured risk_weight and auth_assessment_complete, source configured; one observed authenticated access path cannot rewrite unrelated configured paths. The raw fields are not overwritten.


1. has_access_to

Computes: MCPTool -[HAS_ACCESS_TO]-> MCPResource

Links tools to resources on the same server when capability or description indicates access.

Three Cypher passes: - Capability-DB: Tool has database_access capability AND resource URI scheme is postgres/mysql/mongodb/redis. Confidence: 0.7. - Capability-File: Tool has file_read or file_write AND resource URI scheme is file. Confidence: 0.7. - Description match: Tool description contains the complete resource name, or at least two distinct meaningful (four-or-more-character) tokens from a hyphen/underscore-normalized resource name. Requiring two tokens avoids inferring access from generic one-word overlap. Confidence: 0.9.

All edges: risk_weight=0.2, match_type recorded for evidence. Confidence, match type, weight, collector, and scan metadata are refreshed on MERGE so a prior inference cannot retain stale evidence.

2. can_execute

Computes: MCPTool -[CAN_EXECUTE]-> Host

Links tools to their server's host when the tool has shell_access or code_execution in capability_surface. The YAML classifiers require execution-specific terms; database-only names such as execute_query do not produce either capability.

Pattern:

MATCH (s:MCPServer)-[:PROVIDES_TOOL]->(t:MCPTool), (s)-[:RUNS_ON]->(h:Host)
WHERE ANY(cap IN t.capability_surface WHERE cap IN ['shell_access', 'code_execution'])
MERGE (t)-[e:CAN_EXECUTE]->(h)

Confidence: 0.8, risk_weight: 0.1. Both values refresh on MERGE. This is a metadata-derived execution candidate, not observed command execution.

3. shadows

Computes: MCPTool -[SHADOWS]-> MCPTool (cross-server)

Detects tool shadowing: a tool on one server names another server's tool in its description (toLower(t1.description) CONTAINS toLower(t2.name)), which lets it impersonate or override that tool.

Pattern requires s1 <> s2 and t1 <> t2. The match is intentionally target-specific — t1's description must reference t2 by name. It does not branch on the has_cross_references node flag: that flag is target-blind (true if t1 references any sibling tool, see modules/mcp/signals.go), so OR-ing it in made one flagged tool shadow every tool on every other server (a cartesian fan-out of false positives). has_cross_references still feeds tool risk scoring as a node property (server/internal/analysis/riskscore/tool.go).

Confidence scales with injection patterns: 0.9 when has_injection_patterns=true, 0.6 otherwise. Risk weight: 0.4.

POISONS_CONTEXT (second pass): the shadows processor runs a second Cypher pass that emits MCPTool -[POISONS_CONTEXT]-> MCPTool when the source has has_injection_patterns=true and the sink carries a high-blast capability (shell_access, code_execution, credential_access, email_send), scoped to a single agent: both tools must be co-resident under one AgentInstance via (:AgentInstance)-[:TRUSTS_SERVER]->(:MCPServer)-[:PROVIDES_TOOL]->(:MCPTool). This deliberately widens the narrow SHADOWS guard (no description-naming requirement) to model context poisoning while the agent scope prevents a cross-tenant cross product. Fan-out is truncated to 20 sinks per (agent, source) pair to prevent a cartesian blow-up — the query groups on WITH a, src so the cap is genuinely per-agent-per-source, not a single global bucket, and keeps the first 20 sinks by objectid (collect(DISTINCT snk)[..20], deterministic via ORDER BY) rather than dropping the over-cap group entirely. Truncation, not suppression, is deliberate: silently emitting zero edges for a source with >20 sinks would blind the detector in its highest-risk case and let an attacker evade it by registering a 21st sink. The cap is regression-gated by a Go integration test (poisons_context_perf_integration_test.go) that runs in the test-integration CI job; scripts/perf-check.sh remains the operator-facing runtime heuristic, enforcing a ≤200 poisoned-pair-per-agent ceiling (10 source tools × 20 sinks). Confidence: 0.6, risk_weight: 0.4, source_collector='mcp'.

4. poisoned_description

Computes: MCPTool -[POISONED_DESCRIPTION]-> MCPTool (self-loop)

Flags tools whose descriptions contain injection patterns (detected by the rules engine during collection and stored as has_injection_patterns=true).

Confidence: 1.0, risk_weight: 0.8. Self-loop edge -- the finding is about the tool itself.

5. poisoned_instructions

Computes: InstructionFile -[POISONED_INSTRUCTIONS]-> InstructionFile (self-loop)

Flags instruction files marked is_suspicious=true by the Config Collector (imperative overrides, exfiltration commands, hidden Unicode).

Confidence: 1.0, risk_weight: 0.7, source_collector: config.

taints

Computes: MCPTool -[TAINTS]-> MCPTool (cross-server)

Emits a TAINTS edge when a tool that ingests untrusted input (it has an INGESTS_UNTRUSTED edge, or source_trust='private') shares ≥2 input-schema keys with a tool on another server. The schema overlap is computed in pure Cypher against the schema_keys node property (emitted collector-side — no APOC dependency). The ≥2 threshold avoids matching every {type, name} pair. No processor dependencies (reads raw INGESTS_UNTRUSTED edges + node properties), but registered before can_reach so its edges can influence the reachability walk. Confidence: 0.7, risk_weight: 0.3, source_collector='mcp'.

6. can_reach

Computes: AgentInstance -[CAN_REACH]-> MCPResource

The inferred transitive-access summary edge. Two passes:

Direct path (3 hops):

AgentInstance -[TRUSTS_SERVER]-> MCPServer -[PROVIDES_TOOL]-> MCPTool -[HAS_ACCESS_TO]-> MCPResource
Confidence scales inversely with the trust edge's effective_risk_weight (confirmed observed no-auth trust = 1.0, static-key = 0.8, OAuth = 0.5).

Credential chain (6 hops):

AgentInstance -> MCPServer(s1) -> MCPTool(file_read|credential_access)
MCPServer(s2) -[AUTHENTICATES_WITH]-> Identity -[USES_CREDENTIAL]-> Credential
MCPServer(s2) -> MCPTool -> MCPResource
Requires effective_auth_assurance to be explicitly unauthenticated or weak for s1. Unauthenticated is eligible only when effective_auth_source=observed; configured weak authentication remains eligible. The paired effective tuple applies valid live MCP initialize evidence as described above; missing/unknown auth evidence does not match. The credential may come from any supported config location; it must have non-empty observed, exposed material with merge_key=value_hash and identity_basis=value_hash. Confidence: 0.6.

When multiple credential paths connect the same (agent, resource), the processor orders candidates by the complete stable object-ID tuple (a, s1, t1, s2, i, c, t2, r) and selects the first before MERGE. Neo4j relationship IDs are retained only as post-selection evidence and never participate in winner selection, so witness topology cannot flip with relationship recreation order. Evidence nodes follow that tuple, and evidence relationships follow (TRUSTS_SERVER, PROVIDES_TOOL, AUTHENTICATES_WITH, USES_CREDENTIAL, PROVIDES_TOOL, HAS_ACCESS_TO).

Verified-reach upgrade (3rd pass): after building the CAN_REACH edges, can_reach re-correlates any persisted per-agent raw CREDENTIAL_REACH_VERIFIED edge (emitted by the campaign runner's cred-reach scenario) against the freshly rebuilt edges. On a full match it upgrades the CAN_REACH edge in placereach_evidence_state='verified', confidence=1.0, plus the verified scenario/run/oracle/staged-observation/cleanup metadata. It creates no new edge and no second finding, so risk is never double-counted; findings.go reads reach_evidence_state and raises the finding's evidence state to verified.

The ingest pipeline admits that raw campaign edge only after generic validation and campaign-specific envelope/current-topology prevalidation, all before normalization, BeginScan, canonical writes, and reconciliation. Rejected positive or negative submissions leave canonical edges and coverage untouched; diagnostics are limited to a sanitized Postgres rejection audit.

Before the Cypher upgrade, Go reconstructs witness V1 from each raw edge and recomputes its unkeyed fingerprint; invalid evidence remains stored for diagnosis but is excluded from the validated relationship-ID allowlist. Re-correlation then requires a.objectid = witness.agent_id, exact live credential hash/merge key, the exact providing server/resource identity, the fixed scenario/oracle/stage/outcome contract, and equality between the complete ordered witness topology and the current CAN_REACH.evidence_node_ids; every normalized kind must still label its corresponding node. Only a positive publication revision is required—revision equality is deliberately not a gate. Because evidence identity is AgentInstance -> MCPResource and coverage also includes the agent, two agents sharing one credential/resource cannot overwrite or over-upgrade each other.

7. cross_service_credential_chain

Computes: AgentInstance -[CAN_REACH]-> Credential (upstream provider credential material or references)

Joins Config Collector and LiteLLM Looter emissions on Credential.value_hash:

AgentInstance -> MCPServer -[AUTHENTICATES_WITH]-> Identity
    -[USES_CREDENTIAL]-> Credential(c1)
    where c1.value_hash matches...
LiteLLMGateway -[EXPOSES_CREDENTIAL]-> Credential(c1master)
LiteLLMGateway -[EXPOSES_CREDENTIAL]-> Credential(c2, type IN [apiKey, virtual_key])

Both c1 and c1master must have a non-empty hash and explicit merge_key=value_hash, identity_basis=value_hash, material_status=observed, and exposure_status=exposed. Optional HAS_ENV_VAR location evidence is not part of the correlation path.

Evidence nodes are ordered (agent, server, identity, c1, c1master, gateway, c2). Raw relationship evidence is ordered (TRUSTS_SERVER, AUTHENTICATES_WITH, USES_CREDENTIAL, EXPOSES_CREDENTIAL(master), EXPOSES_CREDENTIAL(upstream)); the c1/c1master value-hash correlation is recorded separately as synthetic evidence.

Emits: (AgentInstance)-[:CAN_REACH]->(c2) with evidence including merge_value_hash, via_gateway, upstream_provider. The resulting finding variant is credential_chain_observed_material only when c2 explicitly carries observed, exposed, non-identity material; masked/hashed targets are credential_chain_reference. Confidence: 0.95, hops metadata: 6. via_gateway uses the gateway name when present, then its endpoint, and finally its immutable object ID. The final fallback is required because a standalone LiteLLM loot collection references the gateway without claiming fingerprint-owned display properties.

The same single query also computes credential blast radius at the canonical value_hash grain: count(DISTINCT agent) across every configured credential node representing that secret. The global count is written to every matching c1 and c1master; two config nodes with the same secret therefore cannot race to leave a smaller per-node count on the shared master. Candidate paths are then ordered by their stable seven-node object-ID tuple and reduced to one winner per (agent, upstream credential) before MERGE, so repeated runs and multiple config paths cannot overwrite evidence nondeterministically. blast_radius then amplifies the server credential-handling risk term (see risk-scoring.md).

The value_hash is the cross-collector merge primitive -- same secret value regardless of how each collector derives its objectid.

ifc_violation

Computes: MCPTool -[IFC_VIOLATION]-> MCPTool

Emits an information-flow-control violation edge when an untrusted-input tool (INGESTS_UNTRUSTED -> MCPResource) shares a resource within 3 HAS_ACCESS_TO hops with a sink tool carrying a high-impact capability (credential_access, file_write, email_send). The 1..3 hop cap is the false-positive / performance guard. Depends on has_access_to. Confidence: 0.6, risk_weight: 0.3, source_collector='mcp'.

IFC_VIOLATION carries source_collector='mcp' as detector provenance. Its lifecycle is nevertheless part of the global composite epoch: any promoted complete domain causes all detectors, including IFC, to rebuild from the retained current raw projection.

8. can_exfiltrate

Computes: AgentInstance -[CAN_EXFILTRATE_VIA]-> MCPTool

Requires both conditions: 1. Agent CAN_REACH a resource with sensitivity critical or high 2. Agent trusts a server with a tool having an outbound capability: email_send, network_outbound, file_write, auto_fetch_render, or allowlisted_proxy

Pattern correlates inferred data access with a matched output-channel capability. It does not observe data transfer or prove runtime invocability. The auto_fetch_render / allowlisted_proxy classes broaden the set of candidate channels (see detection-rules.md for the auto_fetch_render host-side caveat). Confidence: 0.8.

9. can_impersonate

Computes: A2AAgent -[CAN_IMPERSONATE]-> A2AAgent (bidirectional)

Uses TF-IDF cosine similarity on skill descriptions. For each pair of A2A agents (from different providers): 1. Loads all agents from Neo4j 2. Builds per-agent document from concatenated skill descriptions 3. Computes TF-IDF vectors via similarity.NewCorpus 4. Emits bidirectional CAN_IMPERSONATE edges where cosine similarity > 0.8

Writes edges via db.WriteEdges() (batch) rather than Cypher MERGE. Risk weight: 0.6.

Agents from the same provider are excluded (impersonation assumes cross-provider).

10. cross_protocol

Computes: A2AAgent -[CAN_REACH]-> MCPResource

The cross-protocol shared-host correlation that single-protocol scanners cannot express:

MATCH (ext:A2AAgent)-[:DELEGATES_TO*1..3]->(int:A2AAgent)
MATCH (int)-[:RUNS_ON]->(h:Host)<-[:RUNS_ON]-(s:MCPServer)
MATCH (a:AgentInstance)-[:TRUSTS_SERVER]->(s)-[:PROVIDES_TOOL]->(t:MCPTool)-[:HAS_ACCESS_TO]->(r:MCPResource)
WHERE ext.effective_auth_assurance = 'unauthenticated'
  AND ext.effective_auth_source = 'observed'
  AND ext.effective_auth_evidence = 'anonymous_probe_succeeded'

Requires exact, observed unauthenticated evidence for the external A2A agent; configured no-auth claims and missing auth are not runtime proof and do not match. The pivot is host co-location: an A2A agent delegates to another agent recorded on the same host as an MCP server. The edge carries cross_protocol=true and confidence 0.5. Finding and pre-built query output label it a shared_host hypothesis, not proven A2A-to-MCP invocation.

confused_deputy

Computes: A2AAgent -[CONFUSED_DEPUTY]-> A2AAgent

Flags a confused-deputy escalation when an unauthenticated or weak A2A agent DELEGATES_TO a strong one. Unauthenticated callers require effective_auth_source=observed; configured weak callers remain eligible. Unknown/custom methods are excluded. The low-trust caller effectively borrows the callee's privileges. Depends on the auth_strength pre-pass and can_reach (ordering). source_collector='a2a'; confidence 0.8, risk weight 0.3.

11. risk_score

Computes: risk_score, risk_score_min, risk_score_max, risk_assessment_complete, and risk_unknown_factors on AgentInstance, A2AAgent, MCPServer, and MCPTool nodes.

Depends on ALL prior processors (uses their edges for scoring). It reads every page of each scored kind under one graph revision before writing any scores; an incomplete page, revision/total change, or count mismatch aborts the processor rather than publishing scores for a partial node set. Per-node calculation and update failures are aggregated while best-effort scoring continues across every node kind; the joined error fails the processor and withholds publication. Per-kind scoring functions live in analysis/riskscore/.

Agent score (0-100): - 0.30 x credential exposure - 0.25 x blast radius (reachable resources) - 0.20 x auth posture - 0.15 x tool surface - 0.10 x poisoning exposure

A2A agent score (0-100): - 0.30 x auth strength - 0.30 x cross-protocol blast radius - 0.25 x delegation surface - 0.15 x impersonation exposure

Server score (0-100): - 0.35 x auth strength - 0.25 x tool risk - 0.20 x exposure - 0.20 x credential handling

Tool score (0-100): - 0.30 x capability class - 0.25 x poisoning indicators - 0.25 x access sensitivity - 0.20 x input validation signals


Composite epoch replacement

When at least one explicit complete raw domain is promoted, beginCompositeEpoch() atomically removes every is_composite=true edge and clears processor-owned Credential.blast_radius. All registered processors then rebuild the global derived graph from the retained current raw projection in dependency order.

CALL {
  MATCH ()-[r]->()
  WHERE r.is_composite = true
  WITH r
  DELETE r
  RETURN count(r) AS deleted
}
CALL {
  MATCH (c:Credential)
  WHERE c.blast_radius IS NOT NULL
  REMOVE c.blast_radius
  RETURN count(c) AS cleared
}
RETURN deleted

The epoch is global because a narrow MCP, config, or A2A replacement can invalidate a composite whose source_collector names another domain. Examples include cross-protocol host pivots, transitive reachability, exfiltration, and cross-service credential chains. Keeping the prior epoch available while recomputing would also let downstream processors consume stale HAS_ACCESS_TO or CAN_REACH inputs and incorrectly refresh them into the new epoch.

When a partial scan adds or updates a confirmed raw fact, that mutation also starts a new derived epoch so analysis reflects the retained graph plus the new positive evidence. Omitted partial-scan facts are retained rather than treated as absent. When there are neither accepted raw mutations nor promoted complete domains, derived processing is skipped and the current composite epoch is left untouched. If epoch retirement or any processor fails, analysis is incomplete and publication is withheld. The mutable projection may contain an incomplete new epoch, but the previously published PostgreSQL revision and its frozen evidence remain unchanged.

This invariant requires every composite edge to be produced by the registered processor set and every processor to rebuild solely from retained raw facts or outputs of earlier processors in the declared order. New processors must obey that contract; manually persisted is_composite=true relationships are not preserved across complete replacements.

INGESTS_UNTRUSTED raw-edge accumulation

INGESTS_UNTRUSTED is a raw edge (is_composite=false), so composite cleanup never touches it. It participates in the same observation-owner reconciliation as every other new raw edge: a complete MCP domain removes the prior MCP token, while partial/unknown coverage retains it.


Findings Snapshot Stage (pipeline)

After post-processing and graph-total capture, the ingest pipeline materializes a candidate findings snapshot. PostgreSQL finalization deletes and replaces all rows for that scan in one transaction, including an empty retry.

Every finding-producing processor records the exact witness node object IDs and Neo4j relationship IDs selected by its detector on the composite edge. During the same ingest, QueryFindings dereferences those IDs once and atomic finalization stores the resulting node/edge snapshots as JSONB with the finding row. Finding detail serves that frozen witness graph; it does not re-run detector-like LIMIT 1 queries against the mutable projection.

When all required graph, analysis, and snapshot stages succeed, the same transaction inserts an immutable posture_publications revision, persists its export, advances posture_state, and records any published collection limitations. Collection coverage may remain limited: confirmed findings are then current, while missing findings are not an all-clear and comparison is disabled. Otherwise the snapshot can remain historical but the published pointer does not move. A degraded retry using the currently published scan ID preserves the prior published rows and export until a new safe revision commits.

Integration-test isolation

Post-processors operate on the whole graph, not a scan-scoped subgraph: risk_score lists every node of a kind, shadows/taints/can_reach MATCH across all servers, etc. That is correct for production (one scan, one graph) but it means two integration-test binaries cannot safely share one Neo4j concurrently — a DETACH DELETE in one binary can vanish a node mid-traversal in another, surfacing as Neo.ClientError.Statement.EntityNotFound or phantom zero-count assertions. Because go test ./... runs package test binaries in parallel, every DB-touching package (analysis, analysis/processors, graph) holds an exclusive advisory file lock (server/internal/dbtest) for its run via TestMain. The lock is a no-op when AGENTHOUND_NEO4J_URI is unset, so unit-only (-short) runs keep full parallelism. New packages that run post-processors against a live DB must add the same TestMain guard.