Coverage for src/ansible_sign/cli.py: 88%

189 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-09-05 08:12 +0000

1import argparse 

2from distlib.manifest import DistlibException 

3import getpass 

4import logging 

5import os 

6import sys 

7 

8from ansible_sign import __version__ 

9from ansible_sign.checksum import ( 

10 ChecksumFile, 

11 ChecksumMismatch, 

12 InvalidChecksumLine, 

13) 

14from ansible_sign.checksum.differ import DistlibManifestChecksumFileExistenceDiffer 

15from ansible_sign.signing import GPGSigner, GPGVerifier 

16 

17__author__ = "Rick Elrod" 

18__copyright__ = "(c) 2022 Red Hat, Inc." 

19__license__ = "MIT" 

20 

21# This is relative to the project root passed in by the user at runtime. 

22ANSIBLE_SIGN_DIR = ".ansible-sign" 

23 

24 

25class AnsibleSignCLI: 

26 def __init__(self, args): 

27 self.logger = logging.getLogger(__name__) 

28 self.logger.debug("Parsing args: %s", str(args)) 

29 self.args = self.parse_args(args) 

30 logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" 

31 logging.basicConfig(level=self.args.loglevel, stream=sys.stdout, format=logformat, datefmt="%Y-%m-%d %H:%M:%S") 

32 

33 def run_command(self): 

34 """ 

35 parse_args() will set self.args.func() to the function we wish to 

36 execute, based on the subcommand the user ran. These 'action functions' 

37 will return the integer exit code with which we exit at the very end. 

38 

39 Roughly: 

40 0 = success 

41 1 = error (e.g. file missing, permissions issue, couldn't parse checksum file, etc.) 

42 2 = checksum verification failed 

43 3 = signature verification failed 

44 4 = signing failed 

45 """ 

46 return self.args.func() 

47 

48 def parse_args(self, args): 

49 """ 

50 Parse command line parameters 

51 

52 Args: 

53 args (List[str]): command line parameters as list of strings 

54 (for example ``["--help"]``). 

55 

56 Returns: 

57 :obj:`argparse.Namespace`: command line parameters namespace 

58 """ 

59 

60 parser = argparse.ArgumentParser(description="Signing and validation for Ansible content") 

61 parser.add_argument( 

62 "--version", 

63 action="version", 

64 version="ansible-sign {ver}".format(ver=__version__), 

65 ) 

66 parser.add_argument( 

67 "--debug", 

68 help="Print a bunch of debug info", 

69 action="store_const", 

70 dest="loglevel", 

71 const=logging.DEBUG, 

72 ) 

73 parser.add_argument( 

74 "--nocolor", 

75 help="Disable color output", 

76 required=False, 

77 dest="nocolor", 

78 default=True if len(os.environ.get("NO_COLOR", "")) else False, 

79 action="store_true", 

80 ) 

81 

82 # Future-proofing for future content types. 

83 content_type_parser = parser.add_subparsers(required=True, dest="content_type", metavar="CONTENT_TYPE") 

84 

85 project = content_type_parser.add_parser( 

86 "project", 

87 help="Act on an Ansible project directory", 

88 ) 

89 project_commands = project.add_subparsers(required=True, dest="command") 

90 

91 # command: gpg-verify 

92 cmd_gpg_verify = project_commands.add_parser( 

93 "gpg-verify", 

94 help=("Perform signature validation AND checksum verification on the checksum manifest"), 

95 ) 

96 cmd_gpg_verify.set_defaults(func=self.gpg_verify) 

97 cmd_gpg_verify.add_argument( 

98 "--keyring", 

99 help=("The GPG keyring file to use to find the matching public key. (default: the user's default keyring)"), 

100 required=False, 

101 metavar="KEYRING", 

102 dest="keyring", 

103 default=None, 

104 ) 

105 cmd_gpg_verify.add_argument( 

106 "--gnupg-home", 

107 help=("A valid GnuPG home directory. (default: the GnuPG default, usually ~/.gnupg)"), 

108 required=False, 

109 metavar="GNUPG_HOME", 

110 dest="gnupg_home", 

111 default=None, 

112 ) 

113 cmd_gpg_verify.add_argument( 

114 "project_root", 

115 help="The directory containing the files being validated and verified", 

116 metavar="PROJECT_ROOT", 

117 ) 

118 

119 # command: gpg-sign 

120 cmd_gpg_sign = project_commands.add_parser( 

121 "gpg-sign", 

122 help="Generate a checksum manifest and GPG sign it", 

123 ) 

124 cmd_gpg_sign.set_defaults(func=self.gpg_sign) 

125 cmd_gpg_sign.add_argument( 

126 "--fingerprint", 

127 help=("The GPG private key fingerprint to sign with. (default: First usable key in the user's keyring)"), 

128 required=False, 

129 metavar="PRIVATE_KEY", 

130 dest="fingerprint", 

131 default=None, 

132 ) 

