Skip to content

Threat model

This document states what PerspectiveGraph protects, what it assumes, and how the main threats are mitigated. It is written for operators deciding whether and how to deploy the engine, and for security researchers reviewing it.

Method: enumerate the trust boundaries and assets, then walk each attack surface with STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege), recording the existing control and the residual risk.

Honesty note: the bundled docker compose / Helm defaults are demo-grade. Several controls below (HMAC on ingest, auth on the API, TLS, encryption at rest) are opt-in and off in the demo. A production deployment must turn them on - see the “Operator assumptions” section and Project status & maturity.

(untrusted) (semi-trusted, outbound)
Internet ─► Dashboard :3000 ─► API :8080 ─┬─► Postgres + Apache AGE (B6 store)
(nginx SPA) (GraphQL, ├─► NATS bus (B6 bus)
auth gate) ├─► Cloud accounts, own cluster (read-only) (B3)
Scanners/CI ─► Ingest :8081 ──────────────┼─► External LLM (Claude / HF) (B4)
(HMAC per tenant) └─► GitHub (open remediation PRs, write) (B5)
  • B1 - Internet ↔ dashboard/API. Untrusted clients reach the SPA and the GraphQL API.
  • B2 - Ingest. Scanners and CI push findings/topology to /ingest/*.
  • B3 - Connectors → customer cloud. Agentless pull with read-only credentials: the cloud accounts’ APIs and, when the Kubernetes connector is on, the API of the cluster the engine runs in.
  • B4 - Engine → external LLM. Optional AI features send attack-path context out.
  • B5 - Engine → GitHub. Remediation-as-PR uses a token with write scope.
  • B6 - Engine ↔ datastore/bus. Graph in Postgres+AGE; events on NATS.
  • B7 - Tenant ↔ tenant. Multi-tenant graphs must stay isolated.
  • B8 - CI runner (the merge gate). In mode: local the gate runs the engine inside the runner: it reads the estate with a cloud read-only credential and computes the verdict there, so B3 moves into a job that also builds and tests the pull request’s own code. Anything that PR can execute during the build can read that credential. Keep the gate in a job of its own with minimal permissions:, and never on pull_request_target
    • that event hands your secrets to a contributor’s code. Fork PRs get no secrets at all and correctly fail closed as UNKNOWN.
# Asset Why it matters
A1 The topology graph (assets, identities, IAM/RBAC, CVEs, attack paths) It is a map of the customer’s attack surface and privilege - high value to an attacker
A2 Credentials the engine holds (cloud read-only role, GitHub token, LLM API key, OIDC/JWKS, per-tenant HMAC secrets, DB DSN) Their compromise pivots into other systems
A3 Audit log integrity Detection and non-repudiation depend on it
A4 Integrity of the PR merge-gate verdict A forced-green gate would let a real attack path merge
Surface STRIDE Threat Control today Residual risk
B2 ingest S/T Forge scanner data to poison the graph or hide a path Per-tenant HMAC verifier, body-size cap, audit on deny. The node-label and edge-type vocabulary is a closed set, rejected with 400 at the door and dropped at the single writer, so a forged event cannot introduce a value that downstream code (the AI prompt renderer, the Cypher builder) assumes is bounded HMAC is opt-in; off in the demo. Enable it in prod. The vocabulary check bounds the shape of forged data, not its content: a well-formed lie is still a lie, which is what the crown-jewel provenance ranking below addresses
B2 ingest T Declaring a complete snapshot to make the engine forget a real asset. Since 1.23 an ingest complete for a scope retracts what it omits: a sender that names a scope it did not send in full - or forges one - removes the assets, and the attack paths through them, that the same source asserted there The same HMAC key guards it as every other write. A pull request’s scan is never complete: the webhook refuses the declaration and the normalizer and broker drop one that reached the bus, by one shared rule (ontology.Event.CompleteSnapshot). An element leaves only when no source asserts it any more, so a snapshot cannot remove what another feed still reports. Nothing is removed until the whole ingest has landed, and an older snapshot cannot undo a newer one. Every sweep that removed something is logged with its source, scope and counts, and counted in perspectivegraph_graph_swept_total. Regression-tested in internal/graph/sweep_contract_test.go on both stores Not a new power: the same key could already overwrite what a node says (crown_jewel: false hides a path as surely). On /ingest/events the sender chooses the event’s source, so a key holder can retract what the AWS connector asserted in its account scope. Per-tenant keys bound it to one tenant. GRAPH_SWEEP=false turns it off
B2 ingest S/T/R Replaying or re-attributing a captured, validly signed request. The v1 signature covers the body alone, and the repository and commit a report counts against travel in the query - so a request seen once (a CI log, a proxy) could be sent again at any time, or with ?sha= changed to put its findings on another commit, or its absence of findings to clear one The v2 signature (X-PerspectiveGraph-Signature-V2) covers the time, method, path, canonical query and body hash; a timestamp more than 5 minutes off is refused, and each signature is accepted once. When v2 is present only v2 counts, so a wrong v2 is not rescued by a right v1. v1 is refused by default (INGEST_HMAC_ACCEPT_V1=false), and the gate, the Action, perspectivegraph ingest and the Postman collection sign v2 alone, so a captured request carries no v1 to fall back on - until 1.39.2 the gate sent both, and stripping v2 from a captured report left a v1 the default accepted. Regression-tested in internal/auth/hmac_v2_test.go Turning v1 back on (INGEST_HMAC_ACCEPT_V1=true) for a sender that cannot sign v2 makes that sender’s requests replayable for as long as it stays on. The seen-signature memory is per replica: inside the 5-minute window a replay to a different replica is accepted once
B3 connectors T Crown-jewel promotion. What makes a target worth attacking is usually a tag, and ec2:CreateTags is granted freely because tagging looks harmless - so an attacker with a foothold can mark junk classification: pii and manufacture paths that dilute a board whose promise is “~5 routes that matter” The analyzer ranks crown-jewel provenance: an authoritative classifier (Macie/DLP) outranks a tag outranks a name heuristic, and ingestion now records crown_jewel_basis: tagged so that ranking actually sees the attacker-writable case. The weight and its reason both surface in the path’s priority factors, so an operator reads “tagged sensitive asset” rather than an unexplained number Ordering bounds the damage but does not remove it: fabricated jewels still inflate path counts and compete for attention below the top slot. An estate that classifies with a real tool is materially harder to poison than one that relies on tags alone
B3 connectors T Crown-jewel demotion - remove the tag so a real target stops being one and its path leaves the board Not possible today: the property is only ever written true, and the store’s merge contract accumulates (MergeProps, later writes win per key, nothing is deleted), so a jewel survives the tag being removed The same contract makes promotion irreversible: an attacker needs tagging rights for one collection cycle, and the fabricated jewel then persists with no operator action that removes it. Legitimate declassification is equally stuck. Fixing it means changing the store’s merge contract - which exists to stop a partial collector erasing another’s data - so it is a deliberate separate change, not a bolt-on
B1 API I/E Unauthenticated read of attack paths / tenant data GraphQL requires a bearer credential (≥ viewer); OIDC Authorization-Code + PKCE login gate; JWKS with key-rotation refetch; brute-force lockout. When OIDC_JWKS_URL is set the binary refuses to start without OIDC_ISSUER and OIDC_AUDIENCE: a verifier that skips aud accepts any token its IdP ever minted, including one issued to a different application sharing the same JWKS Auth is opt-in, and that is the sharp edge: the backend refuses to start unauthenticated only under PG_ENV=production, so an install left on the demo default is open. The Helm chart now closes the case it can see - it refuses to render ingress.enabled without a credential, since that is the moment the install becomes reachable and the ingress publishes both /graphql and /ingest. Exposure arranged outside the chart (a LoadBalancer service, a hand-written Ingress) is still the operator’s to get right. The brute-force lockout is durable with GOVERNANCE_BACKEND=postgres - a lockout already earned survives a restart, a deploy or an OOM, and every replica enforces it - but only the LOCKOUT is stored, not the counter behind it, so partial progress toward the threshold is still forgiven by a restart (a write per failed login would let the abuse drive the writes). On the file backend it remains per-process and per-replica. A lockout set by another replica is seen within 30s, since the request path reads a cached snapshot rather than the database. Its key space is capped (50k) so a flood evicts the oldest rather than growing without bound - which also means a large enough flood can push a real attacker’s counter out. None of this is a substitute for lockout at the IdP
B1 API I Publishing the instance on purpose. API_ANONYMOUS_ROLE=viewer gives a credential-less caller the viewer role instead of a 401, which is what a public demo or a company-wide read-only dashboard needs - and, set on an instance holding a real estate’s findings, hands an attacker the map Only viewer is accepted: a role that can write must be tied to a credential, and anything else (including a typo) stops the process at startup rather than being downgraded. Writes stay admin-only (403), and a presented-but-wrong token still fails 401 instead of falling through to anonymous - otherwise revoking a leaked token would demote its holder to public read rather than locking them out. Off unless set; a warning names the consequence at every startup. The rate limit and the brute-force lockout stay per visitor: every visitor arrives through a proxy, so the compose recipe trusts the dashboard’s nginx (TRUSTED_PROXY_CIDRS) - otherwise one person’s wrong tokens would lock every visitor out. Held by internal/auth/anonymous_test.go, internal/api/publicreadonly_test.go and internal/config/publicrecipe_test.go The DATA is the operator’s call: everything the dashboard shows becomes public, so a published instance carries sample data only. /ingest is unaffected and must never be proxied - it is how the graph is written. See OPERATIONS.md §11
B7 tenants I/E One tenant reading another’s graph Tenant stamped from the authenticated principal; isolation covered by tests Depends on auth being enabled; verify per deployment
B7 tenants I A tenant name read as more than a name. The graph, the analyzer and ingest key a tenant by its normalized form (graph.NormalizeTenant, [a-z0-9_]), but full-text search built its OpenSearch index name from the tenant as the token stated it - and in an OpenSearch path an index name is an expression: a tenant of * searched every tenant’s index, x,<prefix>-acme named another tenant’s. A tenant claim is whatever the IdP puts in the token, which some directories fill from an attribute the user can edit. The same mismatch left a tenant such as Acme-Corp searching an index nobody writes, and reading the analyzer’s history (trend, MTTR, path ages) under a spelling it was never written with The index name and the history reads use the normalized tenant, as ingest and the analyzer always did. Regression-tested in internal/search/search_test.go and internal/api/tenant_history_test.go. Since 1.39.4 a tenant has one spelling past authentication: tokens, OIDC claims and ingest signatures normalize it (tenant.Normalize), the governance stores normalize what they are handed, and records written under a token’s spelling are re-keyed at startup (internal/auth/tenant_spelling_test.go, internal/pgmigrate/tenants_test.go) Normalization is many-to-one: acme-corp and acme.corp share a graph, an index and the governance stores. Give tenants names already in [a-z0-9_], and map the tenant claim from an attribute users cannot edit
B7 tenants I/E Landing in a tenant by omission, or two tenants under one name. An OIDC token that named no tenant was read as the default tenant’s - the only reading with one tenant, but with several the default is a tenant like the others, and a subject the IdP failed to place in one read its graph. Separately, tenant names are normalized, so two configured names such as acme-corp and acme.corp became one graph, and the second ingest secret silently replaced the first Once a tenant besides the default is configured (INGEST_HMAC_SECRETS, API_TOKENS, CONNECTOR_TENANT), a token without the tenant claim is refused. The engine refuses to start on two configured names that normalize alike, and on two ingest secrets for one tenant. Regression-tested in internal/auth/jwt_tenant_test.go and cmd/perspectivegraph/tenantnames_test.go Tenants that only an IdP names are not known at startup: two spellings arriving in tokens still share a graph, so the IdP should mint tenant claims already in [a-z0-9_]
B7 apps I/T One team acting on another team’s paths inside the same tenant. The apps claim scopes a principal to a set of applications, and the path reads honour it - but the governance stores behind them (suppressions, tickets, validations) are keyed by path id and were scoped by TENANT alone. So an app-scoped admin could suppress a path belonging to another application, hiding a real finding from the people responsible for it, and read the whole tenant’s boards. matchPath compounded it: it resolves a path id from a substring of a crown-jewel name, so out-of-scope target names could be enumerated a letter at a time Every path-keyed governance read and write now resolves the path through the caller’s scoped set first (scopedPathIDs / mayActOnPath), including the by-id mutations (close a ticket, delete a validation), which look the record up before mutating. An unrestricted principal gets a nil filter and skips the work entirely, so the single-team deployment is unchanged. Regression-tested in internal/api/governance_scoping_test.go The tenant-wide calibration and precision/recall aggregates are deliberately not scoped: they measure the engine, carry no path id, asset name or application, are computed in the store shared by both backends, and the same numbers are served by the GraphQL validation/calibration fields - withholding them from the REST board alone would be a control in name only. A scoped principal therefore learns how many verdicts exist tenant-wide and how well the model is calibrated overall
B6 store/bus I/T Read/modify the graph or events directly Network isolation; TLS to Postgres (POSTGRES_SSLMODE) and in-app TLS are configurable TLS + at-rest encryption are the operator’s job (use a managed DB); demo runs plaintext local
B6 store/bus S/T Publishing onto the bus, or logging into the bundled database, from any pod in the cluster. Events on NATS are trusted: the ingest webhook’s signature check runs before them, not after. The chart’s NATS accepted any client, so any workload that could reach port 4222 published events straight into the graph - or deleted the stream - and the bundled Postgres used the password perspective on every install Since 1.24 the chart’s NATS requires a user and password, generated on install and kept in a Secret, that the backend presents (NATS_USER/NATS_PASSWORD, also for an external NATS). The bundled Postgres gets a random password on a new install. networkPolicy.enabled (on by default) admits only the backend to NATS and Postgres, and backendFrom can close the backend to the rest of the cluster NetworkPolicy is inert on a CNI that does not enforce it, which is why NATS also authenticates. An existing install keeps its Postgres password - rotating it is ALTER ROLE plus the Secret. Rendering with helm template (Argo CD, Flux) cannot keep a generated password, so there the operator sets one or brings a Secret. The single NATS user can do everything the backend can; a compromised backend pod is still the bus
B6 store T Writing a record into a governance store, or the audit log, on its volume. With STORE_ENCRYPTION_KEY set the stores and the audit log were sealed, but a record the key had not sealed was read anyway - the rule that kept stores written before the key readable. So whoever could write to the volume, without the key, could add a suppression that hides a path, edit a ticket or a verdict, or rewrite the audit chain in plaintext, which verified because its hashes carry no key Since 1.39.4 a record the key did not seal is refused: the engine does not start on it, and verify-audit fails on it. What predates the key is sealed once, explicitly - perspectivegraph seal-stores with the engine stopped, or one start with STORE_SEAL_EXISTING=true - and the audit chain is verified before it is sealed, so a broken one is not certified Without a key the stores are plaintext, and anyone who can write the volume can edit them. Under GOVERNANCE_BACKEND=postgres the engine seals only the audit fields: the other stores are as safe as the database’s own access control. STORE_SEAL_EXISTING left on seals whatever plaintext it finds at every start
B6 store I A backup is the whole map in one file. The single-VM recipe dumps the database into ./backups every day, and with the governance state in Postgres one dump holds the graph, the triage decisions, the tickets, the verdicts and the audit chain Dumps are written mode 600, as the operator, in a directory only the operator can enter. The audit chain inside a dump stays sealed with STORE_ENCRYPTION_KEY, which is not in it The graph is in the dump in the clear: pg_dump does not encrypt. Copy the dumps off the machine encrypted, and keep ./secrets apart from them - together they are the attack map and the key to its history (Production on one machine)
B3 connectors E Over-broad cloud credentials pivoted from the engine Read-only model, AssumeRole, least-privilege grant (ec2:Describe*, iam:GetAccountAuthorizationDetails ≈ SecurityAudit) An operator can still attach an over-broad role - document and review the grant
B3 connectors I/E The Kubernetes connector’s token. With connectors.enabled={kubernetes} the backend reads the cluster it runs in, and for that - and only then - its pod mounts a service-account token, bound to a ClusterRole. Whoever compromises the backend holds that token The role grants get and list on pods, services, service accounts, nodes, ingresses and the RBAC objects, and get on the one aws-auth config map: no secrets, no config maps in general, no verb that writes. A test keeps the role and the connector’s list of kinds the same, and the API server is verified against the cluster’s CA. Off by default; without it no pod of the chart mounts a token at all (since 1.36) What the token reads is the cluster’s whole workload and RBAC inventory - which is what the graph already holds (A1), so the pod was worth that much before. It also reads every pod’s spec, where a credential someone wrote into a container’s environment in plain text would be. A role edited by hand to grant more is the operator’s to notice: the chart’s is the one that is tested
B4 LLM I Topology context sent to a third-party LLM AI is self-gated on an API key (off by default); every call audited; secrets scrubbed on ingest When enabled, attack-path context leaves your boundary. Opt-in and operator-owned
B5 GitHub/GitLab E/T The destination of a forge write is untrusted input. The repository a comment, a commit status or a remediation PR goes to is read from a node property (repo_slug), and node properties arrive over ingest - reachable by every scanner holding the shared HMAC key, the least-trusted credential in the deployment. One planted node therefore redirected an authenticated write to any repository the token could reach. The commit status is the sharp end: a success on a commit in a repository where this check is required opens a merge gate rather than closing one, and the commenter and checker run automatically from the analyzer, with no human in the loop REPO_ALLOWLIST bounds every forge write to repositories the operator named (exact slug or owner/*), consulted at each of the three write paths. It fails closed: unset means no writes, and a nil allowlist at a call site denies rather than permits. Slugs are shape-validated (no dot segments, no separators inside a segment) before they reach a URL, and each path segment is escaped. Regression-tested in internal/action/repoguard_test.go. A success is only ever posted by the pass of the tenant whose route closed: one tenant’s pass once cleared every tenant’s red commits (internal/action/attribution_test.go) Two controls, neither sufficient alone: the allowlist bounds where the engine asks to write, the token scope bounds what the forge lets it write. An owner wildcard is as wide as the owner - prefer exact slugs. A write to an allowed repository is still driven by ingested data, so it can be made to fire on the wrong PR within that repository
B5 GitHub/GitLab T What the engine writes there is shaped by names it did not choose. A remediation PR carries Terraform built around asset names, and the PR comment draws the path with them - and a name is the environment’s: an EC2 Name tag is anyone’s with ec2:CreateTags. A name used to go in as it came. In the Terraform, a line break ended the comment or the string it sat in and added blocks of its own - an IAM policy attachment granting AdministratorAccess, inside a pull request opened under the engine’s name as the fix - and ${...} became an expression Terraform evaluates. In the comment, a run of backticks closed the code block and a line break started a heading of its own, saying whatever the name said In generated Terraform every environment-derived string is a value: an HCL string literal with quotes, backslashes and line breaks escaped and ${/%{ doubled, a one-line quoted comment, or a single-quoted shell word in a command shown for copying. In the comment and in the hints, a name is one line set in a code span or a fenced block that nothing inside it can close, and the remediation PR’s title and body take the same treatment. Regression-tested in internal/remediation/hcl_test.go (every generator, against payloads that break each context; the output also re-read by terraform fmt) and internal/action/comment_test.go Structure, not meaning: a name can still say something misleading inside its value, which is why every generated PR says to review it before merging, and why the merge gate’s verdict is a commit status the name never reaches
ingest I Secrets embedded in scanned artifacts land in the graph Secret scrubbing on ingest Best-effort; do not rely on it as the only control
B4 AI T Prompt injection via ingested content steering the AI summary. An asset’s name is an AWS Name tag and ec2:CreateTags is widely granted, so the attacker this tool exists to detect can write text into the prompt that produces the executive briefing Environment-derived strings are collapsed to a single bounded line (no line breaks, control characters stripped, length capped, fence tags neutralised), the context arrives inside <environment-data> tags, and every system prompt tells the model that block is data and never instructions. Applied on both routes - the summary’s path context and /ai/explain’s remediation hints, which embed asset names in prose. The hostile inputs are a permanent test (internal/api/injection_test.go), which failed before the containment existed. A self-assessment later found the containment covered names but not the two other environment-derived fields rendered into the same line - the target’s label and each hop’s edge type - which reached the prompt raw. Both are now sanitised there, and more to the point the vocabulary that bounds them is enforced where every event passes (graph.ApplyEvent) and at the ingest door, instead of in the Apache AGE store alone - which had left the in-memory default, what the demo runs, accepting any string. Every payload in the test now runs through all three fields Containment, not elimination. No escaping makes a model immune to persuasive text - a name reading “decommissioned” may still colour an answer. What is removed is the ability to forge structure (a forged turn, a fake list entry, an early fence close) and to spend the context window on one tag. Treat AI output as advisory, not authoritative
B4 MCP T What a tool returns can steer the agent that asked. The MCP server hands an agent asset names, labels and remediation text from the graph, and an asset’s name is as attacker-writable here as in the row above The eight tools only read: nothing suppresses a path, opens a pull request or records a verdict, each declares readOnlyHint on the wire, and a test fails if a tool is added without that decision. The server is a client of the API, so it sees what the token it was given may see and no more Injected text cannot make the engine write, but it can still persuade the agent, and that agent may hold tools of its own. The results are not rewritten on the way out: a host should treat them as data about an estate an attacker can partly name
B1/B2 D Denial of service via large or frequent payloads Body-size limits on ingest and API; per-client-IP token buckets on both (API_RATE_RPS, INGEST_RATE_RPS, default 60/30) with the limiter as the outermost middleware and a capped client table; connectors leader-gated (replicas don’t multiply calls) The limiter keys on the connecting peer, so behind a proxy it limits the proxy: terminate at a gateway that limits per real client. State is in-memory and per-replica
B1 API D A cheap request that costs the server a lot. Depth limits alone do not bound work: a document three levels deep can alias one expensive field thousands of times, and fragments that each spread the next twice expand exponentially with no cycle for a cycle guard to catch The query guard budgets both depth (15) and total field resolutions (2000, ~5× the dashboard’s heaviest query), with the expensive fields weighted by what they cost, and fragment costs are memoised so measuring a document is linear in its size. Regression-tested: a 1.2 KB non-cyclic fragment bomb that previously took the guard over ten seconds to measure - before any field resolved - is now rejected in microseconds. The guard cannot see list sizes, so heavy work is also bounded where it runs: one request may run at most 20 heavy analyses (what-ifs, fix verifications, custom risk simulations, k-shortest searches), identical ones within a request run once, and at most half the cores run them at once across the process - a request that waits 20 s for a slot is told the server is busy. Measured before that: on a 4,344-node estate the dashboard’s own “verify this fix” query, which asked for every fix’s proof to show one, did not finish in five minutes Cheap fields - the paths, the plan, the default riskSimulation - read the analysis the analyzer cached and cost little per request; the per-IP rate limit is what bounds how many of them arrive. The heavy-analysis cap is per replica, and the budget per request: a client that sends many requests is limited by API_RATE_RPS, not by the budget
B1 Gate D, I The merge gate’s comparison takes a report and runs a full analysis. POST /gate/impact parses an arbitrary scanner report and computes the critical paths twice, on a copy of the tenant’s graph - an expensive request with a caller-chosen body, on the API port rather than behind the ingest signature Refused to anonymous callers whenever auth is on (a public read-only instance offers it to nobody), bounded like the ingest webhook (32 MiB body) and like every heavy analysis (the per-request budget, the process-wide half-the-cores cap, the per-IP rate limit). It writes nothing: the report is applied to an in-memory copy, never to the store, and a test holds the live graph unchanged after a call. An app-scoped caller gets the right count - a route to or from assets it may not see is still a route the change opens - with those routes’ assets unnamed The count itself is a signal: an app-scoped caller learns that a route exists outside its applications, not where. A viewer credential that leaks can run comparisons up to the rate limit, per replica
B1 AI D Spending the operator’s model budget. Every /ai/* call is a paid request to the model provider, and those endpoints asked only for the viewer role - which a read-only public instance gives every visitor Anonymous callers are refused (403) whenever auth is on, and aiEnabled answers false to them so the dashboard hides the features; a separate per-client limit, AI_RATE_PER_MIN (default 10), applies to signed-in callers too A viewer credential that leaks can still spend up to that limit per client address, and the limit is per replica. Keep the provider’s own spending cap as the final bound
the tool itself T Supply-chain compromise of the build Digest-pinned base images (distroless, non-root, read-only rootfs), SHA-pinned GitHub Actions, govulncheck/gosec/CodeQL/gitleaks/Trivy gates + parser fuzzing in CI, Dependabot; release images are cosign-signed with an SBOM + SLSA provenance No formal third-party penetration test yet (automated + community review only)
B1 R Actions not attributable Tamper-evident audit log (sealed); auth.deny and mutating actions recorded Strong non-repudiation needs shipping the log to external WORM storage. Every request carries an X-Request-Id - taken from the caller when well-formed, generated otherwise - that reaches the structured logs and the audit record’s fields alike, so one HTTP call ties to its audit entry without guesswork
B1 metrics I GET /metrics is open by design - unauthenticated and unthrottled, so a scrape never starves - but it sits on the same mux and port as the API, and several series carry a tenant label: analyzer_critical_paths, analyzer_graph_nodes, analyzer_graph_edges. Anyone who can reach the port therefore enumerates tenant names and learns how large each estate is and how many critical paths it currently has - and, since 1.37, which release is running (perspectivegraph_build_info) Path contents, asset names and scores are never exposed - only counts, timings and the version Closable: set METRICS_ADDR (e.g. 127.0.0.1:9090) and /metrics moves to its own listener and LEAVES the API mux entirely - values-production.yaml does this. It is off by default because /metrics on the API port is declared stable surface and moving it silently would break every existing scrape config. Left on the API port the residual stands: “which tenants exist, and which has the worst posture right now” is the shape of a targeting signal, so do not expose that port directly

The graph in A1 is sensitive: it describes how a real environment can be attacked, so treat the datastore as you would a secrets store. It also contains personal data, and the AI features send some of it outside your boundary when enabled - both are covered in detail in the next section, along with retention and the transfer question.

Personal data and compliance (GDPR / NIS2)

Section titled “Personal data and compliance (GDPR / NIS2)”

This tool processes personal data. Saying so plainly, and saying exactly which, is cheaper for everyone than letting a data protection officer discover it during review.

You are the controller. The project ships software; the deployment that ingests your estate decides the purposes and means, and is therefore the controller under GDPR Art. 4. Nothing is sent to the maintainers - there is no telemetry, no phone-home, no hosted component.

What personal data the system holds, and where

Section titled “What personal data the system holds, and where”
Where What Kind
Graph (Postgres/AGE or memory) IAM user names from the iam collector; email addresses from the sso collector ({"email":"alice@acme.com"}); whatever names your estate puts in asset tags Directly identifying
Audit log The acting subject per request: anonymous, hmac, token:<8-hex SHA-256 fingerprint> or jwt:<sub> (the OIDC subject claim, an opaque identifier) Pseudonymous
Application log The same subjects, plus remote IP on auth.deny / lockout alerts Pseudonymous + IP

The audit log is pseudonymous by design: it never records a bearer token, an email or a user name - only a truncated hash or the IdP’s opaque subject. That is the Art. 32 measure it looks like, and it is deliberate. It remains personal data under Recital 26, but the exposure if the file leaks is materially smaller than the graph’s.

The graph is the sensitive artefact, and it holds directly identifying data.

Legitimate interest (Art. 6(1)(f)) is the basis that fits, and GDPR Recital 49 names network and information security explicitly as such an interest, including preventing unauthorised access. Document that assessment; do not leave it to be inferred. If your organisation treats security tooling under a different basis, nothing here depends on the choice.

  • Graph: bounded. A complete snapshot (GRAPH_SWEEP, on by default) removes what its source no longer lists - an identity deleted from IAM leaves the graph at the next pull - and GRAPH_TTL prunes whatever no source has re-observed within the window.
  • Audit log: bounded when you set a window, and unbounded until you do. On the Postgres-backed chain, AUDIT_RETENTION prunes records older than the window. On the file-backed chain there is no automatic pruning: rotate it (see the operations runbook).

The tension worth naming rather than hiding: the audit log is hash-chained so that tampering is detectable, which means deleting a record from the middle breaks every hash after it. Erasure (Art. 17) and tamper-evidence pull in opposite directions.

The answer is truncation, not surgery. Retention removes a prefix - the oldest records, in the order they arrived - and writes a checkpoint holding the sequence number and hash of the last record it removed. Verification then starts at the first surviving record and checks that it still links to that hash, so the retained window is exactly as tamper-evident as the whole chain was. Deleting one record out of the middle remains impossible to do invisibly, which is the property the chain exists for; the prune itself is recorded in the chain it shortened. The file-backed chain does the same thing at file granularity: retire whole files and archive or destroy them intact. Both of which are also why the subjects are pseudonymous in the first place - there is far less to erase.

With the AI features enabled, personal data leaves your boundary. The attack-path context sent to Anthropic or a HuggingFace-compatible endpoint includes asset names, and asset names in a real estate routinely carry user names and email addresses. That is a Chapter V transfer, not merely an architectural preference, and it needs the usual paperwork (adequacy, SCCs, or a provider inside the EEA).

It is off by default and every call is audited (ai.query / ai.summary / ai.explain). Leave ANTHROPIC_API_KEY and the HF endpoint unset and no data leaves. The HuggingFace path accepts any OpenAI-compatible endpoint, so an EEA-hosted or self-hosted model keeps the transfer inside your boundary while keeping the feature.

  • Access / rectification: identities live in the graph as nodes; query by name through the API and correct them at the source (the graph is derived, so fixing IAM or the IdP and letting the next pass re-ingest is the durable fix).
  • Erasure: remove the identity at the source and let GRAPH_TTL prune it, rather than editing the graph by hand. For the audit log, see the rotation note above.

For entities in scope (in Italy, D.Lgs. 138/2024), several obligations map onto artefacts this tool already produces. It does not make you compliant - no tool does - but these are things you would otherwise have to build:

  • Tamper-evident logging of access to security data - the hash-chained audit log, verifiable with perspectivegraph verify-audit.
  • Supply-chain security of the tooling itself - release images and binaries are cosign-signed, carry an SPDX SBOM and SLSA build provenance, and can be verified before they run.
  • Vulnerability handling and disclosure - see SECURITY.md.
  • Risk-management evidence - the OSCAL assessment-results export (GET /export/oscal) renders posture in a format an assessor can consume.

Operator assumptions (what you must do for production)

Section titled “Operator assumptions (what you must do for production)”
  1. Enable auth on the API (OIDC) and HMAC on ingest.
  2. Enable TLS (in-app or at the ingress) and use an external, managed, encrypted PostgreSQL+AGE - not the bundled demo database image.
  3. Store A2 credentials in a secret manager, not environment variables in plaintext.
  4. Grant connectors the minimum read-only role; review the policy.
  5. Put the engine behind a gateway/WAF; apply network policy between components (networkPolicy.enabled in the chart, on by default).
  6. Rotate the GitHub token and LLM key; scope the GitHub token to the target repo.
  7. Copy backups off the machine encrypted, and keep the encryption key apart from them.

Host/OS/hypervisor security; the security of the Kubernetes platform the engine runs on; physical access; the correctness of the third-party scanners whose output is ingested; and any threat that assumes an already-compromised operator or CI system. Security review to date is automated + community-based (CodeQL taint analysis, gosec, govulncheck, Trivy, and fuzzing of the ingest parse boundary in CI, plus GitHub Private Vulnerability Reporting); the engine has not yet undergone a formal third-party penetration test (see the maturity note).

Please use the process in SECURITY.md (GitHub Private Vulnerability Reporting). Do not open a public issue for a security report.