Skip to content

Commit 8b6ea4b

Browse files
authored
[confcom] Add more thorough tests for --with-containers (#9428)
* Define which policy fields care about ordering * Prep for PR * Update version and history * Fix style checks * Attempt to fix style not flagged locally * Add missing opa_get command to setup.py * Update expected sha for opa * Fix variable name typo * Pin OPA version * Fix formatting * Use SSL verification when getting OPA binary
1 parent 2dec301 commit 8b6ea4b

File tree

29 files changed

+2083
-33
lines changed

29 files changed

+2083
-33
lines changed

src/confcom/HISTORY.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
Release History
44
===============
55

6+
1.4.2
7+
++++++
8+
* Update policy model to use pydantic and explicitly declare collections where order doesn't affect function. These fields will serialize in alphabetical order and comparisons will ignore order.
9+
610
1.4.0
711
++++++
812
* Add --with-containers flag to acipolicygen and acifragmentgen to allow passing container policy definitions directly
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
import os
7+
8+
9+
def get_binaries_dir():
10+
binaries_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "bin")
11+
if not os.path.exists(binaries_dir):
12+
os.makedirs(binaries_dir)
13+
return binaries_dir
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
import hashlib
7+
import json
8+
import os
9+
from pathlib import Path
10+
import platform
11+
import subprocess
12+
from typing import Iterable
13+
14+
import requests
15+
16+
from azext_confcom.lib.binaries import get_binaries_dir
17+
18+
_opa_path = os.path.abspath(os.path.join(get_binaries_dir(), "opa"))
19+
_expected_sha256 = "fe8e191d44fec33db2a3d0ca788b9f83f866d980c5371063620c3c6822792877"
20+
21+
22+
def opa_get():
23+
24+
opa_fetch_resp = requests.get(
25+
f"https://openpolicyagent.org/downloads/v1.10.1/opa_{platform.system().lower()}_amd64",
26+
verify=True,
27+
)
28+
opa_fetch_resp.raise_for_status()
29+
30+
assert hashlib.sha256(opa_fetch_resp.content).hexdigest() == _expected_sha256
31+
32+
with open(_opa_path, "wb") as f:
33+
f.write(opa_fetch_resp.content)
34+
35+
os.chmod(_opa_path, 0o755)
36+
return _opa_path
37+
38+
39+
def opa_run(args: Iterable[str]) -> subprocess.CompletedProcess:
40+
return subprocess.run(
41+
[_opa_path, *args],
42+
check=True,
43+
stdout=subprocess.PIPE,
44+
text=True,
45+
)
46+
47+
48+
def opa_eval(data_path: Path, query: str):
49+
return json.loads(opa_run([
50+
"eval",
51+
"--format", "json",
52+
"--data", str(data_path),
53+
query,
54+
]).stdout.strip())
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
from typing import Any
7+
from pydantic.dataclasses import dataclass as _dataclass, Field
8+
from pydantic import field_serializer
9+
10+
11+
# The policy model is represented as pydantic dataclasses, this makes
12+
# serialisation to/from JSON trivial.
13+
14+
# For some collections in the model, the order has no semantic meaning
15+
# (e.g. environment rules). We mark such fields using a custom OrderlessField
16+
# class which is an extension of the pydantic Field class. This custom class
17+
# just sets a metadata flag we can read later.
18+
19+
# We then also extend the dataclass decorator to sort these fields with this
20+
# flag before serialisation and comparison.
21+
22+
23+
def dataclass(cls=None, **dataclass_kwargs):
24+
def wrap(inner_cls):
25+
26+
# This method uses a pydantic field serializer to operate on fields
27+
# before serialisation. Here we look for "orderless" fields and sort them.
28+
@field_serializer("*")
29+
def _sort_orderless(self, value, info):
30+
field = type(self).__pydantic_fields__[info.field_name]
31+
if (field.json_schema_extra or {}).get("orderless"):
32+
return sorted(value, key=repr)
33+
return value
34+
setattr(inner_cls, "_sort_orderless", _sort_orderless)
35+
36+
# This custom equality method sorts "orderless" fields before comparison.
37+
def __eq__(self, other):
38+
def compare_field(name, field_info):
39+
if (field_info.json_schema_extra or {}).get("orderless"):
40+
return (
41+
sorted(getattr(self, name), key=repr) ==
42+
sorted(getattr(other, name), key=repr)
43+
)
44+
return getattr(self, name) == getattr(other, name)
45+
46+
return (
47+
type(self) is type(other) and
48+
all(
49+
compare_field(name, field_info)
50+
for name, field_info in self.__pydantic_fields__.items()
51+
)
52+
)
53+
setattr(inner_cls, "__eq__", __eq__)
54+
55+
return _dataclass(inner_cls, eq=False, **dataclass_kwargs)
56+
57+
# This adds support for using the decorator with or without parentheses.
58+
if cls is None:
59+
return wrap
60+
return wrap(cls)
61+
62+
63+
def OrderlessField(**kwargs: Any):
64+
return Field(json_schema_extra={"orderless": True}, **kwargs)

