0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【IBM Concert】GitHubのURLからSBOM・CVE登録を自動で行うワークフロー

0
Last updated at Posted at 2026-10-13

はじめに

本ワークフローは、GitHub上のdocker-compose.yamlからコンテナイメージを抽出し、SBOM生成、脆弱性スキャン、およびIBM Concertへのアプリケーション登録を自動化します。
IBM Concertを初めて利用する際の環境準備や、デモ環境へのアプリケーション登録作業を簡素化し、短期間で評価を開始できるようにすることを目的としています。

ワークフローの処理概要

入力: GitHubのdocker-compose.yamlのURL
↓
SSH_1: docker-compose.yamlからイメージ一覧を取得 → Trivyでスキャン
↓
SSH_2: Concertにアプリケーション定義とSBOMを登録
↓
SSH_3: ConcertにCVEスキャン結果を登録

ワークフローを実行した結果

Before

IBM Concertにアプリケーションが未登録の状態です。アプリケーション一覧には何も表示されていません。
スクリーンショット 2026-10-07 16.27.31.png

After

ワークフロー実行後、アプリケーション・SBOM・CVEスキャン結果がIBM Concertに登録された状態です。アプリケーション一覧にコンテナイメージごとの脆弱性情報が表示されます。
スクリーンショット 2026-10-08 10.42.45.png

スクリーンショット 2026-10-08 10.42.03.png

前提条件

動作確認済みバージョン

ソフトウェア バージョン
IBM Concert 3.0.0
Trivy 0.74.0
curl 7.76.1
python3 3.9.21

動作確認済みリポジトリ

以下のGitHubリポジトリで動作を確認済みです。github_url の入力値としてご利用いただけます。

アプリケーション名 GitHub URL
Robot Shop https://github.com/instana/robot-shop/blob/master/docker-compose.yaml
Sock Shop https://github.com/microservices-demo/microservices-demo/blob/master/deploy/docker-compose/docker-compose.yml
Spring PetClinic https://github.com/spring-petclinic/spring-petclinic-microservices/blob/master/docker-compose.yml

準備が必要なもの

  1. IBM ConcertのAPIキーを取得済み
  2. Trivy・curl・python3 がインストール済み
  3. スキャン対象のdocker-compose.yamlに image: 形式でイメージが定義されていること

APIキーの取得

画面右上のタブからAPIキーを取得します。
スクリーンショット 2026-10-07 15.47.42.png

curl・python3 インストール

sudo dnf install -y curl python3

trivyインストール

cat << 'EOF' | sudo tee /etc/yum.repos.d/trivy.repo
[trivy]
name=Trivy repository
baseurl=https://aquasecurity.github.io/trivy-repo/rpm/releases/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://aquasecurity.github.io/trivy-repo/rpm/public.key
EOF

sudo dnf install -y trivy

補足:image: 形式で定義されたイメージとは

docker-compose.yaml において、以下のように image: キーでイメージ名が直接指定されている形式が対象です。build: のみ定義されたサービスはスキャン対象外になります。
スクリーンショット 2026-10-07 12.35.34.png

ワークフロー作成

IBM Concert Workflowsの制約上、コマンドはすべて1行で記載しています。各ステップのCommandをそのままコピー&ペーストしてご利用ください。

全体像

作成するワークフローの全体像です。SSH、Assignは+→Commonで検索すると出てきます。
スクリーンショット 2026-10-09 12.31.16.png

1) 開始ブロック:変数の定義

スクリーンショット 2026-10-07 14.24.45.png

  • scan_result
    ワークフロー完了時にスキャンおよびアップロード結果を表示するための出力変数
  • api_key
    IBM ConcertのAPI Key
  • concert_url
    IBM ConcertのURL
  • github_url
    スキャン対象の docker-compose.yaml の GitHub blob URL(例:https://github.com/instana/robot-shop/blob/master/docker-compose.yaml)

2) SSH_1:Trivyで脆弱性スキャン

docker-compose.yaml からコンテナイメージを抽出し、Trivyで脆弱性スキャンを実行して、jsonファイルを生成します。

authKey

以下の内容を参考に
スクリーンショット 2026-10-07 18.43.47.png

command(expressionモード)

