🔖

OPA/Regoの応用(脆弱性管理)

2021/12/23に公開

この記事はOPA/Regoアドベントカレンダーの23日目です。今回、次回ではOPA/Regoを使った応用事例について(構想段階のものも含めて)紹介したいと思います。本日は組織内で開発しているプロダクトの脆弱性管理についてになります。

今回のトピックは筆者がUbie Tech Talk 〜Ubieを支えるプロダクト基盤と分析環境〜アーカイブ)で講演させてもらった内容をベースに、発表時には時間の都合上割愛させてもらった技術の詳細について深堀りしたいと思います。発表資料やアーカイブは以下で閲覧できますので、興味のある方は併せてご覧ください。

https://www.youtube.com/watch?v=39ELMaNzPEg

https://speakerdeck.com/mizutani/trivy-rego

脆弱性の「管理」とは

すでに発表・資料内で説明しているのですが、簡単に整理だけしたいと思います。

昨今のソフトウェア開発(特にWebサービス周り)は3rd partyのパッケージを利用しないという選択肢はほぼなく、OSSを中心にその恩恵をうけながらの開発が主流です。外部パッケージの利用は開発のスピードを大幅に引き上げてくれる反面、そのパッケージに脆弱性が発見された場合にはリスクとなってしまいます。

脆弱性の検知のツールや脆弱性情報の流通は以前に比べて格段に改善されてきましたが、パッケージの更新などが遅滞なくできるかと言うとその限りではありません。アップグレードによる破壊的変更リスクの対応で更新できない、あるいはパッケージ側での対応が追いつかないなど様々な理由がありますが、「脆弱性のあるパッケージを更新できない・しない」という選択肢は現実に起こりえます。

では更新しないとなった場合、それは

  • どのような理由か
  • 誰が判断したのか
  • いつまでそのようにしておくのか

という状態がわかるようにする必要があります。

また、上記をプロダクト開発チームが判断したとしても、セキュリティチームでは別の判断をするというケースもあります。脆弱性による影響は難しく、また対応するかどうかの判断は事業的な判断も絡むためチームによって過剰、あるいは過小にリスクを判断してしまう場合もありえます。しかし、深刻な影響が見込まれるような脆弱性の場合は確実に修正する必要があり、評価の指針や基準を整備する必要もあります。

このような 状態の把握 および 対応判断の評価 をまとめて、脆弱性の「管理」と呼んでいます。

管理のツール化

昔の脆弱性の管理はスプレッドシートのような「管理票」を用いて人間が検査・起票・更新・判断などをしてきました。しかし現代ではContinuous Integration (CI) の文化やツールが成長した結果、検査・起票・更新についてはソフトウェアで解決することができるようになりました。検査に関しては多様な静的解析ツールが出現し、起票や更新(つまり 状態の把握 )についても専用のサービス[1]も充実してきました。

一方で 対応判断の評価 については現状だとまだあまり機能として充実したものはないと考えていました。独自のルールで評価をできるものはありますが、先述したとおり脆弱性は組織や環境、そのプロダクトが取り扱う情報によって影響範囲が変化するため、CVSSのような普遍的かつ共通の深刻度だけで対応を判断するのは困難です。

このような「状況に合わせて判断をする」というような仕組みはまさにOPA/Regoの得意とするところであり、この機能まで盛り込んだのが筆者が開発したOctovy[2]になります。

https://github.com/m-mizutani/octovy

OctovyはGitHub Appとして動作し、Appがインストールされたリポジトリ(あるいは組織)でコンテンツのpushがあった場合にWebhookでOctovyのサーバへ通知が送信され、その後Trivyによってコードをスキャンします。スキャンした結果はDBに格納しつつ、OPAサーバに結果を問い合わせることでどのような対応をすればいいかという判定がOPAサーバから応答されます。

(他の記事でも述べていますが)OPAを使う利点は以下のようなものがあります。

  • 判断がぶれない:定義したポリシーに基づいて決定的に判定されるため、人間が記憶に頼りながらの対応などに比べてミスしにくくかつ結果も決定的になる
  • ポリシーを共有・レビュー可能:GitHubなどでポリシーを管理することで変更履歴やレビューなども併せて実施し、透明性を保つことができる
  • テスト可能:記述したポリシーが意図したとおりに動作しているかを事前に確認できる。さらに、ポリシーを追加・変更した際、他のポリシーに影響を及ぼしていないかを確認し、積極的にポリシーの変更ができるようになる

