What BlastRadius does with your data
This page is docs/DATA-HANDLING.md from the BlastRadius repository,
rendered. Not a summary of it — the same words, held to the code by the
same tests.
Written to be checked, not to be trusted. Every claim here names the file that makes it true, so you can read the code instead of taking our word for it. If you find a claim that is not true of the code in this repository, that is a security report — see SECURITY.md.
Status, 25 August 2026: BlastRadius was deployed to production on 25 August 2026 and reviews pull requests from there. It is still a private beta — installation is by invitation, and there is no Marketplace listing yet. Every subprocessor in the table below is now in use; none is pending.
The short version
- Your plan is redacted in your own CI runner, before anything is uploaded.
- What we receive is a redacted, schema-stripped plan. It is parsed in memory, analysed, and dropped. It is never written to a disk and never written to the database.
- What we keep is the findings — the resource a rule named and the attribute values it quoted — for thirty days.
- The model that writes the prose never sees plan JSON.
- Uninstalling the GitHub App deletes everything we hold for you, immediately.
The rest of this page is the detail behind each of those.
Where the code runs
There are two halves, and the split is the whole design.
In your runner. The blastradius GitHub Action runs terraform show -json itself, as a child process, and keeps the output in memory. It does not write the JSON to $RUNNER_TEMP or anywhere else — because a file on the runner is something a later upload-artifact step, a cache action, or a debug re-run can read, and none of those are ours to audit. The plan is redacted, then stripped to the subset we model, then uploaded.
On our servers. The backend receives the redacted document over HTTPS, parses it, analyses it, stores the findings, and posts one comment and one check run on your pull request.
The Action is shipped as a bundled but unminified file, on purpose. It is the code that sees your plan before anything leaves your runner, and reading it yourself is the point.
What leaves your runner
Exactly one request per terraform root, per push. Its body is:
| Field | What it is |
|---|---|
plan | Your plan, redacted and stripped — see below |
repo | Owner and repository name |
pullRequest | The pull request number |
headSha | The commit the check run attaches to |
workspace | An optional label for this root, e.g. infra/prod — you choose it |
actionVersion | Which release of the Action ran |
ciRunUrl | A link back to the GitHub Actions run |
Plus two credentials in headers: your repository's upload token, and a GitHub Actions ID token that GitHub signed to prove which workflow run produced the bytes. Nothing is uploaded without both.
Uploads over 10 MB are refused rather than truncated.
Redaction, in your runner
packages/analyzer/src/redact.ts is the file, and its header is the honest version of this section — including a list of what it does not catch. Four mechanisms, in descending order of how much we trust them:
- Terraform's own sensitivity marks —
before_sensitive/after_sensitive,sensitive_values, and asensitive = truedeclaration in the configuration block. This is your own declaration plus the provider's schema, so it is authoritative where it speaks. - The mirror. Any string something authoritative condemns is then redacted everywhere else in the document too — because Terraform marks attributes, not values, and the same password can sit unmarked inside a Helm values blob two resources later.
- A name denylist — attribute names that mean "credential", plus the handful that mean "an opaque document people put credentials in" (
user_data,container_definitions), matched case-insensitively. - A short list of unmistakable credential formats — a PEM private key header, an AWS access key id, GitHub / Slack / Stripe / Anthropic / GitLab / Google token prefixes, and a URL carrying
user:password@. Every pattern is a published, self-identifying format, so a match is not a guess.
Five blocks are blanked wholesale, regardless of name or mark, because they carry attribute values by construction and nothing downstream reads them: the top-level variables block (your tfvars), planned_values, prior_state, configuration.provider_config (inline provider credentials), and variable defaults at any module depth.
Where the input disagrees with itself — a mask whose shape contradicts the value it describes, a document deeper or larger than our limits — the answer is always "redact". And if the redaction walk does not finish, the Action refuses to upload at all rather than uploading a partly-redacted plan.
What redaction does not catch
Said plainly, because a page that only lists strengths is not worth reading:
- A secret of no recognisable shape, passed under a name nobody would guess, that the plan marks nowhere. All four mechanisms miss it.
- A second copy of a secret that only ever appeared inside one of the five wholesale blocks. Those are blanked by position, so they do not teach the mirror.
- A secret used as a
for_eachkey or in a resource name. Those end up inside resource addresses, and addresses are what the blast radius is built from, so redaction is not allowed to rewrite them. - A secret shaped like an identifier — a UUID-formatted API key is redacted where the plan marks it and not chased elsewhere, because nothing can tell it from an ARN.
The full list, with the reasoning and the corpus evidence for each, is in that file's header under "What this does not protect, said out loud".
And then the schema strips more
After redaction, the Action validates the document against our schema, which models only what the analyzer reads. Everything unmodelled is dropped, not carried — there are no passthrough escapes. Blocks that never reach us at all include configuration.provider_config, provisioners (your remote-exec command strings), variable defaults, generated_config for import blocks, resource identity blocks (which carry account IDs and regions), and the full planned_values / prior_state duplicates.
That is a second line of defence, not a substitute for redaction.
What we store
The complete list of columns in our database is asserted as a literal in packages/backend/src/db/schema.test.ts, so a new one cannot be added without somebody editing that list on purpose. Read that file if you want the whole answer; this is the plain-English version.
About your account: the GitHub installation id, your account login and numeric id, whether the installation is suspended. Per repository: GitHub's numeric repo id, the owner and name, whether it is private, and one boolean recording whether your last configuration file pinned which workflows may report.
About each run: which repository and installation, the pull request number, the head commit sha, the workspace label you chose, the Action's version, the size of the plan in bytes, how many changes it held, how many were classified as noise, the Terraform version, a link to your CI run, whether it succeeded and where it failed if it did not, the model id that wrote the prose, the ids and URLs of the comment and check run we posted, and timestamps.
About each finding: the rule that fired, the severity (and the severity we chose before your config overrode it), the resource address, type, name, module and action, our title and detail sentences, the evidence — attribute paths with the scalar values a rule quoted — the blast radius, and the model-written prose.
Two numbers about your upload rate, overwritten in place. A list of spent webhook delivery GUIDs, which holds no customer data and exists to stop a captured webhook being replayed.
The honest caveat
"We never store your plan" is true. "We store nothing from your plan" is not.
A finding quotes attribute values — that is what makes it a review rather than a diff. So our database holds a description of parts of your infrastructure: which resources are changing, and the specific values a rule thought were worth showing you. That is the smallest set of plan-derived strings this product can be useful with, and it is still real information about your estate. The thirty-day window exists because of it.
Beyond findings, exactly two things from the plan are copied into columns:
runs.terraform_version— the one plan field we copy verbatim.runs.coverage.unsupported— a list of provider names read off the plan, so a comment can say "we have no rules for this stack".
That accounting is enforced rather than promised. scripts/src/eval/persistence.corpus.test.ts runs all forty-two corpus fixtures through the real persistence path and fails if any string in any table occurs in the plan without being either a string that run's findings carried or one of those two declared items.
One more string is worth naming because it comes from your repository rather than your plan: if your .blastradius.yml cannot be read, runs.config_notice stores the one-line reason — which can include a key path from your own file, or the YAML parser's complaint and its line number. Never an excerpt of the file.
It is never written to a disk
scripts/src/eval/filesystem.test.ts asserts, as a literal list, which shipped files in this product name a filesystem module at all. There is exactly one: the Action's entry point, which reads GitHub's event payload and appends the job summary. It touches neither the plan nor anything derived from one.
The backend names none. It could not write a plan to a file if it wanted to.
Retention and deletion
Thirty days, on runs and every finding that hangs off them. It is a constant in the code (packages/backend/src/db/retention.ts), not an environment variable — a number a deploy could typo into "forever" is not a promise.
A sweep runs every six hours, on whichever machine claims a Postgres advisory lock first, deleting in batches oldest-first so a backlog cannot break the purge permanently. A sweep that could not finish logs the fact.
Nothing else expires, and each has a reason: your installation, repositories and upload tokens are your account rather than its history, and expiring a token after a quiet month would give you a red build for doing nothing. The spent webhook GUIDs have to outlive the account they protect. The rate-limit row is two numbers overwritten in place.
Uninstalling deletes everything. Every foreign key in the schema cascades from the installation row, so removing the App is a single DELETE that takes your repositories, upload tokens, runs, findings and every other row keyed to you with it. That is asserted on the keys themselves, not by running one delete and hoping the others behave.
What we cannot delete is the comment and the check run on your pull request. Those live in your repository, under GitHub's retention, and you can delete them yourself.
What goes to a language model
Prose only, and it is the last step.
The model never sees plan JSON, and never decides severity. Severity comes from one file (packages/analyzer/src/severity.ts) that runs before the model is called. What crosses the network is a hand-built projection, named field by field in packages/narrator/src/narrate.ts:
- a position number —
1,2,3— never the finding's real id, which contains your resource address and never leaves our process; - the severity, the rule name;
- the resource address, type and action;
- our own title and detail sentences;
- up to a bounded number of evidence entries — attribute paths and the values a rule quoted;
- the addresses in the blast radius, and a count;
- your repository, pull request number and workspace label, as context.
There is no overload of that function that takes a plan. A field added to a finding cannot widen the payload by accident; it has to be added to the projection on purpose, and a test asserts the projection as a whole value. The invariant tests read the request bytes through an injected fetch, not our own view of them, so an SDK that started appending something would fail them.
If the model call fails for any reason — no key, a network error, a refusal, a malformed response, a finding id it invented — the comment falls back to deterministic prose we wrote. Nothing is lost that you would notice, because every rule's detail sentence is the product's actual floor and the model is a polish pass on top of it.
The model provider is Anthropic. What our code guarantees is what is sent; what happens to it afterwards is governed by Anthropic's commercial terms. We do not train anything on your data and we have no model of our own.
Subprocessors
| Who | What they receive | Status |
|---|---|---|
| GitHub | Everything, by definition — it is where your code, your pull request, our comment and our check run live. We hold an App installation on your account. | In use |
| Anthropic | The finding projection described above. Never plan JSON. | In use |
| Neon | Hosts the Postgres database: your account rows, runs and findings. | In use |
| Fly.io | Runs the backend process. Your redacted plan passes through its memory; nothing is written to its disks. | In use |
| Sentry | Error reports — see below. Optional; unset, nothing is installed and errors go to stderr only. | In use |
| Cloudflare | DNS for the product domain, and the static site this page is published on. Email routing for the address at the bottom of this page. | In use |
What our access to GitHub actually is
The App asks for four repository permissions and nothing else:
| Permission | Why |
|---|---|
| Pull requests — read & write | To post and update the one review comment |
| Checks — read & write | To post the check run the merge button reads |
| Contents — read-only | To fetch .blastradius.yml, and nothing else |
| Metadata — read-only | Mandatory; GitHub preselects it |
Contents: read is the one worth explaining, because it sounds like "reads your code". In practice the backend makes one contents request: GET on .blastradius.yml (or .blastradius.yaml) at your repository's default branch, capped at 64 KB. There is no other path in the code that reads a file from your repository.
The GitHub client is ours rather than a library, and it is a short, fixed list of paths — mint an installation token, read a pull request, read that one file, list / create / update the review comment, create and update the check run, and (for the sign-in page only, with your token) read your identity and your installations. All of them are in packages/backend/src/github/.
We receive four webhook events, and we chose two of them. installation and installation_repositories are sent to every GitHub App automatically and no App can unsubscribe from them — they are how we learn we have been installed, removed, or given access to a different set of repositories. The two we subscribed to are pull_request, and repository, the last only so that a renamed or transferred repository does not silently stop being reviewed. Every delivery is HMAC-verified before it is parsed, and each delivery GUID is spent once, so a captured delivery cannot be replayed.
The upload token
A repository-scoped credential you mint yourself, signed in with GitHub, at /app. It authorises uploads for exactly one repository.
- The secret is 256 bits from a CSPRNG. We store a SHA-256 of it and never the value; the plaintext is shown once, at creation.
- It is checked in constant time against one row, so we can tell "revoked" from "never issued".
- Rotation is additive — mint the new one, then revoke the old — so rolling a token never gives you a red build.
- Losing the App's access to a repository deletes its row and its tokens with it. There is no soft delete, because a soft-deleted token is one that keeps working if anybody forgets a check.
An upload token alone is not enough to report a verdict. Every upload also carries a GitHub-signed Actions ID token, bound to the repository and to the pull request the run is for, and the check run is posted only on the commit GitHub itself calls that pull request's head. You can narrow it further with the workflows: key in .blastradius.yml, which pins which workflow file may report.
Signing in to the token page
Browser sessions are a single encrypted cookie — AES-256-GCM over your GitHub user token, two hours, httpOnly, SameSite=Lax, Secure over TLS. There is no sessions table, so a stolen database backup contains no sessions. The cost, stated plainly: a session cannot be revoked before it expires, which is why it is two hours.
Who may see and issue what is decided by GitHub, asked on every request, using your token — not by our tables. The repository list is filtered to those where you have admin, because issuing an upload token is equivalent to creating a repository secret. Nothing about it is cached.
Who at BlastRadius can see your data
One person operates this product, and the honest answer is that anybody holding the database credential can query the database. What is worth stating is what the tooling does, because tooling is what gets used day to day.
There is one internal dashboard, at /admin, behind a shared secret and separate from the customer-facing page in every respect — no shared session, no shared cookie, no shared code path. It shows installs, runs per day, the error rate, which pipeline stage failures happen in, and which rules are firing.
Nothing derived from a plan is on it, and that is kept by a projection rather than by discipline: every query names its columns one at a time, and what comes back is a type with nowhere for a finding to sit. resource_address, title, detail, evidence and prose — the columns that describe your infrastructure — are not read by any of them. The guard is a test that seeds a run whose every plan-derived column carries a distinctive string and asserts the rendered page contains none of them.
What the dashboard does show from a customer is the address of the thing that broke: an account login, a repository name, a workspace label, a pull request number. None of it comes from a plan.
If the secret is unset, the route is not registered at all and the URL is a 404. If it is set to something short, the process refuses to start.
Logs and error reports
packages/backend/src/log.ts names every field a log line may carry, one at a time. A caller cannot add a field, and a value carrying an undeclared key has it dropped rather than printed.
What a line can hold: uuids from our own tables (installation, repo, run), the pull request number, the route pattern (never the requested path), the HTTP method from a closed set, an error code from a closed set, a reason string chosen from string literals in our own source, an error's class name and one stack frame — never an error's message — the workflow reference off a GitHub-signed token, and a count of rows a retention sweep deleted.
Never a resource address, an attribute value, a finding, a plan fragment or a configuration file. That is enforced by three layers: the field inventory, a scrubber that flattens and bounds every string, and packages/backend/src/log.corpus.test.ts, which runs the real pipeline over a plan whose every string is a canary and asserts that none of them reaches a log line or a Sentry envelope.
Sentry gets the same record and nothing else. There is no Sentry SDK — the whole point of one is to collect context automatically (an error's message, a stack, breadcrumbs off every console call), and every one of those is a channel this product refuses. What is sent is one envelope containing the event code, the same fields the log line carried, and two tags. Only errors go; a customer being over their rate limit or having a misconfigured workflow is a support answer, not an exception.
Limits, so a busy repository knows where the edges are
| Upload size | 10 MB, refused above that |
| Upload rate | a burst of 120 per installation, refilling at 60 a minute |
| Concurrent uploads in flight, per machine | 4, with a bounded queue |
| Configuration file | 64 KB |
These are set far above real traffic, deliberately: a wrongly refused upload costs you a review.
Questions, and things this page does not cover
The terms of service and the privacy notice are pages on the public site rather than files in this repository, and deliberately: neither is a claim about code, so neither is a thing a test could hold to anything. This page is the technical statement, and it is the one that will still be checkable against the code.
Anything else, or a claim here you think is wrong: security@blastradiusapp.com.