src/confcom/azext_confcom/lib/policy.py

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
# Licensed under the MIT License. See License.txt in the project root for license information.
44
# --------------------------------------------------------------------------------------------
55

6-
from dataclasses import dataclass, field
7-
from typing import Literal, Optional
6+
from typing import Literal, Optional, List
7+
from azext_confcom.lib.orderless_dataclasses import dataclass, OrderlessField, Field
88

99

1010
def get_default_capabilities():
11-
return [
11+
return (
1212
"CAP_AUDIT_WRITE",
1313
"CAP_CHOWN",
1414
"CAP_DAC_OVERRIDE",
@@ -23,16 +23,16 @@ def get_default_capabilities():
2323
"CAP_SETPCAP",
2424
"CAP_SETUID",
2525
"CAP_SYS_CHROOT"
26-
]
26+
)
2727

2828

2929
@dataclass
3030
class ContainerCapabilities:
31-
ambient: list[str] = field(default_factory=list)
32-
bounding: list[str] = field(default_factory=get_default_capabilities)
33-
effective: list[str] = field(default_factory=get_default_capabilities)
34-
inheritable: list[str] = field(default_factory=list)
35-
permitted: list[str] = field(default_factory=get_default_capabilities)
31+
ambient: List[str] = OrderlessField(default_factory=list)
32+
bounding: List[str] = OrderlessField(default_factory=get_default_capabilities)
33+
effective: List[str] = OrderlessField(default_factory=get_default_capabilities)
34+
inheritable: List[str] = OrderlessField(default_factory=list)
35+
permitted: List[str] = OrderlessField(default_factory=get_default_capabilities)
3636

3737

3838
@dataclass
@@ -44,32 +44,35 @@ class ContainerRule:
4444

4545
@dataclass
4646
class ContainerExecProcesses:
47-
command: list[str]
48-
signals: Optional[list[str]] = None
47+
command: List[str]
48+
signals: Optional[List[str]] = OrderlessField(default=None)
4949
allow_stdio_access: bool = True
5050

5151

52-
@dataclass
52+
@dataclass()
5353
class ContainerMount:
5454
destination: str
5555
source: str
5656
type: str
57-
options: list[str] = field(default_factory=list)
57+
options: List[str] = OrderlessField(default_factory=list)
5858

5959

6060
@dataclass
6161
class ContainerUser:
62-
group_idnames: list[ContainerRule] = field(default_factory=lambda: [ContainerRule(pattern="", strategy="any")])
62+
group_idnames: List[ContainerRule] = \
63+
OrderlessField(default_factory=lambda: [ContainerRule(pattern="", strategy="any")])
6364
umask: str = "0022"
64-
user_idname: ContainerRule = field(default_factory=lambda: ContainerRule(pattern="", strategy="any"))
65+
user_idname: ContainerRule = \
66+
Field(default_factory=lambda: ContainerRule(pattern="", strategy="any"))
6567