"echo 'aW1wb3J0IG9zLCBzeXMsIGpzb24sIHN1YnByb2Nlc3MsIHVybGxpYi5yZXF1ZXN0LCBzc2wsIHJlLCBkYXRldGltZQoKZ2l0aHViX3VybCA9IHN5cy5hcmd2WzFdCnBhcnRzID0gcmUuc3ViKHInaHR0cHM/Oi8vZ2l0aHViXC5jb20vJywgJycsIGdpdGh1Yl91cmwpLnNwbGl0KCcvJykKb3JnX25hbWUgPSBwYXJ0c1swXQpyZXBvX25hbWUgPSBwYXJ0c1sxXQpicmFuY2ggPSBwYXJ0c1szXSBpZiBsZW4ocGFydHMpID4gMyBlbHNlICdtYXN0ZXInCmZpbGVfcGF0aCA9ICcvJy5qb2luKHBhcnRzWzQ6XSkgaWYgbGVuKHBhcnRzKSA+IDQgZWxzZSAnZG9ja2VyLWNvbXBvc2UueWFtbCcKcmVwbyA9IG9yZ19uYW1lICsgJy8nICsgcmVwb19uYW1lCmFwcCA9IHJlcG9fbmFtZQpvcmcgPSBhcHAucmVwbGFjZSgnLScsICcnKS5yZXBsYWNlKCdfJywgJycpCnJhd191cmwgPSBmJ2h0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS97cmVwb30ve2JyYW5jaH0ve2ZpbGVfcGF0aH0nCgpjdHggPSBzc2wuX2NyZWF0ZV91bnZlcmlmaWVkX2NvbnRleHQoKQpjb250ZW50ID0gdXJsbGliLnJlcXVlc3QudXJsb3BlbihyYXdfdXJsLCBjb250ZXh0PWN0eCkucmVhZCgpLmRlY29kZSgndXRmLTgnKQoKaW1hZ2VzID0gW10KZm9yIGxpbmUgaW4gY29udGVudC5zcGxpdGxpbmVzKCk6CiAgICBsaW5lID0gbGluZS5zdHJpcCgpCiAgICBpZiBsaW5lLnN0YXJ0c3dpdGgoJ2ltYWdlOicpOgogICAgICAgIGltZyA9IGxpbmUuc3BsaXQoJzonLCAxKVsxXS5zdHJpcCgpCiAgICAgICAgaW1nID0gaW1nLnJlcGxhY2UoJyInLCAnJykucmVwbGFjZSgiJyIsICcnKS5zdHJpcCgpCiAgICAgICAgaW1nID0gcmUuc3ViKHInXCRcez9SRVBPW159XSpcfT8nLCBvcmcsIGltZykKICAgICAgICBpbWcgPSByZS5zdWIocidcJFx7P1RBR1tefV0qXH0/JywgJ2xhdGVzdCcsIGltZykKICAgICAgICBpZiBpbWcuc3RhcnRzd2l0aCgnLycpOgogICAgICAgICAgICBpbWcgPSBvcmcgKyBpbWcKICAgICAgICBlbGlmICcvJyBub3QgaW4gaW1nIGFuZCBub3QgYW55KGltZy5zdGFydHN3aXRoKGspIGZvciBrIGluIFsncmVkaXMnLCAncmFiYml0bXEnLCAnbW9uZ28nLCAnbXlzcWwnLCAncG9zdGdyZXMnLCAndmF1bHQnLCAnY29uc3VsJywgJ2VsYXN0aWNzZWFyY2gnLCAnbmdpbngnLCAnYWxwaW5lJ10pOgogICAgICAgICAgICBpbWcgPSBvcmcgKyAnLycgKyBpbWcKICAgICAgICBpZiAnOicgbm90IGluIGltZzoKICAgICAgICAgICAgaW1nICs9ICc6bGF0ZXN0JwogICAgICAgIGVsaWYgaW1nLmVuZHN3aXRoKCc6Jyk6CiAgICAgICAgICAgIGltZyArPSAnbGF0ZXN0JwogICAgICAgIGlmIGltZyBub3QgaW4gaW1hZ2VzOgogICAgICAgICAgICBpbWFnZXMuYXBwZW5kKGltZykKCnByaW50KGYiPT09IEZvdW5kIHtsZW4oaW1hZ2VzKX0gaW1hZ2VzID09PSIpCmZvciBpbWcgaW4gaW1hZ2VzOgogICAgcHJpbnQoZiIgLSB7aW1nfSIpCgpmb3IgaSwgaW1nIGluIGVudW1lcmF0ZShpbWFnZXMsIDEpOgogICAgc2FmZV9uYW1lID0gaW1nLnJlcGxhY2UoJy8nLCAnXycpLnJlcGxhY2UoJzonLCAnXycpCiAgICByYXdfZmlsZSA9IGYiL3RtcC97c2FmZV9uYW1lfV9yYXcuanNvbiIKICAgIGNsZWFuX2ZpbGUgPSBmIi90bXAve3NhZmVfbmFtZX1fY2xlYW4uanNvbiIKICAgIHByaW50KGYiW3tpfS97bGVuKGltYWdlcyl9XSBTY2FubmluZzoge2ltZ30iKQogICAgc3VicHJvY2Vzcy5ydW4oZiJ0cml2eSBpbWFnZSAtLWZvcm1hdCBjeWNsb25lZHggLS1zY2FubmVycyB2dWxuIC0tcXVpZXQge2ltZ30gPiB7cmF3X2ZpbGV9IDI+JjEiLCBzaGVsbD1UcnVlKQogICAgaWYgbm90IG9zLnBhdGguZXhpc3RzKHJhd19maWxlKSBvciBvcy5wYXRoLmdldHNpemUocmF3X2ZpbGUpID09IDA6CiAgICAgICAgcHJpbnQoZiIgIFNLSVA6IHNjYW4gZmFpbGVkIikKICAgICAgICBjb250aW51ZQogICAgdHJ5OgogICAgICAgIHdpdGggb3BlbihyYXdfZmlsZSkgYXMgZjoKICAgICAgICAgICAgZCA9IGpzb24ubG9hZChmKQogICAgICAgIGRbJ3NwZWNWZXJzaW9uJ10gPSAnMS42JwogICAgICAgIGlmICckc2NoZW1hJyBpbiBkOgogICAgICAgICAgICBkWyckc2NoZW1hJ10gPSAnaHR0cDovL2N5Y2xvbmVkeC5vcmcvc2NoZW1hL2JvbS0xLjYuc2NoZW1hLmpzb24nCiAgICAgICAgaWYgJ21ldGFkYXRhJyBpbiBkIGFuZCAnY29tcG9uZW50JyBpbiBkWydtZXRhZGF0YSddOgogICAgICAgICAgICBjbmFtZSA9IGltZy5zcGxpdCgnLycpWy0xXS5zcGxpdCgnOicpWzBdCiAgICAgICAgICAgIGRbJ21ldGFkYXRhJ11bJ2NvbXBvbmVudCddWyduYW1lJ10gPSBjbmFtZQogICAgICAgICAgICBkWydtZXRhZGF0YSddWydjb21wb25lbnQnXVsndmVyc2lvbiddID0gJzEuMC4wJwogICAgICAgIGZvciB2IGluIGQuZ2V0KCd2dWxuZXJhYmlsaXRpZXMnLCBbXSk6CiAgICAgICAgICAgIHZbJ2Rlc2NyaXB0aW9uJ10gPSAnJwogICAgICAgICAgICBpZiAnYWR2aXNvcmllcycgaW4gdiBhbmQgbGVuKHZbJ2Fkdmlzb3JpZXMnXSkgPiAxOgogICAgICAgICAgICAgICAgdlsnYWR2aXNvcmllcyddID0gdlsnYWR2aXNvcmllcyddWzoxXQogICAgICAgIHdpdGggb3BlbihjbGVhbl9maWxlLCAndycpIGFzIGY6CiAgICAgICAgICAgIGpzb24uZHVtcChkLCBmKQogICAgICAgIHByaW50KGYiICBPSzoge29zLnBhdGguZ2V0c2l6ZShjbGVhbl9maWxlKS8vMTAyNH1LQiIpCiAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgcHJpbnQoZiIgIEVSUk9SOiB7ZX0iKQoKcHJpbnQoIj09PSBTY2FuIGNvbXBsZXRlZCA9PT0iKQo=' | base64 -d > /tmp/scan1.py && python3 /tmp/scan1.py " + $github_url

