DEV Community

Cover image for Serverless IaC Risk Analysis: The Architecture Behind Blast Radius
Scott Burgholzer for AWS Community Builders

Posted on Originally published at blog.scottburgholzer.tech

Serverless IaC Risk Analysis: The Architecture Behind Blast Radius

Series: Introducing Blast Radius — See What Breaks Before You Deploy

The first two articles in this series were about what Blast Radius does and how to wire it into your pipeline. This one is about how it's built. If you've ever wanted to design a system that analyzes infrastructure changes before they deploy, or you're just curious how the risk scores in your PR comment actually get calculated, this is the under-the-hood tour.

I'm going to walk the same path the data walks: from the moment you submit a changeset, through format normalization, dependency discovery, scoring, the AI layer, and out to the frontend. Along the way I'll call out the decisions that mattered and a few that cost me a day to get right.

Four Constraints That Shaped Everything

Every meaningful decision in Blast Radius traces back to one of four constraints. It's worth stating them up front, because the rest of this article is really just the consequences.

It has to accept multiple IaC formats without the analysis engine knowing which one. CDK, CloudFormation, and Terraform describe the same change three different ways. If the scoring logic had to branch on "is this Terraform or CloudFormation," every feature would multiply across every format, and adding a fourth tool would mean touching code everywhere. The engine has to see exactly one shape.

It's asynchronous by nature. A real analysis takes 10-30 seconds. It's making live calls to AWS Config and Resource Explorer, walking a dependency graph, and optionally waiting on a Bedrock model. You can't hold an API Gateway request open that long. The "submit" step and the "give me the answer" step have to be decoupled, which means state has to live somewhere in between.

It should cost nothing at idle and scale with concurrent PRs. This is a tool that runs when someone opens an infrastructure PR. That might be zero times today and forty times tomorrow. Paying for an always-on cluster to serve bursty, unpredictable traffic is the wrong shape. Serverless bills per execution and scales out without anyone tuning autoscaling groups.

The output has to be explainable. A gate that says "blocked: risk 82" and can't tell you why is a gate people rip out the first time it's wrong. Every number the system produces has to decompose back into the inputs that created it. That constraint is the reason the scoring formula is three weighted factors and not a trained model.

Multi-format, async, serverless, explainable. Keep those in mind. Everything below is a response to one of them.

The Canonical Format — The One Decision That Matters Most

If I had to point to a single choice that made the rest of the system tractable, it's this: normalize every input into one canonical format at the edge, then build exactly one analysis engine behind it.

Here's the problem it solves. The same "replace this EC2 instance" event looks completely different depending on who's describing it:

  • Terraform encodes a replacement as an action array: ["delete", "create"]
  • CloudFormation encodes it as Action: "Modify" with Replacement: "True"
  • CDK calls it changeType: "REPLACE"

Three tools, one real-world event, three vocabularies. If discovery and scoring had to understand all three dialects, they'd carry that translation burden forever. Instead, an adapter translates each dialect into a single normalized record the moment it enters the system:

{
  resourceType: "AWS::EC2::Instance",
  resourceId: "i-abc123",
  provider: "aws",
  modificationType: "Replace"  // one consistent representation
}
Enter fullscreen mode Exit fullscreen mode

From that point on, nothing downstream knows or cares where the change came from. Dependency discovery, risk scoring, visualization prep; they all operate on canonical manifests. Terraform-specific and CloudFormation-specific knowledge is quarantined inside the adapters.

The payoff shows up when you add a new tool. Supporting Pulumi or Ansible isn't a cross-cutting change; it's one adapter. You write a function that turns the new tool's diff into canonical records, and the entire engine works unchanged. That's the first constraint satisfied structurally, not by discipline. The architecture makes the wrong thing hard to do.

This is also why the pipeline decides whether to run an adapter as its very first branch, before anything else. The sourceFormat field only exists on the original input; once the manifest is canonical, that information is intentionally gone. So the system asks "does this need conversion?" up front. If the input is already canonical, it skips the adapter entirely and goes straight to validation.

The canonical format funnel: three IaC formats converging through adapters into one manifest and one engine
Three formats, three adapters, one canonical manifest, one engine.

Why Step Functions Orchestrates the Pipeline

