☸️

NewRelicとEventBridgeを連携させた自動アラート対応の仕組み

2025/01/02に公開

こんにちは、kzk_maedaです。

NewRelicで検知したアラート、自動でなんやかんやしてAWS上のリソースに変更を入れて対応したいケースありますよね??

そんな仕組みを作ってみたよという話を書きます。

解決したい課題

ここでは、攻撃に類すると考えられる不正なアクセスが一定数以上発生した際に、送信元IPアドレスを自動的にAWS WAFで遮断したい、というケースを想定します。

また、誤検知時での遮断影響を小さくするため、一定時間経過したら遮断を解除するところまでを自動で行うことを目指します。

全体像

作りたいシステムの全体概観はこちらです。

処理の流れは下記のようになります。

  1. 不正と考えられるアクセスをNewRelicで検知する
  2. NewRelicのAlert PolicyからEventBridgeをキックする
  3. EventBridgeがLambdaをキックし、AWS WAFにアタッチされているIP Setのルールを編集する

NewRelicのAlertがOpenされた時は、対象のIPアドレスをBlockListに追加し、逆にNewRelicのAlertがCloseされた時は対象のIPアドレスをBlockListから除外するという動きになります。

これにより、攻撃検知時には対象のIPアドレスからのアクセスを遮断し、一定期間後にNewRelicのアラートが自動クローズされると、対象のIPアドレスの遮断を解除することが可能となります。

実装上のポイント

実際に実装していく上でいくつかポイントになることがあるので記載していきます。

NewRelic上のAlert Conditions

Alert Conditionsでは、以下のNRQLで攻撃と疑われるアクセスを検知し、IPアドレスごとに集計するようにできます。

SELECT 
  count(*)
FROM Transaction
  where appName = 'MyApp'
  and http.statusCode >= 400 and http.statusCode != 404 and AND http.statusCode < 500
facet
  capture(x-forwarded-for, r'(?P<ip>(?:[0-9]{1,3}\.){3}[0-9]{1,3}|[a-fA-F0-9:]+),.*')

ここでは http.statusCode が400系で、404(Not Found)を除くアクセスを対象にしている、とします。

ポイントは facet に渡している値です。
ここではバックエンドサーバーを監視しているNewRelic Agentが検知するアクセスを対象にしているため、単純なSource IP属性では送信元のIPアドレスを取得できません。
Proxy ServerやLBを経由すると、バックエンドサーバーで確認できるSource IP属性は上書きされているので、アクセス経路の情報を持つ x-forwarded-for の属性を取得する必要があります。

また、少し複雑な以下の正規表現によって、IPv4、IPv6の両方を対象に、カンマで区切られた最初のIPアドレスを抽出しています。

r'(?P<ip>(?:[0-9]{1,3}\.){3}[0-9]{1,3}|[a-fA-F0-9:]+),.*'

これにより、対象の送信元IPアドレスごとのアクセスを集計することが可能となります。

NewRelicとEventBridgeとの連携

NewRelicとEventBridgeとの連携については、以下のNewRelic公式のPostなどにも記載されているので、そちらもご参照ください。
https://newrelic.com/jp/blog/how-to-relic/notification-awseventbridge

ここでは、後段のLambdaに渡したいIPアドレス情報をどのように取り扱うかについてピックアップします。

NewRelicのWorkflowを設定する際にEventBridgeとの連携設定を選択することができますが、デフォルトではIPアドレスに関する情報は引き渡されません。

いくつか方法はありそうなのですが、今回以下のようにして実装しました。

Workflowで通知先にEventBridgeを選択している箇所でEditすると、

EventBridgeに連携するデータの構造を編集できます。

IPアドレスはtagから取得することができそうだったので、jsonに以下を追記するようにしました。

"ip": {{ json accumulations.tag.ip }},

LambdaでのAWS WAF IP Setの操作

NewRelic→EventBridgeから連携されてきたデータをLambdaで受け取って、あとは好きにコードを書いて任意の処理を実行することが可能です。

今回はAWS WAFのIP Setを操作して対象のIPを遮断したり遮断解除したりすることを行いたいのですが、IP SetはIPv4/IPv6それぞれで個別にリソースを作成する必要があります。

そのため、Lambdaの中でどちらのIP Setを操作する必要があるのか判断して処理する必要があることに留意して記述します。

Node.jsで記述したサンプルコードを載せておきます。対象のIP Setは環境変数経由で渡すようにしています。

Lambdaのソース
import net from "net";
import { WAFV2Client, GetIPSetCommand, UpdateIPSetCommand } from "@aws-sdk/client-wafv2";

const IPSET_IPV4_ARN = process.env.IPSET_IPV4_ARN;
const IPSET_IPV6_ARN = process.env.IPSET_IPV6_ARN;
const wafClient = new WAFV2Client({ region: "us-east-1" });

// IPv4かIPv6かを判別する関数
const isIPv4 = (ip) => {
  if (net.isIPv4(ip)) {
    return true;
  } else if (net.isIPv6(ip)) {
    return false;
  } else {
    throw new Error(`Invalid IP address: ${ip}`);
  }
};

// IPアドレスのフォーマット関数
const formatIPAddress = (ip) => {
  return isIPv4(ip) ? `${ip}/32` : `${ip}/128`;
};

// IP Setの情報を取得する関数
const getIPSet = async (ipsetArn) => {
  const [name, id] = ipsetArn.split("/").slice(-2);
  const command = new GetIPSetCommand({
    Name: name,
    Scope: "CLOUDFRONT",
    Id: id,
  });
  const response = await wafClient.send(command);
  return { ipset: response.IPSet, lockToken: response.LockToken };
};

