Skip to main content

Rego 103:値とルールの種類

feature cloud security

2023年11月16日

0 分で読めます

このブログシリーズでは、Open Policy Agent(OPA)エンジンの開発者が作ったポリシー言語、Regoを基礎からわかりやすく紹介します。Regoを初めて使う方や、ポリシーをコードとして記述する方法を学びたい方にぴったりの内容です。

全3回のシリーズでは、次の内容を取り上げます。

  • 第1回:Rego 101:Regoの概要

  • 第2回:Rego 102:AND/ORによるクエリの結合とカスタムメッセージ

  • 第3回(今回):値とルールの種類

改めて説明すると、RegoはOpen Policy Agent(OPA)フレームワークの開発元が作った宣言型クエリ言語です。Cloud Native Computing Foundation(CNCF)は2019年4月、OPAをインキュベーションレベルのホストプロジェクトとして受け入れ、2021年にはOPAがインキュベーション段階を卒業しました。

Regoはポリシーをコードとして記述するために使われます。これにより、バージョン管理やモジュール設計などのプログラミング手法を、クラウドやInfrastructure as Code(IaC)リソースの評価に適用できます。OPAは、Regoで記述されたポリシーコードを評価するエンジンです。また、SnykではRego言語をカスタムルールに使用しています。

第2回のおさらい

第2回では、次の使い方を紹介しました。

  • ANDルールとORルール

  • defaultキーワード

  • notキーワード

  • カスタムdenyメッセージ

今回は、セットルール、オブジェクトルール、関数、反復処理を取り上げ、シリーズを締めくくります。

値の種類

Regoにおける値とは、何らかのデータを表すものです。各値には特定の型があります。Regoの型は、スカラーと複合の2種類に分けられます。

スカラー値は、単一のデータ単位を表し、次の型があります。

  • 文字列は二重引用符で囲みます。

  • 数値には、正負の整数と小数があります。

  • ブール値はtrueまたはfalseのみです。

  • nullは値が存在しないことを表します。

複合値は、値の集合を表し、次の型があります。

  • 配列

  • オブジェクト

  • セット

このブログシリーズを読んできた方は、スカラー値と複合値の例をすでにいくつかご覧になったはずです。複合値は少し複雑なので、ここで詳しく見ていきましょう。

複合値

配列は、角括弧で囲まれた1つ以上の値を含む順序付きリストです。文字列の配列、数値の配列、配列の配列、異なる型を混在させた配列などを作成できます。

  • ["alice", "bob", "carlotta"]

  • [2, -5, 3.8]

  • [[1, 2, 3], [4, 5, 6]]

  • [true, "banana", 17]

配列内の要素は、配列内でのインデックス(位置)を指定して参照できます。インデックスは常に0から始まるため、配列の最初の項目はインデックス0、2番目はインデックス1となります。以下のusers配列をご覧ください。

users := ["alice", "bob", "carlotta"]
             0       1        2 

usersリストの最初の項目を取得するには、次の構文を使います。

users[0]  # evaluates to "alice"

2番目の項目を変数adminに割り当てるには、次のように指定します。

admin := users[1]  # "bob" is assigned to admin

users[3]という構文で、リストに存在しない4番目の項目を取得しようとすると、OPAは一致するものを見つけられず、出力は空のセット{}(未定義)になります。

オブジェクトは、波括弧で囲まれた1つ以上のキーと値のペアからなる順序なしのリストです。キーと値には任意の型を指定でき、型が一致している必要もありません。キーと対応する値はコロンで区切ります。

  • { "alice": "admin", "bob": "user", "carlotta": "user" }

  • { "ports": [80, 443] }

  • { 80: true }

キーを指定すると、オブジェクトの値にアクセスできます。たとえば、以下のusers オブジェクトには、キーと値のペアが3つ含まれています。

users := { "alice": "admin", "bob": "user", "carlotta": "user" }

クエリでキーが"alice"のペアの値を取得するには、次の構文を使います。

users["alice"]  # evaluates to "admin"

セットは、波括弧で囲まれた1つ以上の一意な値からなる順序なしのリストです。値には任意の型を指定できます。

  • {1, 2, 3}

  • {"alice", "bob", "carlotta"}

