LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

脆弱性の修復コマンドをGitHubのIssueから実行するAction作ってみた

はじめに

インフラGの鈴木です。先日高知競馬で負けた後、朝5時に起き、エクストリーム出勤してこの記事を書いています。
ところで、前回AWS Inspectorから脆弱性の検出結果をGitHubのIssueに自動起票するワークフローを作りました。その結果、大量にissueがつくられてすごく面倒になりました。
made.livesense.co.jp 上記の記事の通り推奨コマンドがAPIから取れるので、これをSystems ManagerのRun Commandを使って実行すればいいのではと思い作ってみました。
その結果、まあまあしんどかったので供養がてら記事にします。

イメージ

実行

実行トリガーと実行結果

サマリ

フローチャート

縦に長くなってしまった

しんどいポイント

VS インタラクティブな操作

  • アップデート作業は基本的にインタラクティブな操作(y/nで選ぶetc...)が求められるので、その瞬間にコケます

    • インストールするか否かのy/nに関しては-yをつければ解決しますが…
    • 設定方式が変わった(confからyamlなど)ときに現設定を残すか、キーボードで選ぶようなやつが出た瞬間にコケます
      • これに関してはどうしようもないので手で実行するようにしました
  • 他にも実行に問題はありませんが、こんなメッセージが標準エラー出力にでます

    • Dockerfileによく書くDEBIAN_FRONTEND=noninteractiveを設定することで、インタラクティブ操作を無視してインストールできるようです
    • その場合デフォルト値が採用される
----------ERROR-------
debconf: unable to initialize frontend: Dialog
debconf: (TERM is not set, so the dialog frontend is not usable.)
debconf: falling back to frontend: Readline
debconf: unable to initialize frontend: Readline
debconf: (This frontend requires a controlling tty.)
debconf: falling back to frontend: Teletype
dpkg-preconfigure: unable to re-open stdin:

APIからstdoutが取れるが、途中で切れる

  • マネジメントコンソールからはフルログがダウンロードできるが、APIからは切れてしまいます
    • paginateされているのかと思ったら、boto3のドキュメントに何もありませんでした
    • 仕方ないので、コンソールへのリンクを作ってコメントで貼ることにしました
      • https://ap-northeast-1.console.aws.amazon.com/systems-manager/run-command/${コマンドID}/${インスタンスID}?region=ap-northeast-1に誘導することにした
        Output truncatedとある

sudoでコマンド叩こうとするとttyがなくてエラーになったが…

  • エラー内容は以下でした、Run Commandでsudo apt ほにゃらら -yをしようとしました。
----------ERROR-------
sudo: sorry, you must have a tty to run sudo
failed to run commands: exit status 1
  • 結論から言うとRun Commandで使用されるユーザーをssm-userだと思い込んでいたのですが、実際に使われているユーザーはrootでした
    • ssmのドキュメントAWS-RunShellScriptを使ってwhoamiを実行したところ判明しました
    • マネジメントコンソールからログインするときはssm-userなので勘違いでハマってしまいました…
    • ドキュメントの目立つところに書いておいて欲しい…
  • 仮にssm-userでRun Commandされていた場合どうしたらいいのでしょうか?
    • これの解決方法は/etc/sudoersDefaults:ssm-user !requirettyを追加すればいいです。
      • Defaults requiretty はsudoにtty(端末)からの実行を必要とさせるオプションです。この設定が有効だと、cronやスクリプトからのsudo実行をブロックします
    • 弊社にはまあまあな数のEC2がありますが、仮に全インスタンスの/etc/sudoersを機械的に置き換えるのは流石に勇気がいりますね…

実装

Issueへのコメントを実行トリガーにする

  • issue_commentを使えばissueにコメントされた場合にワークフローを実行することができます
    • さらにissueのタイトルやコメントの内容をもとにifで制御しています
    • ドキュメントを読むと色々なことができそうでワクワクしますね。
on:
  issue_comment:
    types:
      - created

jobs:
  vulnerability-issue-comment-execute:
    if: contains(github.event.issue.title, 'CVE') && contains(github.event.comment.body, '/execute')

実行トリガーのコメントにリアクションでいいねをつける

  • GitHub ActionはREST APIでさまざまなことができます
- name: Create a reaction
  uses: actions/github-script@v7
  with:
    script: |
      await github.rest.reactions.createForIssueComment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        comment_id: context.payload.comment.id,
        content: "+1",
      })

Issue本文からコマンドと対象インスタンスを取得する

サンプルIssue本文

  • これをシェルでどうにかします。
### 説明
Alon Zahavi は、Linux カーネル内の NVMe-oF/TCP サブシステムを発見しました。
 特定のキューの初期化エラーを適切に処理しませんでした
 状況により、解放後使用の脆弱性が発生します。リモート攻撃者
 これを利用してサービス妨害 (システムクラッシュ) を引き起こしたり、場合によっては
 任意のコードを実行します。
### 原文

 Alon Zahavi discovered that the NVMe-oF/TCP subsystem in the Linux kernel
 did not properly handle queue initialization failures in certain
 situations, leading to a use-after-free vulnerability. A remote attacker
 could use this to cause a denial of service (system crash) or possibly
 execute arbitrary code.