// IP SetにIPアドレスを追加する関数
const updateIPSet = async (ipsetArn, addresses) => {
  const { ipset, lockToken } = await getIPSet(ipsetArn);
  const currentAddresses = new Set(ipset.Addresses);
  const formattedAddresses = new Set(addresses.map(formatIPAddress));
  const addressesToAdd = Array.from([...formattedAddresses].filter((ip) => !currentAddresses.has(ip)));

  if (addressesToAdd.length === 0) {
    console.log("IP address already exists in IP Set. No update needed.");
    return;
  }

  const newAddresses = Array.from(new Set([...currentAddresses, ...addressesToAdd]));
  const [name, id] = ipsetArn.split("/").slice(-2);

  const command = new UpdateIPSetCommand({
    Name: name,
    Scope: "CLOUDFRONT",
    Id: id,
    LockToken: lockToken,
    Addresses: newAddresses,
    Description: ipset.Description,
  });
  await wafClient.send(command);
  console.log(`Added IP addresses: ${addressesToAdd}`);
};

// IP SetからIPアドレスを削除する関数
const removeIPFromIPSet = async (ipsetArn, addresses) => {
  const { ipset, lockToken } = await getIPSet(ipsetArn);
  const currentAddresses = new Set(ipset.Addresses);
  const formattedAddresses = new Set(addresses.map(formatIPAddress));
  const addressesToRemove = Array.from([...formattedAddresses].filter((ip) => currentAddresses.has(ip)));

  if (addressesToRemove.length === 0) {
    console.log("IP address not found in IP Set. No removal needed.");
    return;
  }

  const newAddresses = Array.from([...currentAddresses].filter((ip) => !addressesToRemove.includes(ip)));
  const [name, id] = ipsetArn.split("/").slice(-2);

  const command = new UpdateIPSetCommand({
    Name: name,
    Scope: "CLOUDFRONT",
    Id: id,
    LockToken: lockToken,
    Addresses: newAddresses,
    Description: ipset.Description,
  });
  await wafClient.send(command);
  console.log(`Removed IP addresses: ${addressesToRemove}`);
};

// ログ出力用関数
const logStructuredMessage = (level, details) => {
  const logEntry = {
    level,
    timestamp: new Date().toISOString(),
    ...details,
  };
  if (level === "error") {
    console.error(JSON.stringify(logEntry));
  } else {
    console.log(JSON.stringify(logEntry));
  }
};


export const handler = async (event) => {
  const detail = event.detail || {};
  const ipAddresses = detail.ip || [];
  const state = detail.state;

  for (const ip of ipAddresses) {
    const context = {
      ip,
      ipFormat: isIPv4(ip) ? "IPv4" : "IPv6",
      ipsetArn: isIPv4(ip) ? IPSET_IPV4_ARN : IPSET_IPV6_ARN,
      state,
    }

    try {
      if (state === "ACTIVATED") {
        logStructuredMessage("info", {
          ...context,
          action: "add",
        });
        await updateIPSet(context.ipsetArn, [context.ip]);
      } else if (state === "CLOSED") {
        logStructuredMessage("info", {
          ...context,
          action: "remove",
        });
        await removeIPFromIPSet(context.ipsetArn, [context.ip]);
      } else {
        logStructuredMessage("warn", {
          ...context,
          action: "unknown",
          message: "Unknown state",
        });
      }
    } catch (error) {
      logStructuredMessage("error", {
        ...context,
        action: "error",
        errorMessage: error.message,
        stackTrace: error.stack,
      });
    }
  }

  return { status: "completed" };
};

追加で、Cloudwatch InsightsでどんなIPが遮断・解除されたかを簡単に追跡できるよう、構造化されたログを出力するようにもしています。

以下のクエリで操作ログを簡単に確認することが可能です。

fields @timestamp, action, ip, ipsetArn, state
| filter action = "add" or action = "remove"
| sort @timestamp desc

一点注意点として、Lambdaの処理で狙ったIPアドレスだけを削除するようなことはできないので、remove時には

  1. 現状のIP Setの設定を取得する
  2. IPアドレスのリストから、除外したいIPアドレスを抜き取ったリストを返却する
  3. 1で取得した設定を含めてIP Setを更新する

という流れになります。

もしIP SetのリソースをTerraformなどのIaCで管理していた場合、上記の中でTerraformで定義した設定が消えてしまい、driftが発生する可能性があるため、設定した情報も必ず含めてUpdateするように気をつけましょう。

AWS WAFにアタッチするIP Setの構成

最後にIP Setの構成についての実装上の工夫ポイントです。
このシステムでは操作対象のIP Setに対して、IPアドレスのブロックと解除を繰り返すことになります。
一方、システムを運用していると、恒久的にblockしたいIPアドレスも存在することとなります。通常、このリソースは対象のIPアドレスも含めてTerraformなどのIaCで管理することになるかと思います。

これらの時間軸の異なるblock IPの管理を同じIP Setで行うと、IaCによる変更とイベントトリガーの変更が不整合を起こしてしまうリスクがあります。

そのため、今回の構成では、NewRelicで検知した攻撃疑いのIPアドレスを一時的にblockするIP Setと、恒久的にblockするIPアドレスを管理するIP Setは分けるように設計しています。

まとめ

攻撃と疑われるアクセスが発生した際に、NewRelicでのアラート検知から、オペレーションの自動化までをEventBridge + Lambdaで実装した例について記載してみました。

NewRelicでのAlertからEventBridge + Lambdaをhookできるので、他にも任意の運用上の操作を自動化できる余地があると思います。

どこかの誰かの参考になれば幸いです!!

Progate Tech Blog

Discussion