【検証】VPCからBrave Searchを使う:Network Firewallで api.search.brave.com だけ穴を開けて、Web検索・AI回答が通るか試す

【検証】VPCからBrave Searchを使う:Network Firewallで api.search.brave.com だけ穴を開けて、Web検索・AI回答が通るか試す

Fargate/AgentCore上で動くAIエージェントにBrave Search APIを使ったWeb検索機能を組み込む際、AWS Network Firewallで安全に外向き通信を許可する方法を、実際のハンズオンで検証しました。`api.search.brave.com`という1つのドメインを許可するだけで、Web検索からAI回答(要約)まで全機能が通ることを確認しています。
2026.06.04

こんにちは、けーまです。

FargateやAgentCore上で動くAIエージェントに、Web検索機能を組み込みたいという要望をよく聞きます。
ただ、企業のネットワーク要件では、VPCから外部インターネットへの通信を自由には許さず、AWS Network Firewallのようなファイアウォールで「許可した宛先だけ」に穴を開けて通すのが一般的です。
そこで気になるのが、Web検索を足すために開ける穴はいくつ必要か、です。

Brave Search APIのドキュメントを確認すると、Web SearchやSummarizer(AI要約)などのエンドポイントは、いずれも api.search.brave.com のサブパスに集約されています。
例えば、Web Searchのリクエストは次のように api.search.brave.com 宛に送ります。

curl "https://api.search.brave.com/res/v1/web/search?q=machine+learning+tutorials&freshness=pw"

引用元: API ドキュメント: Web Search API | Brave Search API

つまり、Network Firewallで api.search.brave.com の1ドメインだけ許可すれば、AIエージェントのWeb検索もAI回答も通るはずです。
本記事では、この見立てが本当に成り立つかを実際に検証します。
単一VPCとNetwork Firewallの最小構成を組み、api.search.brave.com を1ドメインだけ許可した状態で、curl・Pythonプログラム・AI回答(要約)まで本当に動くかを確認しました。
2026年6月時点で、東京リージョン(ap-northeast-1)に実際にデプロイして検証した結果をまとめています。
AIエージェントに検索機能を足すときの、ファイアウォールの穴の開け方の参考にしていただければと思います。

対象読者:VPCから外部API(Web検索API等)を呼ぶためにファイアウォールの穴あけを検討している方、AWS Network Firewallのドメインフィルタ(SNIフィルタ)を試したい方。

1. このハンズオンで作るもの

CDKで次の土台を一発デプロイし、その後にCLIで「ドメインの穴あけ」を体験します。

  • 単一VPC・単一AZ。3層のサブネット(workload / public(NAT) / firewall)
  • AWS Network Firewall(ステートフル・初期は全egress遮断)
  • 検証用 EC2(Amazon Linux 2023)。SSM Session Manager で接続
  • SSM・Secrets Manager 用の VPCインターフェースエンドポイント
  • Brave APIキー用の Secrets Manager シークレット

ドメイン許可(api.search.brave.com)は、あえてCDKに含めていません。
それをデプロイ後にCLIで開けるのが、このハンズオンの主役です。

今回いちばん確かめたいのは、api.search.brave.com の1ドメインだけを許可した状態で、Web検索からAI回答(要約)まで、Brave Search APIの機能がひととおり動くか、です。

2. 構成とトラフィックの流れ

2.1 全体構成

今回検証した構成の全体像です。

VPCからBrave Search APIへegressする構成図。単一VPCに3層のサブネットを置き、Network Firewallでドメインを判定する
単一VPC構成。セキュリティグループはTCP443にポートを絞り、宛先ドメインの判定はNetwork Firewallが担う

通信は workload サブネットのEC2から出発し、NAT Gateway → Network Firewall → Internet Gateway の順に経由して api.search.brave.com へ届きます。
workloadサブネットのEC2に付けたセキュリティグループは、外向きをポート443に絞るだけで、宛先IPは 0.0.0.0/0 のままです。
どのドメインに出られるかはNetwork FirewallがSNIを見て判定します。
APIキーは、インターネットに出ず、VPCエンドポイント経由でSecrets Managerから取得します。

2.2 戻りトラフィックもNetwork Firewallを通す

行き(EC2 → インターネット)だけでなく、戻り(インターネットからの応答)も必ずNetwork Firewallを通す必要があります。
今回使うドメイン(SNI)フィルタは「ステートフル」な検査で、1つの通信の行きと戻りを同じ経路でまとめて見る必要があるためです(Network Firewallにはステートレスな検査もありますが、ドメイン判定はステートフル側で行います)。
戻りがNetwork Firewallを経由しないと検査が非対称になり、通信が壊れます。

そこで、Internet Gatewayに「edge route table」を関連付け、戻り(宛先=NATのいるpublicサブネット)をNetwork Firewallへ迂回させます。
上の構成図で、Internet GatewayからNetwork Firewallへ戻る赤い点線がこの経路です。

