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.
System overview and trust boundaries
Section titled “System overview and trust boundaries” (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 (AWS/Azure, 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.
- 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: localthe 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 minimalpermissions:, and never onpull_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.
Assets to protect
Section titled “Assets to protect”| # | 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 |
Threats and mitigations
Section titled “Threats and mitigations”| 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. The gate, the Action and the Postman collection sign v2 |
v1 stays accepted by default (INGEST_HMAC_ACCEPT_V1=true) so no sender breaks on upgrade, and while it is, stripping v2 from a captured request downgrades it to v1 - set it to false once perspectivegraph_ingest_signatures_total{version="v1"} stays at 0. 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 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 in values-production.yaml) 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 |
| 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 |
| 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 |
| 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 |
| 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 |
Path contents, asset names and scores are never exposed - only counts and timings | 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 |
Data handling and privacy
Section titled “Data handling and privacy”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.
Lawful basis
Section titled “Lawful basis”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.
Retention, and one honest tension
Section titled “Retention, and one honest tension”- 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 - andGRAPH_TTLprunes 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_RETENTIONprunes 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.
Transfers outside the EEA
Section titled “Transfers outside the EEA”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.
Data subject requests
Section titled “Data subject requests”- 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_TTLprune 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)”- Enable auth on the API (OIDC) and HMAC on ingest.
- Enable TLS (in-app or at the ingress) and use an external, managed, encrypted PostgreSQL+AGE - not the bundled demo database image.
- Store A2 credentials in a secret manager, not environment variables in plaintext.
- Grant connectors the minimum read-only role; review the policy.
- Put the engine behind a gateway/WAF; apply network policy between components
(
networkPolicy.enabledin the chart, on invalues-production.yaml). - Rotate the GitHub token and LLM key; scope the GitHub token to the target repo.
Out of scope
Section titled “Out of scope”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).
Reporting a vulnerability
Section titled “Reporting a vulnerability”Please use the process in SECURITY.md (GitHub Private Vulnerability Reporting). Do not open a public issue for a security report.