はじめに
本ワークフローは、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にアプリケーションが未登録の状態です。アプリケーション一覧には何も表示されていません。

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

前提条件
動作確認済みバージョン
| ソフトウェア | バージョン |
|---|---|
| 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 |
準備が必要なもの
- IBM ConcertのAPIキーを取得済み
- Trivy・curl・python3 がインストール済み
- スキャン対象のdocker-compose.yamlに
image:形式でイメージが定義されていること
APIキーの取得
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: のみ定義されたサービスはスキャン対象外になります。

ワークフロー作成
IBM Concert Workflowsの制約上、コマンドはすべて1行で記載しています。各ステップのCommandをそのままコピー&ペーストしてご利用ください。
全体像
作成するワークフローの全体像です。SSH、Assignは+→Commonで検索すると出てきます。

1) 開始ブロック:変数の定義
-
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
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 を外出しにしているため、接続先の環境が変わっても設定変更なしに利用できます。



