An Inventory You Can Query Beats a Vulnerability List You Have to Grep

Nullify's whole premise is that security work — finding, triaging, program-managing, and fixing exposure — can run end-to-end, cheaply, on AI that stays controlled and measurable, so a team's output stops being capped by how many people it can hire. Decoupling outcomes from headcount is the point: the results scale with the machinery, not the org chart. And it matters more this year than last, because the attackers have found the same lever. They are scaling reconnaissance with automation, not headcount — an adversary can now enumerate your cloud, your code, and your CI faster than a human team can open three consoles and cross-reference them by eye. If the defender's answer to "what is our exposure right now?" takes an afternoon of manual correlation, the attacker has already run the query.

So watch what actually happens when you ask most security teams that question. Someone opens the cloud console in one tab, the CI/CD system in another, and the vulnerability scanner in a third. They start cross-referencing by eye: this critical finding is in that dependency, that dependency is in this container image, that image runs on this service — is that service even reachable from the internet? An hour later they have a partial, stale, human-assembled answer, and no way to reproduce it tomorrow without doing the whole thing again.

That is grep. It's a manual walk across disconnected lists, and it does not scale past a few dozen assets — exactly the kind of work that has to become a query if outcomes are ever going to outrun headcount. The failure isn't that the scanners miss things — it's that their outputs never share an identity, so nothing joins. A SAST finding, an EC2 instance, and a repository live in three different systems with three different ID schemes, and the correlation only exists in a security engineer's head.

We built the opposite: a unified asset graph where every finding and every piece of infrastructure is a node with a canonical identity, joined on the same keys, queryable like a database. "What is our exposure" stops being a cross-referencing exercise and becomes a query that returns a structured answer. This is the triage pillar of our find → triage → program-manage → fix loop working the way it should — visibility and measurability as a first-class capability, not a byproduct of scanning.

The mechanism: one graph, one identity space

The core of it is a file we call the kind resolver. Its job is deceptively simple: for every node the platform produces, decide which canonical database row it maps back to and what ID joins it there. We describe the design in the code itself as a "typed-canonical-FK" model — every node must resolve to a canonical row, or it's dropped and counted in telemetry. That constraint is what does the work: it forces a SAST finding, an EC2 instance, and a repo onto the same graph with the same identity semantics, instead of three separate lists.

Nine finding kinds route into that graph as first-class nodes:

  • SAST findings → sast_findings
  • SCA dependency findings → sca_dependency_findings
  • SCA container findings → sca_containerfile_findings
  • Secrets (credentials) → secrets_credentials_findings
  • Secrets (sensitive data) → secrets_sensitive_data_findings
  • CSPM findings → cspm_findings
  • SCPM findings → scpm_findings
  • DAST pentest findings → dast_pentest_findings
  • DAST bug-hunt findings → dast_bughunt_findings

Every one carries a finding ID that becomes its join key. SAST, SCA, secrets, cloud posture, supply chain, and two flavors of dynamic testing all sit on one graph, joined identically — not stitched together in a report after the fact.

On the infrastructure side, the identity problem is harder, and how we solved it is instructive. A "host" node can be an EC2 instance, an elastic IP, a Cloud Run service, or a load balancer depending on the cloud and the context. Early on we routed those by Go type into per-category tables, and the mismatches got silently dropped. So we route them instead into a single unified view, context_asset_inventory_all, which is a UNION ALL across 22 typed category tables — compute, container, cluster, serverless, network, connectivity, load balancer, CDN/DNS, storage, database, messaging, data pipeline, identity, security, observability, CI/CD, AI/ML, repos, projects, artifacts, pipelines, and artifact repositories. The inventory isn't one giant polymorphic blob and it isn't 22 disconnected silos. It's 22 typed tables unioned into one queryable surface, which is exactly the shape you want when the question is "show me everything, joined."

Multi-cloud makes identity messier still. ARNs, GCP resource names, and ARM IDs don't agree on format, and a node's native ID sometimes diverges from its true inventory identifier. The resolver reads an optional canonical-ID field (set upstream by our cloud-ID layer) to absorb those differences into one identity space. There's even a small, telling fix in the code: the repo-context graph builder emits IDs like repo-<repoID> while the inventory writer stores the bare <repoID>, so the resolver strips the repo- prefix to make the join actually resolve. That's the unglamorous plumbing that decides whether cross-source joins work at all — and it's what separates a queryable inventory from a pile of near-misses.

