# Building a STRIDE-Aware Fraud-Pattern Review Agent on Amazon Bedrock

> Source: <https://dev.to/raj_mohamed_3de1e4c27eacc/-building-a-stride-aware-fraud-pattern-review-agent-on-amazon-bedrock-df5>
> Published: 2026-10-21 06:08:13+00:00

A step-by-step build: from model access to a deployed agent, with every console screen along the way.

**Raj Mohamad Sabibullah** · Senior Solution Architect

I spend most of my week in the unglamorous middle layer of a digital bank build: a card management system on one side, a core banking system on the other, Kafka topics carrying every authorization and reversal in between, and a security team that wants a threat model for anything that so much as reads a transaction record. It is not the part of the stack that gets a conference talk. But it is exactly the part where agentic AI turned out to be more useful than I expected — not as a chatbot bolted onto a mobile app, but as a colleague sitting inside the fraud-ops workflow, reading runbooks faster than any analyst can, and never seeing a live PAN.

This post is the build log I wish I'd had going in: every step from requesting model access to a working CLI invocation, in order, with the console screen at each step. It's long on purpose — a previous, shorter version of this post got fair feedback that it skipped the "how," so this one doesn't skip anything. Account IDs, transaction references and internal system names below are illustrative, not real; the steps, screens and configuration are the real sequence I used.

**What we're building** An agent that takes a flagged transaction reference from a cards-ops analyst, classifies it against a STRIDE-based fraud taxonomy, cites the PCI DSS 4.0 control it relied on, and returns a risk rating — without ever seeing or returning an unmasked card number.

On this page

Before touching the console, make sure you have:

`eu-central-1` for data-residency reasons — check what your own compliance scope requires before picking a region).
With that in place, the build runs in the order below — each step depends on the one before it.

Bedrock model access is granted per account, per region, and it is not on by default. This trips up almost everyone the first time — an agent creation will fail silently referencing a model you haven't been granted.

Go to **Amazon Bedrock → Model access** and request access to the models you need. For this build that's Claude 3.7 Sonnet (the agent's reasoning model) and Titan Text Embeddings V2 (for the knowledge base).

Model access is a one-time, per-region grant. Approval for Anthropic models is usually instant; some providers require a use-case form.

Access typically activates within a few minutes for Anthropic and Amazon models. Confirm the status shows **Access granted** before moving on — every later step assumes this is done.

The agent's domain knowledge — STRIDE runbooks, the PCI DSS control mapping, prior incident write-ups — needs to live somewhere Bedrock can ingest it from. Create a dedicated S3 bucket and upload the source documents.

bash

```
aws s3 mb s3://stride-runbooks-kb-src-eucentral1 --region eu-central-1
aws s3 cp ./runbooks/ s3://stride-runbooks-kb-src-eucentral1/ --recursive
```

One file per STRIDE category kept retrieval clean later — a runbook that blends two categories produces a knowledge-base chunk that blends two answers.

A few things that mattered here:

This is where the runbooks become something the agent can retrieve from. Go to **Amazon Bedrock → Knowledge bases → Create knowledge base** and work through the wizard.

The first two steps of the wizard (name/description, then pointing at the S3 data source) are straightforward. The step worth slowing down on is vector store configuration:

Chunk size was the single highest-leverage tuning knob in this whole build — more than model choice or prompt wording.

Configuration choices that mattered:

After the wizard finishes, **sync the data source** (Knowledge base → Data source → Sync) so the documents in S3 are actually embedded and indexed — creating the knowledge base does not automatically ingest the files. Sync typically takes a few minutes for a handful of PDFs; watch the sync history for failures before assuming it worked.

Every Bedrock agent runs under an IAM role that Bedrock assumes to invoke the model, retrieve from the knowledge base, and call your action-group Lambdas. This is the control that turns "an LLM agent" into "an LLM agent that provably cannot write to your core-banking system," and it's worth building before you build the agent itself.

The permissions boundary is the layer that matters most: even if the Lambda code changes next year to add a write path, the boundary denies it at the IAM layer regardless of what the code tries to do.

Two design decisions here, not just defaults:

`lambda:InvokeFunction` on `*`. This is more setup work and it is the difference between "the agent can call three specific, reviewed functions" and "the agent can call any Lambda in the account," which is not a sentence I wanted to say to our security team.
The trust relationship restricts `sts:AssumeRole` to the `bedrock.amazonaws.com` service principal, scoped further with an `aws:SourceAccount` condition — standard practice, worth double-checking it's actually there rather than assumed.

The agent doesn't touch any real system directly — every action it can take is a Lambda function behind an action group. We built three:

| Function | Purpose | Access | 
|---|---|---|
| `cms-read-proxy-fn` | Looks up a transaction by reference ID, returns a masked record | Read-only, scoped IAM role into the CMS read replica | 
| `stride-classifier-fn` | Scores the transaction against STRIDE categories using rules + recent MSK velocity features | Reads from MSK consumer group, no write access anywhere | 
| `sns-notify-fn` | Publishes an alert to the cards-ops on-call topic when risk is HIGH | `sns:Publish` on one topic only | 

A trimmed version of the transaction-lookup handler, showing the masking that happens before anything reaches the model:

python · cms-read-proxy-fn

``` python
import json
import boto3

def lambda_handler(event, context):
    reference_id = event["parameters"][0]["value"]
    record = fetch_from_cms_readonly(reference_id)  # internal read-only client

    # Mask before this ever leaves the function — the agent and the
    # guardrail are a second line of defense, not the first.
    masked_pan = "•••• •••• •••• " + record["pan"][-4:]

    response_body = {
        "referenceId": reference_id,
        "amount": record["amount"],
        "currency": record["currency"],
        "maskedPan": masked_pan,
        "deviceFingerprint": record["device_fingerprint"],
        "authCount90s": record["velocity_90s"],
        "merchantCategoryCode": record["mcc"],
    }

    return {
        "messageVersion": "1.0",
        "response": {
            "actionGroup": event["actionGroup"],
            "apiPath": event["apiPath"],
            "httpMethod": event["httpMethod"],
            "httpStatusCode": 200,
            "responseBody": {"application/json": {"body": json.dumps(response_body)}},
        },
    }
```

The masking happening inside the Lambda — not left to the model or the guardrail to catch — is deliberate. Guardrails and instructions are the second and third layers; the function itself should never emit a raw PAN in the first place.

Deploy these three functions however your team normally deploys Lambdas (console, SAM, CDK — we moved to CDK for this in step 11). Note each function's ARN; you'll need them for the action group and the IAM role above.

With the knowledge base synced, the execution role scoped, and the Lambdas deployed, go to **Amazon Bedrock → Agents → Create agent**.

Everything the agent can reason with or act on attaches to this one resource: model, instructions, action groups, knowledge base, and guardrail.

Fill in, in order:

`fraud-pattern-review-agent`.
agent instructions

```
You are a fraud-pattern review assistant for the XY Bank Cards Workstream.
Given a transaction reference, cross-check STRIDE threat categories against the
knowledge base of card-fraud runbooks, summarize risk indicators in plain
language, cite the matching control from PCI DSS 4.0 / CBE regulation where
applicable, and NEVER return raw PAN, CVV, or cardholder PII in your response.
Always call the transaction-lookup action before concluding a risk rating.
```

Three lessons baked into that wording:

**State the negative constraint as its own capitalized sentence, early.** Buried as a passive aside, it gets ignored more often than as a standalone line.

**Force the tool call explicitly.** Without "always call the transaction-lookup action before concluding," the agent would occasionally answer from a prior turn's context instead of looking the transaction up fresh.

**Name the citation format you want.** Asking for "the matching control from PCI DSS 4.0 / CBE regulation where applicable" produces consistent, checkable citations — paired with contextual grounding in the guardrail (step 8), which stops the model from inventing a plausible-sounding control number it didn't actually retrieve.

Save the agent. It exists now, but it can't do anything yet — no action groups, no knowledge base link, no guardrail. Those are the next three steps.

An action group tells the agent what it can call and how — Bedrock uses an OpenAPI schema to generate the function-calling interface, and a Lambda function to actually execute the call.

Go to the agent's **Action groups** tab and create one per Lambda (`transaction-lookup`, `stride-classifier`, `analyst-notify`). For each, select "Define with API schemas," point it at the corresponding Lambda ARN, and provide the schema.

The OpenAPI schema is what lets the model decide, on its own, when to call this action and with what parameters — get the field descriptions right and the model's tool-use gets noticeably more reliable.

The schema for `transaction-lookup`:

yaml · openapi schema

```
openapi: 3.0.0
info:
  title: Transaction Lookup API
  version: 1.0.0
paths:
  /transaction/{referenceId}:
    get:
      summary: Retrieve a masked transaction record by reference ID
      operationId: getTransaction
      parameters:
        - name: referenceId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Masked transaction record
          content:
            application/json:
              schema:
                type: object
                properties:
                  referenceId: { type: string }
                  amount: { type: number }
                  currency: { type: string }
                  maskedPan: { type: string, example: "•••• •••• •••• 4471" }
                  deviceFingerprint: { type: string }
                  authCount90s: { type: integer }
                  merchantCategoryCode: { type: string }
```

Two practical notes: the `summary` field on each operation matters more than you'd expect — it's what the model reads to decide *when* to call this tool, so write it as a clear instruction, not a terse label. And repeat this for `stride-classifier` and `analyst-notify` with their own schemas before moving on — an agent with only one of three action groups attached will happily answer without ever invoking the other two.