timeout

3600

(参考)

以下のコードをBase64エンコードして1行のコマンドにまとめたものです。

import os, sys, json, subprocess, urllib.request, ssl, re, datetime

github_url = sys.argv[1]
parts = re.sub(r'https?://github\.com/', '', github_url).split('/')
org_name = parts[0]
repo_name = parts[1]
branch = parts[3] if len(parts) > 3 else 'master'
file_path = '/'.join(parts[4:]) if len(parts) > 4 else 'docker-compose.yaml'
repo = org_name + '/' + repo_name
app = repo_name
org = app.replace('-', '').replace('_', '')
raw_url = f'https://raw.githubusercontent.com/{repo}/{branch}/{file_path}'

ctx = ssl._create_unverified_context()
content = urllib.request.urlopen(raw_url, context=ctx).read().decode('utf-8')

images = []
for line in content.splitlines():
    line = line.strip()
    if line.startswith('image:'):
        img = line.split(':', 1)[1].strip()
        img = img.replace('"', '').replace("'", '').strip()
        img = re.sub(r'\$\{?REPO[^}]*\}?', org, img)
        img = re.sub(r'\$\{?TAG[^}]*\}?', 'latest', img)
        if img.startswith('/'):
            img = org + img
        elif '/' not in img and not any(img.startswith(k) for k in ['redis', 'rabbitmq', 'mongo', 'mysql', 'postgres', 'vault', 'consul', 'elasticsearch', 'nginx', 'alpine']):
            img = org + '/' + img
        if ':' not in img:
            img += ':latest'
        elif img.endswith(':'):
            img += 'latest'
        if img not in images:
            images.append(img)

print(f"=== Found {len(images)} images ===")
for img in images:
    print(f" - {img}")

for i, img in enumerate(images, 1):
    safe_name = img.replace('/', '_').replace(':', '_')
    raw_file = f"/tmp/{safe_name}_raw.json"
    clean_file = f"/tmp/{safe_name}_clean.json"
    print(f"[{i}/{len(images)}] Scanning: {img}")
    subprocess.run(f"trivy image --format cyclonedx --scanners vuln --quiet {img} > {raw_file} 2>&1", shell=True)
    if not os.path.exists(raw_file) or os.path.getsize(raw_file) == 0:
        print(f"  SKIP: scan failed")
        continue
    try:
        with open(raw_file) as f:
            d = json.load(f)
        d['specVersion'] = '1.6'
        if '$schema' in d:
            d['$schema'] = 'http://cyclonedx.org/schema/bom-1.6.schema.json'
        if 'metadata' in d and 'component' in d['metadata']:
            cname = img.split('/')[-1].split(':')[0]
            d['metadata']['component']['name'] = cname
            d['metadata']['component']['version'] = '1.0.0'
        for v in d.get('vulnerabilities', []):
            v['description'] = ''
            if 'advisories' in v and len(v['advisories']) > 1:
                v['advisories'] = v['advisories'][:1]
        with open(clean_file, 'w') as f:
            json.dump(d, f)
        print(f"  OK: {os.path.getsize(clean_file)//1024}KB")
    except Exception as e:
        print(f"  ERROR: {e}")

print("=== Scan completed ===")

3) SSH_2:Concertにアプリケーション定義とSBOMを登録

アプリケーション定義と各コンポーネントのSBOMをConcertに登録します。

authKey

ssh_1と同じもの

command(expressionモード)