What "queryable" actually means — a real command, not a promise

We want to be concrete here, because "queryable" is a word every vendor uses and few can back with a command you can run. Ours is a shipped CLI subcommand tree — asset-graph search | summary | nodes | node | subgraph | reachability | landing — where each subcommand maps to a real REST endpoint backed by real SQL.

The one that best answers "what is our exposure" is reachability:

`` context asset-graph reachability --node-id arn:aws:ec2:...:instance/i-123 ``

That returns whether the target is internet-facing and the actual path that makes it so — for example:

`` Internet -> ALB -> Target Group -> ECS Service -> Task Definition ``

Under the hood it's a breadth-first search from the tenant's synthetic Internet node toward the target, walking an allowlist of exposure edge types — ALLOWS_INGRESS_FROM, ROUTED_BY, ROUTES_TO, EXPOSED_BY, HOSTS, IN_VPC, FORWARDS_TO, CAN_REACH, and a handful more. Those edges carry the search from the Internet node down to the reachable compute node — the load balancer, the target group, the forwarding rule, the service:

`` Internet -CAN_REACH-> LB -ROUTES_TO-> Target Group -FORWARDS_TO-> ECS Service ``

That single traversal answers is this service internet-facing, and through which load balancer. It stops at the service, though. The build-provenance edges that connect a running service back to its image and the pipeline that built it — USES_TASK_DEFINITION, RUNS_IMAGE, STORED_IN — are deliberately outside the reachability allowlist, so they're never walked by that BFS. The graph still encodes the full attack-path shape as one connected chain of typed edges:

`` Internet -CAN_REACH-> LB -ROUTES_TO-> Target Group -FORWARDS_TO-> ECS Service -USES_TASK_DEFINITION-> Task Definition -RUNS_IMAGE-> buildArtifact -STORED_IN-> buildArtifactRepository ``

but reading it end to end — which container image is exposed to the internet, through which load balancer, built by which pipeline — takes two composable queries, not one: reachability to prove the service is exposed, then a subgraph walk down the build-provenance tail to name the image and the pipeline behind it. Both run against the same graph on the same shared identities — so the correlation that used to mean cross-referencing a cloud console, a CI/CD system, and a vulnerability scanner by hand is now two joined queries instead of an afternoon.

The other everyday query is full-text search:

`` context asset-graph search --query "frontend service" --type host --limit 10 ``

This is backed by a search_haystack column we build per node at write time. For a SAST finding, the writer pulls the title, the AI-rewritten title, the file path, and the rule URL — joined on the finding ID — and folds them into one searchable text column. Search then uses Postgres pg_trgm word-similarity ranking rather than a substring ILIKE, because we learned the hard way that substring hits often score zero similarity and produce non-deterministic ordering.