Then, on the agent's main page, link the **knowledge base** created in step 3 under the Knowledge base section, and give it its own retrieval instructions (a short sentence telling the agent when to consult it — e.g., "Use this knowledge base to look up STRIDE categories and their matching compliance controls").

This is the step I'd tell anyone building something similar to budget the most time for — it took longer than the agent and action groups combined, mostly in review cycles with our security team, not console clicks.

Go to **Amazon Bedrock → Guardrails → Create guardrail**.

A guardrail is a separate, versioned resource from the agent — you can update the guardrail and ship a stricter policy without redeploying the agent itself.

Three configuration sections did most of the work:

`CREDIT_DEBIT_CARD_NUMBER` and `CREDIT_DEBIT_CARD_CVV` as built-in PII types, action set to Once created, attach the guardrail to the agent from the agent's main configuration page (visible in the step 6 screenshot above, bottom right). Guardrails are versioned independently of the agent, so tightening a filter later doesn't require touching the agent resource at all — useful when a security review asks for a stricter policy mid-project.

With instructions, action groups, knowledge base, and guardrail all attached, click **Prepare** on the agent (top right of the Agent builder screen) — this compiles the current draft configuration into something invocable. Then open the **Test** window to run it end to end before anything touches a real alias.

A full round trip in the test window: two action-group calls, a STRIDE classification, a cited control, and a masked card number — all before this reaches a real alias.

What to check here, beyond "did it answer":

Iterate on the instructions and chunking here — this loop is fast (no redeploy needed) and it's where most of the actual tuning happens.

The agent's `DRAFT` state is for testing only. To integrate it into anything real, create a **version** (an immutable snapshot of the prepared configuration) and point an **alias** at it — your application code calls the alias, not the draft, so you can promote a new version without changing any calling code.

prod-v1 stays pinned to a reviewed, immutable version. Testing a new instruction wording happens against staging first.

Go to **Aliases → Create alias**, name it (`prod-v1`), and either create a new version from the current draft or point it at an existing one. Keep a `staging` alias around pointed at whatever draft you're actively iterating on — it lets you validate a change against the same integration path production uses, without touching `prod-v1` until you're ready.

Once the console configuration behaves the way you want, capture it as infrastructure-as-code so it ships through the same pipeline as everything else in the workstream, with a clean audit trail for the security sign-off — no console drift between what was reviewed and what's running.

typescript · cdk stack

``` js
const guardrail = new bedrock.CfnGuardrail(this, 'CardsPiiGuardrail', {
  name: 'cards-pii-guardrail-v3',
  blockedInputMessaging: 'This request cannot be processed under current policy.',
  blockedOutputsMessaging: 'This response was withheld by the cards data guardrail.',
  sensitiveInformationPolicyConfig: {
    piiEntitiesConfig: [
      { type: 'CREDIT_DEBIT_CARD_NUMBER', action: 'BLOCK' },
      { type: 'CREDIT_DEBIT_CARD_CVV', action: 'BLOCK' },
    ],
  },
  contextualGroundingPolicyConfig: {
    filtersConfig: [
      { type: 'GROUNDING', threshold: 0.75 },
      { type: 'RELEVANCE', threshold: 0.70 },
    ],
  },
});

const agent = new bedrock.CfnAgent(this, 'FraudPatternAgent', {
  agentName: 'fraud-pattern-review-agent',
  foundationModel: 'anthropic.claude-3-7-sonnet-20250219-v1:0',
  instruction: fs.readFileSync('./agent-instructions.txt', 'utf-8'),
  guardrailConfiguration: {
    guardrailIdentifier: guardrail.attrGuardrailId,
    guardrailVersion: guardrail.attrVersion,
  },
  agentResourceRoleArn: agentExecutionRole.roleArn,
});

const alias = new bedrock.CfnAgentAlias(this, 'ProdAlias', {
  agentId: agent.attrAgentId,
  agentAliasName: 'prod-v1',
});
```

Bootstrap once per account/region if you haven't already, then deploy:

```
cdk bootstrap aws://104829xxxxxx/eu-central-1 --profile xybank-nonprod
cdk deploy FraudPatternAgentStack --profile xybank-nonprod
```

Same agent, same configuration, now deployed through CDK — IAM role, Lambda action groups, guardrail, knowledge base and alias all in one stack.

The stack outputs (`AgentId`, `AgentAliasId`, `KnowledgeBaseId`) are what your calling application needs — capture them in your deployment pipeline's output rather than hardcoding them, since a version promotion can change the alias ID.

With an alias live, the agent is callable via the `bedrock-agent-runtime` API — from the CLI for a smoke test, or from your cards-ops console backend for the real integration.

bash · smoke test