6668

6769
@dataclass
6870
class FragmentReference:
6971
feed: str
7072
issuer: str
7173
minimum_svn: str
72-
includes: list[Literal["containers", "fragments", "namespace", "external_processes"]]
74+
includes: List[Literal["containers", "fragments", "namespace", "external_processes"]] = \
75+
OrderlessField(default_factory=list)
7376
path: Optional[str] = None
7477

7578

@@ -78,18 +81,18 @@ class FragmentReference:
7881
class Container:
7982
allow_elevated: bool = False
8083
allow_stdio_access: bool = True
81-
capabilities: ContainerCapabilities = field(default_factory=ContainerCapabilities)
82-
command: Optional[list[str]] = None
83-
env_rules: list[ContainerRule] = field(default_factory=list)
84-
exec_processes: list[ContainerExecProcesses] = field(default_factory=list)
84+
capabilities: ContainerCapabilities = Field(default_factory=ContainerCapabilities)
85+
command: Optional[List[str]] = None
86+
env_rules: List[ContainerRule] = OrderlessField(default_factory=list)
87+
exec_processes: List[ContainerExecProcesses] = OrderlessField(default_factory=list)
8588
id: Optional[str] = None
86-
layers: list[str] = field(default_factory=list)
87-
mounts: list[ContainerMount] = field(default_factory=list)
89+
layers: List[str] = Field(default_factory=list)
90+
mounts: List[ContainerMount] = OrderlessField(default_factory=list)
8891
name: Optional[str] = None
8992
no_new_privileges: bool = False
9093
seccomp_profile_sha256: str = ""
91-
signals: list[str] = field(default_factory=list)
92-
user: ContainerUser = field(default_factory=ContainerUser)
94+
signals: List[str] = OrderlessField(default_factory=list)
95+
user: ContainerUser = Field(default_factory=ContainerUser)
9396
working_dir: str = "/"
9497

9598