脆弱性やプロダクトに関するメタ情報の管理

ここまで説明した要件だとGitHub Action で Trivy + OPA/Rego による脆弱性管理、およびGitHub Actionsから得られた結果をOPAサーバに問い合わせるで紹介した内容の組み合わせで実現できそうですが、Trivyから得られる以外にも以下のような情報を利用したいと考えました。

  • プロダクト(リポジトリ)の特徴: リポジトリで管理しているプロダクトにもいろいろな性質があります。最もわかりやすい性質の一つは「外部サービスとして提供しているか?」ではないかと思います。たとえ同じコードであっても、インターネット上で公開されていて誰でもアクセス可能なものと、社内ネットワークやクラウド上の内部ネットワークからしかアクセスできないものではリスクが大きく違ってきます。さらに細かく分解すると、インターネットから直接アクセスできるものでもIdentity-Aware Proxy (IAP) を通さないとアクセスできないもの、ログイン画面までは誰でも開けるがログインしないと殆どの機能にアクセスできないものなど様々です。またプロダクトによってあつかう情報も様々で、公開情報を発信するだけのプロダクトと機微な個人情報をあつかうプロダクトではやはり深刻度に違いが生まれます。これらの種類に応じてリスクを分別すべきと考えると、プロダクト(≒リポジトリ)の特徴に関する情報を管理し、リスク評価および対応の判定に利用するべきと考えられます。
  • 独自に評価した脆弱性の深刻度: ソフトウェアの脆弱性はCVSSなどで深刻度を定量化する手法が定義されていますが、これによって得られた深刻度をそのまま利用するのは実際には困難と考えられます。1つ目の理由は定量化する方法でも評価方法にブレがあるということです。例えばCVE-2021-41132はNISTとCVE Numbering Authorities(CNA)による評価は score 6.1 (Medium) と score 9.8 (critical) で大きく差が開いています[3]。これはどちらが優れているか、というような問題ではなく、評価者によってブレるということは当然ながら組織や環境によっても評価が異なる、というのがポイントです。その組織内での使われ方や環境に応じて、脆弱性の悪用可能性が変わってくるため、最終的には組織内で独自に深刻度を判断していく必要があります。
  • 脆弱性の対応状況: その脆弱性がすでにトリアージされて対応指針が決まっているのか、あるいは単純に放置されているだけなのかも判断の基準となってきます。直ちに深刻な影響がある脆弱性については、理由のいかんを問わず直ちに対応するべきですが、一定リスクを受容できるようなタイプの脆弱性(例えば可用性に影響があるが、一時的に落ちてもリトライすれば復旧できるもの)は方針が決まっていれば急ぎの対応を求める必要あありません。

実はこのあたりの情報もOPAサーバ内のdata (base document) で管理していくというスタイルも考えられます。これはポリシーとメタ情報をどの程度統合・分離して運用するのがいいかというバランスを考えて運用する必要があります。今回はメタ情報をより更新しやすくする、ということで評価に必要なデータを送る側(Octovy)の内部でこれらの状態を持つという判断をしました。

これらの情報を元に、リスクやそれに伴う対応を自動的に判定・実行したい、というのがOctovyの狙いです。対応としては主に以下の2つが考えられます。

  1. デプロイを止める: 直ちに深刻な影響がある脆弱性に関してはデプロイするだけで即座に甚大な被害をもたらす可能性があります。すべての脆弱性のデプロイを止めようとすると事業の目的と相反してしまい、開発者が修正に対して消極的になってしまいます。そこでピンポイントでどうしてもこれはそのままサービスに乗せてほしくない脆弱性に限り、デプロイを止めるようにします。
  2. 修正を促す: 直ちに影響はないものの、他の脆弱性と組み合わせることで大きな被害がでたり、あるいは現状と違う使われ方をすることでリスクが大きくなるというような脆弱性です。これも「すべてを直ちに」とすることで修正側の負荷だけを上げてしまうことから、早めに修正して欲しい脆弱性を特定し、伝えていく必要があると考えています。