"echo 'aW1wb3J0IG9zLCBzeXMsIGpzb24sIHN1YnByb2Nlc3MsIHVybGxpYi5yZXF1ZXN0LCBzc2wsIHJlLCBkYXRldGltZQoKZ2l0aHViX3VybCAgPSBzeXMuYXJndlsxXQphcGlfa2V5ICAgICA9IHN5cy5hcmd2WzJdCmNvbmNlcnRfdXJsID0gc3lzLmFyZ3ZbM10KdXBsb2FkX3VybCAgPSBjb25jZXJ0X3VybC5yc3RyaXAoJy8nKSArICcvaW5nZXN0aW9uL2FwaS92MS91cGxvYWRfZmlsZXMnCgpwYXJ0cyA9IHJlLnN1YihyJ2h0dHBzPzovL2dpdGh1YlwuY29tLycsICcnLCBnaXRodWJfdXJsKS5zcGxpdCgnLycpCm9yZ19uYW1lICA9IHBhcnRzWzBdCnJlcG9fbmFtZSA9IHBhcnRzWzFdCmJyYW5jaCAgICA9IHBhcnRzWzNdIGlmIGxlbihwYXJ0cykgPiAzIGVsc2UgJ21hc3RlcicKZmlsZV9wYXRoID0gJy8nLmpvaW4ocGFydHNbNDpdKSBpZiBsZW4ocGFydHMpID4gNCBlbHNlICdkb2NrZXItY29tcG9zZS55YW1sJwpyZXBvICAgID0gb3JnX25hbWUgKyAnLycgKyByZXBvX25hbWUKYXBwICAgICA9IHJlcG9fbmFtZQpvcmcgICAgID0gYXBwLnJlcGxhY2UoJy0nLCAnJykucmVwbGFjZSgnXycsICcnKQpyYXdfdXJsID0gZidodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20ve3JlcG99L3ticmFuY2h9L3tmaWxlX3BhdGh9JwoKY3R4ICAgICA9IHNzbC5fY3JlYXRlX3VudmVyaWZpZWRfY29udGV4dCgpCmNvbnRlbnQgPSB1cmxsaWIucmVxdWVzdC51cmxvcGVuKHJhd191cmwsIGNvbnRleHQ9Y3R4KS5yZWFkKCkuZGVjb2RlKCd1dGYtOCcpCgppbWFnZXMgPSBbXQpmb3IgbGluZSBpbiBjb250ZW50LnNwbGl0bGluZXMoKToKICAgIGxpbmUgPSBsaW5lLnN0cmlwKCkKICAgIGlmIGxpbmUuc3RhcnRzd2l0aCgnaW1hZ2U6Jyk6CiAgICAgICAgaW1nID0gbGluZS5zcGxpdCgnOicsIDEpWzFdLnN0cmlwKCkKICAgICAgICBpbWcgPSBpbWcucmVwbGFjZSgnIicsICcnKS5yZXBsYWNlKCInIiwgJycpLnN0cmlwKCkKICAgICAgICBpbWcgPSByZS5zdWIocidcJFx7P1JFUE9bXn1dKlx9PycsIG9yZywgaW1nKQogICAgICAgIGltZyA9IHJlLnN1YihyJ1wkXHs/VEFHW159XSpcfT8nLCAnbGF0ZXN0JywgaW1nKQogICAgICAgIGlmIGltZy5zdGFydHN3aXRoKCcvJyk6CiAgICAgICAgICAgIGltZyA9IG9yZyArIGltZwogICAgICAgIGVsaWYgJy8nIG5vdCBpbiBpbWcgYW5kIG5vdCBhbnkoaW1nLnN0YXJ0c3dpdGgoaykgZm9yIGsgaW4gWydyZWRpcycsICdyYWJiaXRtcScsICdtb25nbycsICdteXNxbCcsICdwb3N0Z3JlcycsICd2YXVsdCcsICdjb25zdWwnLCAnZWxhc3RpY3NlYXJjaCcsICduZ2lueCcsICdhbHBpbmUnXSk6CiAgICAgICAgICAgIGltZyA9IG9yZyArICcvJyArIGltZwogICAgICAgIGlmICc6JyBub3QgaW4gaW1nOgogICAgICAgICAgICBpbWcgKz0gJzpsYXRlc3QnCiAgICAgICAgZWxpZiBpbWcuZW5kc3dpdGgoJzonKToKICAgICAgICAgICAgaW1nICs9ICdsYXRlc3QnCiAgICAgICAgaWYgaW1nIG5vdCBpbiBpbWFnZXM6CiAgICAgICAgICAgIGltYWdlcy5hcHBlbmQoaW1nKQoKIyAtLS0gYXBwbGljYXRpb25fc2JvbSB1cGxvYWQgLS0tCm5vd19zdHIgPSBkYXRldGltZS5kYXRldGltZS5ub3coZGF0ZXRpbWUudGltZXpvbmUudXRjKS5zdHJmdGltZSgnJVktJW0tJWRUJUg6JU06JVMuJWYnKVs6LTNdICsgJ1onCmNvbXBzID0gW10KZm9yIGltZyBpbiBpbWFnZXM6CiAgICBjbmFtZSA9IGltZy5zcGxpdCgnLycpWy0xXS5zcGxpdCgnOicpWzBdCiAgICBjb21wcy5hcHBlbmQoewogICAgICAgICJib20tcmVmIjogZiJjb21wb25lbnQ6e2NuYW1lfSIsCiAgICAgICAgInR5cGUiOiAiY29tcG9uZW50IiwKICAgICAgICAibmFtZSI6IGNuYW1lLAogICAgICAgICJwcm9wZXJ0aWVzIjogWwogICAgICAgICAgICB7Im5hbWUiOiAicmVwb3NpdG9yeSIsICJ2YWx1ZSI6IGYiaHR0cHM6Ly9naXRodWIuY29tL3tyZXBvfSJ9LAogICAgICAgICAgICB7Im5hbWUiOiAiaW1hZ2VfbmFtZSIsICJ2YWx1ZSI6IGltZ30KICAgICAgICBdCiAgICB9KQoKY29uY2VydF9kZWYgPSB7CiAgICAiYm9tLXJlZiI6IGYiYXBwbGljYXRpb246e2FwcH06MS4wLjAiLAogICAgImJvbUZvcm1hdCI6ICJDb25jZXJ0RGVmIiwKICAgICJzcGVjVmVyc2lvbiI6ICIyLjAuMCIsCiAgICAiY3JpdGljYWxpdHkiOiAxLAogICAgImRhdGEtc2Vuc2l0aXZpdHkiOiAxLAogICAgIm1ldGFkYXRhIjogewogICAgICAgICJ0aW1lc3RhbXAiOiBub3dfc3RyLAogICAgICAgICJ0eXBlIjogImFwcGxpY2F0aW9uIiwKICAgICAgICAiYXBwbGljYXRpb24tbmFtZSI6IGFwcCwKICAgICAgICAiYXBwbGljYXRpb24tdmVyc2lvbiI6ICIxLjAuMCIKICAgIH0sCiAgICAiY29tcG9uZW50cyI6IGNvbXBzCn0KCmNkZWZfcGF0aCA9IGYiL3RtcC97YXBwfV9jb25jZXJ0X2RlZi5qc29uIgp3aXRoIG9wZW4oY2RlZl9wYXRoLCAndycpIGFzIGY6CiAgICBqc29uLmR1bXAoY29uY2VydF9kZWYsIGYsIGluZGVudD0yKQoKY21kX2RlZiA9IFsKICAgICdjdXJsJywgJy1rJywgJy1zJywgJy1vJywgJy9kZXYvbnVsbCcsICctdycsICcle2h0dHBfY29kZX0nLAogICAgJy1YJywgJ1BPU1QnLCB1cGxvYWRfdXJsLAogICAgJy1IJywgZidBdXRob3JpemF0aW9uOiBDX0FQSV9LRVkge2FwaV9rZXl9JywKICAgICctSCcsICdJbnN0YW5jZUlkOiAwMDAwLTAwMDAtMDAwMC0wMDAwJywKICAgICctSCcsICdhY2NlcHQ6IGFwcGxpY2F0aW9uL2pzb24nLAogICAgJy1GJywgJ2RhdGFfdHlwZT1hcHBsaWNhdGlvbl9zYm9tJywKICAgICctRicsIGYnZmlsZW5hbWU9QHtjZGVmX3BhdGh9JwpdCmNvZGVfZGVmID0gc3VicHJvY2Vzcy5jaGVja19vdXRwdXQoY21kX2RlZikuZGVjb2RlKCkuc3RyaXAoKQpwcmludChmImFwcF9kZWZpbml0aW9uOiB7Y29kZV9kZWZ9IikKCiMgLS0tIHBhY2thZ2Vfc2JvbSB1cGxvYWQgLS0tCm1ldGFfanNvbiA9IGpzb24uZHVtcHMoeyJhcHBsaWNhdGlvbl9uYW1lIjogYXBwLCAiYXBwbGljYXRpb25fdmVyc2lvbiI6ICIxLjAuMCJ9KQpyZXN1bHRzID0gW10KZm9yIGltZyBpbiBpbWFnZXM6CiAgICBjbmFtZSA9IGltZy5zcGxpdCgnLycpWy0xXS5zcGxpdCgnOicpWzBdCiAgICBzYWZlX25hbWUgPSBpbWcucmVwbGFjZSgnLycsICdfJykucmVwbGFjZSgnOicsICdfJykKICAgIGNsZWFuX2ZpbGUgPSBmIi90bXAve3NhZmVfbmFtZX1fY2xlYW4uanNvbiIKICAgIHNsaW1fZmlsZSAgPSBmIi90bXAve3NhZmVfbmFtZX1fc2xpbS5qc29uIgogICAgaWYgbm90IG9zLnBhdGguZXhpc3RzKGNsZWFuX2ZpbGUpIG9yIG9zLnBhdGguZ2V0c2l6ZShjbGVhbl9maWxlKSA9PSAwOgogICAgICAgIHJlc3VsdHMuYXBwZW5kKGYie2NuYW1lfSBTQk9NOiBTS0lQIChubyBzY2FuIGZpbGUpIikKICAgICAgICBjb250aW51ZQogICAgdHJ5OgogICAgICAgIHdpdGggb3BlbihjbGVhbl9maWxlKSBhcyBmOgogICAgICAgICAgICBkID0ganNvbi5sb2FkKGYpCiAgICAgICAgZC5wb3AoJ2RlcGVuZGVuY2llcycsIE5vbmUpCiAgICAgICAgZC5wb3AoJ3NlcnZpY2VzJywgTm9uZSkKICAgICAgICBkLnBvcCgnY29tcG9zaXRpb25zJywgTm9uZSkKICAgICAgICBmb3IgdiBpbiBkLmdldCgndnVsbmVyYWJpbGl0aWVzJywgW10pOgogICAgICAgICAgICB2WydhZHZpc29yaWVzJ10gPSB2LmdldCgnYWR2aXNvcmllcycsIFtdKVs6MV0KICAgICAgICAgICAgaWYgbGVuKHYuZ2V0KCdkZXNjcmlwdGlvbicsICcnKSkgPiAxMDA6CiAgICAgICAgICAgICAgICB2WydkZXNjcmlwdGlvbiddID0gdlsnZGVzY3JpcHRpb24nXVs6MTAwXQogICAgICAgIHdpdGggb3BlbihzbGltX2ZpbGUsICd3JykgYXMgZjoKICAgICAgICAgICAganNvbi5kdW1wKGQsIGYsIHNlcGFyYXRvcnM9KCcsJywgJzonKSkKICAgICAgICBzaXplX2tiID0gb3MucGF0aC5nZXRzaXplKHNsaW1fZmlsZSkgLy8gMTAyNAogICAgZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgogICAgICAgIHJlc3VsdHMuYXBwZW5kKGYie2NuYW1lfSBTQk9NOiBzbGltIGVycm9yIHtlfSIpCiAgICAgICAgY29udGludWUKICAgIGNtZF9zYm9tID0gWwogICAgICAgICdjdXJsJywgJy1rJywgJy1zJywgJy1vJywgJy9kZXYvbnVsbCcsICctdycsICcle2h0dHBfY29kZX0nLAogICAgICAgICctWCcsICdQT1NUJywgdXBsb2FkX3VybCwKICAgICAgICAnLUgnLCBmJ0F1dGhvcml6YXRpb246IENfQVBJX0tFWSB7YXBpX2tleX0nLAogICAgICAgICctSCcsICdJbnN0YW5jZUlkOiAwMDAwLTAwMDAtMDAwMC0wMDAwJywKICAgICAgICAnLUgnLCAnYWNjZXB0OiBhcHBsaWNhdGlvbi9qc29uJywKICAgICAgICAnLUYnLCAnZGF0YV90eXBlPXBhY2thZ2Vfc2JvbScsCiAgICAgICAgJy1GJywgZidtZXRhZGF0YT17bWV0YV9qc29ufScsCiAgICAgICAgJy1GJywgZidmaWxlbmFtZT1Ae3NsaW1fZmlsZX0nCiAgICBdCiAgICBjb2RlID0gc3VicHJvY2Vzcy5jaGVja19vdXRwdXQoY21kX3Nib20pLmRlY29kZSgpLnN0cmlwKCkKICAgIHJlc3VsdHMuYXBwZW5kKGYie2NuYW1lfSBTQk9NOiB7Y29kZX0gKHtzaXplX2tifUtCKSIpCgpmb3IgciBpbiByZXN1bHRzOgogICAgcHJpbnQocikKcHJpbnQoIj09PSBwYWNrYWdlX3Nib20gdXBsb2FkIGNvbXBsZXRlZCA9PT0iKQo=' | base64 -d > /tmp/scan2_generic.py && python3 /tmp/scan2_generic.py " + $github_url + " " + $api_key + " " + $concert_url

