1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JenkinsでSBOM生成と脆弱性検知を自動化する : 2.SBOM生成と脆弱性検知パイプラインの作成

Posted at

今回は、Jenkinsのパイプラインを作る機能の一つであるフリースタイルジョブを使って、ビルドされた成果物に対して、SBOM生成、脆弱性検知を行うビルドパイプラインを作成していきます。

Jenkinsのジョブの初期設定については、前回の記事をご覧ください。
本記事では、パイプラインの動作を記述したshellスクリプトについて解説を行います。

このパイプラインでは、以下のような前提を想定しています。

  1. mainブランチ一つだけ利用し、開発を進める
  2. ブランチへのタグ付けは行わない
  3. ビルド成果物はコンテナイメージとする

本パイプラインで実行されるシェルスクリプトは、以下2つに分かれています。

  1. Script1: SBOMを生成する
  2. Script2: 脆弱性検知を行い、その結果をSlackへ通知する

これらのスクリプトをJenkinsジョブの設定にあるBuild Stepsへshellスクリプトを書き込みます。

パイプラインアーキテクチャ (作成中)

全スクリプト (作成途中)

こちら、全スクリプトです。

SBOM用スクリプト

#現在のタグとその前のタグの環境変数を定義
#VERSION_TAG_LIST=`git tag -l 'v*'` || true
#TAG_HASH=`git rev-list ${VERSION_TAG_LIST} --max-count=1` || true
#TAG_NAME=`git describe --abbrev=0 --tags ${TAG_HASH}` || true
#PRE_TAG_HASH=`git rev-list ${VERSION_TAG_LIST} --skip=1 --max-count=1` || true
#PRE_TAG_NAME=`git describe --abbrev=0 --tags ${PRE_TAG_HASH}` || true
#echo $TAG_NAME || true
#echo $PRE_TAG_NAME || true

BRANCH_NAME=`echo $GIT_BRANCH | cut -d '/' -f 2`
REPOSITORY_NAME='Jenkins_pipeline_test'


#sbomディレクトリを作成し、最新ブランチに対してsbom_${BRANCH_NAME}.jsonを生成
SBOM_DIR=sbom/
SBOM_DIR_CURRE=sbom_curre/
SBOM_DIR_PAST=sbom_past/
SBOM_FILE="sbom_${REPOSITORY_NAME}_${BRANCH_NAME}"
SBOM_LIST_DIR=sbom_list/
SBOM_LIST_DIR_CURRE=sbom_list_curre/
SBOM_LIST_DIR_PAST=sbom_list_past/
SBOM_FILE_ARRANGED="sbom_list_${REPOSITORY_NAME}_${BRANCH_NAME}"
#ディレクトリを作る
if [ ! -d ${SBOM_DIR} ]; then
  mkdir -p ${SBOM_DIR}${SBOM_DIR_CURRE}
  mkdir -p ${SBOM_DIR}${SBOM_DIR_PAST}
fi
if [ ! -d ${SBOM_LIST_DIR} ]; then
  mkdir -p ${SBOM_LIST_DIR}${SBOM_LIST_DIR_CURRE}
  mkdir -p ${SBOM_LIST_DIR}${SBOM_LIST_DIR_PAST}
fi
if [ ! -d ${SBOM_LIST_DIR}${SBOM_LIST_DIR_CURRE}${SBOM_FILE_ARRANGED} ]; then
  mkdir -p ${SBOM_LIST_DIR}${SBOM_LIST_DIR_CURRE}${SBOM_FILE_ARRANGED}
fi
#vulnディレクトリを作成し、現在のタグに対してvuln_table_${BRANCH_NAME}.txtを生成
VULN_DIR="vuln/"
GRYPE_FILE="vuln_table_${REPOSITORY_NAME}_${BRANCH_NAME}"
VULN_DIR_CURRE="vuln_curre/"
VULN_DIR_PAST="vuln_past/"
if [ ! -d ${VULN_DIR} ]; then
  mkdir -p ${VULN_DIR}${VULN_DIR_CURRE}
  mkdir -p ${VULN_DIR}${VULN_DIR_PAST}
fi

#Docker build
imageName=poetry
imageTag="sbom"_$BUILD_ID
TARGET=$imageName:$imageTag

docker build -t ${TARGET} .


#syftでSBOMを生成する
sbom_create() {
  local _target=$1
  local _sbom_curre_file=$2
  local _sbom_past_file=$3
  syft --version
  if [ -e  "${_sbom_curre_file}" ]; then
    cp "${_sbom_curre_file}"  "${_sbom_past_file}"
  fi
  syft image:"${_target}" -o cyclonedx-json="${_sbom_curre_file}"
}
echo "---> start syft scan"
sbom_create ${TARGET} ${SBOM_DIR}${SBOM_DIR_CURRE}${SBOM_FILE}.json ${SBOM_DIR}${SBOM_DIR_PAST}${SBOM_FILE}_prev.json

docker image rm ${TARGET}

脆弱性検知

#脆弱性検知
DAY=`date +%Y%m%d%H%M`

BRANCH_NAME=`echo $GIT_BRANCH | cut -d '/' -f 2`
REPOSITORY_NAME='Jenkins_pipeline_test'

#sbomディレクトリを作成し、現在のタグに対してsbom_${TAG_NAME}.jsonを生成
#sbomディレクトリ
SBOM_DIR=sbom/
SBOM_DIR_CURRE=sbom_curre/
SBOM_DIR_PAST=sbom_past/
SBOM_FILE="sbom_${REPOSITORY_NAME}_${BRANCH_NAME}"
SBOM_DIFF_DIR=sbom_diff/