セットには順序がないため、要素が同じであれば、並び順が異なっていても2つのセットは等しくなります。たとえば、{1, 2, 3}と{2, 3, 1}は等しいセットです。

要素がセットに含まれているかどうかを調べることができます。次のセットがあるとします。

nums := {1, 2, 3}

クエリで整数4がnumsセットに含まれているかを調べるには、次の構文を使います。

nums[4]  # does not evaluate to true

ルールの種類

Regoには、次のような種類のルールがあります。

  • 単一の結果を生成する完全ルール。

  • セットを生成するルール。

  • オブジェクトを生成するルール。

  • 関数。実際にはルールとは少し異なります。

これらはどれも、クエリで構成される本体を持つことができます。違いは、クエリの構成方法と返される情報にあります。次のセクションでは、完全ルールから順に、それぞれの種類を説明します。

完全ルール

このシリーズを読んできた方は、完全ルールをすでにご覧になったはずです。

allow := true {
  input.user == "alice"
}

完全ルールは、変数に単一の値を割り当てます。上の例では、クエリの条件が満たされると、変数allowにtrueが割り当てられます。

もう1つ例を見てみましょう。クエリの条件が満たされると、数値80が変数allowed_portに割り当てられます。

allowed_port := 80 {
  input.account_id != "123456789012"
}

完全ルールには、上の例のようにクエリを1つ含めることも、第2回で紹介した別の例のように複数含めることもできます。

allow := true {
  input.user == "alice"
  input.environment == "prod"
}

定数

どんな場合でも特定の値を保持する変数が必要な場合はどうすればよいでしょうか。他のプログラミング言語では、これを定数と呼びます。

次のように記述できます。

pi := 3.14 {
  true
}

クエリの条件は常に満たされるため、常にtrueと評価されます。つまり、変数piの値は常に3.14です。

構文糖衣を使えば、本体を省略することもできます。

pi := 3.14

上記は完全ルールです。プログラム内のどこでpiを参照しても、常に3.14を表します。

セット内包表記とオブジェクト内包表記

変数に値のコレクションを割り当てたい場面は数多くあります。そこで使うのが、セット内包表記とオブジェクト内包表記です。

セット内包表記

セット内包表記は、変数に要素を1つずつ追加してセットを生成します。入力ファイルを反復処理し、特定の条件を満たす値をすべて集めたセットを生成したい場合があります。そのような場合にセット内包表記を使えます。

システム全体で現在ログインしているユーザーをすべて含む配列を表す、次の入力ドキュメントがあるとします。

{
  "users": [
    "alice",
    "bob",
    "carlotta"
  ]
}

管理者権限を持つのはAliceだけ、という会社のポリシーを覚えていますか?ログイン中の非管理者ユーザーをすべて集めた、一意なリスト(セット)を作成したいとします。

次のようにセットを生成できます。

nonadmins[name] {
  name := input.users[i]
  name != "alice"
}

これを説明するために、まずルール本体を見てから、ヘッドを見ていきましょう。

ルール本体:OPAにinput.users配列を検索させ、"alice"と一致しない要素を見つけます。

どのように動作するのでしょうか。先ほど、配列内の項目はインデックスで参照すると説明しました。ここではインデックスを表すために変数iを使います(別の名前でもかまいません)。入力を1回処理するごとに、この値が増えるためです。OPAはクエリを実行する際、iを各項目のインデックスに置き換えながらusersリストを反復処理し、要素を1つずつ取得します。命令型プログラミングをご存じなら、命令型ループに似ていますが、まったく同じではありません。

OPAが入力のusers配列を最初に処理すると、内部では次のように動作します。

name := input.users[0]  # Evaluates to "alice"
name != "alice"  # This condition is not fulfilled, so OPA discards the username and moves on.

2回目の処理では、次のように動作します。

name := input.users[1]  # Evaluates to "bob"
name != "alice"  # This condition evaluates to true, so OPA adds the list item -- the username "bob" -- to the nonadmins set.

3回目の処理では、次のように動作します。