(参考)

以下のコードをBase64エンコードして1行のコマンドにまとめたものです。

import os, sys, json, subprocess, urllib.request, ssl, re, datetime

github_url  = sys.argv[1]
api_key     = sys.argv[2]
concert_url = sys.argv[3]
upload_url  = concert_url.rstrip('/') + '/ingestion/api/v1/upload_files'

parts = re.sub(r'https?://github\.com/', '', github_url).split('/')
org_name  = parts[0]
repo_name = parts[1]
branch    = parts[3] if len(parts) > 3 else 'master'
file_path = '/'.join(parts[4:]) if len(parts) > 4 else 'docker-compose.yaml'
repo    = org_name + '/' + repo_name
app     = repo_name
org     = app.replace('-', '').replace('_', '')
raw_url = f'https://raw.githubusercontent.com/{repo}/{branch}/{file_path}'

ctx     = ssl._create_unverified_context()
content = urllib.request.urlopen(raw_url, context=ctx).read().decode('utf-8')

images = []
for line in content.splitlines():
    line = line.strip()
    if line.startswith('image:'):
        img = line.split(':', 1)[1].strip()
        img = img.replace('"', '').replace("'", '').strip()
        img = re.sub(r'\$\{?REPO[^}]*\}?', org, img)
        img = re.sub(r'\$\{?TAG[^}]*\}?', 'latest', img)
        if img.startswith('/'):
            img = org + img
        elif '/' not in img and not any(img.startswith(k) for k in ['redis', 'rabbitmq', 'mongo', 'mysql', 'postgres', 'vault', 'consul', 'elasticsearch', 'nginx', 'alpine']):
            img = org + '/' + img
        if ':' not in img:
            img += ':latest'
        elif img.endswith(':'):
            img += 'latest'
        if img not in images:
            images.append(img)