#日付処理
#cp ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt ${VULN_DIR}${VULN_DIR_PAST}${GRYPE_FILE}_${DAY}.txt
cp ${SBOM_DIR}${SBOM_DIR_CURRE}${SBOM_FILE}.json ${SBOM_DIR}${SBOM_DIR_PAST}${SBOM_FILE}_${DAY}.json


#grype sbom:./${SBOM_DIR}${SBOM_DIR_CURRE}${SBOM_FILE}.json --only-fixed -o table=${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}_fixed.txt
#grype sbom:./${SBOM_DIR}${SBOM_DIR_CURRE}${SBOM_FILE}.json --only-notfixed -o table=${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}_notfixed.txt

VULN_DIR="vuln/"
GRYPE_FILE="vuln_table_${REPOSITORY_NAME}_${BRANCH_NAME}"
VULN_DIR_CURRE="vuln_curre/"
VULN_DIR_PAST="vuln_past/"
if [ ! -d ${VULN_DIR} ]; then
  mkdir -p ${VULN_DIR}${VULN_DIR_CURRE}
  mkdir -p ${VULN_DIR}${VULN_DIR_PAST}
fi


#grypeで最新developブランチのSBOMをスキャンする
grype_vuln_scan() {
  local _sbom_target=$1
  local _results_file=$2
  #grype db status
  #export GRYPE_DB_AUTO_UPDATE=false
  #export GRYPE_CHECK_FOR_APP_UPDATE=false
  grype sbom:./"${_sbom_target}" -o table="${_results_file}".txt
  grype sbom:./"${_sbom_target}" -o json="${_results_file}".json
}