Given the async and serverless constraints, something has to coordinate a multi-step workflow where each step is a Lambda, steps can fail independently, and the whole thing runs unattended for half a minute. The candidates were the usual ones: chain Lambdas with SQS between them, fan events through EventBridge, or write a custom orchestrator.

I chose Step Functions, and the reason is worth unpacking.

SQS and EventBridge are great for decoupling events, but they're poor at expressing a workflow. The moment you need "run A, then B, then conditionally C, and if any of them fails, mark the whole thing failed," you end up implementing a state machine on top of queues: visibility timeouts, dead-letter queues, correlation IDs, and no single place to see where an execution actually is. A custom orchestrator has the same problem, plus you own all the retry and error semantics yourself.

Step Functions gives you the workflow as a first-class, inspectable object. The full pipeline reads almost like the outline of the analysis itself:

Is format "canonical"?
  No  → Adapter converts to canonical → prepare state
  Yes → skip adapter
→ Ingestion validates the manifest
→ Progress update (20%)
→ Resource Resolver discovers dependencies (AWS Config)
→ Progress update (40%)
→ Risk Assessor scores each resource
→ Progress update (60%)
→ Visualization Prep formats for the frontend + S3
→ Progress update (80%)
→ Is enableSummary = true?
  Yes → Risk Summary generates the AI explanation
  No  → skip summary
→ Mark analysis complete (100%)
Enter fullscreen mode Exit fullscreen mode

Two implementation details in there are load-bearing, and both were the source of real bugs during development.

State preservation with resultPath

Each step needs the outputs of prior steps, not just its own immediate input. The naive approach — letting each Lambda's return value replace the state — throws away everything that came before. The pipeline uses resultPath for Discovery, Scoring, and Visualization Prep, which nests each step's output under a new key instead of overwriting the state object:

After Ingestion:  { analysisId, sourceFormat, validatedManifest, options }
After Discovery:  { ...above, discoveryResult: { dependencyGraph, coverage } }
After Scoring:    { ...above, scoringResult: { scoredResources, riskSummary } }
After VisPrep:    { ...above, visualizationResult: { ... } }
Enter fullscreen mode Exit fullscreen mode

That's why Scoring can be handed an explicit payload of { dependencyGraph: $.discoveryResult.dependencyGraph, manifest: $.validatedManifest } — the state still carries both. Using outputPath here instead of resultPath would silently drop the manifest, and the failure wouldn't surface until scoring tried to read a field that no longer existed.

No zombie analyses

Every task state catches States.ALL. On any failure, the pipeline routes to UpdateStatusFailed (which marks the analysis failed in DynamoDB) and then to a terminal PipelineFailed state. This is a small thing that matters enormously for trust: it guarantees the frontend never shows an analysis stuck "running" forever. A crashed Lambda becomes a clean "failed" status the UI can render and the CLI can exit on, instead of a poll loop that never resolves. The CLI's stale detection (five unchanged polls = assumed failure) is a backstop, but the pipeline's catch-everything design is the primary guarantee.

The conditional branches — adapter-or-not at the front, summary-or-not near the end — are the reason Step Functions fits. The summary check uses Condition.and(isPresent('$.options.enableSummary'), booleanEquals(...)) specifically so a missing field doesn't crash the choice state. Expressing that kind of branching in a queue-based system is possible but miserable. Here it's a Choice state you can read at a glance.

Dependency Discovery — Reading the Real Graph, Not the Code

This is the part that makes Blast Radius more than a linter. Everything else operates on data; discovery goes and finds the data by asking AWS what's actually wired together in your account.

The Resource Resolver is the most complex Lambda in the system, and the algorithm is a bounded graph traversal. For each changed resource it queries AWS Config for relationships, then recursively walks those relationships up to maxDepth (default 5), building a graph of nodes and edges as it goes. The Config query itself is a SQL-like statement against the configuration aggregator:

SELECT relationships.resourceId, relationships.resourceType,
       relationships.name, awsRegion, accountId
WHERE resourceId = '<resourceId>'
Enter fullscreen mode Exit fullscreen mode