```
aws bedrock-agent-runtime invoke-agent \
  --agent-id FRAUD7X2QK9 \
  --agent-alias-id TSTALIASID \
  --session-id test-001 \
  --input-text "Review txn XYB-2026-88213910" \
  --profile xybank-nonprod
```

For the actual application integration, the same call goes through the AWS SDK from the Lambda orchestrator behind API Gateway (see the architecture diagram below) — the CLI call above is purely for verifying the deployed alias works before wiring up the real request path.

python · orchestrator

``` python
import boto3

client = boto3.client("bedrock-agent-runtime", region_name="eu-central-1")

response = client.invoke_agent(
    agentId="FRAUD7X2QK9",
    agentAliasId="PRODALIASID",
    sessionId=session_id,
    inputText=analyst_query,
)

for event in response["completion"]:
    if "chunk" in event:
        print(event["chunk"]["bytes"].decode())
```

That's the full path — model access, knowledge base, IAM boundary, action groups, agent, guardrail, alias, IaC, invocation. Everything from here is tuning.

Zooming back out, here's how all the pieces built above fit together:

The agent orchestrates through Lambda action groups; nothing writes back to the core-banking system, and PII never leaves the guardrail boundary.

API Gateway and the Lambda orchestrator in front of the agent (left side of the diagram) handle request-level auth against our existing identity-provider session before anything reaches Bedrock — that layer isn't part of the Bedrock build itself, but it's what makes "who called this agent and were they authorized" answerable, which matters as much to a security review as anything inside Bedrock does.

STRIDE was built for software threat modeling, not fraud triage, so we adapted rather than adopted it:

| STRIDE category | What it looks like in a card transaction | Primary control we cite | 
|---|---|---|
| **S** poofing | Card-not-present transaction from a new device fingerprint, no prior history with the payee | PCI DSS 4.0 Req 8.4.2 — MFA for CNP | 
| **T** ampering | Amount or MCC mismatch between authorization and clearing message | ISO 8583 field-level integrity checks | 
| **R** epudiation | Multiple rapid authorizations with no matching customer-initiated event in the channel logs | Non-repudiation logging, regulatory circular on transaction logging | 
| **I** nformation disclosure | Any flow where the agent itself would need to return unmasked PAN/CVV | Bedrock Guardrail sensitive-info filter (this is the one the guardrail exists to stop) | 
| **D** enial of service | Velocity spikes consistent with card-testing rather than genuine usage | Rate-limiting at API Gateway + card-testing runbook | 
| **E** levation of privilege | Any request pattern that resembles probing the core-banking proxy for scopes beyond transaction read | IAM least privilege + CloudWatch anomaly alarms | 

The knowledge base holds one runbook section per row, which is why the chunking decision in step 3 mattered as much as it did — a chunk that blends "spoofing" and "repudiation" guidance produces exactly the kind of blended, half-right answer that erodes an analyst's trust in the tool after two or three uses.

A few honest notes, since most write-ups skip this part:

**The first version of the instructions let the agent answer from memory when knowledge base retrieval came back thin.** Turning on contextual grounding in the guardrail (step 8) fixed this more reliably than any amount of prompt tuning — treat grounding as a guardrail-layer control, not a prompt-layer one.

**Chunk size was the single highest-leverage tuning knob**, above model choice, above temperature, above instruction wording. If your retrieval quality is inconsistent, look at chunking (step 3) before you look at the model.

**IAM scoping (step 4) took longer than every other piece of this build combined.** Getting the security team comfortable with "an LLM agent has a role that can invoke this Lambda" required the permissions boundary, a CloudTrail-backed review process, and a tabletop walkthrough of what happens if the model is prompt-injected through a transaction memo field. Budget for that conversation; it's not a formality in a cards environment.

**I'd add a human-in-the-loop confirmation step before `sns-notify-fn` fires in production**, even though it's a read-only-adjacent notification. An agent that pages someone at 3 a.m. on a false positive burns trust fast.

None of this is exotic. It's the same discipline any of us would apply to a human-facing fraud process — least privilege, grounded evidence, a clear audit trail — applied to an agent instead of a person. The interesting part isn't that Bedrock can do agentic orchestration; it's that the hard 80% of shipping it in a bank was identical to the hard 80% of shipping anything else in a bank.

The natural extension is multi-agent collaboration — a supervisor agent that routes between this fraud-pattern reviewer and a separate compliance-documentation agent for SAR (suspicious activity report) drafting, which Bedrock now supports natively. I haven't shipped that part yet, so I'll leave it for a follow-up post once it's been through a security review rather than write about it speculatively here.

If you're building something similar on a legacy cards or core-banking stack, I'd genuinely like to compare notes — particularly on how you scoped IAM for action groups touching a system of record, and how your security team handled the guardrail sign-off. Drop a comment or find me on LinkedIn.