detect_high_critical_vulnerabilities_fixed() {

    local _fixed_state="$1"

    cd "${_fixed_state}"
    grep -E 'AV:N|AV:A' vuln_"${_fixed_state}"_tmp.csv | grep -E 'High|Critical' > vuln_"${_fixed_state}".csv || true
    # 直前のコマンドの終了コードを取得
    exit_code=$?
    echo ${exit_code}
    # 終了コードが0以外(エラーが発生した)場合はスキップする
    if [ $exit_code -ne 0 ]; then
        echo "${_fixed_state}" ": High or Critical are not found. Skipping the execution of the shell script."
        #exit $exit_code
    else
        #cp vulntmp.csv vulntmp1.csv
        rm vuln_"${_fixed_state}"_tmp.csv
        awk -F '","' '!seen[$1 $3]++' vuln_"${_fixed_state}".csv > vuln_"${_fixed_state}"_tmp.csv
        #sed -i '1s/^/Package name,version, purl, fixed-in, VULNERABILITY, SEVERITY, CVSS1, CVSS2, CVSS3, CVSS4\n/' vulntmp.csv
        
        awk -F '","' '{printf("| %-15s | %-12s | %-13s | %-12s | %s |\n", $1, $2, $3, $4, $5)}' vuln_"${_fixed_state}"_tmp.csv > marktable_"${_fixed_state}".txt
        sed -i '1s/^/|パッケージ名|バージョン|修正バージョン|VULNERABILITY|SEVERITY|\n/' marktable_"${_fixed_state}".txt
        sed -i '2 i\|-----------------|--------------|--------------|----------------------|----------|' marktable_"${_fixed_state}".txt
        sed 's/^\|"//g; s/"$//g' marktable_"${_fixed_state}".txt > marktable_"${_fixed_state}"0.txt
        VULNERABILITY_DATA=\`\`\`$(cat marktable_"${_fixed_state}"0.txt)\`\`\`
        rm marktable_"${_fixed_state}".txt marktable_"${_fixed_state}"0.txt
    fi
    cd ..
}

detect_high_critical_vulnerabilities_other() {

    local _fixed_state="$1"

    cd "${_fixed_state}"
    grep -E 'AV:N|AV:A' vuln_"${_fixed_state}"_tmp.csv | grep -E 'High|Critical' > vuln_"${_fixed_state}".csv || true
    # 直前のコマンドの終了コードを取得
    exit_code=$?
    echo ${exit_code}
    # 終了コードが0以外(エラーが発生した)場合はスキップする
    if [ $exit_code -ne 0 ]; then
        echo "${_fixed_state}" ": High or Critical are not found. Skipping the execution of the shell script."
        #exit $exit_code
    else
        #cp vulntmp.csv vulntmp1.csv
        rm vuln_"${_fixed_state}"_tmp.csv
        awk -F '","' '!seen[$1 $3]++' vuln_"${_fixed_state}".csv > vuln_"${_fixed_state}"_tmp.csv
        #sed -i '1s/^/Package name,version, purl, fixed-in, VULNERABILITY, SEVERITY, CVSS1, CVSS2, CVSS3, CVSS4\n/' vulntmp.csv
        
        awk -F '","' '{printf("| %-15s | %-12s | %-13s | %-12s |\n", $1, $2, $3, $4)}' vuln_"${_fixed_state}"_tmp.csv > marktable_"${_fixed_state}".txt
        sed -i '1s/^/|パッケージ名|バージョン|VULNERABILITY|SEVERITY|\n/' marktable_"${_fixed_state}".txt
        sed -i '2 i\|-----------------|--------------|--------------|----------|' marktable_"${_fixed_state}".txt
        sed 's/^\|"//g; s/"$//g' marktable_"${_fixed_state}".txt > marktable_"${_fixed_state}"0.txt
        VULNERABILITY_DATA=\`\`\`$(cat marktable_"${_fixed_state}"0.txt)\`\`\`
        rm marktable_"${_fixed_state}".txt marktable_"${_fixed_state}"0.txt
    fi
    cd ..
}

echo "---> grype vulnerability-scan"
grype_vuln_scan ${SBOM_DIR}${SBOM_DIR_CURRE}${SBOM_FILE}.json ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}

CRITICAL_NUM=`grep -o "Critical" ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt | wc -l`
HIGH_NUM=`grep -o "High" ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt | wc -l`

DIRECTORY_NAME=${GRYPE_FILE}/

if [ ! -d "${DIRECTORY_NAME}" ]; then
    mkdir -p ${DIRECTORY_NAME}
fi

if [ -e "${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.json" ]; then
	cp ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt ${DIRECTORY_NAME}
    cp ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.json ${DIRECTORY_NAME}
    cp ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt ${VULN_DIR}${VULN_DIR_PAST}${GRYPE_FILE}_${DAY}.txt
    cp ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.json ${VULN_DIR}${VULN_DIR_PAST}${GRYPE_FILE}_${DAY}.json
fi

cd $DIRECTORY_NAME

# make fixed directory to store fixed vulnerabilities.
if [ ! -d "fixed" ]; then
    mkdir -p fixed
fi
# FIXED
if cat ${GRYPE_FILE}.json | jq -r '.matches[] | .vulnerability.fix.state | contains("fixed")'  | grep true >/dev/null; then
    cat ${GRYPE_FILE}.json | jq -r '
    .matches[] 
    | select(.vulnerability.fix.state == "fixed") 
    | [
        .artifact.name,
        .artifact.version,
        .vulnerability.fix.versions[],
        if (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss = null) and (.relatedVulnerabilities[0].id as $CVE | .vulnerability.id == $CVE)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        elif (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss != null)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.vulnerability.cvss | last |.version),
            (.vulnerability.cvss | last |.vector)
        else
            .relatedVulnerabilities[0].id,
            .relatedVulnerabilities[0].severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        end
    ] | @csv' > fixed/vuln_fixed_tmp.csv

    detect_high_critical_vulnerabilities_fixed fixed
    VULNERABILITY_DATA_FIXED=${VULNERABILITY_DATA}
else
    # .vulnerability.fix.stateが"fixed"を含まない場合、メッセージを表示
    echo "The 'fixed' string is not found. Skipping the execution of the shell script."
fi

#NON-FIXED
if [ ! -d "not-fixed" ]; then
    mkdir -p not-fixed
fi

if cat ${GRYPE_FILE}.json | jq -r '.matches[] | .vulnerability.fix.state | contains("not-fixed")' | grep true >/dev/null; then
    cat ${GRYPE_FILE}.json | jq -r '
    .matches[] 
    | select(.vulnerability.fix.state == "not-fixed") 
    | [
        .artifact.name,
        .artifact.version,
        if (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss = null) and (.relatedVulnerabilities[0].id as $CVE | .vulnerability.id == $CVE)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        elif (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss != null)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.vulnerability.cvss | last |.version),
            (.vulnerability.cvss | last |.vector)
        else
            .relatedVulnerabilities[0].id,
            .relatedVulnerabilities[0].severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        end
    ] | @csv' > not-fixed/vuln_not-fixed_tmp.csv
    
    detect_high_critical_vulnerabilities_other not-fixed
    VULNERABILITY_DATA_NOTFIXED=${VULNERABILITY_DATA}

else
    # .vulnerability.fix.stateが"not-fixed"を含まない場合、メッセージを表示
    echo "The 'not-fixed' string is not found. Skipping the execution of the shell script."
fi

# UNKNOWN
if [ ! -d "unknown" ]; then
    mkdir -p unknown
fi
if cat ${GRYPE_FILE}.json | jq -r '.matches[] | .vulnerability.fix.state | contains("unknown") // contains("wont-fix")' | grep true  >/dev/null; then
    cat ${GRYPE_FILE}.json | jq -r '
    .matches[] 
    | select(.vulnerability.fix.state == "unknown" or .vulnerability.fix.state == "wont-fix") 
    | [
        .artifact.name,
        .artifact.version,
        if (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss = null) and (.relatedVulnerabilities[0].id as $CVE | .vulnerability.id == $CVE)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        elif (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss != null)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.vulnerability.cvss | last |.version),
            (.vulnerability.cvss | last |.vector)
        else
            .relatedVulnerabilities[0].id,
            .relatedVulnerabilities[0].severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        end
    ] | @csv' > unknown/vuln_unknown_tmp.csv
        
    detect_high_critical_vulnerabilities_other unknown
    VULNERABILITY_DATA_UNKOWN=${VULNERABILITY_DATA}
else
    # .vulnerability.fix.stateが"unknown"を含まない場合、メッセージを表示
    echo "The 'unknown' string is not found. Skipping the execution of the shell script."
fi

cd ..

LIB=`awk 'BEGIN {
  # テーブルヘッダーを出力
  print "| NAME | INSTALLED-VERSION |"
  print "|------|-----------|"
}
NR>1 {
  # NAME と INSTALLED 列だけを出力
  printf("| %s | %s |\n", $1, $2)
}' ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt | uniq`