The key insight is why AWS Config. It tracks the live relationship graph: which security group is attached to which network interface, which instance sits behind which load balancer. That's the ground truth IaC tools are blind to. Resource Explorer is the fallback for resources Config doesn't have full relationship data on, and that feeds a coverage classification:

  • Full — Config returned relationship data for this resource.
  • Partial — Resource Explorer found the resource but Config had limited data.
  • Unknown — the resource couldn't be found in either service.

Coverage is surfaced honestly rather than hidden. If the graph is incomplete, the output says so.

Three engineering details keep discovery correct and affordable:

  • Cycle protection. Real infrastructure graphs have cycles. The traversal carries a visited-set so a circular relationship (A depends on B depends on A) terminates instead of looping forever.
  • An LRU cache, 10,000 entries. Deep graphs re-encounter the same resources constantly. Caching Config responses turns a query storm into something far cheaper, and the resolver reports cacheStats: { hits, misses } so the effect is measurable.
  • Filtering incomplete relationships at the source. Config sometimes returns relationship entries with a missing resourceId. If those slipped through, they'd become orphan nodes and dangling edges — a graph that renders wrong and scores wronger. They're dropped the moment they come back from Config, which is the cleanest place to enforce the invariant. (The API layer filters again before handing data to the frontend, as defense in depth.)

The resolver runs with 1024 MB of memory and a 90-second timeout. Config result sets can be large, and this is the one step where being generous with resources pays off. It also uses the same retry-with-backoff wrapper as every other AWS call in the system: transient throttling retries, hard failures mark coverage unknown and let the pipeline continue rather than aborting the whole analysis over one unresolvable resource.

The Risk Formula — Deliberately Simple

The explainability constraint is most visible here. It would have been easy, and worse, to train a model. Instead, every resource's risk is a weighted sum of three factors, each normalized to 0-100:

impactScore = round(
  (depthScore        × 0.30) +
  (criticalityScore  × 0.40) +
  (changeTypeSeverity × 0.30)
)
Enter fullscreen mode Exit fullscreen mode

The three factors answer three intuitive questions:

Factor Question it answers Weight Behavior
Depth How far from the change? 30% Depth 1 = 100, depth 5 = 60, depth 10 = 10
Criticality How important is this resource type? 40% Database = 100, Lambda = 75, S3 = 50, log group = 25
Change severity How dangerous is the action? 30% Remove = 100, Replace = 80, Modify = 50, Add = 30

Criticality carries the most weight, and that's on purpose. What is affected matters more than how close it is or what's being done to it. Deleting a throwaway log group one hop away is genuinely less scary than modifying something three hops from a production database, and the weights are tuned so the numbers agree with that intuition. A Critical database at depth 1 hit by a Remove scores 100 (maximum danger); a Low log group at depth 5 hit by a Modify lands around 35 (medium, probably fine). Depth and change type split the remaining 60% evenly because both matter and neither should dominate.

The score-to-category mapping is a plain linear cut, and that's intentional too:

Score Category
75-100 Critical (red)
50-74 High (orange)
25-49 Medium (yellow)
0-24 Low (green)

No curves, no thresholds that shift based on the graph. A reviewer can look at any score, ask "which factor drove that," and get a straight answer. Each scored resource even carries its dependencyChain — the ordered breadcrumb like sg-abc123 → ec2-instance-1 → rds-prod — and its highestRiskPath, the actual worst-case edges. So "why is this an 82" is always answerable from the resource itself. That auditability is the whole point. A gate people trust is a gate they can interrogate.

The AI Layer — Advisory, Structured, and Optional

The scoring formula is good at "how bad is each individual resource." It's deliberately bad at "is there a systemic pattern here." A fan-out where ten resources each score a harmless 65 but all hang off the single security group you're touching. Any one is fine; all ten failing together is an outage. That's the gap the AI layer fills.

Three principles govern how it's wired in.

The AI sees the whole graph; it doesn't compute the scores. There's a clean division of labor. The deterministic engine produces the scored resources and the dependency graph. The Risk Summary Lambda hands that entire picture to a Bedrock model (an Anthropic Claude inference profile by default) and asks for a judgment about the deployment as a whole. The model isn't second-guessing arithmetic. It's reasoning about structure the arithmetic can't see.

Its output is structured, not prose-only. The Lambda parses the model's response into a typed shape:

{ "summary": "…markdown…", "recommendDeploy": true, "confidence": "high" }
Enter fullscreen mode Exit fullscreen mode