# --- application_sbom upload ---
now_str = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
comps = []
for img in images:
    cname = img.split('/')[-1].split(':')[0]
    comps.append({
        "bom-ref": f"component:{cname}",
        "type": "component",
        "name": cname,
        "properties": [
            {"name": "repository", "value": f"https://github.com/{repo}"},
            {"name": "image_name", "value": img}
        ]
    })

concert_def = {
    "bom-ref": f"application:{app}:1.0.0",
    "bomFormat": "ConcertDef",
    "specVersion": "2.0.0",
    "criticality": 1,
    "data-sensitivity": 1,
    "metadata": {
        "timestamp": now_str,
        "type": "application",
        "application-name": app,
        "application-version": "1.0.0"
    },
    "components": comps
}

cdef_path = f"/tmp/{app}_concert_def.json"
with open(cdef_path, 'w') as f:
    json.dump(concert_def, f, indent=2)

cmd_def = [
    'curl', '-k', '-s', '-o', '/dev/null', '-w', '%{http_code}',
    '-X', 'POST', upload_url,
    '-H', f'Authorization: C_API_KEY {api_key}',
    '-H', 'InstanceId: 0000-0000-0000-0000',
    '-H', 'accept: application/json',
    '-F', 'data_type=application_sbom',
    '-F', f'filename=@{cdef_path}'
]
code_def = subprocess.check_output(cmd_def).decode().strip()
print(f"app_definition: {code_def}")

# --- package_sbom upload ---
meta_json = json.dumps({"application_name": app, "application_version": "1.0.0"})
results = []
for img in images:
    cname = img.split('/')[-1].split(':')[0]
    safe_name = img.replace('/', '_').replace(':', '_')
    clean_file = f"/tmp/{safe_name}_clean.json"
    slim_file  = f"/tmp/{safe_name}_slim.json"
    if not os.path.exists(clean_file) or os.path.getsize(clean_file) == 0:
        results.append(f"{cname} SBOM: SKIP (no scan file)")
        continue
    try:
        with open(clean_file) as f:
            d = json.load(f)
        d.pop('dependencies', None)
        d.pop('services', None)
        d.pop('compositions', None)
        for v in d.get('vulnerabilities', []):
            v['advisories'] = v.get('advisories', [])[:1]
            if len(v.get('description', '')) > 100:
                v['description'] = v['description'][:100]
        with open(slim_file, 'w') as f:
            json.dump(d, f, separators=(',', ':'))
        size_kb = os.path.getsize(slim_file) // 1024
    except Exception as e:
        results.append(f"{cname} SBOM: slim error {e}")
        continue
    cmd_sbom = [
        'curl', '-k', '-s', '-o', '/dev/null', '-w', '%{http_code}',
        '-X', 'POST', upload_url,
        '-H', f'Authorization: C_API_KEY {api_key}',
        '-H', 'InstanceId: 0000-0000-0000-0000',
        '-H', 'accept: application/json',
        '-F', 'data_type=package_sbom',
        '-F', f'metadata={meta_json}',
        '-F', f'filename=@{slim_file}'
    ]
    code = subprocess.check_output(cmd_sbom).decode().strip()
    results.append(f"{cname} SBOM: {code} ({size_kb}KB)")