### 詳細
https://people.canonical.com/~ubuntu-security/cve/2023/CVE-2023-5178.html
### インスタンス名
hoge-instance(i-hogehoge)
### 対処方法
\`\`\`
apt-get update && apt-get upgrade
\`\`\`

コマンドの取得

  • 整形とsudo権限の付加を行なっています
    • ちなみに何故かラインフィードとキャリッジリターンが混在しており、1日ハマりました
    • tee -a "$GITHUB_OUTPUT"はdebug用です
- name: Get issue body
  id: get_command
  run: |
    commands=$(echo $ISSUE_BODY|sed 's/.*対処方法//'|sed 's/```//g'|sed '/^$/d')
    if [[ $commands == *" && "* ]]; then
      commands="${commands// && / -y && }"
    fi
    commands="${commands} -y"
    echo "commands=${commands}" | tee -a "$GITHUB_OUTPUT"
  env:
    ISSUE_BODY: ${{ github.event.issue.body }}```

インスタンスIDの取得

  • シンプルに正規表現で抜き出しました
- name: Get Instance ID
  id: get_instance_id
  run: |
    instance_id=$(echo $ISSUE_BODY|grep -o 'i-[a-zA-Z0-9]*')
    echo "instance_id=${instance_id}" | tee -a "$GITHUB_OUTPUT"
  env:
    ISSUE_BODY: ${{ github.event.issue.body }}

OIDCでAWSへの操作権限を安全に取得する

【参考】IAMロールの権限

OIDCで権限の取得

- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::XXXXXXXXXX:role/hoge-ssm-run-command # 各自のiamロールに置き換える
    aws-region: ap-northeast-1

コマンドの実行

  • サブシェル内のsed -e "s/[\r\n]\+//g"|tr -s ' 'は前述のLFとCRの除去です、このタイミングで行わないと正しく除去できなかった(なぜ?)
  • 先ほど取得したコマンドとインスタンスIDをここで使用します
- name: send command
  id: send_command
  run: |
    commands=$(echo "${{ steps.get_command.outputs.commands }}"|sed -e "s/[\r\n]\+//g"|tr -s ' ')
    echo $commands # debug
    command_id=$(aws ssm send-command \
      --document-name "AWS-RunShellScript" \
      --instance-ids "${{ steps.get_instance_id.outputs.instance_id }}" \
      --parameters commands="\"$commands\"" \
      --output json|jq -r '.Command.CommandId') &&
    echo "command_id=${command_id}" | tee -a "$GITHUB_OUTPUT"

実行結果の取得

  • 複数行の出力を取得するためにEOFを使うについてはこの記事が詳しいです、ちなみに弊社VPoEの記事です
- name: get command status
  id: get_command_status
  run: |
    command_id="${{ steps.send_command.outputs.command_id }}"
    if [[ -z "$command_id" ]]; then
      echo "Error: command_id is empty"
      exit 1
    fi

    status=""

    while [[ "$status" != "Success" && "$status" != "Failed" ]]
    do
        output=$(aws ssm list-command-invocations --command-id "$command_id" --details --output json)
        status=$(echo $output | jq -r ".CommandInvocations[0].Status")
        sleep 10 # sleep for 10 seconds
    done

    if [[ "$status" == "Failed" ]] ; then
      echo "Command failed. Exiting."
      exit 1
    fi

    echo "Final command status: $status"
    echo "Full command output: $output" #debug

    # 複数行の出力を取得するためにEOFを使う
    EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
    echo "report<<$EOF" | tee -a "$GITHUB_OUTPUT"
    jq -r ".CommandInvocations[0].CommandPlugins[0].Output" <<< "$output" | tee -a "$GITHUB_OUTPUT"
    echo "$EOF" | tee -a "$GITHUB_OUTPUT"

実行結果をissueにコメントで貼り付ける

- name: Post result to issue
  uses: actions/github-script@v7
  with:
    github-token: ${{secrets.GITHUB_TOKEN}}
    script: |
      const output = `${{steps.get_command_status.outputs.report}}`;
      github.rest.issues.createComment({
          issue_number: context.issue.number,
          owner: context.repo.owner,
          repo: context.repo.repo,
          body: `Here is the result of the command execution:\n\`\`\`${output}\`\`\``
        });

debugに役にたつ結果をissueにコメントで貼り付ける

- name: Comment Result
  if: always()
  uses: actions/github-script@v7
  env:
    GHA_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
    AWS_URL: https://ap-northeast-1.console.aws.amazon.com/systems-manager/run-command/${{ steps.send_command.outputs.command_id }}/${{ steps.get_instance_id.outputs.instance_id }}?region=ap-northeast-1
  with:
    script: |
      const output = `## ${ context.workflow }
      #### Command Result 📖\`${{ steps.get_command_status.outcome }}\`
      *↑successじゃなかったら何かがおかしいよ↑、GHA Result Detailsで確認してね*
      *なんかダメだったら手動でコマンド叩き入れてください*

      *GHA Result Details: [${ process.env.GHA_URL }](${ process.env.GHA_URL })*;
      *AWS Result Details: [${ process.env.AWS_URL }](${ process.env.AWS_URL })*`;
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: output
      });

作ってみて

  • よく考えたら推奨コマンドによく出てくるapt update && apt upgradeを何も考えずに叩いたら、依存関係無視してインストールするので最悪ぶっ壊れるのでは?
    • Inspector v2 + EventBridgeでAWSが脆弱性検知したらRun Commandで自動実行も考えましたが、人の承認がないコマンド実行は怖いのでこうしました
    • モノ自体は何人から良いと言われたので、運用しつつ、改善したいと思います
  • 結論:こういうのを考えなくていいマネージドサービスは神