Skip to main content

Regoによるソースコード位置の自動特定

feature Cloud Compliance

2024年2月12日

0 分で読めます

SnykはOpen Policy AgentのRegoを愛用しています。Snyk IaCはRegoで記述された豊富なルールを基盤としており、お客様も独自のカスタムルールを追加できます。

Snyk IaCに一連の機能改善をリリースしました。このブログでは、特に興味深い機能である、ルール違反のソースコード位置を自動で特定する仕組みについて技術的に掘り下げます。

既知の問題に照らしてIaCファイルをチェックすると、更新された `snyk iac test` コマンドは、各ルール違反について正確なファイル名、行番号、列番号を表示します。カスタムルールでも、ユーザー側で何も作業することなく機能します。

公開ACLが無効化され、パブリックポリシーのブロック設定に問題がある、重大度の高いS3バケットのセキュリティ問題を表示するターミナル。

ただし、この手法の単独の概念実証を示す前に、いくつか簡略化する必要があります。完全な実装は、当社の統合ポリシーエンジンでご覧いただけます。

まずはCloudFormationの例を見てみましょう。当社のIaCエンジンは多くの形式に対応し、特にTerraformを重視していますが、依存関係があまり多くなくても解析できるため、CloudFormationはよい例です(結局のところ、単なるYAMLです)。

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId:
        Ref: Vpc
      CidrBlock: 10.0.0.0/24
  PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId:
        Ref: Vpc
      CidrBlock: 10.0.128.0/20

サブネットで `/24` より大きなCIDRブロックを使わないようにするため、次のようなRegoポリシーを記述します。

package policy

deny[resourceId] {
    resource := input.Resources[resourceId]
    resource.Type == "AWS::EC2::Subnet"
    [_, mask] = split(resource.Properties.CidrBlock, "/")
    to_number(mask) < 24
}

この方法では、`deny` によって拒否されたリソースのセットが返されます。Regoの仕組みの詳細には踏み込みませんが、詳しく学びたい方には、優れたOPA by Exampleコースをおすすめします。

この問題は、次の2つの部分に分けられます。

  1. ポリシーが `CidrBlock` 属性を使用していることを推論する

  2. ソースコードの位置を取得する

コードに慣れるためにも、まずは(2)から始めましょう。

ソース位置の取得

ソース位置は次のような形式です。

type Location struct {
    File   string
    Line   int
    Column int
}
func (loc Location) String() string {
    return fmt.Sprintf("%s:%d:%d", loc.File, loc.Line, loc.Column)
}

YAML内のパスを表す補助型も導入します。YAMLの入れ子になったドキュメントには、配列とオブジェクトの2種類があります。

some_array:
- hello
- world
some_object:
  foo: bar

任意のサブドキュメントを参照するには、JSON Pathに似たものを使えます。上の例では、[`"some_array", 1`] が `"word"` を指します。ただし、概念実証では配列に対応しないため、文字列の配列だけで十分です。

type Path []string

パスの例は次のようになります。

Path{"Resources", "PrivateSubnet", "Properties", "CidrBlock"}

これで、YAMLを読み込み、指定した `Paths` の `Location` を取得する便利な型を用意できます。

type Source struct {
    file string
    root *yaml.Node
}
func NewSource(file string) (*Source, error) {
    bytes, err := ioutil.ReadFile(file)
    if err != nil {
        return nil, err
    }

    var root yaml.Node
    if err := yaml.Unmarshal(bytes, &root); err != nil {
        return nil, err
    }

    return &Source{file: file, root: &root}, nil
}

`Path` のソース位置を見つけるには、YAMLノードのツリーをたどります。

func (source *Source) Location(path Path) *Location {
    cursor := source.root
    for len(path) > 0 {
        switch cursor.Kind {
        // Ignore multiple docs in our PoC.
        case yaml.DocumentNode:
            cursor = cursor.Content[0]
        case yaml.MappingNode:
            // Objects are stored as an array.
            // Content[2 * n] holds to the key and
            // Content[2 * n + 1] to the value.
            for i := 0; i < len(cursor.Content); i += 2 {
                if cursor.Content[i].Value == path[0] {
                    cursor = cursor.Content[i+1]
                    path = path[1:]
                }
            }
        }
    }
    return &Location{
        File:   source.file,
        Line:   cursor.Line,
        Column: cursor.Column,
    }
}