for r in results:
    print(r)
print("=== package_sbom upload completed ===")

4) SSH_3:ConcertにCVEを登録

各コンポーネントのCVEスキャン結果をConcertに登録します。

authKey

ssh_1と同じもの

command(expressionモード)

"echo 'aW1wb3J0IG9zLCBzeXMsIGpzb24sIHN1YnByb2Nlc3MsIHVybGxpYi5yZXF1ZXN0LCBzc2wsIHJlLCBkYXRldGltZQoKZ2l0aHViX3VybCAgPSBzeXMuYXJndlsxXQphcGlfa2V5ICAgICA9IHN5cy5hcmd2WzJdCmNvbmNlcnRfdXJsID0gc3lzLmFyZ3ZbM10KdXBsb2FkX3VybCAgPSBjb25jZXJ0X3VybC5yc3RyaXAoJy8nKSArICcvaW5nZXN0aW9uL2FwaS92MS91cGxvYWRfZmlsZXMnCgpwYXJ0cyA9IHJlLnN1YihyJ2h0dHBzPzovL2dpdGh1YlwuY29tLycsICcnLCBnaXRodWJfdXJsKS5zcGxpdCgnLycpCm9yZ19uYW1lICA9IHBhcnRzWzBdCnJlcG9fbmFtZSA9IHBhcnRzWzFdCmJyYW5jaCAgICA9IHBhcnRzWzNdIGlmIGxlbihwYXJ0cykgPiAzIGVsc2UgJ21hc3RlcicKZmlsZV9wYXRoID0gJy8nLmpvaW4ocGFydHNbNDpdKSBpZiBsZW4ocGFydHMpID4gNCBlbHNlICdkb2NrZXItY29tcG9zZS55YW1sJwpyZXBvICAgID0gb3JnX25hbWUgKyAnLycgKyByZXBvX25hbWUKYXBwICAgICA9IHJlcG9fbmFtZQpvcmcgICAgID0gYXBwLnJlcGxhY2UoJy0nLCAnJykucmVwbGFjZSgnXycsICcnKQpyYXdfdXJsID0gZidodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20ve3JlcG99L3ticmFuY2h9L3tmaWxlX3BhdGh9JwoKY3R4ICAgICA9IHNzbC5fY3JlYXRlX3VudmVyaWZpZWRfY29udGV4dCgpCmNvbnRlbnQgPSB1cmxsaWIucmVxdWVzdC51cmxvcGVuKHJhd191cmwsIGNvbnRleHQ9Y3R4KS5yZWFkKCkuZGVjb2RlKCd1dGYtOCcpCgppbWFnZXMgPSBbXQpmb3IgbGluZSBpbiBjb250ZW50LnNwbGl0bGluZXMoKToKICAgIGxpbmUgPSBsaW5lLnN0cmlwKCkKICAgIGlmIGxpbmUuc3RhcnRzd2l0aCgnaW1hZ2U6Jyk6CiAgICAgICAgaW1nID0gbGluZS5zcGxpdCgnOicsIDEpWzFdLnN0cmlwKCkKICAgICAgICBpbWcgPSBpbWcucmVwbGFjZSgnIicsICcnKS5yZXBsYWNlKCInIiwgJycpLnN0cmlwKCkKICAgICAgICBpbWcgPSByZS5zdWIocidcJFx7P1JFUE9bXn1dKlx9PycsIG9yZywgaW1nKQogICAgICAgIGltZyA9IHJlLnN1YihyJ1wkXHs/VEFHW159XSpcfT8nLCAnbGF0ZXN0JywgaW1nKQogICAgICAgIGlmIGltZy5zdGFydHN3aXRoKCcvJyk6CiAgICAgICAgICAgIGltZyA9IG9yZyArIGltZwogICAgICAgIGVsaWYgJy8nIG5vdCBpbiBpbWcgYW5kIG5vdCBhbnkoaW1nLnN0YXJ0c3dpdGgoaykgZm9yIGsgaW4gWydyZWRpcycsICdyYWJiaXRtcScsICdtb25nbycsICdteXNxbCcsICdwb3N0Z3JlcycsICd2YXVsdCcsICdjb25zdWwnLCAnZWxhc3RpY3NlYXJjaCcsICduZ2lueCcsICdhbHBpbmUnXSk6CiAgICAgICAgICAgIGltZyA9IG9yZyArICcvJyArIGltZwogICAgICAgIGlmICc6JyBub3QgaW4gaW1nOgogICAgICAgICAgICBpbWcgKz0gJzpsYXRlc3QnCiAgICAgICAgZWxpZiBpbWcuZW5kc3dpdGgoJzonKToKICAgICAgICAgICAgaW1nICs9ICdsYXRlc3QnCiAgICAgICAgaWYgaW1nIG5vdCBpbiBpbWFnZXM6CiAgICAgICAgICAgIGltYWdlcy5hcHBlbmQoaW1nKQoKcmVzdWx0cyA9IFtdCmZvciBpbWcgaW4gaW1hZ2VzOgogICAgY25hbWUgPSBpbWcuc3BsaXQoJy8nKVstMV0uc3BsaXQoJzonKVswXQogICAgc2FmZV9uYW1lID0gaW1nLnJlcGxhY2UoJy8nLCAnXycpLnJlcGxhY2UoJzonLCAnXycpCiAgICBzbGltX2ZpbGUgID0gZiIvdG1wL3tzYWZlX25hbWV9X3NsaW0uanNvbiIKICAgIGNsZWFuX2ZpbGUgPSBmIi90bXAve3NhZmVfbmFtZX1fY2xlYW4uanNvbiIKICAgIHRhcmdldCA9IHNsaW1fZmlsZSBpZiBvcy5wYXRoLmV4aXN0cyhzbGltX2ZpbGUpIGFuZCBvcy5wYXRoLmdldHNpemUoc2xpbV9maWxlKSA+IDAgZWxzZSBjbGVhbl9maWxlCiAgICBpZiBub3Qgb3MucGF0aC5leGlzdHModGFyZ2V0KSBvciBvcy5wYXRoLmdldHNpemUodGFyZ2V0KSA9PSAwOgogICAgICAgIHJlc3VsdHMuYXBwZW5kKGYie2NuYW1lfSBDVkU6IFNLSVAgKG5vIHNjYW4gZmlsZSkiKQogICAgICAgIGNvbnRpbnVlCiAgICBjbWQgPSBbCiAgICAgICAgJ2N1cmwnLCAnLWsnLCAnLXMnLCAnLVgnLCAnUE9TVCcsIHVwbG9hZF91cmwsCiAgICAgICAgJy1IJywgZidBdXRob3JpemF0aW9uOiBDX0FQSV9LRVkge2FwaV9rZXl9JywKICAgICAgICAnLUgnLCAnSW5zdGFuY2VJZDogMDAwMC0wMDAwLTAwMDAtMDAwMCcsCiAgICAgICAgJy1IJywgJ2FjY2VwdDogYXBwbGljYXRpb24vanNvbicsCiAgICAgICAgJy1GJywgJ2RhdGFfdHlwZT1pbWFnZV9zY2FuJywKICAgICAgICAnLUYnLCBmJ2ZpbGVuYW1lPUB7dGFyZ2V0fScKICAgIF0KICAgIHJlc3AgPSBzdWJwcm9jZXNzLmNoZWNrX291dHB1dChjbWQpLmRlY29kZSgpLnN0cmlwKCkKICAgIHJlc3VsdHMuYXBwZW5kKGYie2NuYW1lfSBDVkU6IHtyZXNwfSIpCgpmb3IgciBpbiByZXN1bHRzOgogICAgcHJpbnQocikKcHJpbnQoIj09PSBDVkUgdXBsb2FkIGNvbXBsZXRlZCA9PT0iKQo=' | base64 -d > /tmp/scan3_generic.py && python3 /tmp/scan3_generic.py " + $github_url + " " + $api_key + " " + $concert_url