現状だとOctovyでは、(1)デプロイを止める にしか自動化に対応していませんが、今後は(2)についても対応していきたいと考えています。これによって開発者とのコミュニケーションのコストを減らし、組織内プロダクト全体のリスク最適化を目指したいという狙いです。

脆弱性の対応状況の管理

「プロダクトの特徴」や「独自に評価した脆弱性の深刻度」はそれぞれに任意のラベルを付与するような形式なので想像しやすいと思いますが、「脆弱性の対応状況」管理はちょっと想像しづらいかと思うので簡単に紹介をしたいと思います。Octovyではあるリポジトリに対してスキャンした結果から検出された脆弱性を一覧で表示します。

ここで「Status」の欄から対応状況、言い換えるとトリアージした結果を残すことができます。現状は To be fixedSnoozed(しばらく待ち)、Unaffected(使われていない)、Mitigated (緩和策を実施済み)から1つ選べるようになっています。

選択するとこのように理由を記載できます。Snoozed の場合はどのくらいの期間まで通知などを止めるか選択できます。

対応状況に基づく判断の例としては、以下のようなユースケースを想定しています。

  • 当初影響は小さいと判断したが、実は深刻な影響があった。Snoozedについては直ちにアップデートを促したいが、UnaffectedMitigated はどちらにしても影響がないので、急かす必要はない
  • 当初緩和策を実施していればよかったが、その緩和策を迂回する方法が見つかった。そのためSnoozedMitigatedは直ちに対応が必要だが、Unaffected は対応を急がない

ポリシーの記述方法

それではOctovyで具体的にどのようなルールが記述できるのかについて簡単に紹介したいと思います。まずOctovyでスキャンした際、以下のようなデータがOPAサーバに渡されます。

{
  "repo": {
    "owner": "m-mizutani",
    "name": "octovy",
    "branch": "master",
    "labels": [
      "external",
      "public"
    ],
  },
  "sources": [
    {
      "source": "assets/package-lock.json",
      "packages": [
        {
          "id": 716,
          "type": "gomod",
          "source": "go.sum",
          "name": "github.com/miekg/dns",
          "version": "1.0.14",
          "vuln_ids": [
            "CVE-2019-19794"
          ],
          "vulnerabilities": [
            {
              "id": "CVE-2019-19794",
              "first_seen_at": 1640148963,
              "last_modified_at": 1577986560,
              "title": "golang-github-miekg-dns: predictable TXID can lead to response forgeries",
              "cwe_id": [
                "CWE-338"
              ],
              "severity": "MEDIUM",
              "cvss": [
                "nvd,V3Vector,CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N",
              ],
              "references": [
                "https://github.com/miekg/dns/pull/1044"
              ],
              "status": {
                "id": 1,
                "status": "unaffected",
                "source": "go.sum",
                "pkg_name": "github.com/miekg/dns",
                "pkg_type": "gomod",
                "vuln_id": "CVE-2019-19794",
                "created_at": 1640175033,
                "comment": "not used",
              },
              "custom_severity": "high"
            }
          ]
        },
        {
          "id": 637,
          "type": "gomod",
          "source": "go.sum",
          "name": "github.com/google/renameio",
          "version": "0.1.0"
        }
      ]
    }
  ]
}

見やすさを優先してフィールドや値をかなり省いていますが、要点としては次のとおりです。

  • .repo.labels: リポジトリに任意のラベルを複数設定できます。例えば例では external, public という2つのラベルが付与されています。意味合いとしては「対外的なプロダクト」かつ「ネットワーク的に露出している」と捉えていただければと思います。
  • .sources[]: これはTrivyのスキャン対象ごとのデータが格納されます。ファイルシステムスキャン (trivy fs) では検査したロックファイルごとに分割されます
  • .sources[].packages[]: スキャンされた全パッケージの情報が渡されます。これは脆弱性が検出されなかったパッケージも含まれます。
  • .sources[].packages[].vulnerabilities[].status: その脆弱性の最新の対応状況が格納されます
  • .sources[].packages[].vulnerabilities[].custom_severity: 独自に評価した深刻度が格納されます