name := input.users[2]  # Evaluates to "carlotta"
name != "alice"  # This condition also evaluates to true, so OPA adds the list item -- the username "carlotta" -- to the nonadmins set.

ルールヘッド:変数nonadminsはセット自体を指し、変数nameはセット内の各一意な名前を表します(上で説明したとおり、本体ではinput.users[i]です)。OPAが本体のロジックに従って処理し、現在のnameの値がすべての条件に一致すると、その値がnonadminsセットに追加されます。

全体をまとめると:ユーザーリストを処理するたびに、クエリに記載されたすべての条件を値が満たしていれば、OPAは現在のinput.users[i]の値をnonadmins[name]セットに追加します。

結果は次のnonadminsセットになります。

{
  "nonadmins": [
    "bob",
    "carlotta"
  ]
}

当番の非管理者ユーザーは、BobとCarlottaだとわかります。

反復処理

Regoの反復処理についての注意点として、反復処理は暗黙的です。他の言語、たとえばPythonとは異なり、「while x == true」や「for y in z」のようなループはありません。その代わりに、配列インデックス、セット要素、オブジェクトキーの代わりに変数を使って、配列、セット、オブジェクトを反復処理します。以下のinput.users配列ではiを使っています。

name := input.users[i]

角括弧内に変数を指定すると、特定の値を1つ参照しているのではなく、すべての値を1つずつ調べていることがOPAに伝わります。

命令型ループに慣れている方には少し違和感があるかもしれませんが、簡潔に記述できます。上記は次のPython式と同じです。

for i in users:
  name = i

または、次のようにも書けます。

for i in range(0, len(users)):
  name = users[i]

アンダースコア演算子

インデックスを1回だけ参照する場合は、名前付き変数iの代わりに、ワイルドカード演算子(アンダースコア)を使えます。

nonadmins[name] {
  name := input.users[_]
  name != "alice"
}

イテレーター(変数iなど)として使う場合、ワイルドカード演算子は配列内の任意の値、セット内の任意の要素、またはオブジェクト内の任意のキーを表します。つまり、ワイルドカードは名前のない変数、使い捨ての変数です。上のルールでは、OPAはinput.users配列内のいずれかの名前が"alice"と等しくないかを確認します。一致しない名前があれば、OPAはそれをnonadminsセットに追加します。結果はname := input.users[i]を使った場合とまったく同じです。

一方、ルール内でインデックスを追跡する必要がある場合は、次のように名前付き変数を使います。

deny[msg] {
  input.users[i] != "alice"
  msg := sprintf("User %v is denied access", [input.users[i]])
}

次の入力ドキュメントを使うと…

{
  "users": [
    "alice",
    "bob",
    "carlotta"
  ]
}

…次の出力が得られます。

{
  "deny": [
    "User bob is denied access",
    "User carlotta is denied access"
  ]
}

この場合、名前付き変数を使う必要があります。クエリinput.users[i] != "alice"で確認する名前と、msgクエリで出力する名前が同じであることを保証したいためです。そのため、インデックスを追跡することが重要です。OPAが内部で変数iをインデックスに置き換える動作を見ると、理解しやすくなります。input.users配列を1回反復処理する例を見てみましょう。

deny[msg] {
  input.users[2] != "alice"
  msg := sprintf("User %v is denied access", [input.users[2]])
}

どちらのクエリでもinput.users[2]を使い、同じインデックスの値(この場合は"carlotta")を参照する必要があります。

オブジェクト内包表記

オブジェクト内包表記は、セットルールがセットを生成するのと同様に、変数に要素を1つずつ追加してオブジェクトを生成します。ただし、セットを生成する目的が一意な値のコレクションを作ることなのに対し、オブジェクトルールでは、キーと値のペアのコレクションを生成します。

処理の流れはセットルールの記述とよく似ています。ただし、オブジェクトルールでは、ルールヘッドでキーと値のペアの値部分を指定します。

nonadmins[name] := "logged-in" {
  name = input.users[i]
  name != "alice"
}

この例は、最初のセットルールの例とまったく同じですが、キーと値のペアごとの値を"logged-in"と宣言している点が異なります。