JSON_INFO=`cat << EOF
{
    "text": "レベルHigh, Criticalの脆弱性が見つかりました。",
    "blocks": [
    	{
    		"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "レベルHigh, Criticalの脆弱性が見つかりました。:"
    		}
    	},
    	{
    		"type": "section",
    		"block_id": "section789",
    		"fields": [
    			{
    				"type": "mrkdwn",
    				"text": "*日付*: ${DAY}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*branch*: ${BRANCH_NAME}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*JOB name*: ${JOB_NAME} #${BUILD_ID}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*JOBのURL*: ${BUILD_URL}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*CRITICALの件数*: ${CRITICAL_NUM}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*HIGHの件数*: ${HIGH_NUM}"
    			},
    		]
    	},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "FIXED-INに記されたバージョンへアップデートをお願いします"
    		}
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "${VULNERABILITY_DATA_FIXED}"
    		}
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "次の脆弱性については、修正バージョンが公開されていません。"
    		}
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "${VULNERABILITY_DATA_NOTFIXED}"
    		}
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "次の脆弱性については、脆弱性対策が知られていません。"
    		},
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "${VULNERABILITY_DATA_UNKOWN}"
    		}
    	}
    ]
}
EOF`

#プロキシを設定する場合は、プロキシを環境変数で設定
#export HTTP_PROXY=
#export HTTPS_PROXY=

#Web hook用URLの設定
WEBHOOK_URL=https://hooks.slack.com/services/

if grep -q "Critical" ${DIRECTORY_NAME}${GRYPE_FILE}.json || grep -q "High" ${DIRECTORY_NAME}${GRYPE_FILE}.json; then
  echo "alerm"

  curl -X POST -H 'Content-type: application/json' -d "${JSON_INFO}" "${WEBHOOK_URL}"    
  #curl -H "Content-Type: application/json" -d "${JSON_INFO}" "${WEBHOOK_URL}"
fi

Script1: SBOM生成

Step1-1 : gitのブランチ、タグ情報の抽出と環境変数の設定

まず、パイプラインで生成されるディレクトリ、ファイル名にgitのリポジトリ、ブランチの情報を付与するため、これらの情報の環境変数を設定します。
このスクリプトで使用する環境変数を以下表にまとめました。

環境変数 説明
VERSION_TAG_LIST バージョンづけされたタグのリスト
TAG_HASH タグのリストから現在で最新バージョンタグのハッシュ値を取得する
TAG_NAME 最新バージョンタグの値を取得する
PRE_TAG_HASH 前回作成したバージョンタグのハッシュ値を取得する
PRE_TAG_NAME 前回作成したバージョンタグの値を取得する
BRANCH_NAME 現在のブランチ名を取得する
REPOSITORY_NAME リポジトリ名を設定する
echo "Hello, Jenkins."
echo "---> set environment variable"

BRANCH_NAME=`echo $GIT_BRANCH | cut -d '/' -f 2`
REPOSITORY_NAME='Jenkins_pipeline_test'

Step1-2: ディレクトリ作成

次に、SBOMのファイル名、また、そのファイルを保存するためのディレクトリ名を環境変数として設定します。

環境変数 説明
SBOM_DIR SBOMを保管するためのディレクトリ
SBOM_DIR_CURRE 最新のSBOMを保管するためのディレクトリ
SBOM_DIR_PAST 過去のSBOMを保管するためのディレクトリ
SBOM_FILE 今回のジョブで生成するSBOMのファイル名
環境変数 説明
SBOM_LIST_DIR SBOMから作成したソフトウェアリストを保管するためのディレクトリ
SBOM_LIST_DIR_CURRE 最新のソフトウェアリストを保管するためのディレクトリ
SBOM_LIST_DIR_PAST 過去のソフトウェアリストを保管するためのディレクトリ
SBOM_LIST_FILE 今回作成したSBOMから作成したソフトウェアリスト
環境変数 説明
SBOM_DIFF_DIR SBOMの差分を保管するためのディレクトリ
SBOM_DIFF_FILE SBOMの差分結果が記述されたファイル

設定した環境変数に従い、ディレクトリを生成します。

#sbomディレクトリを作成し、mainブランチの最新のコミットに対してsbom_${REPOSITORY_NAME}_${BRANCH_NAME}.jsonを生成
SBOM_DIR=sbom/
SBOM_DIR_CURRE=sbom_curre/
SBOM_DIR_PAST=sbom_past/
SBOM_FILE="sbom_${REPOSITORY_NAME}_${BRANCH_NAME}"

#SBOMを保管するディレクトリを作成
if [ ! -d ${SBOM_DIR} ]; then
  mkdir -p ${SBOM_DIR}${SBOM_DIR_CURRE}
  mkdir -p ${SBOM_DIR}${SBOM_DIR_PAST}
fi

#脆弱性検知結果を保管するディレクトリを作成
VULN_DIR="vuln/"
GRYPE_FILE="vuln_table_${REPOSITORY_NAME}_${BRANCH_NAME}"
VULN_DIR_CURRE="vuln_curre/"
VULN_DIR_PAST="vuln_past/"
if [ ! -d ${VULN_DIR} ]; then
  mkdir -p ${VULN_DIR}${VULN_DIR_CURRE}
  mkdir -p ${VULN_DIR}${VULN_DIR_PAST}
fi

Step1-3: コンテナイメージのビルド

コンテナイメージをビルドします。ここでビルドされたコンテナイメージをsyftでスキャンし、SBOMを生成します。