So searching for "frontend service" surfaces the infrastructure host node directly. And that node is where the cross-source joins pay off. A SAST finding attaches to its repository through a HAS_FINDING edge; that repository resolves to the exact canonical row the cloud inventory already wrote for it (this is where the repo- prefix strip earns its keep — it's what lets the two IDs meet); and the artifact built from that repo links to the live compute running it through DEPLOYED_FROM and RUNS_IMAGE edges. One hop out with subgraph and the code weakness, the repo it lives in, the image it was built into, and the internet-facing box it runs on are one connected subgraph — joined on shared identity, produced by four different scans, rather than reassembled by hand in a spreadsheet. The join primitive underneath is deliberately blunt: when a triage graph references a cloud host that a separate, earlier scan already committed, the syncer resolves that endpoint against the stored nodes by canonical ID instead of dropping the edge. It is a small resolve with the whole payoff riding on it — a finding from one source and infrastructure from another, meeting on a key they finally share.

Efficiency is the point, not a footnote

None of this matters if answering the query costs a fortune in compute or tokens every time. The graph is engineered so that the structured answer is also the cheap one.

  • The canonical-row lookup that powers search fetches one bulk query per touched table, not one per node — O(tables) instead of O(nodes). At tens of thousands of nodes that's what decides whether a sync finishes at all.
  • Node and edge loads switch to COPY-based bulk upserts above a threshold, specifically so a 100k-plus-node sync survives inside a 900-second Lambda instead of dying on per-row round-trips.
  • Repo-graph loads from S3 fan out but cap at 16 concurrent to avoid throttling.
  • Reachability BFS caps depth at 12 and visited nodes at 5000, because a Fortune-500-scale graph at depth 6 can already reach roughly 50,000 nodes and ship multi-megabyte arrays per query.

Hierarchy rollups tell the same story from the product side. "This VPC contains 4 subnets and 3 hosts" used to be computed by walking the entire graph client-side. It's now a server-side query — a literal before-and-after of "used to be a manual crawl, now it's a query." The efficiency argument lands right there, concretely: the same architecture that makes the answer correct also makes it cheap, and it does so by pushing work into the structure of the graph rather than re-deriving it on every request.

This also feeds directly back into triage quality. Cloud context is one of the strongest severity signals we have — whether a vulnerable service is actually reachable can swing a finding from urgent to negligible, or the reverse. A grep across code alone gets that wrong, because it can't see the network. The graph gets it right because reachability is a join away, and a finding that would otherwise sit in a queue burning attention (and tokens, if an agent picks it up) gets down-ranked automatically before anyone looks at it.

Measurable, and honest about what it doesn't know

A queryable inventory is only trustworthy if you can also see how complete it is. So the graph measures itself. Every sync writes a metadata row recording node count, edge count, links inserted, nodes dropped for a missing canonical row, nodes persisted under an unknown kind, duration, and status. The status is tri-state — success when every source succeeded, partial when some errored, failed when nothing did — and the stale-node sweep that evicts old data only runs on a clean success. A transient blip can't silently delete real assets, and you can always ask the system what it did on the last run rather than trusting that it did the right thing.

The same discipline shows up in the answers themselves. When reachability hits its node cap it sets a Truncated flag and refuses to claim "not reachable." The code comment is blunt about it: internet-facing false plus truncated true means "could not determine," and the UI should show "graph too large," not confidently report the asset is safe. That distinction — we checked and it's safe versus we couldn't check — is one a manual grep process never makes explicit, and it's the difference between a measurement and a guess. Search enforces the same honesty at the input boundary: an unknown object type or edge type surfaces as an error, not a silent empty result set that looks exactly like "no exposure found."

We're equally candid inside the code about inference confidence. As described in more depth in our post on the context system, five matchers stitch in relationships no single source states outright — that a container runs a given image, that an artifact was built by a given pipeline, that a service stores data in a given database. Each emits confidence-scored edges against a documented ladder: a token-boundary environment-variable match scores high, a shared tag lower, a weak name substring lower still. One matcher's docstring openly lists two higher-confidence rungs that were designed but never built, marked as TODOs rather than shipped as aspirational claims. The system doesn't pretend to a precision it doesn't have, and because every edge carries its confidence, a security engineer can see exactly why the graph believes two things are connected.

The lever is always there

Defaulting to an autonomous, queryable answer is not the same as hiding the machinery. Because the graph is structured and self-describing, every layer is inspectable: summary prints the size of your estate in one line, landing renders per-kind counts as a dashboard, subgraph lets you explore the neighborhood around any asset with your own depth and filter bounds, and node returns the raw payload behind any single finding or resource. A security engineer who wants to override the system's severity call, or trace a reachability verdict edge by edge, or audit which findings the graph joined to a given host, can do exactly that. The graph answers on its own by default, but it never asks you to take that answer on faith: every verdict decomposes into the nodes, edges, and confidence scores behind it, so you can audit the machine's reasoning instead of arguing with a score you can't see.

Why this is the game

We've seen, up close, what the absence of this layer costs. When findings aren't correlated, one vulnerable line can generate a SAST, an SCA, a secrets, and a CSPM finding at once — four tickets, four pages, the same developer pinged four times — and that uncorrelated noise has directly damaged customer trust. We've watched a customer have to ask us what their own exposure was, because the answer lived across systems nobody had joined. And we've heard, from more than one security leader, the same underlying want: not another list of findings, but a structured, queryable path from a vulnerability to the business impact of leaving it open.

That thread — from a finding, to the infrastructure it sits on, to whether that infrastructure is reachable, to what it would actually cost to be wrong — only exists if everything shares an identity. Give it that identity and "what is our exposure" becomes something you run, reproduce, and hand to an agent, instead of something a human reassembles by hand every quarter. That is how a security team's output stops tracking its headcount: the graph does the correlation, the measurement keeps it honest, and the scarce attention — human or token — lands only on the exposures that can actually hurt you. The attackers are already automating the same reconnaissance. The defenders who win are the ones who can answer the question faster than the adversary can ask it.

Book a demo