That structure is what makes the --ai-gate from the previous article possible. A CI gate can't act on a paragraph of English; it can act on recommendDeploy: false. The natural-language summary gets written back into the S3 visualization.json so the frontend and PR comment can render it, but the decision is a boolean the pipeline can branch on.

It's optional at every level. The summary step is a conditional branch in the state machine, gated on enableSummary. The Lambda itself checks both ENABLE_BEDROCK_SUMMARY and ENABLE_BEDROCK before doing anything. If Bedrock is disabled server-side, threshold gates keep working perfectly and the AI gate returns a clean error rather than a confusing empty result. This matters because Bedrock model access isn't universal. The system has to be fully useful without it, and it is.

Frontend and API — Where the Shapes Meet

Two design decisions on the presentation side are worth surfacing, because they're the kind of thing that's invisible when it works and infuriating when it doesn't.

Runtime config, not build-time config. The frontend is a static React SPA served from CloudFront, but it needs to know the API Gateway URL, which isn't known until the stack deploys. Baking the URL in at build time would mean rebuilding the frontend for every deployment and every environment. Instead, CDK writes a /config.json alongside the static assets, and the SPA fetches it at runtime to resolve the API URL. The same build artifact works across environments and in both auth and no-auth modes. It's a small pattern that removes a whole category of "works in staging, wrong URL in prod" bugs.

The API translates between two vocabularies. The pipeline's internal visualization format speaks in nodes and edges. The frontend wants scoredResources and a dependencyGraph shaped for Cytoscape.js. Rather than push that translation into the React app, where it'd be re-implemented and drift, the API handler does the mapping in one place. It also does the final orphan-node and dangling-edge filtering here, so the frontend is guaranteed a clean, renderable graph and never has to defend against malformed input. Put the adaptation at the boundary, keep the consumer simple.

Lessons Learned

A few things this system taught me that generalize well beyond Blast Radius.

Node.js 22 Lambda handlers must be async. This one cost real time. On the Node.js 22 runtime, a synchronous handler returns null — the runtime resolves before your work does. The adapters were the victims: written as plain synchronous functions, they'd "succeed" and hand the pipeline a null manifest, and the failure surfaced three steps later in discovery where it made no sense. The fix is trivial (make handlers async); finding it was not. If you're on a recent Node Lambda runtime, make every handler async by default and save yourself the afternoon.

Dependency injection beats module mocking for testability. Every Lambda that talks to AWS takes an optional deps parameter. In tests you pass mock clients directly; in production the Lambda builds its own. The subtle part is how you detect which you got, because the Lambda runtime passes the Context object as the second argument, which is truthy. A naive deps ?? createDefaultDeps() would treat the Context as your dependencies and explode. The pattern that actually works is a shape check: deps && 'configClient' in deps ? deps : createDefaultDeps(). It looks fussy, but it's the difference between tests that inject cleanly and a production crash that only reproduces in the real runtime. (The next article goes deep on the testing philosophy this enables: property-based tests, and zero vi.mock() calls.)

Long-running pipelines need progress, not just a spinner. A 30-second analysis with no feedback feels broken. The pipeline emits explicit progress updates at fixed milestones — 20% after ingestion, 40% after discovery, 60% after scoring, 80% after visualization, 100% at completion — written to DynamoDB where the frontend and CLI can poll them. The percentages are coarse on purpose. They map to real pipeline stages rather than a fake animation, so "stuck at 40%" is actually diagnostic information (discovery is slow or wedged) rather than decoration.

Wrapping Up

Every one of those decisions maps back to a constraint. The canonical format is how Blast Radius supports multiple IaC tools without the engine ever knowing which one produced the change. Step Functions is how a 30-second workflow runs asynchronously without a zombie analysis in sight. Serverless primitives are how it costs nothing when nobody's opening a PR. And the transparent three-factor formula is how a risk score stays something you can argue with instead of something you have to take on faith.

Next in this series, we get into the build itself:

  • Article 4: 349 Tests, Zero Mocks — Building Blast Radius in TypeScript: the engineering story. Property-based testing with fast-check, dependency injection over module mocking, the adapter pattern in practice, and an honest retrospective on what I'd do differently.

Top comments (0)