環境変数 説明
IMAGE_NAME イメージ名
IMAGE_TAG イメージのタグ名
TARGET syftでスキャンするコンテナイメージ
#Docker build
IMAGE_NAME=poetry
IMAGE_TAG="sbom"_$BUILD_ID
TARGET=$IMAGE_NAME:$IMAGE_TAG

docker build -t ${TARGET} .

Step1-4: SBOM生成

syftを使い、SBOMを生成します。ここでは、syftによるSBOM生成をsbom_create()という関数にしています。

#syftでSBOMを生成する
sbom_create() {
  local _target=$1
  local _sbom_curre_file=$2
  local _sbom_past_file=$3
  syft --version
  #新しいSBOMをスキャンする前に、前回生成したSBOMはSBOM名_prev.jsonへコピー
  if [ -e  "${_sbom_curre_file}" ]; then
    cp "${_sbom_curre_file}"  "${_sbom_past_file}"
  fi
  syft image:"${_target}" -o cyclonedx-json="${_sbom_curre_file}"
}
echo "---> start syft scan"
sbom_create ${TARGET} ${SBOM_DIR}${SBOM_DIR_CURRE}${SBOM_FILE}.json ${SBOM_DIR}${SBOM_DIR_PAST}${SBOM_FILE}_prev.json

#スキャン後、イメージは削除
docker image rm ${TARGET}

前回のSBOMが存在する場合、新しいSBOMをスキャンする前に、前回生成したSBOMをSBOM名_prev.jsonというファイルへコピーしています。

上のスクリプトにおいて、syftの各オプション、引数は次の意味があります。

  • image:"${_target}"
    • コンテナイメージに対して、スキャンを実施。:の後にSBOM生成対象となるコンテナイメージを指定する
  • -o cyclonedx-json="${_sbom_curre_file}"
    • SBOMファイルの出力をcyclonedx形式かつjsonフォーマットで出力する

Script4 : 脆弱性検知、結果通知用スクリプト

Step1: 環境変数設定

DAY=`date +%Y%m%d%H%M`

BRANCH_NAME=`echo $GIT_BRANCH | cut -d '/' -f 2`
REPOSITORY_NAME='Jenkins_pipeline_test'

#sbomディレクトリを作成し、現在のタグに対してsbom_${TAG_NAME}.jsonを生成
#sbomディレクトリ
SBOM_DIR=sbom/
SBOM_DIR_CURRE=sbom_curre/
SBOM_DIR_PAST=sbom_past/
SBOM_FILE="sbom_${REPOSITORY_NAME}_${BRANCH_NAME}"
SBOM_DIFF_DIR=sbom_diff/
SBOM_DIFF_FILE1=sbom_diff_${REPOSITORY_NAME}_New_${BRANCH_NAME}_Prev_${BRANCH_NAME}
#SBOM_DIFF_FILE2=sbom_diff_${REPOSITORY_NAME}_New_${BRANCH_NAME}_${TAG_NAME}

生成したファイルに日付情報を付与し、保管する

#日付処理
if [ -e  ${SBOM_DIFF_DIR}${SBOM_DIFF_FILE1}.txt ]; then
  cp ${SBOM_DIFF_DIR}${SBOM_DIFF_FILE1}.txt ${SBOM_DIFF_DIR}${SBOM_DIFF_FILE1}_${DAY}.txt
  echo "END"
fi

cp ${SBOM_DIR}${SBOM_DIR_CURRE}${SBOM_FILE}.json ${SBOM_DIR}${SBOM_DIR_PAST}${SBOM_FILE}_${DAY}.json

脆弱性検知結果を管理するためのディレクトリ作成

環境変数 説明
VULN_DIR 脆弱性検知結果を保管するためのディレクトリ
GRYPE_FILE 脆弱性検知結果のファイル名
VULN_DIR_CURRE 最新の脆弱性検知結果を保管するためのディレクトリ
VULN_DIR_PAST 過去の脆弱性検知結果を保管するためのディレクトリ
#脆弱性検知
#vulnディレクトリを作成し、現在のタグに対してvuln_table_${BRANCH_NAME}.txtを生成
VULN_DIR="vuln/"
GRYPE_FILE="vuln_table_${REPOSITORY_NAME}_${BRANCH_NAME}"
VULN_DIR_CURRE="vuln_curre/"
VULN_DIR_PAST="vuln_past/"
if [ ! -d ${VULN_DIR} ]; then
  mkdir -p ${VULN_DIR}${VULN_DIR_CURRE}
  mkdir -p ${VULN_DIR}${VULN_DIR_PAST}
fi

Step: 脆弱性検知用関数の作成

#grypeで最新developブランチのSBOMをスキャンする
grype_vuln_scan() {
  local _sbom_target=$1
  local _results_file=$2
  #grype db status
  #export GRYPE_DB_AUTO_UPDATE=false
  #export GRYPE_CHECK_FOR_APP_UPDATE=false
  grype sbom:./"${_sbom_target}" -o table="${_results_file}".txt
  grype sbom:./"${_sbom_target}" -o json="${_results_file}".json
}

以下の環境変数の設定は、grypeがSBOMをスキャンする前に、データベースをアップデートされる操作をオフにする設定です。

export GRYPE_DB_AUTO_UPDATE=false
export GRYPE_CHECK_FOR_APP_UPDATE=false