同じ入力ドキュメントを使ってみましょう。

{
  "users": [
    "alice",
    "bob",
    "carlotta"
  ]
}

上記の入力に対してルールを評価すると、次の出力が得られます。

{
  "nonadmins": {
    "bob": "logged-in",
    "carlotta": "logged-in"
  }
}

結果は2つのキーと値のペアを含むnonadminsオブジェクトです。各ペアでは、ユーザー名がキー、"logged-in"が値です。

関数

Regoの関数は、他の言語の関数と同様に、プログラムに何かを実行させるための、モジュール化され再利用可能な方法です。関数の構文はルールの構文に似ており、どちらも同じ方法でクエリを宣言しますが、関数には実際の引数の代わりとなるパラメーターがあり、括弧で囲んで指定します。

たとえば、以下の関数はxの値を受け取り、それを2倍にして、結果をyに割り当てます。

double_function(x) := y {
  y := x + x
}

パッケージ内の別の場所では、次のように引数を渡して、関数に処理させることができます。呼び出すには、次のように引数を渡します。

z := double_function(2)

すると、zは4と評価されます。

引数として12を渡して再度呼び出し、結果をfooに割り当てることもできます。この場合、fooは24と評価されます。

foo := double_function(12)

特定の処理を何度も実行する必要がある場合、特にほかの関数内で繰り返し実行する場合は、関数を使うと便利です。コードをすっきりとモジュール化できるので役立ちます。異なる入力に対して同じ処理を繰り返すなら、その処理を関数として記述できます。

ほかの関数から使う「ヘルパー」関数を記述することもできます。以下では、allow配列内のすべての要素が有効な場合、trueがinput.tags変数に割り当てられます。要素が有効かどうかを判定するため、ヘルパー関数is_lowercase_valueとis_long_enoughは、それぞれ文字列の引数が小文字かどうか、適切な長さかどうかを確認します。これらはどちらもis_valid関数内で使われ、この関数はallowから呼び出されます。

is_lowercase_value(tag) {
  lower(tag) == tag
}

is_long_enough(tag) {
  count(tag) >= 3
}

is_valid(tag) {
  is_lowercase_value(tag)
  is_long_enough(tag)
}

allow {
  tag := input.tags[i]
  is_valid(tag)
}

OPAでルールの例を評価する

Regoのセットルール、オブジェクトルール、関数を試してみましょう。これまでのブログ記事と同様に、OPAを操作する次の2つの方法に焦点を当てます。

  • OPA Playgroundを使う

  • OPAのコマンドラインツールを使う

これらのインターフェースの使い方については、パート1をご覧ください。

今回も、Kubernetesポッドを使った、より実際のユースケースに近い例を取り上げます。入力として使用するJSONマニフェストは次のとおりです。

{
  "apiVersion": "v1",
  "kind": "Pod",
  "metadata": {
    "name": "mypod",
    "labels": {
      "stage": "prod"
    }
  },
  "spec": {
    "shareProcessNamespace": true,
    "containers": [
      {
        "name": "myapp1",
        "image": "myapp1:latest"
      },
      {
        "name": "myapp2",
        "image": "myapp2"
      }
    ]
  }
}

そして、これに対して評価するポリシーがこちらです。「本番環境のポッド内のコンテナでは、latestイメージを使用してはならない」という企業要件を適用します。

is_labeled_prod(labels) {
  labels.stage == "prod"
} {
  labels.stage == "production"
}

latest_containers[container] {
  container := input.spec.containers[_]
  endswith(container.image, ":latest")
}

deny[msg] {
  input.kind == "Pod"
  is_labeled_prod(input.metadata.labels)
  container = latest_containers[_]
  msg := sprintf("Container %v is using latest image on prod", [container.name])
}

このルールセットでは、関数、セットルール、反復処理、アンダースコア演算子など、この記事で説明したいくつかの概念を使っています。仕組みを見ていきましょう。

is_labeled_prod(labels) — これは、渡されたラベルのセットに、値が"stage"または"prod"のtrueラベルが含まれている場合に"production"を返す関数です。