本番では、複数のアプリVPCを Transit Gateway で集約し、中央のegress VPCに置いたNetwork Firewallでまとめて外向きを検査する構成がよく採られます。
今回の検証は「1ドメインだけ許可すれば通るか」を確かめることが目的なので、CDK1ファイルで確実に動かせる単一VPC・単一AZに寄せます。

3. 前提環境

必要なもの 内容
AWS アカウント 検証用アカウント
リージョン 本記事は ap-northeast-1(東京)で実施
Node.js / AWS CDK Node.js 18以上、AWS CDK v2
AWS CLI v2 Network FirewallのCLI操作・SSM接続に使う
Session Manager プラグイン aws ssm start-session に必要
Brave Search API キー AWS Marketplace経由でサブスクライブして取得

Brave Search API は AWS Marketplace にあります。
AWS Marketplaceで「Brave Search API」をサブスクライブし、Braveダッシュボードでキーを発行します(Marketplaceページ:AWS Marketplace: Brave Search API | AWS)。

4. CDKでデプロイする

4.1 app.ts が作るもの

CDKは1つのスタック定義ファイル(app.ts)にまとめています。
app.ts を上から順に追うと、次のものを作っています。

  1. VPC と Internet Gateway10.0.0.0/16 のVPCを作り、IGWをアタッチする。
  2. 3層のサブネット:workload(EC2用・private)/ public(NAT用)/ firewall(Network Firewallエンドポイント用)。すべて単一AZに置く
  3. NAT Gateway:publicサブネットに配置する。プライベートサブネットのEC2はパブリックIPを持たず、外向きはNAT経由にする
  4. Network Firewallのポリシー:ステートフルで STRICT_ORDER、デフォルトアクションを aws:drop_established にする。ルールグループを1つも付けない=全egress遮断の初期状態にする
  5. Network Firewall本体:firewallサブネットにファイアウォールを作る
  6. ルートテーブル4つ:workloadサブネット→NAT、publicサブネット(NAT)→Network Firewall、firewallサブネット→IGW、そしてIGWの戻りをNetwork Firewallへ迂回させる「IGW edge route」。これでNetwork Firewallを必ず通る経路にする
  7. Secrets Manager のシークレット:Brave APIキーのプレースホルダを作る(値は後でCLIで投入する)
  8. VPCインターフェースエンドポイントssm / ssmmessages(SSM接続用)と secretsmanager(キー取得用)。これらをエンドポイント化することで、Network Firewallが全drop/Braveのみ許可でもSSM接続とキー取得がVPC内で完結する
  9. 検証用 EC2:Amazon Linux 2023。SGの外向きは TCP443 to 0.0.0.0/0 とDNS(53)のみ。宛先IPは絞らず、ドメインの制御はNetwork Firewallに任せる

app.ts と設定ファイルの全文は、折り畳みに掲載します。

app.ts(クリックすると展開します)
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
  aws_ec2 as ec2,
  aws_iam as iam,
  aws_logs as logs,
  aws_networkfirewall as netfw,
  aws_secretsmanager as secretsmanager,
} from 'aws-cdk-lib';

// 教材用パラメータ(必要に応じて変更する)
const VPC_CIDR = '10.0.0.0/16';
const WORKLOAD_CIDR = '10.0.1.0/24'; // 検証用 EC2(プライベート)
const PUBLIC_CIDR = '10.0.0.0/24'; // NAT Gateway(パブリック)
const FIREWALL_CIDR = '10.0.2.0/24'; // Network Firewall エンドポイント
const SECRET_NAME = 'brave-search/api-key';
const FIREWALL_NAME = 'brave-egress-fw';

class BraveNfwEgressStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const az = cdk.Fn.select(0, cdk.Fn.getAzs());

    // 1. VPC と Internet Gateway
    const vpc = new ec2.CfnVPC(this, 'Vpc', {
      cidrBlock: VPC_CIDR,
      enableDnsSupport: true,
      enableDnsHostnames: true,
      tags: [{ key: 'Name', value: 'brave-nfw-egress-vpc' }],
    });
    const igw = new ec2.CfnInternetGateway(this, 'Igw', {
      tags: [{ key: 'Name', value: 'brave-nfw-igw' }],
    });
    const igwAttach = new ec2.CfnVPCGatewayAttachment(this, 'IgwAttach', {
      vpcId: vpc.ref,
      internetGatewayId: igw.ref,
    });

    // 2. サブネット3層(すべて単一AZ)
    const workloadSubnet = new ec2.CfnSubnet(this, 'WorkloadSubnet', {
      vpcId: vpc.ref, cidrBlock: WORKLOAD_CIDR, availabilityZone: az,
      mapPublicIpOnLaunch: false, tags: [{ key: 'Name', value: 'workload-private' }],
    });
    const publicSubnet = new ec2.CfnSubnet(this, 'PublicSubnet', {
      vpcId: vpc.ref, cidrBlock: PUBLIC_CIDR, availabilityZone: az,
      mapPublicIpOnLaunch: false, tags: [{ key: 'Name', value: 'nat-public' }],
    });
    const firewallSubnet = new ec2.CfnSubnet(this, 'FirewallSubnet', {
      vpcId: vpc.ref, cidrBlock: FIREWALL_CIDR, availabilityZone: az,
      mapPublicIpOnLaunch: false, tags: [{ key: 'Name', value: 'firewall' }],
    });

    // 3. NAT Gateway(パブリックサブネットに配置)
    const natEip = new ec2.CfnEIP(this, 'NatEip', { domain: 'vpc' });
    const natgw = new ec2.CfnNatGateway(this, 'NatGw', {
      subnetId: publicSubnet.ref, allocationId: natEip.attrAllocationId,
      tags: [{ key: 'Name', value: 'brave-nfw-nat' }],
    });
    natgw.addDependency(igwAttach);

    // 4. Network Firewall(ポリシー+本体)。初期はルールグループ無し=全egress遮断。
    //    drop_strict ではなく drop_established(SNIを読むため接続確立を許す)。
    const policy = new netfw.CfnFirewallPolicy(this, 'FwPolicy', {
      firewallPolicyName: 'brave-egress-policy',
      firewallPolicy: {
        statelessDefaultActions: ['aws:forward_to_sfe'],
        statelessFragmentDefaultActions: ['aws:forward_to_sfe'],
        statefulEngineOptions: { ruleOrder: 'STRICT_ORDER' },
        statefulDefaultActions: ['aws:drop_established'],
      },
    });
    const firewall = new netfw.CfnFirewall(this, 'Firewall', {
      firewallName: FIREWALL_NAME,
      firewallPolicyArn: policy.attrFirewallPolicyArn,
      vpcId: vpc.ref,
      subnetMappings: [{ subnetId: firewallSubnet.ref }],
    });
    // attrEndpointIds は ["<az>:<vpce-id>"] 形式の配列で返る
    const fwEndpointId = cdk.Fn.select(1, cdk.Fn.split(':', cdk.Fn.select(0, firewall.attrEndpointIds)));

    // NFWフローログ
    const fwLogGroup = new logs.LogGroup(this, 'FwFlowLogs', {
      logGroupName: '/brave-nfw/flow',
      retention: logs.RetentionDays.ONE_WEEK,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    new netfw.CfnLoggingConfiguration(this, 'FwLogging', {
      firewallArn: firewall.attrFirewallArn,
      loggingConfiguration: {
        logDestinationConfigs: [
          { logType: 'FLOW', logDestinationType: 'CloudWatchLogs',
            logDestination: { logGroup: fwLogGroup.logGroupName } },
        ],
      },
    });

    // 5. ルートテーブルと経路
    // 5-1. workload → NAT
    const rtWorkload = new ec2.CfnRouteTable(this, 'RtWorkload', { vpcId: vpc.ref, tags: [{ key: 'Name', value: 'rt-workload' }] });
    new ec2.CfnSubnetRouteTableAssociation(this, 'AssocWorkload', { subnetId: workloadSubnet.ref, routeTableId: rtWorkload.ref });
    new ec2.CfnRoute(this, 'WorkloadDefaultRoute', { routeTableId: rtWorkload.ref, destinationCidrBlock: '0.0.0.0/0', natGatewayId: natgw.ref });
    // 5-2. public(NAT) → NFWエンドポイント
    const rtPublic = new ec2.CfnRouteTable(this, 'RtPublic', { vpcId: vpc.ref, tags: [{ key: 'Name', value: 'rt-nat-public' }] });
    new ec2.CfnSubnetRouteTableAssociation(this, 'AssocPublic', { subnetId: publicSubnet.ref, routeTableId: rtPublic.ref });
    new ec2.CfnRoute(this, 'PublicDefaultRoute', { routeTableId: rtPublic.ref, destinationCidrBlock: '0.0.0.0/0', vpcEndpointId: fwEndpointId });
    // 5-3. firewall → IGW
    const rtFirewall = new ec2.CfnRouteTable(this, 'RtFirewall', { vpcId: vpc.ref, tags: [{ key: 'Name', value: 'rt-firewall' }] });
    new ec2.CfnSubnetRouteTableAssociation(this, 'AssocFirewall', { subnetId: firewallSubnet.ref, routeTableId: rtFirewall.ref });
    const fwDefaultRoute = new ec2.CfnRoute(this, 'FirewallDefaultRoute', { routeTableId: rtFirewall.ref, destinationCidrBlock: '0.0.0.0/0', gatewayId: igw.ref });
    fwDefaultRoute.addDependency(igwAttach);
    // 5-4. IGW edge route(戻りをNFWへ)
    const rtIgwEdge = new ec2.CfnRouteTable(this, 'RtIgwEdge', { vpcId: vpc.ref, tags: [{ key: 'Name', value: 'rt-igw-edge' }] });
    const igwEdgeAssoc = new ec2.CfnGatewayRouteTableAssociation(this, 'AssocIgwEdge', { gatewayId: igw.ref, routeTableId: rtIgwEdge.ref });
    igwEdgeAssoc.addDependency(igwAttach);
    new ec2.CfnRoute(this, 'IgwEdgeReturnRoute', { routeTableId: rtIgwEdge.ref, destinationCidrBlock: PUBLIC_CIDR, vpcEndpointId: fwEndpointId });

    // 6. Secrets Manager(プレースホルダ)
    const braveSecret = new secretsmanager.Secret(this, 'BraveApiKey', {
      secretName: SECRET_NAME,
      description: 'Brave Search API key. Set via: aws secretsmanager put-secret-value',
    });
    braveSecret.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY);

    // 7. VPCエンドポイント(ssm / ssmmessages / secretsmanager)
    const endpointSg = new ec2.CfnSecurityGroup(this, 'EndpointSg', {
      vpcId: vpc.ref,
      groupDescription: 'SG for interface endpoints (allow 443 from VPC)',
      securityGroupIngress: [{ ipProtocol: 'tcp', fromPort: 443, toPort: 443, cidrIp: VPC_CIDR, description: 'HTTPS from within VPC' }],
      tags: [{ key: 'Name', value: 'endpoint-sg' }],
    });
    for (const svc of ['ssm', 'ssmmessages', 'secretsmanager']) {
      new ec2.CfnVPCEndpoint(this, `Endpoint-${svc}`, {
        vpcId: vpc.ref,
        serviceName: `com.amazonaws.${this.region}.${svc}`,
        vpcEndpointType: 'Interface',
        subnetIds: [workloadSubnet.ref],
        securityGroupIds: [endpointSg.ref],
        privateDnsEnabled: true,
      });
    }

    // 8. 検証用 EC2(Amazon Linux 2023)。SGの description はASCIIのみ。
    const instanceSg = new ec2.CfnSecurityGroup(this, 'InstanceSg', {
      vpcId: vpc.ref,
      groupDescription: 'Workload SG: egress 443 + DNS only (domain control is on NFW)',
      securityGroupEgress: [
        { ipProtocol: 'tcp', fromPort: 443, toPort: 443, cidrIp: '0.0.0.0/0', description: 'HTTPS out (dest IP not restricted; domain control on NFW)' },
        { ipProtocol: 'udp', fromPort: 53, toPort: 53, cidrIp: VPC_CIDR, description: 'DNS' },
        { ipProtocol: 'tcp', fromPort: 53, toPort: 53, cidrIp: VPC_CIDR, description: 'DNS over TCP' },
      ],
      tags: [{ key: 'Name', value: 'workload-sg' }],
    });
    const role = new iam.Role(this, 'InstanceRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
    });
    braveSecret.grantRead(role);
    const instanceProfile = new iam.CfnInstanceProfile(this, 'InstanceProfile', { roles: [role.roleName] });
    const ami = ec2.MachineImage.latestAmazonLinux2023().getImage(this);
    const instance = new ec2.CfnInstance(this, 'WorkloadInstance', {
      imageId: ami.imageId,
      instanceType: 't3.micro',
      subnetId: workloadSubnet.ref,
      securityGroupIds: [instanceSg.ref],
      iamInstanceProfile: instanceProfile.ref,
      tags: [{ key: 'Name', value: 'brave-workload' }],
    });

    // 9. 出力
    new cdk.CfnOutput(this, 'FirewallName', { value: FIREWALL_NAME });
    new cdk.CfnOutput(this, 'FirewallPolicyArn', { value: policy.attrFirewallPolicyArn });
    new cdk.CfnOutput(this, 'InstanceId', { value: instance.ref });
    new cdk.CfnOutput(this, 'SecretName', { value: SECRET_NAME });
    new cdk.CfnOutput(this, 'SsmStartSession', { value: `aws ssm start-session --target ${instance.ref}` });
  }
}