grypeコマンドのオプションにはそれぞれ次のような意味があります

  • sbom:./${SBOM_DIR}${SBOM_DIR_CURRE}${SBOM_FILE}.json SBOMのスキャンを行う
  • -o table=${SCAN_DIR}${SCAN_DIR_CURRE}${GRYPE_FILE}.txt 出力形式を指定。今回はtxtファイルでテーブルを出力する
  • jsonで出力したい場合は -o json=${SCAN_DIR}${SCAN_DIR_CURRE}${GRYPE_FILE}.jsonとする

Step: 脆弱性検知結果をトリアージする

grypeで出力した脆弱性結果から、

  1. 脆弱性レベルがHigh、Criticalのもの
  2. アタックベクトルがネットワーク経由で攻撃可能なものAV:N
    を抽出します。
  3. 次に、fixedされているか、いないかで場合分けを行っています。
detect_high_critical_vulnerabilities_fixed() {

    local _fixed_state="$1"

    cd "${_fixed_state}"
    grep -E 'AV:N|AV:A' vuln_"${_fixed_state}"_tmp.csv | grep -E 'High|Critical' > vuln_"${_fixed_state}".csv || true
    # 直前のコマンドの終了コードを取得
    exit_code=$?
    echo ${exit_code}
    # 終了コードが0以外(エラーが発生した)場合はスキップする
    if [ $exit_code -ne 0 ]; then
        echo "${_fixed_state}" ": High or Critical are not found. Skipping the execution of the shell script."
        #exit $exit_code
    else
        #cp vulntmp.csv vulntmp1.csv
        rm vuln_"${_fixed_state}"_tmp.csv
        awk -F '","' '!seen[$1 $3]++' vuln_"${_fixed_state}".csv > vuln_"${_fixed_state}"_tmp.csv
        #sed -i '1s/^/Package name,version, purl, fixed-in, VULNERABILITY, SEVERITY, CVSS1, CVSS2, CVSS3, CVSS4\n/' vulntmp.csv
        
        awk -F '","' '{printf("| %-15s | %-12s | %-13s | %-12s | %s |\n", $1, $2, $3, $4, $5)}' vuln_"${_fixed_state}"_tmp.csv > marktable_"${_fixed_state}".txt
        sed -i '1s/^/|パッケージ名|バージョン|修正バージョン|VULNERABILITY|SEVERITY|\n/' marktable_"${_fixed_state}".txt
        sed -i '2 i\|-----------------|--------------|--------------|----------------------|----------|' marktable_"${_fixed_state}".txt
        sed 's/^\|"//g; s/"$//g' marktable_"${_fixed_state}".txt > marktable_"${_fixed_state}"0.txt
        VULNERABILITY_DATA=\`\`\`$(cat marktable_"${_fixed_state}"0.txt)\`\`\`
        rm marktable_"${_fixed_state}".txt marktable_"${_fixed_state}"0.txt
    fi
    cd ..
}
detect_high_critical_vulnerabilities_other() {

    local _fixed_state="$1"

    cd "${_fixed_state}"
    grep -E 'AV:N|AV:A' vuln_"${_fixed_state}"_tmp.csv | grep -E 'High|Critical' > vuln_"${_fixed_state}".csv || true
    # 直前のコマンドの終了コードを取得
    exit_code=$?
    echo ${exit_code}
    # 終了コードが0以外(エラーが発生した)場合はスキップする
    if [ $exit_code -ne 0 ]; then
        echo "${_fixed_state}" ": High or Critical are not found. Skipping the execution of the shell script."
        #exit $exit_code
    else
        #cp vulntmp.csv vulntmp1.csv
        rm vuln_"${_fixed_state}"_tmp.csv
        awk -F '","' '!seen[$1 $3]++' vuln_"${_fixed_state}".csv > vuln_"${_fixed_state}"_tmp.csv
        #sed -i '1s/^/Package name,version, purl, fixed-in, VULNERABILITY, SEVERITY, CVSS1, CVSS2, CVSS3, CVSS4\n/' vulntmp.csv
        
        awk -F '","' '{printf("| %-15s | %-12s | %-13s | %-12s |\n", $1, $2, $3, $4)}' vuln_"${_fixed_state}"_tmp.csv > marktable_"${_fixed_state}".txt
        sed -i '1s/^/|パッケージ名|バージョン|VULNERABILITY|SEVERITY|\n/' marktable_"${_fixed_state}".txt
        sed -i '2 i\|-----------------|--------------|--------------|----------|' marktable_"${_fixed_state}".txt
        sed 's/^\|"//g; s/"$//g' marktable_"${_fixed_state}".txt > marktable_"${_fixed_state}"0.txt
        VULNERABILITY_DATA=\`\`\`$(cat marktable_"${_fixed_state}"0.txt)\`\`\`
        rm marktable_"${_fixed_state}".txt marktable_"${_fixed_state}"0.txt
    fi
    cd ..
}

echo "---> grype vulnerability-scan"
grype_vuln_scan ${SBOM_DIR}${SBOM_DIR_CURRE}${SBOM_FILE}.json ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}

CRITICAL_NUM=`grep -o "Critical" ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt | wc -l`
HIGH_NUM=`grep -o "High" ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt | wc -l`
DIRECTORY_NAME=${GRYPE_FILE}/

if [ ! -d "${DIRECTORY_NAME}" ]; then
    mkdir -p ${DIRECTORY_NAME}
fi

if [ -e "${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.json" ]; then
	cp ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt ${DIRECTORY_NAME}
    cp ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.json ${DIRECTORY_NAME}
    cp ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt ${VULN_DIR}${VULN_DIR_PAST}${GRYPE_FILE}_${DAY}.txt
    cp ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.json ${VULN_DIR}${VULN_DIR_PAST}${GRYPE_FILE}_${DAY}.json
fi

cd $DIRECTORY_NAME
# make fixed directory to store fixed vulnerabilities.
if [ ! -d "fixed" ]; then
    mkdir -p fixed
fi
# FIXED
if cat ${GRYPE_FILE}.json | jq -r '.matches[] | .vulnerability.fix.state | contains("fixed")'  | grep true >/dev/null; then
    cat ${GRYPE_FILE}.json | jq -r '
    .matches[] 
    | select(.vulnerability.fix.state == "fixed") 
    | [
        .artifact.name,
        .artifact.version,
        .vulnerability.fix.versions[],
        if (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss = null) and (.relatedVulnerabilities[0].id as $CVE | .vulnerability.id == $CVE)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        elif (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss != null)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.vulnerability.cvss | last |.version),
            (.vulnerability.cvss | last |.vector)
        else
            .relatedVulnerabilities[0].id,
            .relatedVulnerabilities[0].severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        end
    ] | @csv' > fixed/vuln_fixed_tmp.csv

    detect_high_critical_vulnerabilities_fixed fixed
    VULNERABILITY_DATA_FIXED=${VULNERABILITY_DATA}
else
    # .vulnerability.fix.stateが"fixed"を含まない場合、メッセージを表示
    echo "The 'fixed' string is not found. Skipping the execution of the shell script."
fi
#NON-FIXED
if [ ! -d "not-fixed" ]; then
    mkdir -p not-fixed
fi

if cat ${GRYPE_FILE}.json | jq -r '.matches[] | .vulnerability.fix.state | contains("not-fixed")' | grep true >/dev/null; then
    cat ${GRYPE_FILE}.json | jq -r '
    .matches[] 
    | select(.vulnerability.fix.state == "not-fixed") 
    | [
        .artifact.name,
        .artifact.version,
        if (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss = null) and (.relatedVulnerabilities[0].id as $CVE | .vulnerability.id == $CVE)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        elif (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss != null)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.vulnerability.cvss | last |.version),
            (.vulnerability.cvss | last |.vector)
        else
            .relatedVulnerabilities[0].id,
            .relatedVulnerabilities[0].severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        end
    ] | @csv' > not-fixed/vuln_not-fixed_tmp.csv
    
    detect_high_critical_vulnerabilities_other not-fixed
    VULNERABILITY_DATA_NOTFIXED=${VULNERABILITY_DATA}

else
    # .vulnerability.fix.stateが"not-fixed"を含まない場合、メッセージを表示
    echo "The 'not-fixed' string is not found. Skipping the execution of the shell script."
fi
# UNKNOWN
if [ ! -d "unknown" ]; then
    mkdir -p unknown
fi
if cat ${GRYPE_FILE}.json | jq -r '.matches[] | .vulnerability.fix.state | contains("unknown") // contains("wont-fix")' | grep true  >/dev/null; then
    cat ${GRYPE_FILE}.json | jq -r '
    .matches[] 
    | select(.vulnerability.fix.state == "unknown" or .vulnerability.fix.state == "wont-fix") 
    | [
        .artifact.name,
        .artifact.version,
        if (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss = null) and (.relatedVulnerabilities[0].id as $CVE | .vulnerability.id == $CVE)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        elif (.vulnerability.id | contains("CVE-")) and (.vulnerability.cvss != null)
        then
            .vulnerability.id,
            .vulnerability.severity,
            (.vulnerability.cvss | last |.version),
            (.vulnerability.cvss | last |.vector)
        else
            .relatedVulnerabilities[0].id,
            .relatedVulnerabilities[0].severity,
            (.relatedVulnerabilities[0].cvss | last | .version),
            (.relatedVulnerabilities[0].cvss | last | .vector)
        end
    ] | @csv' > unknown/vuln_unknown_tmp.csv
        
    detect_high_critical_vulnerabilities_other unknown
    VULNERABILITY_DATA_UNKOWN=${VULNERABILITY_DATA}
else
    # .vulnerability.fix.stateが"unknown"を含まない場合、メッセージを表示
    echo "The 'unknown' string is not found. Skipping the execution of the shell script."
fi

cd ..

Teams通知メッセージの作成

上でトリアージし、修正されているか、いないかで場合分けした脆弱性を通知するためのメッセージを作成します。今回は、Slackへ通知していますが、Teamsへ通知することも可能です。


LIB=`awk 'BEGIN {
  # テーブルヘッダーを出力
  print "| NAME | INSTALLED-VERSION |"
  print "|------|-----------|"
}
NR>1 {
  # NAME と INSTALLED 列だけを出力
  printf("| %s | %s |\n", $1, $2)
}' ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt | uniq`

