1#!/usr/bin/env python3

2# Copyright 2021 - 2024 Canonical Ltd.

3# All rights reserved.

4#

5# Written by:

6# Patrick Liu <patrick.liu@canonical.com>

7# Patrick Chang <patrick.chang@canonical.com>

8#

9# Checkbox is free software: you can redistribute it and/or modify

10# it under the terms of the GNU General Public License version 3,

11# as published by the Free Software Foundation.

12#

13# Checkbox is distributed in the hope that it will be useful,

14# but WITHOUT ANY WARRANTY; without even the implied warranty of

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

16# GNU General Public License for more details.

17#

18# You should have received a copy of the GNU General Public License

19# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.

20

21 import argparse

22 import json

23 import logging

24 import os

25 import re

26 import sys

27 from checkbox_support.snap_utils.snapd import Snapd

28

29

30 def test_system_confinement():

31 """

32 Test the system's confinement and sandbox features.

33

34 This test checks if the system's confinement is 'strict'. If it

35 is 'strict', the test passes; otherwise, it checks the presence

36 of required features and prints out errors for any missing ones.

37

38 Variables:

39 features_should_include (list): A list of sandbox features that

40 must be present in the 'apparmor' category.

41 Returns:

42 str: A detailed output of the system's confinement and

43 sandbox features in JSON format.

44 """

45 features_should_include = [

46 "kernel:caps",

47 "kernel:dbus",

48 "kernel:domain",

49 "kernel:file",

50 "kernel:mount",

51 "kernel:namespaces",

52 "kernel:network",

53 "kernel:ptrace",

54 "kernel:signal",

55 "parser:unsafe",

56 ]

57

58 data = Snapd().get_system_info()

59

60 confinement = data["confinement"]

61 if confinement == "strict":

62 print('System confinement is "strict"')

63 print("Test PASS")

64 return 0

65

66 sandbox_features = data["sandbox-features"]

67 sandbox_features_output = (

68 "\nOUTPUT: confinement: {}\nOUTPUT: sandbox-features:\n{}".format(

69 confinement, json.dumps(sandbox_features, indent=2)

70 )

71 )

72

73 missing_features = []

74 if "apparmor" not in sandbox_features:

75 logging.error("Cannot find 'apparmor' in sandbox-features")

76 else:

77 for feature in features_should_include:

78 if feature not in sandbox_features["apparmor"]:

79 missing_features.append(feature)

80

81 if missing_features:

82 logging.error("Cannot find '%s' in apparmor", missing_features)

83

84 categories_to_check = ["mount", "udev"]

85 for category in categories_to_check:

86 if category not in sandbox_features:

87 logging.error("Cannot find '%s' in sandbox-features", category)

88 break

89 for feature in sandbox_features[category]:

90 if "cgroup-v2" in feature:

91 logging.error("cgroup(%s) must NOT be v2", feature)

92

93 return sandbox_features_output

94

95

96 class SnapsConfinementVerifier:

97 """

98 Test the confinement status of all installed snaps.

99

100 A snap's confinement should be 'strict', devmode should be False,

101 and it should not have a sideloaded revision that starts with 'x'.

102

103 Attributes:

104 _official_allowlist (list): A list of regex patterns exempted from

105 the confinement check. Snaps matching these patterns will be

106 excluded from the test. Customize this list to define specific

107 snaps that should be ignored based on their names or patterns.

108

109 _allowlist_from_config_var (list): A list of snap names

110 from the SNAP_CONFINEMENT_ALLOWLIST environment variable.

111 Snaps matching these patterns will be excluded from the test.

112

113 _desired_attributes (list): A list of attributes of a snap that

114 are of interest for testing. The attributes include "name",

115 "confinement", "devmode", and "revision".

116

117 Methods:

118 _is_snap_in_allow_list(snap_name: str) -> bool:

119 Check if a snap is in the allowlist.

120

121 _is_snap_confinement_not_strict(snap_confinement: str) -> bool:

122 Check if a snap's confinement is not 'strict'.

123

124 _is_snap_devmode(snap_devmode: bool) -> bool:

125 Check if a snap is in devmode.

126

127 _is_snap_sideloaded_revision(snap_revision: str) -> bool:

128 Check if a snap has a sideloaded revision starting with 'x'.

129

130 _extract_attributes_from_snap(target_snap: dict) -> (bool, dict):

131 Extract desired attributes from a snap's information.

132

133 verify_snap() -> int:

134 Perform the snap verification test on all installed snaps.

135 Returns the exit code: 0 if the test passes for all snaps,

136 otherwise 1.

137 """