パスのセットとツリー

これで、ポリシー内で使われるソース位置を自動的に推論する問題を、属性パスを自動的に推論する問題へと置き換えられました。

これは、ほかの理由からも重要です。たとえばSnykは、同じポリシーをIaCリソースとクラウドスキャンで検出したリソースの両方に適用できます。後者には意味のあるソース位置はありませんが、意味のある属性パスはあります。

属性パスのセットを定義したいところです。パスは配列に基づいているため、残念ながらGoでセットとして `map[Path]struct{}` のようなものは使えません。

代わりに、これらを再帰的なツリーに格納する必要があります。

type PathTree map[string]PathTree

この表現にはほかの利点もあります。一般に、ポリシーが使うパスのうち、より長いものだけを対象にします。より具体的だからです。例のポリシーは `Path{"Resources", "PrivateSubnet", "Properties"}` と `Path{"Resources", "PrivateSubnet", "Properties", "CidrBlock"}` の両方を使っていますが、対象にするのは後者だけです。

ツリーに `Path` を挿入する再帰メソッドを定義します。

func (tree PathTree) Insert(path Path) {
    if len(path) > 0 {
        if _, ok := tree[path[0]]; !ok {
            tree[path[0]] = map[string]PathTree{}
        }
        tree[path[0]].Insert(path[1:])
    }
}

また、Pathsの一覧を取り出す方法も用意します。少し余分なメモリ割り当てが発生しますが、許容できます。

func (tree PathTree) List() []Path {
    if len(tree) == 0 {
        // Return the empty path.
        return []Path{{}}
    } else {
        out := []Path{}
        for k, child := range tree {
            // Prepend `k` to every child path.
            for _, childPath := range child.List() {
                path := Path{k}
                path = append(path, childPath...)
                out = append(out, path)
            }
        }
        return out
    }
}

これで、ポリシーが使った `Paths` を適切に格納し、それらをソース位置に変換する方法が用意できました。

静的解析と実行時解析

次に、与えられた入力のどの `Paths` がポリシーで使われているかを特定し、それらをツリーに `Insert` する必要があります。

これは簡単な問題ではありません。パスを使う前に、コードが入力をさまざまな方法で処理する可能性があるからです。障害となり得る例を示すため、ユーザー定義関数( `has_bad_subnet`)だけでなく、組み込み関数( `object.get`)も調べる必要があります。

has_bad_subnet(props) {
    [_, mask] = split(props.CidrBlock, "/")
    to_number(mask) < 24
}

deny[resourceId] {
    resource := input.Resources[resourceId]
    resource.Type == "AWS::EC2::Subnet"
    has_bad_subnet(object.get(resource, "Properties", {}))
}

幸い、この問題に取り組むのは私たちだけではありません。最初のプログラムが書かれて以来、人々はプログラムの動作に関心を持ってきました。コードについてこのような問いに答える方法は、一般に2つあります。

  • 静的解析: 構文木、型、その他OPAインタープリターから取得(または追加)できる静的情報を調べて答えを導きます。ポリシーを実行せずに済むため、ポリシーの作成者を信頼できない場合にも有効です。一方、静的解析手法では、偽陰性や偽陽性が生じることも少なくありません。

  • 実行時解析: 特定のポリシーの実行を追跡し、実行時情報を調べて、使われている `Paths` を推論します。欠点は、実際にポリシーを実行する必要があることと、この解析の追加によりポリシーの評価が遅くなる可能性があることです。

両方の方法を試しましたが、より確実に実装しやすく、パフォーマンスへのオーバーヘッドも無視できる程度だったため、後者を採用しました。また、どちらか一方を選ぶ必要はありません。両者を組み合わせたハイブリッド方式も可能です。

OPAには、インタープリターの動作に関するイベントを受け取るためのTracerインターフェースがあります。トレーサーは、メトリクスやデバッグ情報を一元管理されたログに送信する用途でよく使われます。しかし今回は、別の目的に利用します。