JSON_INFO=`cat << EOF
{
    "text": "レベルHigh, Criticalの脆弱性が見つかりました。",
    "blocks": [
    	{
    		"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "レベルHigh, Criticalの脆弱性が見つかりました。:"
    		}
    	},
    	{
    		"type": "section",
    		"block_id": "section789",
    		"fields": [
    			{
    				"type": "mrkdwn",
    				"text": "*日付*: ${DAY}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*branch*: ${BRANCH_NAME}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*JOB name*: ${JOB_NAME} #${BUILD_ID}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*JOBのURL*: ${BUILD_URL}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*CRITICALの件数*: ${CRITICAL_NUM}"
    			},
                {
    				"type": "mrkdwn",
    				"text": "*HIGHの件数*: ${HIGH_NUM}"
    			},
    		]
    	},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "FIXED-INに記されたバージョンへアップデートをお願いします"
    		}
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "${VULNERABILITY_DATA_FIXED}"
    		}
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "次の脆弱性については、修正バージョンが公開されていません。"
    		}
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "${VULNERABILITY_DATA_NOTFIXED}"
    		}
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "次の脆弱性については、脆弱性対策が知られていません。"
    		},
		},
        {
        	"type": "section",
    		"text": {
    			"type": "mrkdwn",
    			"text": "${VULNERABILITY_DATA_UNKOWN}"
    		}
    	}
    ]
}
EOF`