133 cmd_gpg_sign.add_argument( 

134 "-p", 

135 "--prompt-passphrase", 

136 help="Prompt for a GPG key passphrase", 

137 required=False, 

138 dest="prompt_passphrase", 

139 default=False, 

140 action="store_true", 

141 ) 

142 cmd_gpg_sign.add_argument( 

143 "--gnupg-home", 

144 help=("A valid GnuPG home directory. (default: the GnuPG default, usually ~/.gnupg)"), 

145 required=False, 

146 metavar="GNUPG_HOME", 

147 dest="gnupg_home", 

148 default=None, 

149 ) 

150 cmd_gpg_sign.add_argument( 

151 "project_root", 

152 help="The directory containing the files being validated and verified", 

153 metavar="PROJECT_ROOT", 

154 ) 

155 return parser.parse_args(args) 

156 

157 def _generate_checksum_manifest(self): 

158 differ = DistlibManifestChecksumFileExistenceDiffer 

159 checksum = ChecksumFile(self.args.project_root, differ=differ) 

160 try: 

161 manifest = checksum.generate_gnu_style() 

162 except FileNotFoundError as e: 

163 if os.path.islink(e.filename): 

164 self._error(f"Broken symlink found at {e.filename} -- this is not supported. Aborting.") 

165 return False 

166 

167 if e.filename.endswith("/MANIFEST.in"): 167 ↛ 172line 167 didn't jump to line 172 because the condition on line 167 was always true

168 self._error("Could not find a MANIFEST.in file in the specified project.") 

169 self._note("If you are attempting to sign a project, please create this file.") 

170 self._note("See the ansible-sign documentation for more information.") 

171 return False 

172 raise e 

173 except DistlibException as e: 

174 self._error(f"An error was encountered while parsing MANIFEST.in: {e}") 

175 if self.args.loglevel != logging.DEBUG: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true

176 self._note("You can use the --debug global flag to view the full traceback.") 

177 self.logger.debug(e, exc_info=e) 

178 return False 

179 for warning in checksum.warnings: 179 ↛ 180line 179 didn't jump to line 180 because the loop on line 179 never started

180 self._warn(warning) 

181 self.logger.debug( 

182 "Full calculated checksum manifest (%s):\n%s", 

183 self.args.project_root, 

184 manifest, 

185 ) 

186 return manifest 

187 

188 def _error(self, msg): 

189 if self.args.nocolor: 

190 print(f"[ERROR] {msg}") 

191 else: 

192 print(f"[\033[91mERROR\033[0m] {msg}") 

193 

194 def _ok(self, msg): 

195 if self.args.nocolor: 

196 print(f"[OK ] {msg}") 

197 else: 

198 print(f"[\033[92mOK \033[0m] {msg}") 

199 

200 def _note(self, msg): 

201 if self.args.nocolor: 

202 print(f"[NOTE ] {msg}") 

203 else: 

204 print(f"[\033[94mNOTE \033[0m] {msg}") 

205 

206 def _warn(self, msg): 

207 if self.args.nocolor: 

208 print(f"[WARN ] {msg}") 

209 else: 

210 print(f"[\033[93mWARN \033[0m] {msg}") 

211 

212 def validate_checksum(self): 

213 """ 

214 Validate a checksum manifest file. Print a pretty message and return an 

215 appropriate exit code. 

216 

217 NOTE that this function does not actually check the path for existence, it 

218 leaves that to the caller (which in nearly all cases would need to do so 

219 anyway). This function will throw FileNotFoundError if the manifest does not 

220 exist. 

221 """ 

222 differ = DistlibManifestChecksumFileExistenceDiffer 

223 checksum = ChecksumFile(self.args.project_root, differ=differ) 

224 checksum_path = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt") 

225 checksum_file_contents = open(checksum_path, "r").read() 

226 

227 try: 

228 manifest = checksum.parse(checksum_file_contents) 

229 except InvalidChecksumLine as e: 

230 self._error(f"Invalid line encountered in checksum manifest: {e}") 

231 return 1 

232 

233 try: 

234 checksum.verify(manifest, diff=True) 

235 except ChecksumMismatch as e: 

236 self._error("Checksum validation failed.") 

237 self._error(str(e)) 

238 return 2 

239 except FileNotFoundError as e: 

240 if os.path.islink(e.filename): 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true

241 self._error(f"Broken symlink found at {e.filename} -- this is not supported. Aborting.") 

242 return 1 

243 

244 if e.filename.endswith("MANIFEST.in"): 244 ↛ 256line 244 didn't jump to line 256 because the condition on line 244 was always true

245 self._error("Could not find a MANIFEST.in file in the specified project.") 

246 self._note("If you are attempting to verify a signed project, please ensure that the project directory includes this file after signing.") 

247 self._note("See the ansible-sign documentation for more information.") 

248 return 1 

249 except DistlibException as e: 

250 self._error(f"An error was encountered while parsing MANIFEST.in: {e}") 