type locationTracer struct {
    tree PathTree
}
func newLocationTracer() *locationTracer {
    return &locationTracer{tree: PathTree{}}
}
func (tracer *locationTracer) Enabled() bool {
    return true
}

項の使用状況をトレースする

Regoは表現力の高い言語です。インタープリター向けに単純な形式へ変換する処理が一部行われますが、それでもかなりの数のイベントが発生します。

そのうち対象とするのは2つだけです。次の場合、値が使用されたとみなします。

1. 別の式に対して統合される(代入と考えることができます。詳しくは説明しません)。たとえば、次のような場合です。

x = input.Foo

これは `==` と `:=` も対象にします。失敗する可能性のあるテストなので、左辺と右辺の両方を使用したと判断できます。

2. 次のように、組み込み関数の引数として使用される。

regex.match("/24$", input.cidr)

Regoはいくつかの概念を遅延評価型言語から取り入れていますが、組み込み関数の引数は、関数の呼び出し前に必ず完全に具体化されます。そのため、組み込み関数に渡された引数はすべて使用されたと言えます。

3. 次のように、単独の式として使用される。

volume.encrypted

これは、真偽値の評価や属性の存在確認によく使われます。

では、実装に移りましょう。2つのイベントを照合し、コードを読みやすくするために、処理を個別の関数に委譲します。

func (tracer *locationTracer) Trace(event *topdown.Event) {
    switch event.Op {
    case topdown.UnifyOp:
        tracer.traceUnify(event)
    case topdown.EvalOp:
        tracer.traceEval(event)
    }
}

`PathTree` への挿入処理は、後ほど補助関数 `used(*ast.Term)` で扱います。まずは、統合処理の左辺と右辺を使用済みとしてマークします。

func (tracer *locationTracer) traceUnify(event *topdown.Event) {
    if expr, ok := event.Node.(*ast.Expr); ok {
        operands := expr.Operands()
        if len(operands) == 2 {
            // Unification (1)
            tracer.used(event.Plug(operands[0]))
            tracer.used(event.Plug(operands[1]))
        }
    }
}

`event.Plug` は、変数を実際の値で埋めるためのヘルパーです。

`EvalOp` イベントは、前述の(2)と(3)の両方を対象とします。組み込み関数の場合、項の配列があり、最初の要素が関数、残りの要素が引数です。`ast.BuiltinMap` を調べることで、組み込み関数かどうかを確認できます。

単独の式の場合は簡単です。

func (tracer *locationTracer) traceEval(event *topdown.Event) {
    if expr, ok := event.Node.(*ast.Expr); ok {
        switch terms := expr.Terms.(type) {
        case []*ast.Term:
            if len(terms) < 1 {
                // I'm not sure what this is, but it's definitely
                // not a built-in function application.
                break
            }
            operator := terms[0]
            if _, ok := ast.BuiltinMap[operator.String()]; ok {
                // Built-in function call (2)
                for _, term := range terms[1:] {
                    tracer.used(event.Plug(term))
                }
            }
        case *ast.Term:
            // Standalone expression (3)
            tracer.used(event.Plug(terms))
        }
    }
}

項に注釈を付ける

`used(*ast.Term)` を実装する際、次の疑問が生じます。項が与えられたとき、入力内の `Path` にどう対応付ければよいでしょうか。

入力ドキュメント内で一致する項を検索する方法もあります。しかし、`10.0.0.0/24` のような文字列は入力内に何度も現れる可能性があり、偽陽性が大量に発生します。

代わりに、すべての項にそのパスを注釈として付けます。OPAの項にはメタデータを含めることができ、その中にはRegoのソースファイル内の位置も含まれます。このフィールドに入力の `Path` を格納できます。少々裏技的ではありますが、このフィールドは位置を格納するためのものなので、十分に解釈を広げれば本来の用途から大きく外れてはいません。

次のスニペットは、CloudFormationテンプレートの最初の数行に注釈を付ける方法を示しています。

