Security Hubのアラート通知をJira APIで連携して自動起票してみた
こんにちは!製造ビジネステクノロジー部の小林です!
Security Hubを運用していると、軽微なアラートはすぐに対応できても、対応に時間がかかるものはJiraのようなチケット管理ツールで管理することがありますよね。でも、アラートが来るたびに手動でチケットを起票するのは、なかなか大変な作業です。
そこで今回は、Jira APIを使って、Security Hubからのアラート通知を自動でJiraチケットとして起票する仕組みを構築してみました!
前提条件
- Jira Software のスクラム機能を利用
- AWS CDK (TypeScript) でインフラは構築
- AWS SDK v3を使用
やりたいこと
今回実現したいことは以下の通りです。
- Security Hubがアラートを検知
- EventBridge RuleでMEDIUM以上のアラートをキャプチャしLambdaをトリガーする
- Lambdaは Jira API を利用して、自動的にチケットを起票する
- 起票されたチケットのタイトルには コントロールIDと重要度が含まれる
- 起票されたチケットの説明欄では以下のテンプレートに沿って情報を表示する
重要度
<重要度を表示>
環境
[ ] 開発環境
[ ] 本番環境
リソース
<リソースARNを表示>
対応内容
[ ] 静観
[ ] リソース修正
[ ] リソース削除
[ ] アラート抑制
アラート内容
<アラート詳細を表示>
やってみた
Jira スクラムの利用開始
Jira Softwareでスクラムボードを初めて利用する場合は、いくつかのセットアップが必要です。
こちらでスクラムテンプレートを利用したプロジェクトを開始します。
新規サイトで開始します。
任意のサイト名を入力し、続けるを選択します。
任意のプロジェクト名を入力してプロジェクトを作成します。ここでは「Security Hub 運用」で作成します。
プロジェクトが作成されました。プロジェクト作成時にボードが自動的に作成されています。
既にスプリントが作成されていますが、スプリントを開始するためには新しい作業項目を追加してから開始する必要があります。新しい課題を作成するには、「作成」ボタンをクリックします。
課題タイプを選択する画面が表示されます。「ストーリー」や「タスク」など、適切な課題タイプを選びましょう。今回はタスクで作成します。
これでスプリントを開始できるので「スプリントを開始」をクリックします。スプリント期間などを設定したら完了です。
スプリントが開始されました。これでスクラムボードの準備は完了です。
プロジェクト設定画面に移動し、プロジェクトキーを取得します。このキーは後ほどJira APIを使用する際に必要となります。
Jira API トークンの作成
Jira API を利用するためには、API トークンが必要です。以下の手順で作成します。
アトラシアンアカウントにアクセス
https://id.atlassian.com/manage-profile/security/api-tokens にアクセスし、ログインします。
API トークンを作成
「API トークンを作成」ボタンをクリックします。
任意のトークン名を入力します。また、セキュリティ保護のためトークンの期限は1年以内となっているので、期限が来る前に更新対応が必要になります。
トークンをコピー
作成された API トークンをコピーします。このトークンは再表示されないため、必ず安全な場所に保管してください。
これでトークンの作成が完了しました!
インフラ構築
それでは必要なリソースを構築していきます。今回の自動連携システムは、AWS CDK (TypeScript) を使用してインフラを構築します。ディレクトリ構成は以下の通りです。
security-alert-jira
├── bin/
│ └── security-allert-jira.ts # CDKアプリケーションのエントリーポイント
├── lib/
│ ├── security-alert-jira-stack.ts # メインのCDKスタック
│ ├── constructs/
│ │ ├── security-hub-construct.ts # Security Hub
│ │ ├── event-rule-construct.ts # EventBridge Rule
│ │ ├── lambda-construct.ts # Lambda関数
│ │ └── secrets-construct.ts # Secrets Manager
│ └── lambda/
│ └── jira-integration.ts # Jira連携用のLambda関数コード
├── package.json
└── tsconfig.json
Security Hubの作成
まず、AWS Security Hubを有効化します。これにより、AWSアカウント内のセキュリティ状態を自動的にモニタリングし、検出結果(Findings)を集約できるようになります。
lib/constructs/security-hub-construct.ts
import { Construct } from 'constructs';
import * as securityhub from 'aws-cdk-lib/aws-securityhub';
/**
* AWS Security Hubを設定するためのコンストラクトクラス
* このクラスはAWS Security Hubを有効化し、推奨されるセキュリティ標準を適用する
*/
export class SecurityHubConstruct extends Construct {
public readonly securityHub: securityhub.CfnHub;
constructor(scope: Construct, id: string) {
super(scope, id);
// Security Hubを有効化
this.securityHub = new securityhub.CfnHub(this, 'SecurityHub', {
enableDefaultStandards: true, // AWS Foundations BenchmarkとAWS Foundational Security Best Practicesを適用する
autoEnableControls: true, // Security Hubが提供する最新のセキュリティチェックを常に適用する
});
}
}
EventBridge Ruleの作成
Security Hubで特定の条件のアラートが検出されたときに、Lambda関数を自動的に呼び出すためのEventBridge Ruleを設定します。
lib/constructs/event-rule.ts
import { Construct } from 'constructs';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as securityhub from 'aws-cdk-lib/aws-securityhub';
interface EventRuleProps {
lambda: lambda.Function;
securityHub: securityhub.CfnHub;
}
/**
* Security Hubの検出結果をLambda関数に転送するためのEventBridge Ruleを設定するコンストラクト
* このクラスは、特定の条件に一致するSecurity Hubの検出結果をキャプチャし、指定されたLambda関数に転送する
*/
export class EventRuleConstruct extends Construct {
constructor(scope: Construct, id: string, props: EventRuleProps) {
super(scope, id);
/**
* Security Hubの検出結果をキャプチャするためのEventBridge Rueを作成
* MEDIUM以上の重要度を持つ新しい検出結果のみを対象とする
*/
const rule = new events.Rule(this, 'SecurityHubFindingsRule', {
eventPattern: {
source: ['aws.securityhub'], // イベントソースをSecurity Hubに設定
detailType: ['Security Hub Findings - Imported'], // イベントタイプをSecurity Hubのインポートされた検出結果に設定
detail: {
findings: {
Severity: {
Label: ['MEDIUM', 'HIGH', 'CRITICAL'] // MEDIUM以上の重要度を持つ検出結果を対象とする
},
Workflow: {
Status: ['NEW'] // 新しい検出結果のみを対象とする
}
}
}
}
});
/**
* 作成したEventBridge RuleのターゲットとしてLambda関数を追加
* これにより、条件に一致するイベントが発生した時にLambda関数が呼び出される
*/
rule.addTarget(new targets.LambdaFunction(props.lambda));
}
}
Secrets Manager の作成
Jira APIの認証情報(URL、ユーザー名、APIトークン、プロジェクトキー)は、ハードコードせずにSecrets Managerに保存します。Lambda関数はこのSecrets Managerから認証情報を取得してJira APIを呼び出します。
lib/constructs/secrets.ts
import { Construct } from 'constructs';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import { SecretValue } from 'aws-cdk-lib';
/**
* JiraのAPI認証情報をSecrets Managerに保存し、Lambda関数からアクセスできるようにする
*/
export class SecretsConstruct extends Construct {
public readonly secret: secretsmanager.Secret;
constructor(scope: Construct, id: string) {
super(scope, id);
/**
* JiraのAPI認証情報をSecrets Managerに保存
* 注意: ここではダミーの値を使用している。実際のJiraのURL、ユーザー名、プロジェクトキー、APIトークンはデプロイ後に更新する必要がある
*/
this.secret = new secretsmanager.Secret(this, 'JiraApiCredentials', {
secretName: 'jira-security-hub-integration', // Secrets Managerに保存するシークレットの名前
secretStringValue: SecretValue.unsafePlainText(JSON.stringify({
JIRA_URL: 'https://example.atlassian.net', // JiraのURL(実際のURLに置き換える必要がある)
JIRA_USER: 'user@example.com', // Jiraのユーザー名(実際のユーザー名に置き換える必要がある)
JIRA_PROJECT_KEY: 'example-project', // Jiraのプロジェクトキー(実際のプロジェクトキーに置き換える必要がある)
JIRA_API_TOKEN: 'dummy-token' // JiraのAPIトークン(実際のトークンに置き換える必要がある)
}))
});
}
}
Lambda関数の作成
Security Hubから送られてくるアラートを受け取り、Jira APIを呼び出してチケットを起票するLambda関数を構築します。
lib/constructs/lambda-for-jira.ts
import { Construct } from 'constructs';
import * as nodejsLambda from 'aws-cdk-lib/aws-lambda-nodejs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as path from 'path';
import { Duration } from 'aws-cdk-lib';
interface LambdaConstructProps {
secretsArn: string;
}
/**
* Security HubのアラートをJiraに転送するためのLambda関数を作成
*/
export class LambdaConstruct extends Construct {
public readonly function: lambda.Function;
constructor(scope: Construct, id: string, props: LambdaConstructProps) {
super(scope, id);
// NodejsFunction を使用してシンプルに Lambda を定義
this.function = new nodejsLambda.NodejsFunction(this, 'JiraIntegrationFunction', {
runtime: lambda.Runtime.NODEJS_22_X,
entry: path.join(__dirname, '../../src/lambda/jira-integration.ts'),
handler: 'handler',
timeout: Duration.seconds(30), // Lambda関数のタイムアウト設定(30秒)
environment: {
JIRA_SECRETS_ARN: props.secretsArn // Secrets ManagerのARNを環境変数として設定
},
bundling: {
externalModules: [], // 外部モジュールの指定(空配列は全ての依存関係をバンドルに含めることを意味する)
minify: true, // バンドルを最小化
forceDockerBundling: false, // Dockerを使用したバンドリングを強制しない
}
});
// Secrets Managerへのアクセス権限を付与
this.function.addToRolePolicy(new iam.PolicyStatement({
actions: ['secretsmanager:GetSecretValue'],
resources: [props.secretsArn]
}));
}
}
- nodejsLambda.NodejsFunction を使用することで、TypeScriptで書かれたLambdaソースのバンドル(依存関係の解決やトランスパイル)が自動で行われます
Lambda関数のソースの作成
実際にJiraチケットを作成するLambda関数のロジックです。Security Hubのアラート情報から必要なデータを抽出し、Jira APIの仕様に合わせた形式でチケットを作成します。
src/lambda/jira-integration.ts
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import axios from 'axios';
// Jira認証情報のためのインターフェース定義
interface JiraSecrets {
JIRA_URL: string;
JIRA_USER: string;
JIRA_API_TOKEN: string;
JIRA_PROJECT_KEY: string;
}
// Lambda関数のメインハンドラー
export const handler = async (event: any): Promise<any> => {
try {
// 受信したイベントからSecurity Hubの検出結果(finding)を抽出
const finding = event.detail.findings[0];
// 検出結果から必要な情報を取得
const description = finding.Description || '説明なし'; // アラートの説明
const severity = finding.Severity?.Label || 'MEDIUM'; // アラートの重要度
// Security Hubの検出結果IDからコントロールIDを抽出
// 例: arn:.../v1.0.0/EC2.1/finding/... から "EC2.1" を取得
const controlIdMatch = finding.Id?.match(/\/([^/]+)\/finding\//);
const controlId = controlIdMatch ? controlIdMatch[1] : '不明なコントロール';
// リソースARNの抽出
let resourceArn = 'N/A';
if (finding.Resources && finding.Resources.length > 0) {
resourceArn = finding.Resources[0].Id || 'N/A';
}
// AWS Secrets ManagerからJiraの認証情報を取得
const secretsClient = new SecretsManagerClient();
const secretResponse = await secretsClient.send(
new GetSecretValueCommand({
SecretId: process.env.JIRA_SECRETS_ARN || '' // 環境変数からシークレットのARNを取得
})
);
// シークレットの値が空の場合はエラーをスロー
if (!secretResponse.SecretString) {
throw new Error('シークレットの値が空です');
}
// 取得したシークレット文字列をJSONとしてパース
const secrets: JiraSecrets = JSON.parse(secretResponse.SecretString);
// 必須のJira認証情報が不足している場合はエラーをスロー
if (!secrets.JIRA_URL || !secrets.JIRA_USER || !secrets.JIRA_API_TOKEN || !secrets.JIRA_PROJECT_KEY) {
throw new Error('必要なJira認証情報がシークレットに不足しています');
}
// Jira URLの末尾のスラッシュを削除し、一貫性を保つ
secrets.JIRA_URL = secrets.JIRA_URL.replace(/\/+$/, '');
// Jiraチケットの作成関数を呼び出し、チケットキーを取得
const issueKey = await createJiraIssue(secrets, controlId, description, resourceArn, severity);
// 処理成功時のレスポンス
return {
statusCode: 200,
body: { message: 'チケットが正常に作成されました', issueKey }
};
} catch (error: unknown) {
// エラー発生時の基本的なエラーハンドリング
if (error instanceof Error) {
console.error('Jiraチケット作成中にエラーが発生しました:', error.message);
} else {
console.error('Jiraチケット作成中に不明なエラーが発生しました。');
}
// 汎用的な500エラーを返す
return {
statusCode: 500,
body: { message: 'Jiraチケット作成中にエラーが発生しました' }
};
}
};
// Jiraプロジェクトで利用可能な課題タイプを取得する関数
async function getAvailableIssueTypes(secrets: JiraSecrets, headers: any): Promise<any[]> {
try {
// Jira APIからプロジェクトのメタデータ(課題タイプ含む)を取得
const response = await axios.get(
`${secrets.JIRA_URL}/rest/api/3/issue/createmeta?projectKeys=${secrets.JIRA_PROJECT_KEY}&expand=projects.issuetypes`,
{ headers }
);
// 課題タイプが見つかった場合はそれを返す
if (response.data.projects && response.data.projects.length > 0) {
return response.data.projects[0].issuetypes || [];
}
// 課題タイプが見つからなかった場合は空の配列を返す
return [];
} catch (error: unknown) {
// 課題タイプ取得時のエラーはログに記録するが、処理は続行(フォールバックで対応)
if (axios.isAxiosError(error)) {
console.error('課題タイプの取得中にエラーが発生しました:', error.message);
} else {
console.error('課題タイプの取得中に不明なエラーが発生しました。');
}
return [];
}
}
// Jiraチケットを作成するメイン関数
async function createJiraIssue(
secrets: JiraSecrets,
controlId: string, // チケットのサマリーに含めるコントロールID
alertDescription: string, // チケットの説明に含めるアラート内容
resourceArn: string, // チケットの説明に含めるリソースARN
severity: string // チケットのサマリーと説明に含める重要度
): Promise<string> {
try {
// Jira APIリクエスト用の認証ヘッダーを作成
const auth = Buffer.from(`${secrets.JIRA_USER}:${secrets.JIRA_API_TOKEN}`).toString('base64');
const headers = {
'Content-Type': 'application/json',
'Authorization': `Basic ${auth}`
};
// 利用可能な課題タイプを取得
const issueTypes = await getAvailableIssueTypes(secrets, headers);
// Jiraチケットの説明フィールドに含めるコンテンツを定義(ADF形式)
// 指定されたテンプレートと太文字の形式を適用
const jiraDescriptionContent = [
{
type: 'paragraph',
content: [{ type: 'text', text: '重要度', marks: [{ type: 'strong' }] }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: severity }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: '環境', marks: [{ type: 'strong' }] }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: '[ ] 開発環境' }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: '[ ] 本番環境' }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'リソース', marks: [{ type: 'strong' }] }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: `${resourceArn}` }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: '対応内容', marks: [{ type: 'strong' }] }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: '[ ] 静観' }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: '[ ] リソース修正' }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: '[ ] リソース削除' }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: '[ ] アラート抑制' }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'アラート内容', marks: [{ type: 'strong' }] }]
},
{
type: 'paragraph',
content: [{ type: 'text', text: alertDescription }]
}
];
let issueType; // 使用する課題タイプを格納する変数
// 利用可能な課題タイプの中から「タスク」または「Task」を検索し、選択する
const taskType = issueTypes.find(t => t.name === "タスク" || t.name === "Task");
if (taskType) {
issueType = taskType;
console.log(`課題タイプ「${issueType.name}」が選択されました。`);
} else {
// 「タスク」が見つからない場合はエラーをスローし、チケット作成を中止
throw new Error('Jiraプロジェクトで「タスク」課題タイプが見つかりませんでした。');
}
// Jira APIを呼び出してチケットを作成
const createResponse = await axios.post(
`${secrets.JIRA_URL}/rest/api/3/issue`,
{
fields: {
project: { key: secrets.JIRA_PROJECT_KEY },
summary: `[Security Hub] ${severity} - ${controlId}`, // サマリーを「[Security Hub] <Severity> - <Control ID>」に変更
description: {
type: 'doc',
version: 1,
content: jiraDescriptionContent // カスタムテンプレートを使用
},
issuetype: { id: issueType.id }, // 課題タイプIDを指定
}
},
{ headers }
);
const issueKey = createResponse.data.key;
console.log(`Jiraチケットが正常に作成されました: ${issueKey}`);
// チケットをアクティブなスプリントに追加(失敗しても処理は続行)
await addIssueToActiveSprint(secrets, headers, issueKey);
return issueKey; // 成功したチケットキーを返す
} catch (error: unknown) {
// チケット作成処理全体でエラーが発生した場合のハンドリング
if (axios.isAxiosError(error)) {
console.error('Jiraチケット作成中にエラーが発生しました(Axiosエラー):', error.message);
} else if (error instanceof Error) {
console.error('Jiraチケット作成中にエラーが発生しました:', error.message);
} else {
console.error('Jiraチケット作成中に不明なエラーが発生しました。');
}
// Jiraチケット作成失敗を示すエラーをスロー
throw new Error('Jiraチケットの作成に失敗しました。');
}
}
// 作成したチケットをアクティブなスプリントに追加する関数
async function addIssueToActiveSprint(secrets: JiraSecrets, headers: any, issueKey: string): Promise<void> {
try {
// Jira APIから対象プロジェクトのアクティブなボードを取得
const boardResponse = await axios.get(
`${secrets.JIRA_URL}/rest/agile/1.0/board?projectKeyOrId=${secrets.JIRA_PROJECT_KEY}`,
{ headers }
);
if (boardResponse.data.values && boardResponse.data.values.length > 0) {
const boardId = boardResponse.data.values[0].id;
// 取得したボードIDを使って、アクティブなスプリントを取得
const sprintResponse = await axios.get(
`${secrets.JIRA_URL}/rest/agile/1.0/board/${boardId}/sprint?state=active`,
{ headers }
);
// アクティブなスプリントが存在する場合、チケットを追加
if (sprintResponse.data.values && sprintResponse.data.values.length > 0) {
const sprintId = sprintResponse.data.values[0].id;
// Jira APIを呼び出し、指定されたチケットをスプリントに追加
await axios.post(
`${secrets.JIRA_URL}/rest/agile/1.0/sprint/${sprintId}/issue`,
{ issues: [issueKey] },
{ headers }
);
console.log(`チケット ${issueKey} がスプリントに正常に追加されました。`);
} else {
console.log('アクティブなスプリントが見つかりませんでした。チケットはスプリントに追加されません。');
}
} else {
console.log(`プロジェクト ${secrets.JIRA_PROJECT_KEY} のボードが見つかりませんでした。チケットはスプリントに追加されません。`);
}
} catch (error: unknown) {
// スプリントへの追加はチケット作成の主要な要件ではないため、エラーが発生してもログに記録するのみ
if (axios.isAxiosError(error)) {
console.error('スプリントにチケットを追加中にエラーが発生しました:', error.message);
} else {
console.error('スプリントにチケットを追加中に不明なエラーが発生しました。');
}
}
}
- Jira認証 (APIトークン)
- Jira APIへの認証には、アトラシアンが推奨するAPIトークン認証(Basic認証)を使用しています。Secrets Managerから取得したユーザー名とAPIトークンをBase64エンコードしてAuthorizationヘッダーに含めることで、認証を行っています。詳細についてはJira Cloud REST API ドキュメントの認証を参照ください。
// Jira APIリクエスト用の認証ヘッダーを作成
const auth = Buffer.from(`${secrets.JIRA_USER}:${secrets.JIRA_API_TOKEN}`).toString('base64');
const headers = {
'Content-Type': 'application/json',
'Authorization': `Basic ${auth}`
};
Jira APIに関する参考ドキュメント
Jira Cloud REST APIには、課題の作成以外にも様々な操作を行うための豊富なエンドポイントが用意されています。今回の実装に関連する主なAPIドキュメントを簡単にご紹介します。
課題の作成 (Create issue)
今回の Lambda 関数でチケットを起票するために利用している主要なエンドポイントです。課題のフィールド、課題タイプ、プロジェクトなどを指定して新しい課題を作成します。
課題タイプのメタデータ取得 (Get create meta for projects or issue types)
プロジェクトで利用可能な課題タイプや、それぞれの課題タイプで利用可能なフィールドに関するメタデータを取得します。今回のコードでは、getAvailableIssueTypes 関数でこのAPIを使用しています。
ボードの取得 (Get all boards)
スプリントに課題を追加するために、まずボードの情報を取得する際に利用します。
スプリントの取得 (Get all sprints)
アクティブなスプリントの情報を取得する際に利用します。
課題をスプリントに追加 (Add issues to sprint):
作成した課題を特定のスプリントに追加する際に利用します。
以上で必要なリソースの準備ができたのでデプロイしていきましょう。
Secrets Managerに必要な情報を登録
デプロイが完了したらSecrets Managerに冒頭で取得したプロジェクトキーとJira APIの情報を登録していきます。
サンプルの値が入っているのでこちらを実際に値に修正します。
動作確認
ではSecurity Hubアラートを手動で通知して、Jiraにチケットが起票されるか確認してみましょう!ワークフローのステータスで「新規」をクリックするとアラートが発報されます。
Jiraボードを確認するとTO DO列にチケットが起票されていますね。
チケットのタイトル、説明欄ともに想定通りの内容で表示されました!
念の為、CloudwatchでLambdaログにエラーがないか確認します。
正常に実行できているようです。
おわりに
今回は、Security Hub のアラート通知を Jira API で自動チケット化する仕組みを構築しました。これにより、手動での起票作業をなくし、セキュリティインシデントへの対応を効率化できます。
しかし、この仕組みにはまだ改善の余地があり、特に重要なのが重複チケットの防止です。現状では、同じ Security Hub アラートが複数回発生するたびに、新しいJiraチケットが作成されてしまいます。
この課題を解決するため、今後は検出結果ID、コントロールID、リソースARNといった情報を使って、既存のチケットがないかを事前に確認するロジックを実装する予定です。
この記事が、Security Hub の運用改善を検討されている皆さんの参考になれば幸いです!