まずこのデータが渡された場合に記述できる、わかりやすいポリシーを解説したいと思います。

default conclusion = "success"

fail_msg[msg] {
	vuln := input.sources[_].packages[_].vulnerabilities[_]
	vuln.custom_severity == "Critical"
	msg := sprintf("%s is critical vulnerability, have to be fixed", [vuln.id])
}

fail_msg[msg] {
	vuln := input.sources[_].packages[_].vulnerabilities[_]
	vuln.custom_severity == "High"
	input.repo.labels[_] == "public"
	msg := sprintf("%s severity is high and this product is exposed to internet.", [vuln.id])
}

conclusion = "failure" {
	count(fail_msg) > 0
}

messages = fail_msg {
	count(fail_msg) > 0
}

上記ポリシーは一言でいうと、「独自の深刻度が Critical」、もしくは「リポジトリが公開状態(public)かつ深刻度が High」の場合にCIを落とす、という動作になります。fail_msg[msg] の記述の意味についてはRegoの基礎(結果の出力編)などで取り上げていますので、よろしければご参照ください。

通常のOPAによるポリシー違反検出だと fail_msgfail といった変数に値を入れるだけで失敗となるのですが、OctovyのCI制御では少し特殊なルールになっています。それが以下の部分です。

conclusion = "failure" {
	count(fail_msg) > 0
}

messages = fail_msg {
	count(fail_msg) > 0
}

Octovyは検査の結果、GitHub Checksを作成してCIの結果を伝えます。GitHub Checksは結果が失敗・成功だけでなく action_required, cancelled, failure, neutral, success, skipped, stale など複数から選択することができます。

Octovyで利用するポリシーもこの自由度を下げないようにするため、conclusion に代入された値をそのままGitHub Checksの結果に送るようにしています。一方、なぜこの結果になったのか?という情報を伝えないと開発者も何をしていいかわからなくなってしまうので、別途 messages という変数を用意し、そこに結果の説明や指示を格納できるようにしています。

このルールではCIの結果を success or failure にしているため、デフォルトで success を設定し、もしfail_msg に値が格納されていたら failures にする、というのが上記のポリシーとなります。

その他のポリシー例

その他、記述できるポリシーとしては以下のようなものがあります。

publicのリポジトリに対してだけ禁止する脆弱性のリストを作る

deny_list_for_public = [
    "CVE-2000-0000",
    "CVE-2001-0000",
]

fail_msg[msg] {
	input.repo.labels[_] == "public"
	vuln := input.sources[_].packages[_].vulnerabilities[_]
    vuln.id == deny_list_for_public[_]
    msg := sprintf("%s is not allowed in public repository", [vuln.id])
}

特定のパッケージ&バージョンは通さない

fail_msg[msg] {
	pkg := input.sources[_].packages[_]
    pkg.name == "some-pkg"
    pkg.version == "0.1.0"
    vuln.id == deny_list_for_public[_]
    msg := "some-pkg v0.1.0 contains malicious code"
}

特定の脆弱性に対し、影響なし(unaffected)以外は通さない

fail_msg[msg] {
	vuln := input.sources[_].packages[_].vulnerabilities[_]
    vuln.id == "CVE-2199-0000"
    vuln.status.status != "unaffected"
    msg := "CVE-2199-0000 must be fixed if not unaffected"
}

まとめ

OPAは現状だとクラウドリソースに関するポリシー制御や認可の領域で利用されることが多いのですが、それ以外の応用の一例として脆弱性管理の事例を紹介しました。明日はもう一つ、別の応用の構想について紹介したいと思います。

脚注
  1. 例えばSynkやYamoryといったツールが挙げられます ↩︎

  2. 言わずもがなですが、名前はOcto(cat) + (Tri)vyを拝借しました ↩︎

  3. 評価のズレの理由は「NISTは公開情報だけで判断しているため」と説明されていますが、この脆弱性に関してはOSSで修正内容も公開されているため、情報の非対称性だけの問題ではないと考えています ↩︎

Discussion