const app = new cdk.App();
new BraveNfwEgressStack(app, 'BraveNfwEgressStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});
app.synth();
package.json / tsconfig.json / cdk.json(クリックすると展開します)

package.json

{
  "name": "brave-nfw-egress-cdk",
  "version": "1.0.0",
  "private": true,
  "scripts": { "synth": "cdk synth", "deploy": "cdk deploy", "destroy": "cdk destroy" },
  "devDependencies": {
    "@types/node": "^22.10.2",
    "aws-cdk": "^2.1000.0",
    "ts-node": "^10.9.2",
    "typescript": "~5.6.3"
  },
  "dependencies": { "aws-cdk-lib": "^2.170.0", "constructs": "^10.4.2" }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "exclude": ["node_modules", "cdk.out"]
}

cdk.json

{
  "app": "npx ts-node --prefer-ts-exts app.ts"
}

4.2 デプロイ手順

cd cdk
npm install

# 初回のみ:CDKブートストラップ
npx cdk bootstrap --profile <YOUR_PROFILE>

# デプロイ(Network Firewallの作成を含むため5〜10分かかる)
npx cdk deploy --profile <YOUR_PROFILE>

自分の環境では、デプロイ完了まで約6分でした。
完了するとOutputsに次の値が出ます(後のステップで使います)。

# 出力例
BraveNfwEgressStack.FirewallName = brave-egress-fw
BraveNfwEgressStack.InstanceId = i-085a093e0b9a31177
BraveNfwEgressStack.SecretName = brave-search/api-key
BraveNfwEgressStack.SsmStartSession = aws ssm start-session --target i-085a093e0b9a31177

5. 【ステップ1】全ブロック状態を確認する

まずEC2に入ります。
OutputsのコマンドでSSM接続します。

aws ssm start-session --target <InstanceId> --profile <YOUR_PROFILE> --region ap-northeast-1

EC2の中から、Braveと適当な別ドメインへ curl してみます。
デプロイ直後はドメイン許可がまだ無いので、どちらも遮断される想定です。

# ドメイン名がIPアドレスに変換(名前解決)できることを確認
getent hosts api.search.brave.com

# まだ何も許可していないため、どのサイトへの接続もタイムアウト(遮断)されることを確認
curl -sS -o /dev/null -w 'http_code=%{http_code} time=%{time_total}s\n' --max-time 12 https://api.search.brave.com/
curl -sS -o /dev/null -w 'http_code=%{http_code} time=%{time_total}s\n' --max-time 12 https://example.com/

実際の出力は次のとおりでした。

# 出力例
15.197.138.111  api.search.brave.com
3.33.153.106    api.search.brave.com

http_code=000 time=12.001807s   # Brave    → タイムアウト
http_code=000 time=12.000979s   # example  → タイムアウト
curl: (28) Connection timed out after 12001 milliseconds

getent hosts ではドメイン名からIPアドレスが引けている一方、HTTPSはどちらも http_code=000 で12秒タイムアウトし、外向き通信が遮断されていることが分かります。

6. 【ステップ2】CLIで api.search.brave.com への穴を開ける

ここが本題です。
Network Firewallでドメインの許可を追加するには、以下の3つの階層を意識する必要があります。

【Network Firewall の設定階層】

 1. ルールグループ (Rule Group)  <-- [作成] 「どのサイトを許可するか」のリスト
          ▼ (アタッチ)
 2. ポリシー (Firewall Policy)  <-- [更新] ルールグループを束ねて、エンジンに渡す
          ▼ (自動同期)
 3. ファイアウォール (Firewall) <-- [反映] VPC内のエンドポイントに設定が届く

今回は、api.search.brave.com(TLSのSNI)への通信だけを許可する「ルールグループ」を新規に作成し、それを 4章のCDKで作成済みのポリシー(brave-egress-policy に紐付けることで、実際にVPC内の通信に穴を開けます。
このポリシーは、CDKで STRICT_ORDER・デフォルトアクション aws:drop_established(ルールグループ無し=全egress遮断)として作ったものです。ここに許可ルールグループを足して、初めて api.search.brave.com への通信が通るようになります。
作業は手元の端末から実行します(EC2の中ではありません)。

まず、許可リスト(ALLOWLIST)のルールグループ定義を作ります。
次の内容を、手元の端末で allow-brave.json というファイル名で保存してください(このあとの create-rule-groupfile://allow-brave.json として読み込みます)。

なお STRICT_ORDER は、ステートフルルールの評価方式の1つです。「自分が付けた順番(Priority、数値が小さいほど先)で上から評価し、どのルールにも当たらなければデフォルトのアクション(今回はドロップ=遮断)にする」という方式で、これにより「許可リストに載せたドメインだけ通し、ほかは全部遮断」という挙動を作れます。CDKのポリシー(brave-egress-policy)を STRICT_ORDER で作っているので、ルールグループ側も STRICT_ORDER に揃えます(評価方式が食い違うとアタッチできません)。

{
  "RulesSource": {
    "RulesSourceList": {
      "Targets": ["api.search.brave.com"],
      "TargetTypes": ["TLS_SNI"],
      "GeneratedRulesType": "ALLOWLIST"
    }
  },
  "StatefulRuleOptions": {
    "RuleOrder": "STRICT_ORDER"
  }
}

このJSONの各項目の意味は次のとおりです。

  • Targets:通す宛先ドメインのリスト。ここに api.search.brave.com だけを書く(=この1ドメインだけ許可する)
  • TargetTypesTLS_SNI:HTTPS通信のTLSハンドシェイクに含まれる接続先ホスト名(SNI)を見て、上の Targets と突き合わせる、という指定
  • GeneratedRulesTypeALLOWLIST:ここに挙げたドメインだけを許可し、それ以外は通さない(許可リスト方式)

つまりこのルールグループが、Network Firewallで api.search.brave.com だけに開ける「1ドメインの穴」の中身です。

6.1 ルールグループの作成

先ほど保存した allow-brave.json から、「ルールグループ」という実体を作成します。

RG_ARN=$(aws network-firewall create-rule-group \
  --rule-group-name allow-brave \
  --type STATEFUL \
  --capacity 100 \
  --rule-group file://allow-brave.json \
  --region ap-northeast-1 --profile <YOUR_PROFILE> \
  --query 'RuleGroupResponse.RuleGroupArn' --output text)

echo "$RG_ARN"
# 出力例
arn:aws:network-firewall:ap-northeast-1:<ACCOUNT_ID>:stateful-rulegroup/allow-brave

6.2 ポリシーへのアタッチ(紐づけ)

次に、作成したルールグループを、CDKで作ったポリシー(brave-egress-policy)に追加します。
Network Firewall はポリシーに紐づいているルールだけを実際に使用するため、この紐付けで初めて許可が有効になります。
更新には現在の UpdateToken が必要です。これは「いま手元のポリシーが最新版である」ことを示す合言葉で、これを添えて送ることで、ほかの操作と衝突せずに安全に上書きできます。先に取得しておきます。

# 現在のポリシーのトークンを取得
TOKEN=$(aws network-firewall describe-firewall-policy \
  --firewall-policy-name brave-egress-policy \
  --region ap-northeast-1 --profile <YOUR_PROFILE> \
  --query 'UpdateToken' --output text)

# ルールグループを参照に加えてポリシーを更新
aws network-firewall update-firewall-policy \
  --firewall-policy-name brave-egress-policy \
  --update-token "$TOKEN" \
  --region ap-northeast-1 --profile <YOUR_PROFILE> \
  --firewall-policy "{
    \"StatelessDefaultActions\": [\"aws:forward_to_sfe\"],
    \"StatelessFragmentDefaultActions\": [\"aws:forward_to_sfe\"],
    \"StatefulEngineOptions\": {\"RuleOrder\": \"STRICT_ORDER\"},
    \"StatefulDefaultActions\": [\"aws:drop_established\"],
    \"StatefulRuleGroupReferences\": [
      {\"ResourceArn\": \"$RG_ARN\", \"Priority\": 100}
    ]
  }"

--firewall-policy に渡しているJSONの各項目は次のとおりです。今回新しく加えたのは最後の StatefulRuleGroupReferences だけで、ほかはCDKが作った初期設定をそのまま指定し直しています。

  • StatelessDefaultActions / StatelessFragmentDefaultActions:ステートレス段の既定動作。aws:forward_to_sfe は「ステートフルエンジンへ転送して検査させる」
  • StatefulEngineOptions.RuleOrderSTRICT_ORDER(上で説明した厳格順)
  • StatefulDefaultActionsaws:drop_established(どのルールにも当たらない通信は遮断)
  • StatefulRuleGroupReferences今回の追加部分。先ほど作った allow-brave ルールグループ($RG_ARN)を Priority: 100 で参照に加える

update-firewall-policy はポリシー全体を置き換えるため、変更しない項目(StatelessDefaultActions など)も省略せず、すべて含めて指定します。

更新後、ファイアウォールの設定が IN_SYNC になるまで待ちます。

aws network-firewall describe-firewall --firewall-name brave-egress-fw \
  --region ap-northeast-1 --profile <YOUR_PROFILE> \
  --query 'FirewallStatus.ConfigurationSyncStateSummary' --output text

自分の環境では数十秒で IN_SYNC になりました。

7. 【ステップ3】質問 → AI回答(要約)まで通す

AIエージェントに検索機能を足すときに欲しいのは、「検索結果URLの一覧」ではなく「質問への回答」です。
Brave Search APIには、Braveが複数ページを読んで回答を合成するSummarizerがあり、これも同じ api.search.brave.com で完結します。
ここでは、開けた穴のまま「質問 → AI回答(要約)」まで届くことを確認します。

API 返ってくるもの 用途
Web Search title・url・description(抜粋) 基本のWeb検索
Summarizer(summary=1/res/v1/summarizer/search Braveが複数ページを読んで合成したAI回答+引用 質問への回答、AIアシスタント

どちらも api.search.brave.com のサブパスなので、開けた穴は1つのままで使えます。

7.1 APIキーを投入する

CDKはプレースホルダのシークレットを作るだけで、値はまだ入っていません。
Brave APIキーを投入します。

aws secretsmanager put-secret-value \
  --secret-id brave-search/api-key \
  --secret-string '<YOUR_BRAVE_API_KEY>' \
  --region ap-northeast-1 --profile <YOUR_PROFILE>

7.2 質問 → AI回答(要約)が通るか確認する

開けた穴で本当にやりたいのは、URLの一覧ではなく「質問への答え」を得ることです。
ここでは試しに「What is AWS Network Firewall and what is it used for?(AWS Network Firewall とは何で、何に使うのか)」という質問を投げ、その答えをBraveのSummarizer(AIによる要約回答)で受け取れるかを確認します。

Summarizerは、この質問文を使って2回リクエストする仕組みです。

  1. 要約キーを受け取る:質問文を通常のWeb検索エンドポイント web/search に渡し、summary=1 を付けて呼びます。summary=1 は「この検索結果をもとにAIの要約回答を作って」とBraveに指示するパラメータです。すると回答本文ではなく、レスポンスの summarizer.key に『要約キー』(このあと答えを受け取るために使うID)が入って返ります。
  2. AI回答を受け取る:その要約キーを summarizer/search エンドポイントの key に渡すと、Braveが複数ページを読んで合成したAI回答が返ります。

流れは「質問 → 要約キー → AI回答」です。
まずキーをSecrets Managerから取得し、curlで1段目(summary=1 を付けて質問を投げる)が api.search.brave.com に通るかを確認します。

TOKEN=$(aws secretsmanager get-secret-value \
  --secret-id brave-search/api-key \
  --query SecretString --output text --region ap-northeast-1)

# Summarizerの1段目:summary=1 付きでWeb検索(宛先は api.search.brave.com)
curl -s -o /dev/null -w 'http_code=%{http_code}\n' \
  -H "X-Subscription-Token: $TOKEN" \
  "https://api.search.brave.com/res/v1/web/search?q=What+is+AWS+Network+Firewall+and+what+is+it+used+for&summary=1&count=5"

# 許可していない別ドメインは依然タイムアウト
curl -sS -o /dev/null -w 'http_code=%{http_code} time=%{time_total}s\n' --max-time 12 https://example.com/

穴あけ前後の比較は次のとおりです。

宛先 ステップ1(穴あけ前) ステップ3(穴あけ後)
api.search.brave.com http_code=000(タイムアウト=遮断) http_code=200(到達・成功)
example.com http_code=000(タイムアウト=遮断) http_code=000(12秒でタイムアウト=遮断のまま)

セキュリティグループは何も変えていません(egressは TCP443 to 0.0.0.0/0 のままです)。
それでも「Braveだけ通って、ほかは通らない」状態になりました。
ドメインの制御がNetwork Firewall側で効いていることが確認できます。

次に、上の2段階(要約キーの取得 → AI回答の取得)を実行するPythonです(実アプリのFargate / AgentCore 上のエージェントを想定)。
中心となるのは次の2リクエストです。

# 中心となる2リクエスト(変数・関数の定義は下の brave_answer.py 全文を参照)
# 1) summary=1 付きでWeb検索し、レスポンスから要約キー(summarizer.key)を取り出す
st1, d1 = get_json(f"{BASE}/web/search?q={question}&summary=1&count=5", key)
skey = (d1.get("summarizer") or {}).get("key")   # 次のリクエストに渡す要約キー

# 2) 要約キーを key に渡して、合成されたAI回答を取得する
st2, d2 = get_json(f"{BASE}/summarizer/search?key={skey}&inline_references=true", key)

キーは環境変数や平文で持たず、Secrets Managerから実行時に取得します。
コピーしてそのまま実行できる全文は次のとおりです。python3 brave_answer.py "<質問文>" で動きます。

brave_answer.py 全文(クリックすると展開します)
#!/usr/bin/env python3
"""VPCからBrave Search APIの「AI要約(Summarizer / AI Answers)」を使い、
質問に対する回答(要約+引用)を取得するサンプル。

フロー(すべて api.search.brave.com の同一ドメインで完結):
  1) /res/v1/web/search?q=...&summary=1   → レスポンスから summarizer.key を得る
  2) /res/v1/summarizer/search?key=...     → 要約(質問への回答)を取得する
要約・本文抽出はBraveのサーバー側で行われるため、外向きの穴は1ドメインのまま。
"""
import json
import sys
import urllib.parse
import urllib.request
import urllib.error

REGION = "ap-northeast-1"
SECRET_ID = "brave-search/api-key"
BASE = "https://api.search.brave.com/res/v1"

def get_api_key() -> str:
    """Secrets Manager からBrave APIキーを取得する(boto3 が無ければ AWS CLI 経由)。"""
    try:
        import boto3  # type: ignore

        return boto3.client("secretsmanager", region_name=REGION).get_secret_value(
            SecretId=SECRET_ID
        )["SecretString"]
    except ImportError:
        import subprocess

        return subprocess.run(
            ["aws", "secretsmanager", "get-secret-value", "--secret-id", SECRET_ID,
             "--query", "SecretString", "--output", "text", "--region", REGION],
            capture_output=True, text=True, check=True,
        ).stdout.strip()

def get_json(url: str, key: str):
    """GET して (HTTPステータス, JSON) を返す。HTTPErrorでも本文をJSONで返す。"""
    req = urllib.request.Request(
        url, headers={"Accept": "application/json", "X-Subscription-Token": key}
    )
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            return r.status, json.loads(r.read())
    except urllib.error.HTTPError as e:
        try:
            return e.code, json.loads(e.read() or b"{}")
        except Exception:
            return e.code, {}

def extract_summary_text(d: dict) -> str:
    """要約レスポンスから本文テキストを取り出す。

    summary は {type, data} の配列で、data が文字列のもの(回答テキスト)と
    dict のもの(画像・エンティティ等)が混在する。文字列の data だけを連結する。
    """
    parts = []
    summ = d.get("summary")
    if isinstance(summ, list):
        for p in summ:
            if isinstance(p, str):
                parts.append(p)
            elif isinstance(p, dict):
                data = p.get("data")
                if isinstance(data, str):
                    parts.append(data)
                elif isinstance(data, dict):
                    t = data.get("text") or data.get("title")
                    if isinstance(t, str):
                        parts.append(t)
    return "".join(parts).strip()

def main() -> None:
    question = sys.argv[1] if len(sys.argv) > 1 else "What is AWS Network Firewall?"
    key = get_api_key()

    # 1) summary=1 付きで検索して summarizer key を得る
    url1 = f"{BASE}/web/search?" + urllib.parse.urlencode(
        {"q": question, "summary": 1, "count": 5}
    )
    st1, d1 = get_json(url1, key)
    print(f"[1] web/search?summary=1   http={st1}  question={question!r}")

    skey = (d1.get("summarizer") or {}).get("key")
    if not skey:
        print("    -> summarizerキーが返りませんでした(プラン未対応 or 要約対象外クエリ)。")
        print("    -> 代わりに通常の検索スニペットを表示します:")
        for r in (d1.get("web", {}).get("results", []) or [])[:3]:
            print("    -", r.get("title"))
            print("      ", (r.get("description") or "")[:160])
        return

    # 2) summarizer/search で回答(要約)を取得
    url2 = f"{BASE}/summarizer/search?" + urllib.parse.urlencode(
        {"key": skey, "inline_references": "true"}
    )
    st2, d2 = get_json(url2, key)
    print(f"[2] summarizer/search      http={st2}")

    text = extract_summary_text(d2)
    print("=== ANSWER (Braveからの要約回答) ===")
    print(text[:2000] if text else json.dumps(d2, ensure_ascii=False)[:1500])

if __name__ == "__main__":
    main()

EC2上で「What is AWS Network Firewall and what is it used for?」と質問した結果です。

# 出力例(python3 brave_answer.py "What is AWS Network Firewall and what is it used for?")
[1] web/search?summary=1   http=200  question='What is AWS Network Firewall and what is it used for?'
[2] summarizer/search      http=200
=== ANSWER (Braveからの要約回答) ===
AWS Network Firewall is a fully managed, stateful network firewall and intrusion
detection and prevention service designed to protect Amazon Virtual Private Clouds (VPCs).
It acts as a digital guard at the perimeter of your VPC, inspecting and filtering both
incoming and outgoing network traffic ...
- Traffic Filtering and Control: ... IP addresses, ports, protocols, domain names, and URLs ...
- Intrusion Prevention and Threat Detection: ... deep packet inspection (DPI) ... Suricata-compatible rules.
- Encrypted Traffic Inspection: ... decrypt and inspect HTTPS traffic ...
- Centralized Security Management: ... integrates with AWS Firewall Manager ...
- Compliance and Data Protection: ... HIPAA, PCI DSS, and GDPR ...

[1]summary=1 付き検索(要約キーの取得)と [2] の要約取得がどちらも http=200 で、URLの羅列ではなくBraveが複数ページを読んで合成した回答が返りました。
要約も api.search.brave.com の同一ドメインのため、ステップ2で開けた穴のまま、追加のファイアウォール変更なしで通ります。

8. クリーンアップ

Network FirewallエンドポイントやNAT Gatewayは、トラフィックが無くても起動中は課金されます。
ハンズオンが終わったら必ず削除してください。

# 1) CDKスタックを削除(先にスタックを消すとポリシーが消え、ルールグループの参照が外れる)
cd cdk
npx cdk destroy --profile <YOUR_PROFILE>

# 2) CLIで作ったルールグループを削除(CDK管理外のため手動)
aws network-firewall delete-rule-group \
  --rule-group-name allow-brave --type STATEFUL \
  --region ap-northeast-1 --profile <YOUR_PROFILE>

# 3) シークレットは復旧猶予が付く。即時削除したい場合は force 削除
aws secretsmanager delete-secret \
  --secret-id brave-search/api-key --force-delete-without-recovery \
  --region ap-northeast-1 --profile <YOUR_PROFILE>

さいごに

「Brave Search APIは api.search.brave.com の1ドメインで完結する」という見立てを、Network Firewallで実際に検証しました。
api.search.brave.com だけを許可した状態で、Web検索からSummarizerによるAI回答まで、すべて通ることを確認できました。

責務分担を言葉で整理すると、次のようになります。

  • セキュリティグループ:ポートを絞る。今回はアプリ(EC2 / Fargate)の外向きを TCP443 to 0.0.0.0/0 にした。宛先はBraveの変動IPで絞れないため、IPは開けたままにする
  • Network Firewall:ドメインを絞る。ここで開ける穴は api.search.brave.com の1つだけで、ほかのドメインはすべて遮断される

VPCからファイアウォール経由で外に出す構成では、この2つを重ねます。
ポートはセキュリティグループ、ドメインはNetwork Firewallで絞る、という分担です。
今回のBrave Search APIの場合、ファイアウォールに開ける穴は api.search.brave.com(HTTPS / 443)の1つで足りました。
これはBrave Search APIに固有のドメインです。Web検索APIは Tavily など他のサービスも、呼び出し先がそれぞれ特定のドメインに集約されているため、使うサービスに対応した1ドメインを開ければよい、という考え方は共通します。
Fargateや AgentCore 上のAIエージェントにWeb検索を足すときも、採用したWeb検索APIに対応する特定の1ドメインを開けるだけで、検索からAI回答まで届きます。

参考

この記事をシェアする

関連記事