latest_containers[container] — これはセットルールです。OPAはアンダースコア演算子をイテレーターとして使い、入力内のコンテナのいずれかがlatestイメージを使用しているかを確認します。使用している場合は、そのコンテナをlatest_containersセットに追加します。イメージ名が":latest"で終わるかどうかで、latestイメージかを判定します。

deny[msg] — これもセットルールです。パート2で紹介したdeny[msg]セットルールをさらに発展させたもので、3つのクエリが含まれています。OPAはそれぞれに対して次の処理を行います。

  1. input.kindが「Pod」かどうかを確認します。

  2. input.metadata.labelsを渡してis_labeled_prod関数を呼び出し、値が"prod"または"production"の"stage"ラベルがあるかを確認します。

  3. latest_containersセットを反復処理し、セット内にコンテナが1つでもあるかを確認します。

上記3つのクエリがすべてtrueと評価された場合(つまり、OPAが各条件に一致する入力を見つけた場合)、そのときOPAは、ポリシーに準拠していないコンテナ名を含むカスタムメッセージをdenyセットに追加します。

すぐに試せるよう、この内容を用意したPlaygroundを作成しました。https://play.openpolicyagent.org/p/HgHE4w2b4y 

PlaygroundでEvaluateボタンを選択してルールを評価するか、OPAをローカルで実行している場合はopa eval -i input.json -d check_prod_pod.rego "data.rules.check_prod_pod" --format prettyなどのコマンドを実行すると、次の出力が表示されます。

{
  "deny": [
    "Container myapp1 is using latest image on prod"
  ],
  "latest_containers": [
    {
      "image": "myapp1:latest",
      "name": "myapp1"
    }
  ]
}

出力の最初の項目はdenyセットで、myapp1 コンテナがポリシーに準拠していないことを示すメッセージが含まれています。また、各コンテナの名前とイメージを含むlatest_containersセットの要素も確認できます。この例では、コンテナはmyapp1だけです。

myapp2のイメージ名をmyapp2:latestに変更するとどうなるか見てみましょう(19行目)。ルールを再度評価すると、denyセットにmyapp1とmyapp2の両方のメッセージが追加され、latest_containersセットにも両方のコンテナが含まれます。

{
  "deny": [
    "Container myapp1 is using latest image on prod",
    "Container myapp2 is using latest image on prod"
  ],
  "latest_containers": [
    {
      "image": "myapp1:latest",
      "name": "myapp1"
    },
    {
      "image": "myapp2:latest",
      "name": "myapp2"
    }
  ]
}

最後に、"stage": "prod"の行(7行目)を削除し、入力を次のようにしてみましょう。

    "labels": {
    }

ルールを評価すると、latest_containersには引き続き両方のコンテナが含まれますが、denyセットは空になっています。

{
  "deny": [],
  "latest_containers": [
    {
      "image": "myapp1:latest",
      "name": "myapp1"
    },
    {
      "image": "myapp2:latest",
      "name": "myapp2"
    }
  ]
}

このポッドには本番環境を示すラベルが付いていないため、(企業ポリシー上)コンテナがlatestイメージを使用しても問題ありません。つまり、このポッドはポリシーに準拠しています。

次のステップ

おめでとうございます。Regoの記述方法を解説した全3回のブログシリーズを最後までお読みいただきました。

さらに詳しく知りたい方は、こちらのリソースをご覧ください。

Regoを使ってSnyk IaCのカスタムルールを記述する方法に関心がある方は、こちらのドキュメントをご覧ください。Snykに組み込まれたセキュリティルールセットやコンプライアンス対応ルールセットに加え、IaC+のカスタムルールを使えば、SDLC全体にわたって独自のセキュリティ管理を設定できます。

IaC+は、問題のUI、ルールセット、ポリシーエンジンを通じて、IDE、SCM、CLI、CI/CD、Terraform Cloud、AWS、Azure、Google Cloudなどのデプロイ済みクラウド環境にわたる設定上の問題を、コードからクラウドまで一元的に可視化し、管理できます。

CTFを始めよう

オンデマンドのバーチャル入門ワークショップで、CTFチャレンジの解き方を学びましょう。