diff --git a/ast/builtins.go b/ast/builtins.go index 1ab7dc0a93..1531183ae1 100644 --- a/ast/builtins.go +++ b/ast/builtins.go @@ -200,6 +200,7 @@ var DefaultBuiltins = [...]*Builtin{ NetCIDROverlap, NetCIDRIntersects, NetCIDRContains, + NetCIDRContainsMatches, NetCIDRExpand, // Glob @@ -1745,6 +1746,34 @@ var NetCIDRContains = &Builtin{ ), } +// NetCIDRContainsMatches checks if collections of cidrs or ips are contained within another collection of cidrs and returns matches. +var NetCIDRContainsMatches = &Builtin{ + Name: "net.cidr_contains_matches", + Decl: types.NewFunction( + types.Args(netCidrContainsMatchesOperandType, netCidrContainsMatchesOperandType), + types.NewSet(types.NewArray([]types.Type{types.A, types.A}, nil)), + ), +} + +var netCidrContainsMatchesOperandType = types.NewAny( + types.S, + types.NewArray(nil, types.NewAny( + types.S, + types.NewArray(nil, types.A), + )), + types.NewSet(types.NewAny( + types.S, + types.NewArray(nil, types.A), + )), + types.NewObject(nil, types.NewDynamicProperty( + types.S, + types.NewAny( + types.S, + types.NewArray(nil, types.A), + ), + )), +) + /** * Deprecated built-ins. */ diff --git a/docs/content/policy-reference.md b/docs/content/policy-reference.md index f99de40681..1997c13205 100644 --- a/docs/content/policy-reference.md +++ b/docs/content/policy-reference.md @@ -74,9 +74,9 @@ complex types. | `output := object.union(objectA, objectB)` | `output` is a new object which is the result of an asymmetric recursive union of two objects where conflicts are resolved by choosing the key from the right-hand object (`objectB`). For example: `object.union({"a": 1, "b": 2, "c": {"d": 3}}, {"a": 7, "c": {"d": 4, "e": 5}})` will result in `{"a": 7, "b": 2, "c": {"d": 4, "e": 5}}` | | `filtered := object.filter(object, keys)` | `filtered` is a new object with the remaining data from `object` with only keys specified in `keys` which is an array, object, or set of keys. For example: `object.filter({"a": {"b": "x", "c": "y"}, "d": "z"}, ["a"])` will result in `{"a": {"b": "x", "c": "y"}}`). | | `filtered := json.filter(object, paths)` | `filtered` is the remaining data from `object` with only keys specified in `paths` which is an array or set of JSON string paths. For example: `json.filter({"a": {"b": "x", "c": "y"}}, ["a/b"])` will result in `{"a": {"b": "x"}}`). Paths are not filtered in-order and are deduplicated before being evaluated. | -| `output := json.remove(object, paths)` | `output` is a new object which is the result of removing all keys specified in `paths` which is an array or set of JSON string paths. For example: `json.remove{"a": {"b": "x", "c": "y"}, ["a/b"]}` will result in `{"a": {"c": "y"}}`. Paths are not removed in-order and are deduplicated before being evaluated. | +| `output := json.remove(object, paths)` | `output` is a new object which is the result of removing all keys specified in `paths` which is an array or set of JSON string paths. For example: `json.remove{"a": {"b": "x", "c": "y"}, ["a/b"]}` will result in `{"a": {"c": "y"}}`. Paths are not removed in-order and are deduplicated before being evaluated. | -* When `keys` are provided as an object only the top level keys on the object will be used, values are ignored. +* When `keys` are provided as an object only the top level keys on the object will be used, values are ignored. For example: `object.remove({"a": {"b": {"c": 2}}, "x": 123}, {"a": 1}) == {"x": 123}` regardless of the value for key `a` in the keys object, the following `keys` object gives the same result `object.remove({"a": {"b": {"c": 2}}, "x": 123}, {"a": {"b": {"foo": "bar"}}}) == {"x": 123}`. @@ -604,9 +604,62 @@ The table below shows examples of calling `http.send`: | Built-in | Description | | ------- |-------------| | ``net.cidr_contains(cidr, cidr_or_ip)`` | `output` is `true` if `cidr_or_ip` (e.g. `127.0.0.64/26` or `127.0.0.1`) is contained within `cidr` (e.g. `127.0.0.1/24`) and false otherwise. Supports both IPv4 and IPv6 notations.| +| ``output := net.cidr_contains_matches(cidrs, cidrs_or_ips)`` | `output` is a `set` of tuples identifying matches where `cidrs_or_ips` are contained within `cidrs`. This function is similar to `net.cidr_contains` except it allows callers to pass collections of CIDRs or IPs as arguments and returns the matches (as opposed to a boolean result indicating a match between two CIDRs/IPs.) See below for examples. | | ``net.cidr_intersects(cidr1, cidr2)`` | `output` is `true` if `cidr1` (e.g. `192.168.0.0/16`) overlaps with `cidr2` (e.g. `192.168.1.0/24`) and false otherwise. Supports both IPv4 and IPv6 notations.| | ``net.cidr_expand(cidr)`` | `output` is the set of hosts in `cidr` (e.g., `net.cidr_expand("192.168.0.0/30")` generates 4 hosts: `{"192.168.0.0", "192.168.0.1", "192.168.0.2", "192.168.0.3"}` | +**`net.cidr_contains_matches` examples** + +The `output := net.cidr_contains_matches(a, b)` function allows callers to supply +strings, arrays, sets, or objects for either `a` or `b`. The `output` value in +all cases is a set of tuples (2-element arrays) that identify matches, i.e., +elements of `b` contained by elements of `a`. The first tuple element refers to +the match in `a` and the second tuple element refers to the match in `b`. + +| Input Type | Output Type | +| --- | --- | +| `string` | `string` | +| `array` | `array` index | +| `set` | `set` element | +| `object` | `object` key | + + +```live:netcidrcontainsmatches:module:hidden +package netcidrcontainsmatches +``` + +If both operands are string values the function is similar to `net.cidr_contains`. + +```live:netcidrcontainsmatches/strings:query:merge_down +net.cidr_contains_matches("1.1.1.0/24", "1.1.1.128") +``` +```live:netcidrcontainsmatches/strings:output +``` + +Either (or both) operand(s) may be an array, set, or object. + +```live:netcidrcontainsmatches/array:query:merge_down +net.cidr_contains_matches(["1.1.1.0/24", "1.1.2.0/24"], "1.1.1.128") +``` +```live:netcidrcontainsmatches/array:output +``` + +The array/set/object elements may be arrays. In that case, the first element must be a valid CIDR/IP. + +```live:netcidrcontainsmatches/tuples:query:merge_down +net.cidr_contains_matches([["1.1.0.0/16", "foo"], "1.1.2.0/24"], ["1.1.1.128", ["1.1.254.254", "bar"]]) +``` +```live:netcidrcontainsmatches/tuples:output +``` + +If the operand is a set, the outputs are matching elements. If the operand is an object, the outputs are matching keys. + +```live:netcidrcontainsmatches/sets_and_objects:query:merge_down +net.cidr_contains_matches({["1.1.0.0/16", "foo"], "1.1.2.0/24"}, {"x": "1.1.1.128", "y": ["1.1.254.254", "bar"]}) +``` +```live:netcidrcontainsmatches/sets_and_objects:output:merge_down +``` + ### Rego | Built-in | Description | | ------- |-------------| diff --git a/topdown/cidr.go b/topdown/cidr.go index b65628daef..3ad74ea3b6 100644 --- a/topdown/cidr.go +++ b/topdown/cidr.go @@ -1,6 +1,7 @@ package topdown import ( + "errors" "fmt" "math/big" "net" @@ -111,6 +112,75 @@ func builtinNetCIDRContains(a, b ast.Value) (ast.Value, error) { return ast.Boolean(cidrContained), nil } +var errNetCIDRContainsMatchElementType = errors.New("element must be string or non-empty array") + +func getCIDRMatchTerm(a *ast.Term) (*ast.Term, error) { + switch v := a.Value.(type) { + case ast.String: + return a, nil + case ast.Array: + if len(v) == 0 { + return nil, errNetCIDRContainsMatchElementType + } + return v[0], nil + default: + return nil, errNetCIDRContainsMatchElementType + } +} + +func evalNetCIDRContainsMatchesOperand(operand int, a *ast.Term, iter func(cidr, index *ast.Term) error) error { + switch v := a.Value.(type) { + case ast.String: + return iter(a, a) + case ast.Array: + for i := range v { + cidr, err := getCIDRMatchTerm(v[i]) + if err != nil { + return fmt.Errorf("operand %v: %v", operand, err) + } + if err := iter(cidr, ast.IntNumberTerm(i)); err != nil { + return err + } + } + return nil + case ast.Set: + return v.Iter(func(x *ast.Term) error { + cidr, err := getCIDRMatchTerm(x) + if err != nil { + return fmt.Errorf("operand %v: %v", operand, err) + } + return iter(cidr, x) + }) + case ast.Object: + return v.Iter(func(k, v *ast.Term) error { + cidr, err := getCIDRMatchTerm(v) + if err != nil { + return fmt.Errorf("operand %v: %v", operand, err) + } + return iter(cidr, k) + }) + } + return nil +} + +func builtinNetCIDRContainsMatches(bctx BuiltinContext, args []*ast.Term, iter func(*ast.Term) error) error { + result := ast.NewSet() + err := evalNetCIDRContainsMatchesOperand(1, args[0], func(cidr1 *ast.Term, index1 *ast.Term) error { + return evalNetCIDRContainsMatchesOperand(2, args[1], func(cidr2 *ast.Term, index2 *ast.Term) error { + if v, err := builtinNetCIDRContains(cidr1.Value, cidr2.Value); err != nil { + return err + } else if vb, ok := v.(ast.Boolean); ok && bool(vb) { + result.Add(ast.ArrayTerm(index1, index2)) + } + return nil + }) + }) + if err == nil { + return iter(ast.NewTerm(result)) + } + return err +} + func builtinNetCIDRExpand(bctx BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { s, err := builtins.StringOperand(operands[0].Value, 1) @@ -153,5 +223,6 @@ func init() { RegisterFunctionalBuiltin2(ast.NetCIDROverlap.Name, builtinNetCIDRContains) RegisterFunctionalBuiltin2(ast.NetCIDRIntersects.Name, builtinNetCIDRIntersects) RegisterFunctionalBuiltin2(ast.NetCIDRContains.Name, builtinNetCIDRContains) + RegisterBuiltinFunc(ast.NetCIDRContainsMatches.Name, builtinNetCIDRContainsMatches) RegisterBuiltinFunc(ast.NetCIDRExpand.Name, builtinNetCIDRExpand) } diff --git a/topdown/cidr_test.go b/topdown/cidr_test.go index 5df63935f4..e8b1877ac0 100644 --- a/topdown/cidr_test.go +++ b/topdown/cidr_test.go @@ -75,6 +75,66 @@ func TestNetCIDRContains(t *testing.T) { } } +func TestNetCIDRContainsMatches(t *testing.T) { + tests := []struct { + note string + rules []string + expected interface{} + }{ + { + note: "strings", + rules: []string{`p = x { x := net.cidr_contains_matches("1.1.1.0/24", "1.1.1.1") }`}, + expected: `[["1.1.1.0/24", "1.1.1.1"]]`, + }, + { + note: "arrays", + rules: []string{`p = x { x := net.cidr_contains_matches(["1.1.2.0/24", "1.1.1.0/24"], ["1.1.1.1", "1.1.2.1"]) }`}, + expected: `[[0,1], [1,0]]`, + }, + { + note: "arrays of tuples", + rules: []string{`p = x { x := net.cidr_contains_matches([["1.1.2.0/24", 1], "1.1.1.0/24"], ["1.1.1.1", "1.1.2.1"]) }`}, + expected: `[[0,1], [1,0]]`, + }, + { + note: "bad array", + rules: []string{`p = x { x := net.cidr_contains_matches(["1.1.2.0/24", "1.1.1.0/24"], ["1.1.1.1", data.a[0]]) }`}, + expected: &Error{Code: BuiltinErr, Message: "net.cidr_contains_matches: operand 2: element must be string or non-empty array"}, + }, + { + note: "sets of strings", + rules: []string{`p = x { x := net.cidr_contains_matches({"1.1.2.0/24", "1.1.1.0/24"}, {"1.1.1.1", "1.1.2.1"}) }`}, + expected: `[["1.1.1.0/24", "1.1.1.1"], ["1.1.2.0/24", "1.1.2.1"]]`, + }, + { + note: "sets of tuples", + rules: []string{`p = x { x := net.cidr_contains_matches({["1.1.2.0/24", "foo"], ["1.1.1.0/24", "bar"]}, {["1.1.1.1", "baz"], ["1.1.2.1", "qux"]}) }`}, + expected: `[[["1.1.1.0/24", "bar"], ["1.1.1.1", "baz"]], [["1.1.2.0/24", "foo"], ["1.1.2.1", "qux"]]]`, + }, + { + note: "bad set", + rules: []string{`p = x { x := net.cidr_contains_matches({["1.1.2.0/24", "foo"], ["1.1.1.0/24", "bar"]}, {data.a[0], ["1.1.2.1", "qux"]}) }`}, + expected: &Error{Code: BuiltinErr, Message: `net.cidr_contains_matches: operand 2: element must be string or non-empty array`}, + }, + { + note: "bad set tuple element", + rules: []string{`p = x { x := net.cidr_contains_matches({["1.1.2.0/24", "foo"], ["1.1.1.0/24", "bar"]}, {[], ["1.1.2.1", "qux"]}) }`}, + expected: &Error{Code: BuiltinErr, Message: `net.cidr_contains_matches: operand 2: element must be string or non-empty array`}, + }, + { + note: "objects", + rules: []string{`p = x { x := net.cidr_contains_matches({"k1": "1.1.1.1/24", "k2": ["1.1.1.2/24", 1]}, "1.1.1.128") }`}, + expected: `[["k1", "1.1.1.128"], ["k2", "1.1.1.128"]]`, + }, + } + + data := loadSmallTestData() + + for _, tc := range tests { + runTopDownTestCase(t, data, tc.note, tc.rules, tc.expected) + } +} + func TestNetCIDRExpand(t *testing.T) { tests := []struct { note string