251 if self.args.loglevel != logging.DEBUG: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 self._note("You can use the --debug global flag to view the full traceback.") 

253 self.logger.debug(e, exc_info=e) 

254 return 1 

255 

256 for warning in checksum.warnings: 256 ↛ 257line 256 didn't jump to line 257 because the loop on line 256 never started

257 self._warn(warning) 

258 

259 self._ok("Checksum validation succeeded.") 

260 return 0 

261 

262 def gpg_verify(self): 

263 signature_file = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt.sig") 

264 manifest_file = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt") 

265 

266 if not os.path.exists(signature_file): 

267 self._error(f"Signature file does not exist: {signature_file}") 

268 return 1 

269 

270 if not os.path.exists(manifest_file): 

271 self._error(f"Checksum manifest file does not exist: {manifest_file}") 

272 return 1 

273 

274 if self.args.keyring is not None and not os.path.exists(self.args.keyring): 

275 self._error(f"Specified keyring file not found: {self.args.keyring}") 

276 return 1 

277 

278 if self.args.gnupg_home is not None and not os.path.isdir(self.args.gnupg_home): 

279 self._error(f"Specified GnuPG home is not a directory: {self.args.gnupg_home}") 

280 return 1 

281 

282 verifier = GPGVerifier( 

283 manifest_path=manifest_file, 

284 detached_signature_path=signature_file, 

285 gpg_home=self.args.gnupg_home, 

286 keyring=self.args.keyring, 

287 ) 

288 

289 result = verifier.verify() 

290 

291 if result.success is not True: 

292 self._error(result.summary) 

293 self._note("Re-run with the global --debug flag for more information.") 

294 self.logger.debug(result.extra_information) 

295 return 3 

296 

297 self._ok(result.summary) 

298 

299 # GPG verification is done and we are still here, so return based on 

300 # checksum validation now. 

301 return self.validate_checksum() 

302 

303 def _write_file_or_print(self, dest, contents): 

304 if dest == "-": 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true

305 print(contents, end="") 

306 return 

307 

308 outdir = os.path.dirname(dest) 

309 

310 if len(outdir) > 0 and not os.path.isdir(outdir): 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 self.logger.info("Creating output directory: %s", outdir) 

312 os.makedirs(outdir) 

313 

314 with open(dest, "w") as f: 

315 f.write(contents) 

316 self.logger.info("Wrote to file: %s", dest) 

317 

318 def gpg_sign(self): 

319 # Step 1: Manifest 

320 manifest_path = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt") 

321 checksum_file_contents = self._generate_checksum_manifest() 

322 if checksum_file_contents is False: 

323 return 1 

324 self._write_file_or_print(manifest_path, checksum_file_contents) 

325 

326 # Step 2: Signing 

327 # Do they need a passphrase? 

328 passphrase = None 

329 if self.args.prompt_passphrase: 

330 self.logger.debug("Prompting for GPG key passphrase") 

331 passphrase = getpass.getpass("GPG Key Passphrase: ") 

332 elif "ANSIBLE_SIGN_GPG_PASSPHRASE" in os.environ: 

333 self.logger.debug("Taking GPG key passphrase from ANSIBLE_SIGN_GPG_PASSPHRASE env var") 

334 passphrase = os.environ["ANSIBLE_SIGN_GPG_PASSPHRASE"] 

335 else: 

336 os.environ["GPG_TTY"] = os.ttyname(sys.stdin.fileno()) 

337 

338 signature_path = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt.sig") 

339 signer = GPGSigner( 

340 manifest_path=manifest_path, 

341 output_path=signature_path, 

342 privkey=self.args.fingerprint, 

343 passphrase=passphrase, 

344 gpg_home=self.args.gnupg_home, 

345 ) 

346 result = signer.sign() 

347 if result.success: 347 ↛ 351line 347 didn't jump to line 351 because the condition on line 347 was always true

348 self._ok("GPG signing successful!") 

349 retcode = 0 

350 else: 

351 self._error("GPG signing FAILED!") 

352 self._note("Re-run with the global --debug flag for more information.") 

353 retcode = 4 

354 

355 self._note(f"Checksum manifest: {manifest_path}") 

356 self._note(f"GPG summary: {result.summary}") 

357 self.logger.debug(f"GPG Details: {result.extra_information}") 

358 return retcode 

359 

360 

361def main(args): 

362 cli = AnsibleSignCLI(args) 

363 cli.logger.debug("Running requested command/passing to function") 

364 exitcode = cli.run_command() 

365 cli.logger.info("Script ends here, rc=%d", exitcode) 

366 return exitcode 

367 

368 

369def run(): 

370 """Calls :func:`main` passing the CLI arguments extracted from :obj:`sys.argv` 

371 

372 This function can be used as entry point to create console scripts with setuptools. 

373 """ 

374 return main(sys.argv[1:]) 

375 

376 

377if __name__ == "__main__": 

378 run()