#プロキシを設定する場合は、プロキシを環境変数で設定
#export HTTP_PROXY=
#export HTTPS_PROXY=

#Web hook用URLの設定
WEBHOOK_URL=https://hooks.slack.com/services/

if grep -q "Critical" ${DIRECTORY_NAME}${GRYPE_FILE}.json || grep -q "High" ${DIRECTORY_NAME}${GRYPE_FILE}.json; then
  echo "alerm"
  
  curl -X POST -H 'Content-type: application/json' -d "${JSON_INFO}" "${WEBHOOK_URL}"
  #curl -H "Content-Type: application/json" -d "${JSON_INFO}" "${WEBHOOK_URL}"
fi

Slackのwebhookの設定

Slackのグループ、チャンネルを作成してください。今回、私はjenkinsの通知テストというチャンネルを作成しました。

以下のURLに行き、Webhook用のURLを作成・コピーしてください。
https://slack.com/services/new/incoming-webhook

Jenkins_Slack1.png
Jenkins_Slack2.png

Slackへの通知結果

Jenkinsのジョブを実行し、Finished: SUCCESSと出ていれば、スクリプトの実行は成功し、Slackへ通知メッセージが送信されているはずです。
Jenkins_Slack3.png

通知メッセージは以下のように送信されます。1段目にJenkinsのジョブや、脆弱数などの基本情報を、その後に、検知された脆弱性の情報を載せています。
Jenkins_Slack4.png

Teamsへ通知を行う場合

本記事では、Slackへ通知を行いましたが、脆弱性検知結果をTeamsへ通知することもできます。ここでは、Teamsへの通知方法についても解説します。
まず、以下のURL記載の方法でteamsのwebhookを作成します。
https://learn.microsoft.com/ja-jp/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook?tabs=newteams%2Cdotnet

webhookのURLを変数WEBHOOK_URLに代入し、以下のjsonメッセージをURLへ渡せば、Teamsへ通知できます。

Teamsの方がMarkdownテーブルが使えるので、きれいに通知できます。

Slackのwebhooksの設定

{
    "schema": "https://adaptivecards.io/schemas/adaptive-card.json",
    "version": "1.0",
    "@type": "MessageCard",
    "@context": "http://schema.org/extensions",
    "themeColor": "d70048",
    "summary": "Grype脆弱性情報通知",
    "title": "Grype脆弱性情報通知",
    "sections": [
        {
            "title": "Details",
            "markdown": true,
            "text": "レベルHigh, Criticalの脆弱性が見つかりました。以下が対象のファイルです: \r - **SBOMリスト : ${SBOM_LIST_DIR}${SBOM_LIST_DIR_CURRE}${SBOM_FILE_ARRANGED}.txt** \r - **vuln結果 : ${VULN_DIR}${VULN_DIR_CURRE}${GRYPE_FILE}.txt**",
            "facts": [
            	{
                    "name": "日付",
                    "value": "${DAY}"
                },
                {
                    "name": "ブランチ",
                    "value": "main"
                },
                {
                    "name": "job名",
                    "value": "${JOB_NAME} #${BUILD_ID}"
                },
                {
                    "name": "CRITICALの件数",
                    "value": "${CRITICAL_NUM}"
                },
                {
                    "name": "HIGHの件数",
                    "value": "${HIGH_NUM}"
                },
            ],
        },
        {
            "markdown": true ,
            "text": "以下のライブラリに対処が必要な脆弱性が検知されました。ご確認ください。",
        },
        {
            "markdown": true,
            "text": "${LIB}"
        },
        {
            "markdown": true ,
            "text": "FIXED-INに記されたバージョンへアップデートをお願いします"
        },
        {
            "markdown": true,
            "text": "${VULNERABILITY_DATA_FIXED}"
        },
        {
            "markdown": true ,
            "text": "次の脆弱性については、修正バージョンが公開されていません。"
        },
        {
            "markdown": true,
            "text": "${VULNERABILITY_DATA_NOTFIXED}"
        },
        {
            "markdown": true ,
            "text": "次の脆弱性については、脆弱性対策が知られていません。"
        },
        {
        	"markdown": true,
            "text": "${VULNERABILITY_DATA_UNKOWN}"
        },
        {
            "markdown": true ,
            "text": "以下からジョブを確認できます"
        }
    ],
    "potentialAction": [
       {
          "@type": "OpenUri",
          "name": "Jenkinsでジョブ結果を確認する",
          "targets": [
            {
              "os": "default",
              "uri": "${BUILD_URL}"
            }
         ]
       }
    ]
}
1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?