Asset & attack graphs
Policies tell Norviq what to enforce; the graphs tell you what to enforce next. As real tool calls flow through the engine, Norviq builds a live map of what each agent can actually reach (the Asset Graph), derives the kill chains an attacker could ride through that reach (the Attack Graph), lets you Simulate a path against your current policy and Defend the gap with a generated draft — and continuously classifies every tool it sees, with a human-in-the-loop promotion flow for the ones it can’t.
flowchart LR
T["Observed<br/>tool calls"] --> A["Asset graph<br/>(agents → tools → data)"]
A --> K["Attack graph<br/>(kill chains, worst-first)"]
K --> S["Simulate<br/>(preview vs live policy)"]
K --> D["Defend / Define intent<br/>(dry-run draft)"]
D --> P["Policy Catalog<br/>review → apply"]
P --> E["Enforced"]
E -->|"paths re-render as blocked"| K
One principle runs through every surface on these pages: evidence, not conjecture. The graphs are built from traffic Norviq actually evaluated; a hop shows the real allow/block counts behind it, an unproven path says so, and every mutating action on these screens is a preview or dry-run draft — nothing enforces until you take a draft through the Policy Catalog’s gated apply flow.
The asset graph
Section titled “The asset graph”Console → Asset Graph. A directed graph, per namespace, of your agents’ real reach:
| Node | What it is |
|---|---|
| Agent | one node per workload identity (keyed by SPIFFE ID), with its agent class, trust-score ring, and cluster |
| Tool | every tool that identity has been observed calling, with a risk level (low → critical) |
| Data | the data stores those tools reach (postgresql/users, es://kb, …) |
Agent→tool edges come from observed, evaluated calls — each carries its call count and real decision history (allow / block / would-block when the namespace is in Monitor mode). Tool→data edges come from Norviq’s mapping of known tools to the stores they reach. If one SPIFFE identity runs multiple agent classes, each class renders as its own sub-node so distinct agents never silently collapse into one.
For example, an report-gen agent in the analytics namespace that has been calling execute_sql
and search_kb produces a graph like this — a stadium is an agent, a rectangle a tool
(with its risk), a cylinder a data store; solid edges are observed calls (with their decision),
dashed edges are the tool→data reach (with the operation):
flowchart LR
RG(["report-gen<br/>agent · trust 0.7"])
RG -->|"12 calls · block"| ES["execute_sql<br/>tool · critical"]
RG -->|"40 calls · allow"| SK["search_kb<br/>tool · low"]
ES -.->|delete| PU[("postgresql/users")]
ES -.->|delete| PO[("postgresql/orders")]
ES -.->|delete| PP[("postgresql/payments")]
SK -.->|read| EK[("elasticsearch/<br/>knowledge_base")]
What’s hidden by default (each with a Show/Hide chip):
- Synthetic identities — red-team, probe, and e2e-harness agents are filtered out (the same shared classifier that keeps them out of compliance evidence), with a “N test/probe agents hidden” chip so the exclusion is always visible.
- Awaiting agents — classes that are deployed (a policy or registration exists) but have made no calls yet. Shown as dimmed, dashed-ring nodes labeled awaiting first tool call — deployed reach that hasn’t been exercised.
Click any node to open the inspector panel:
- Blast radius (agents/tools: everything reachable downstream from here) or Exposure (data nodes trace upstream: who can reach this store). The selected node is never counted in its own radius, and structural identity edges are excluded — the number is honest reach, not padding.
- Connections — every edge with its per-decision counts (
12 allow · 3 block). - Trust score, risk, ns/class/cluster chips, the full SPIFFE identity, last seen, and a View in Audit Log deep link to the raw decisions behind the picture.
The graph persists across restarts — each namespace’s graph is snapshotted and restored, so accumulated reach survives upgrades and pod restarts. It’s append-only in normal operation (oldest nodes evicted only at the size cap); to remove a decommissioned workload or a probe artifact, admins use the housekeeping endpoint:
curl -s -X DELETE "$NRVQ_API_URL/api/v1/asset-graph/node?namespace=chatbot-prod&node_id=<node-id>" \ -H "Authorization: Bearer $NRVQ_ADMIN_TOKEN"It removes the node and its edges from the graph only — audit rows, policies, and decisions are never touched.
Source capability findings
Section titled “Source capability findings”Every data node whose store Norviq recognizes gets a Source capability section in the inspector: what operations that kind of store exposes, and where you stand on each. The shipped source classes:
| Source type | Class | Verbs it exposes |
|---|---|---|
postgresql (aliases postgres/psql/pg) |
datastore | read · write · delete (critical) |
elasticsearch (es/opensearch) |
datastore | read · write (knowledge-poisoning risk) · delete |
smtp (mail/email/ses) |
egress | send (exfiltration risk) |
webhook (http/https) |
egress | send |
s3 (gcs/blob/minio) |
object store | read · write · delete |
filesystem (fs/file) |
object store | read · write · delete |
For each verb, three real signals — granted (an access edge exists), observed (traffic has actually exercised it), defended (policy has blocked/escalated it) — combine into one status:
- UNDEFENDED — observed and nothing defends it. The live gap; this is what the Defend button targets.
- DORMANT GRANT — granted but never exercised. A least-privilege cleanup candidate: reach that exists for no demonstrated reason.
- DEFENDED — observed and policy already answers it.
- LATENT — the store exposes the verb, but nothing in your cluster grants or uses it.
The worst open verb (undefended or dormant, highest risk) drives the node’s severity and the Asset Graph’s Data KPI (“N write/delete-open” — a dormant read never lights it red). Where a verb maps to a real MITRE ATLAS technique (e.g. deletion → AML.T0048, egress → AML.T0040), the finding says so — mappings are never fabricated. A store Norviq doesn’t recognize simply renders unannotated; unknown is left visibly unknown, not guessed.
The attack graph
Section titled “The attack graph”Console → Attack Graph. Norviq walks the asset graph from every agent and derives kill
chains — up to 4 hops, ending at a data store or a dead-end tool — then ranks them worst-first.
Each path card shows severity, source → target, hop count, minimum trust along the path,
blast radius (what else falls if this path is ridden), the chokepoint (the last tool on
the path — block it and the chain dies), a MITRE ATLAS chip where a real technique applies, and a
recommended fix.
Every step carries its evidence: the decision history on that hop and the resolved data operation (read / write / delete / send) with its risk — so a destructive hop never looks like a read hop. A data-store terminal is captioned crown jewel · sensitive; a tool terminal is target · tool.
Two kill chains derived from the asset graph above — the first is exploitable (every hop has allowed traffic end to end, so an attacker who compromises the agent can ride it to the crown jewel), the second is blocked (policy denies the chokepoint, so the chain is dead):
flowchart LR
A(["report-gen<br/>agent · min-trust 0.7"])
A -->|"calls · 12 allowed"| S["execute_sql<br/>chokepoint · critical"]
S -->|"reaches · delete · 8 allowed"| P[("postgresql/payments<br/>crown jewel · sensitive")]
P --> EX{{"EXPLOITABLE — allowed<br/>end to end"}}
A -->|"calls · 5 blocked"| DR["delete_record<br/>chokepoint · critical"]
DR -.->|"reaches · delete"| U[("postgresql/users")]
DR --> BL{{"BLOCKED — policy denies<br/>the chokepoint"}}
Path status (the worst-first ordering):
- Exploitable — every hop has real allowed traffic end-to-end. Proven, not theoretical.
- Unsimulated — the shape exists but there’s no end-to-end traffic yet. Simulate to confirm.
- Blocked — policy stops the chokepoint (or would, in Monitor mode).
Monitor mode is rendered honestly. A hop whose only “blocks” are Monitor-mode softenings draws as a dashed amber “would block” edge — “Monitor · N would-block (logged, not enforced)” — never as an enforced block and never as an open path. The verdict tells you to flip the namespace to Block to actually enforce.
Fresh policies bridge the audit lag. Status comes from recorded traffic, so it lags a just-applied policy. When an applied intent or capability policy already denies a path’s chokepoint, the card gets a teal “defended” chip — “an applied policy denies this tool; this status reflects past traffic — Simulate to confirm.”
The stat strip (Critical / High / Chokepoints / Max blast / Exploitable / Blocked) is clickable and filters the list; chokepoints counts tools that sit on two or more paths — the highest-value single blocks in the namespace.
Simulate (preview)
Section titled “Simulate (preview)”Simulate answers “does my current live policy stop this path — right now?” by running each
tool step of the selected path through the real POST /api/v1/evaluate endpoint under the
path’s own agent identity. Nothing is enforced or persisted by the preview; the result banner
distinguishes four honestly-different outcomes:
| Result | Meaning |
|---|---|
| Blocked by an authored policy | a policy you wrote/applied denies a step — real coverage |
| Would be blocked — Monitor mode | policy covers it, but the namespace only logs; flip to Block to enforce |
| Blocked only by the fail-closed default | no policy is loaded for this namespace at all — the deny-by-default caught it, which is not authored coverage; write a policy |
| Policy gap | every step allowed — the path is open |
A simulate result updates that one path’s status locally; it never inflates the headline Blocked stat or reorders other paths.
What-if and one-click drafts
Section titled “What-if and one-click drafts”- Block this step (what-if) — a per-hop hypothetical toggle: the canvas and verdict show
“a policy blocking
<tool>WOULD neutralize this path”. It’s purely visual — counted separately as “+N what-if”, never merged into the real Blocked stat — and it unlocks: - Draft blocking policy — persists a real dry-run draft for that path (read-only intent, empty allowlist) and hands you the deep link into Policies → Catalog to review it.
Define intended behaviour (the intent allowlist)
Section titled “Define intended behaviour (the intent allowlist)”The Attack Graph’s main defense flow — per agent class, or globally from the toolbar:
- The modal lists the class’s observed tool surface (from real traffic), every tool unchecked by default — positive security means you assert what’s intended. Chokepoint and egress tools are tagged, and each row shows the tool’s classification chip (see the lifecycle below).
- Pick the intended tools and up to four refinement toggles: Read-only, Namespace-scoped, Rate ≤60/min (advisory — the real limiter is a separate engine layer), No external egress.
- A live coverage preview dry-runs the generated policy against every path’s chokepoint in an isolated evaluator key — “covers N of M paths” plus the residual list — before anything is saved. Nothing is persisted or enforced by the preview.
- Apply intent policy stores a single draft per (namespace, class) in the drafts inbox and deep-links to Policies → Catalog, where Review & apply pre-fills the standard policy editor. Enforcement only happens through that gated Apply → Confirm flow.
The generated policy is the default-deny allowlist described in
Writing policies §2
(norviq.intent.<class>, evasion-normalized matching, tighten-only at baseline priority).
Defend a data source
Section titled “Defend a data source”From an Asset Graph data node’s capability findings, Defend turns the worst open verb into a
draft in one click — “Defend: make <class> read-only” (blocks all mutating verbs) or
“Defend: block <verb> for <class>”. Reads are never a defense target.
Because policy evaluates tool calls (there is no “data source” field at enforce time — see Writing policies §1), the generated Rego resolves the source-level intent into tool-name terms, two ways at once:
- the concrete tools observed reaching that source with the target verb, and
- a forward guard: a word-boundary verb-pattern match (e.g. delete →
drop/purge/truncate) on both the raw and evasion-normalized tool name — so a renamed or not-yet-seen destructive tool is caught too, without false-positives like “put” inside “output”.
The draft lands in the same Policy Catalog inbox (grouped From Source Capability) and goes through the same gated review → dry-run → apply flow. Nothing auto-enforces.
Tool classification & the promotion lifecycle
Section titled “Tool classification & the promotion lifecycle”Norviq classifies every tool it sees — including arbitrary cloud and open-source tool names it has never met — into a verb (read / write / delete / send) and a risk:
- the name is tokenized (delimiters and camelCase, so
aws_s3_DeleteObject,s3:DeleteObjectandPutObjectall resolve) and matched whole-token against a verb lexicon — no substring false-positives (compute_inputis not a write;budget_reportis not a read); - control-plane actuation (
open_breaker,set_valve) is recognized as critical; - when the name is inconclusive, the parameters are inspected (a leading SQL verb, an egress destination field);
- on any ambiguity the most destructive reading wins, and destruction/exec/actuation are always critical — a destructive capability is never labeled benign.
Unknown ≠ safe. A tool that can’t be classified surfaces as “unclassified · review” — the operator decides, the system never silently assumes. That’s where the lifecycle comes in:
- Observe — when a tool’s name is unclassifiable but its params reveal the operation, each call stamps that evidence onto the audit row. Run the namespace in Monitor mode and evidence accrues with zero enforcement risk.
- Infer — evidence from the last 7 days aggregates into a per-tool verb histogram
(“observing ·
delete12/14”); ties break toward the more destructive verb. - Promote (admin) — accept the inference, or override to any verb. The risk is always
derived from the verb (
delete⇒ critical), so a promotion can never under-declare. The justifying evidence is stored with the override. Demote returns the tool to observation.
The Tool verbs panel (Attack Graph toolbar) manages the whole lifecycle: Observing candidates with their evidence histograms and per-verb promote buttons, and Learned overrides with verb, risk, who promoted, when, and the evidence — with Demote. Every attack-path card also carries a lifecycle stage tag (“✓ delete · learned” teal, registry-classified risk color, “observing n/m” amber, or “unclassified” grey), and promoting from anywhere updates all surfaces immediately. When the same tool is promoted differently in two namespaces, cross- namespace views show the worse risk.
Two things promotions change — and one they don’t:
- Displays: kill-chain hop operation chips, path severities, and allowlist rows resolve promoted verb → built-in classifier → observed evidence, in that order.
- Generated policies: learned verbs flow into the intent generator’s toggles — a
misleadingly-named tool promoted to
deletecan no longer pass the Read-only toggle, and a tool promoted tosendcounts as an egress sink. The draft’s header names every learned verb it honored. - Not the live decision path: a promotion is classification, not policy. Enforcement still comes only from applied policies.
API reference
Section titled “API reference”All under /api/v1, bearer-token auth, namespace-scoped like every other read route (a non-admin
sees only their own namespace); the marked ones are admin-only.
| Method & path | Purpose |
|---|---|
GET /asset-graph?namespace=<ns>&range=24h&include_synthetic=false&include_awaiting=false |
the asset graph read model (nodes, edges, hidden counts) |
DELETE /asset-graph/node?namespace=<ns>&node_id=<id> |
admin — remove a node + its edges from the graph (graph only) |
GET /threats/attack-paths?ns=<ns>&range=24h |
live kill chains, worst-first, with per-hop evidence |
POST /threats/intent-coverage |
dry-run a candidate allowlist against every path’s chokepoint (nothing persisted) |
POST /threats/intent-draft |
admin — persist an intent draft; returns the Policy Catalog deep link |
GET /threats/intent-drafts?ns=<ns> · GET/DELETE /threats/intent-drafts/{id} |
the drafts inbox |
POST /capability/defend |
admin — turn a source-capability finding into a dry-run draft |
GET /threats/tool-verbs?ns=<ns> |
classification lifecycle state: learned overrides + observing candidates |
POST /threats/tool-verbs/promote · DELETE /threats/tool-verbs?ns=&tool_name= |
admin — promote / demote a tool’s verb |
POST /attack-paths/compute?namespace=<ns> |
admin — recompute the stored path set (the page’s Recompute button) |
Where this fits in the rollout
Section titled “Where this fits in the rollout”A practical loop for a newly-onboarded namespace:
- Put the namespace in Monitor mode and let real traffic build the asset graph (Getting started, enforcement modes).
- Read the Attack Graph worst-first; Simulate the top paths to separate real coverage from fail-closed luck.
- Promote the tools stuck in observing so the intent generator has true verbs to work with.
- Define intended behaviour per agent class (or Defend the worst source finding), dry-run the draft, review it in the Policy Catalog, apply.
- Flip the namespace to Block, re-Simulate, and confirm every riding path renders blocked — then prove it adversarially with the red-team suite.
For mapping your enforced policy to a recognized threat framework and closing the residual gaps, continue to Compliance & coverage.