@@ -99,8 +102,8 @@ class Policy:
99102
package: str = "policy"
100103
api_version: str = "0.10.0"
101104
framework_version: str = "0.2.3"
102-
fragments: list[FragmentReference] = field(default_factory=list)
103-
containers: list[Container] = field(default_factory=list)
105+
fragments: List[FragmentReference] = OrderlessField(default_factory=list)
106+
containers: List[Container] = OrderlessField(default_factory=list)
104107
allow_properties_access: bool = True
105108
allow_dump_stacks: bool = False
106109
allow_runtime_logging: bool = False
@@ -114,5 +117,5 @@ class Fragment:
114117
package: str = "fragment"
115118
svn: str = "0"
116119
framework_version: str = "0.2.3"
117-
fragments: list[FragmentReference] = field(default_factory=list)
118-
containers: list[Container] = field(default_factory=list)
120+
fragments: List[FragmentReference] = OrderlessField(default_factory=list)
121+
containers: List[Container] = OrderlessField(default_factory=list)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
2+
# --------------------------------------------------------------------------------------------
3+
# Copyright (c) Microsoft Corporation. All rights reserved.
4+
# Licensed under the MIT License. See License.txt in the project root for license information.
5+
# --------------------------------------------------------------------------------------------
6+
7+
from dataclasses import asdict
8+
import json
9+
from pathlib import Path
10+
from textwrap import dedent
11+
from typing import Union
12+
13+
from azext_confcom.lib.opa import opa_eval
14+
from azext_confcom.lib.policy import Container, FragmentReference, Fragment, Policy
15+
import re
16+
17+
18+
# This is a single entrypoint for serializing both Policy and Fragment objects
19+
def policy_serialize(policy: Union[Policy, Fragment]):
20+
21+
if isinstance(policy, Fragment):
22+
return fragment_serialize(policy)
23+
24+
policy_dict = asdict(policy)
25+
fragments_json = json.dumps(policy_dict.pop("fragments"), indent=2)
26+
containers_json = json.dumps(policy_dict.pop("containers"), indent=2)
27+
28+
return dedent(f"""
29+
package {policy_dict.pop('package')}
30+
31+
api_version := "{policy_dict.pop('api_version')}"
32+
framework_version := "{policy_dict.pop('framework_version')}"
33+
34+
fragments := {fragments_json}
35+
36+
containers := {containers_json}
37+
38+
{chr(10).join(f"{key} := {str(value).lower()}" for key, value in policy_dict.items() if key.startswith("allow"))}
39+
40+
mount_device := data.framework.mount_device
41+
unmount_device := data.framework.unmount_device
42+
mount_overlay := data.framework.mount_overlay
43+
unmount_overlay := data.framework.unmount_overlay
44+
create_container := data.framework.create_container
45+
exec_in_container := data.framework.exec_in_container
46+
exec_external := data.framework.exec_external
47+
shutdown_container := data.framework.shutdown_container
48+
signal_container_process := data.framework.signal_container_process
49+
plan9_mount := data.framework.plan9_mount
50+
plan9_unmount := data.framework.plan9_unmount
51+
get_properties := data.framework.get_properties
52+
dump_stacks := data.framework.dump_stacks
53+
runtime_logging := data.framework.runtime_logging
54+
load_fragment := data.framework.load_fragment
55+
scratch_mount := data.framework.scratch_mount
56+
scratch_unmount := data.framework.scratch_unmount
57+
58+
reason := {{"errors": data.framework.errors}}
59+
""")
60+
61+
62+
def fragment_serialize(fragment: Fragment):
63+
64+
fragment_dict = asdict(fragment)
65+
fragments_json = json.dumps(fragment_dict.pop("fragments"), indent=2)
66+
containers_json = json.dumps(fragment_dict.pop("containers"), indent=2)
67+
68+
return dedent(f"""
69+
package {fragment_dict.pop('package')}
70+
71+
svn := "{fragment_dict.pop('svn')}"
72+
framework_version := "{fragment_dict.pop('framework_version')}"
73+
74+
fragments := {fragments_json}
75+
76+
containers := {containers_json}
77+
""")
78+
79+
80+
def policy_deserialize(file_path: str):
81+
82+
with open(file_path, 'r') as f:
83+
content = f.read()
84+
85+
package_match = re.search(r'package\s+(\S+)', content)
86+
package_name = package_match.group(1)
87+
88+
PolicyType = Policy if package_name == "policy" else Fragment
89+
90+
raw_json = opa_eval(Path(file_path), f"data.{package_name}")["result"][0]["expressions"][0]["value"]
91+
92+
raw_fragments = raw_json.pop("fragments", [])
93+
raw_containers = raw_json.pop("containers", [])
94+
95+
return PolicyType(
96+
package=package_name,
97+
fragments=[FragmentReference(**fragment) for fragment in raw_fragments],
98+
containers=[Container(**container) for container in raw_containers],
99+
**raw_json
100+
)

src/confcom/azext_confcom/security_policy.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
# --------------------------------------------------------------------------------------------
55

66
import copy
7-
from dataclasses import asdict
87
import json
98
import warnings
109
from enum import Enum, auto
1110
from typing import Any, Dict, List, Optional, Tuple, Union
11+
from pydantic import TypeAdapter
1212

1313
import deepdiff
1414
from azext_confcom import config, os_util
@@ -411,7 +411,10 @@ def _policy_serialization(self, pretty_print=False, include_sidecars: bool = Tru
411411
for container in policy:
412412
container[config.POLICY_FIELD_CONTAINERS_ELEMENTS_ALLOW_STDIO_ACCESS] = False
413413

414-
policy += [asdict(Container(**c)) for c in self._container_definitions]
414+
policy += [
415+
TypeAdapter(Container).dump_python(Container(**c), mode="json")
416+
for c in self._container_definitions
417+
]
415418

416419
if pretty_print:
417420
return pretty_print_func(policy)

0 commit comments

Comments
 (0)