138

139 def __init__(self) -> None:

140 self._official_allowlist = [

141 r"^bugit$",

142 r"checkbox.*",

143 r"^mir-test-tools$",

144 r"^graphics-test-tools$",

145 ]

146 self._allowlist_from_config_var = [

147 element.strip()

148 for element in os.environ.get(

149 "SNAP_CONFINEMENT_ALLOWLIST", ""

150 ).split(",")

151 ]

152 # Define the attributes of snap we are interested in.

153 self._desired_attributes = [

154 "name",

155 "confinement",

156 "devmode",

157 "revision",

158 ]

159

160 def _is_snap_in_allow_list(self, snap_name: str) -> bool:

161 if snap_name in self._allowlist_from_config_var:

162 logging.warning(

163 "This snap is included in the SNAP_CONFINEMENT_ALLOWLIST"

164 " environment variable, a tester defined checkbox config_var."

165 )

166 logging.info("Result: Skip")

167 return True

168 elif any(

169 re.match(pattern, snap_name)

170 for pattern in self._official_allowlist

171 ):

172 logging.warning("This snap is officially defined in the allowlist")

173 logging.info("Result: Skip")

174 return True

175 return False

176

177 def _is_snap_confinement_not_strict(self, snap_confinement: str) -> bool:

178 if snap_confinement != "strict":

179 logging.error(

180 "confinement is expected to be 'strict' but got '{}'".format(

181 snap_confinement

182 )

183 )

184 return True

185 return False

186

187 def _is_snap_devmode(self, snap_devmode: bool) -> bool:

188 if snap_devmode:

189 logging.error("devmode is expected to be 'False' but got 'True'")

190 return True

191 return False

192

193 def _is_snap_sideloaded_revision(self, snap_revision: str) -> bool:

194 if snap_revision and snap_revision.startswith("x"):

195 logging.error(

196 "sideloaded revision is '{}', which is not allowed".format(

197 snap_revision

198 )

199 )

200 return True

201 return False

202

203 def _extract_attributes_from_snap(self, target_snap: dict) -> (bool, dict):

204 return_dict = {}

205 has_error = False

206 for attr in self._desired_attributes:

207 value = target_snap.get(attr)

208 if value is None:

209 has_error = True

210 logging.error(

211 "Snap '{}' not found in the snap data.".format(attr)

212 )

213 continue

214 return_dict.update({attr: value})

215 return has_error, return_dict

216

217 def verify_snap(self) -> bool:

218 exit_code = 0

219 snaps_information = Snapd().list()

220 for snap_info in snaps_information:

221 tmp_exit_code = 0

222 has_error, snap_dict = self._extract_attributes_from_snap(

223 target_snap=snap_info

224 )

225 if has_error:

226 # Mark as fail and skip current snap's checking

227 # if any desired attribute is missing

228 exit_code = 1

229 continue

230

231 logging.info(

232 "=== Checking Snap: {} ===".format(snap_dict.get("name"))

233 )

234

235 # Skip if target snap in allow list

236 if self._is_snap_in_allow_list(snap_dict.get("name")):

237 continue

238

239 tmp_exit_code |= self._is_snap_confinement_not_strict(

240 snap_dict.get("confinement")

241 )

242 tmp_exit_code |= self._is_snap_devmode(snap_dict.get("devmode"))

243 tmp_exit_code |= self._is_snap_sideloaded_revision(

244 snap_dict.get("revision")

245 )

246

247 logging.info(

248 "Result: {}".format("Fail" if tmp_exit_code else "Pass")

249 )

250

251 exit_code |= tmp_exit_code

252 return exit_code

253

254

255 def main():

256 logging.basicConfig(

257 format="%(levelname)s: %(message)s", level=logging.INFO

258 )

259 sub_commands = {

260 "system": test_system_confinement,

261 "snaps": SnapsConfinementVerifier().verify_snap,

262 }

263 parser = argparse.ArgumentParser()

264 parser.add_argument("subcommand", type=str, choices=sub_commands)

265 args = parser.parse_args()

266 return sub_commands[args.subcommand]()

267

268

269 if __name__ == "__main__":

270 sys.exit(main())