Resources:                    # ["Resources"]
  Vpc:                        # ["Resources", "Vpc"]
    Type: AWS::EC2::VPC       # ["Resources", "Vpc", "Type"]
    Properties:               # ["Resources", "Vpc", "Properties"]
      CidrBlock: 10.0.0.0/16  # ["Resources", "Vpc", "Properties", "CidrBlock"]

`annotate` は再帰的に走査し、値の各ノードにおける `Path` を特定します。簡潔にするため、オブジェクトのみをサポートし、セットと配列は対象外とします。

func annotate(path Path, term *ast.Term) {
    // Annotate current term by setting location.
    if bytes, err := json.Marshal(path); err == nil {
        term.Location = &ast.Location{}
        term.Location.File = "path:" + string(bytes)
    }
    // Recursively annotate children.
    switch value := term.Value.(type) {
    case ast.Object:
        for _, key := range value.Keys() {
            if str, ok := key.Value.(ast.String); ok {
                path = append(path, string(str))
                annotate(path, value.Get(key))
                path = path[:len(path)-1]
            }
        }
    }
}

この注釈があれば、`used(*ast.Term)` を簡単に記述できます。覚えておくべきなのは、すべての値に注釈が付くわけではないことです。注釈を付けるのは入力ドキュメントに由来する値だけで、たとえばRegoソースコードに埋め込まれたリテラルには付けません。

func (tracer *locationTracer) used(term *ast.Term) {
    if term.Location != nil {
        val := strings.TrimPrefix(term.Location.File, "path:")
        if len(val) != len(term.Location.File) {
            // Only when we stripped a "path" suffix.
            var path Path
            json.Unmarshal([]byte(val), &path)
            tracer.tree.Insert(path)
        }
    }
}

まとめ

以上です。配列や、HCLのようなより複雑なIaC言語に適用する方法など、多くの詳細は省略しました。

さらに、ポリシー内で確認する `Type` 属性も、使用済みとしてマークしています。これは理想的ではないため、代替案としてリソース指向のRego APIを提供するよう取り組んでいます。ただし、この例の範囲を超えるため、ここでは詳しく説明しません。

これらの機能について詳しく知りたい方は、コア実装のsnyk/policy-engine、または更新されたSnyk IaCをご覧ください。Snyk IaCには、この機能に加えて、網羅的なルールバンドルなど、さまざまな機能が含まれています。

最後に、すべてをまとめてデバッグ情報を出力するメイン関数を示します。これまでに定義した基本要素をほぼそのまままとめ、例に対して実行するだけです。ただし、この記事を再現可能な単独の例として機能させるため、掲載しておきます。

func infer(policy string, file string) error {
    source, err := NewSource(file)
    if err != nil {
        return err
    }

    bytes, err := ioutil.ReadFile(file)
    if err != nil {
        return err
    }

    var node yaml.Node
    if err := yaml.Unmarshal(bytes, &node); err != nil {
        return err
    }

    var doc interface{}
    if err := yaml.Unmarshal(bytes, &doc); err != nil {
        return err
    }

    input, err := ast.InterfaceToValue(doc)
    if err != nil {
        return err
    }

    annotate(Path{}, ast.NewTerm(input))
    if bytes, err = ioutil.ReadFile(policy); err != nil {
        return err
    }

    tracer := newLocationTracer()
    results, err := rego.New(
        rego.Module(policy, string(bytes)),
        rego.ParsedInput(input),
        rego.Query("data.policy.deny"),
        rego.Tracer(tracer),
    ).Eval(context.Background())
    if err != nil {
        return err
    }

    fmt.Fprintf(os.Stderr, "Results: %v\n", results)
    for _, path := range tracer.tree.List() {
        fmt.Fprintf(os.Stderr, "Location: %s\n", source.Location(path).String())
    }
    return nil
}
func main() {
    if err := infer("policy.rego", "template.yml"); err != nil {
        fmt.Fprintf(os.Stderr, "%s\n", err)
        os.Exit(1)
    }
}

この概念実証の全コードはこちらのgistでご覧いただけます。

開発者のために設計されたIaCセキュリティ

Snykは、統合されたポリシー・アズ・コードエンジンにより、SDLCからクラウドでの実行時までInfrastructure as Codeを保護します。すべてのチームが安全に開発、デプロイ、運用できるよう支援します。