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
« 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
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
17__author__ = "Rick Elrod"
18__copyright__ = "(c) 2022 Red Hat, Inc."
19__license__ = "MIT"
21# This is relative to the project root passed in by the user at runtime.
22ANSIBLE_SIGN_DIR = ".ansible-sign"
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")
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.
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()
48 def parse_args(self, args):
49 """
50 Parse command line parameters
52 Args:
53 args (List[str]): command line parameters as list of strings
54 (for example ``["--help"]``).
56 Returns:
57 :obj:`argparse.Namespace`: command line parameters namespace
58 """
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 )
82 # Future-proofing for future content types.
83 content_type_parser = parser.add_subparsers(required=True, dest="content_type", metavar="CONTENT_TYPE")
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")
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 )
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)
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
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
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}")
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}")
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}")
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}")
212 def validate_checksum(self):
213 """
214 Validate a checksum manifest file. Print a pretty message and return an
215 appropriate exit code.
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()
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
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
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
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)
259 self._ok("Checksum validation succeeded.")
260 return 0
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")
266 if not os.path.exists(signature_file):
267 self._error(f"Signature file does not exist: {signature_file}")
268 return 1
270 if not os.path.exists(manifest_file):
271 self._error(f"Checksum manifest file does not exist: {manifest_file}")
272 return 1
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
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
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 )
289 result = verifier.verify()
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
297 self._ok(result.summary)
299 # GPG verification is done and we are still here, so return based on
300 # checksum validation now.
301 return self.validate_checksum()
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
308 outdir = os.path.dirname(dest)
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)
314 with open(dest, "w") as f:
315 f.write(contents)
316 self.logger.info("Wrote to file: %s", dest)
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)
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())
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
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
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
369def run():
370 """Calls :func:`main` passing the CLI arguments extracted from :obj:`sys.argv`
372 This function can be used as entry point to create console scripts with setuptools.
373 """
374 return main(sys.argv[1:])
377if __name__ == "__main__":
378 run()