(参考)

以下のコードをBase64エンコードして1行のコマンドにまとめたものです。

import os, sys, json, subprocess, urllib.request, ssl, re, datetime

github_url  = sys.argv[1]
api_key     = sys.argv[2]
concert_url = sys.argv[3]
upload_url  = concert_url.rstrip('/') + '/ingestion/api/v1/upload_files'

parts = re.sub(r'https?://github\.com/', '', github_url).split('/')
org_name  = parts[0]
repo_name = parts[1]
branch    = parts[3] if len(parts) > 3 else 'master'
file_path = '/'.join(parts[4:]) if len(parts) > 4 else 'docker-compose.yaml'
repo    = org_name + '/' + repo_name
app     = repo_name
org     = app.replace('-', '').replace('_', '')
raw_url = f'https://raw.githubusercontent.com/{repo}/{branch}/{file_path}'

ctx     = ssl._create_unverified_context()
content = urllib.request.urlopen(raw_url, context=ctx).read().decode('utf-8')

images = []
for line in content.splitlines():
    line = line.strip()
    if line.startswith('image:'):
        img = line.split(':', 1)[1].strip()
        img = img.replace('"', '').replace("'", '').strip()
        img = re.sub(r'\$\{?REPO[^}]*\}?', org, img)
        img = re.sub(r'\$\{?TAG[^}]*\}?', 'latest', img)
        if img.startswith('/'):
            img = org + img
        elif '/' not in img and not any(img.startswith(k) for k in ['redis', 'rabbitmq', 'mongo', 'mysql', 'postgres', 'vault', 'consul', 'elasticsearch', 'nginx', 'alpine']):
            img = org + '/' + img
        if ':' not in img:
            img += ':latest'
        elif img.endswith(':'):
            img += 'latest'
        if img not in images:
            images.append(img)

results = []
for img in images:
    cname = img.split('/')[-1].split(':')[0]
    safe_name = img.replace('/', '_').replace(':', '_')
    slim_file  = f"/tmp/{safe_name}_slim.json"
    clean_file = f"/tmp/{safe_name}_clean.json"
    target = slim_file if os.path.exists(slim_file) and os.path.getsize(slim_file) > 0 else clean_file
    if not os.path.exists(target) or os.path.getsize(target) == 0:
        results.append(f"{cname} CVE: SKIP (no scan file)")
        continue
    cmd = [
        'curl', '-k', '-s', '-X', 'POST', upload_url,
        '-H', f'Authorization: C_API_KEY {api_key}',
        '-H', 'InstanceId: 0000-0000-0000-0000',
        '-H', 'accept: application/json',
        '-F', 'data_type=image_scan',
        '-F', f'filename=@{target}'
    ]
    resp = subprocess.check_output(cmd).decode().strip()
    results.append(f"{cname} CVE: {resp}")

for r in results:
    print(r)
print("=== CVE upload completed ===")

5) Assignブロック:実行結果を表示

SBOMおよびCVEのアップロード結果を確認

value

"=== SBOM Upload Result ===\n" + $SSH_2.result + "\n\n=== CVE Upload Result ===\n" + $SSH_3.result

まとめ

本ワークフローにより、GitHub上のdocker-compose.yamlのURLを1つ入力するだけで、複数コンテナイメージのSBOM生成・脆弱性スキャン・IBM Concertへの登録までを自動化することができます。
手作業では数十分かかるアプリケーション登録作業をワンクリックで完結でき、IBM Concertの評価や初期セットアップにかかる時間を短縮できます。
また、ワークフローの入力変数として concert_url と api_key を外出しにしているため、接続先の環境が変わっても設定変更なしに利用できます。

関連リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?