Coverage for muuntaa/cli.py: 90.00%

62 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-04 20:39:23 +00:00

1import argparse 

2import logging 

3import pathlib 

4import sys 

5from typing import Union 

6 

7import muuntaa.advisor as advisor 

8import muuntaa.config as cfg 

9import muuntaa.writer as writer 

10from muuntaa import ( 

11 APP_ALIAS, 

12 APP_NAME, 

13 ConfigType, 

14 ENCODING, 

15 INPUT_FILE_KEY, 

16 OVERWRITABLE_KEYS, 

17 ScopedMessages, 

18 VERSION, 

19 log, 

20) 

21 

22FALLBACK_CVSS3_VERSION = '3.0' 

23MAGIC_CMD_ARG_ENTERED = 'cmd-arg-entered' 

24 

25scoped_log = log.log # noqa 

26 

27 

28def parse_request(argv: Union[list[str], None] = None) -> tuple[Union[int, ConfigType], ScopedMessages]: 

29 """Parse the request as load configuration and mix in (overwrite) command line parameter values.""" 

30 if argv is None: 

31 argv = sys.argv[1:] # pragma: no cover 

32 parser = argparse.ArgumentParser( 

33 prog=APP_ALIAS, description=APP_NAME, formatter_class=argparse.RawTextHelpFormatter 

34 ) 

35 # General args 

36 parser.add_argument('-v', '--version', action='version', version=VERSION) 

37 parser.add_argument( 

38 '--input-file', dest='input_file', type=str, required=True, help='CVRF XML input file to parse', metavar='PATH' 

39 ) 

40 parser.add_argument( 

41 '--output-dir', 

42 dest='output_dir', 

43 type=str, 

44 default='./', 

45 metavar='PATH', 

46 help='CSAF output dir to write to. Filename is derived from /document/tracking/id.', 

47 ) 

48 parser.add_argument( 

49 '--print', 

50 dest='print', 

51 action='store_true', 

52 default=False, 

53 help='Additionally prints CSAF JSON output on stdout.', 

54 ) 

55 parser.add_argument( 

56 '--force', 

57 action='store_const', 

58 const='cmd-arg-entered', 

59 help=( 

60 'If used, the converter produces output even if it is invalid (errors occurred during conversion).\n' 

61 'Target use case: best-effort conversion to JSON, fix the errors manually, e.g. in Secvisogram.' 

62 ), 

63 ) 

64 

65 # Document Publisher args 

66 parser.add_argument('--publisher-name', dest='publisher_name', type=str, help='Name of the publisher.') 

67 parser.add_argument( 

68 '--publisher-namespace', 

69 dest='publisher_namespace', 

70 type=str, 

71 help='Namespace of the publisher. Must be a valid URI', 

72 ) 

73 

74 # Document Tracking args 

75 parser.add_argument( 

76 '--fix-insert-current-version-into-revision-history', 

77 action='store_const', 

78 const='cmd-arg-entered', 

79 help=( 

80 'If the current version is not present in the revision history the current version is\n' 

81 'added to the revision history. Also, warning is produced. By default, an error is produced.' 

82 ), 

83 ) 

84 

85 # Document References args 

86 parser.add_argument( 

87 '--force-insert-default-reference-category', 

88 action='store_const', 

89 const='cmd-arg-entered', 

90 help="When 'Type' attribute not present in 'Reference' element, then force using default value 'external'.", 

91 ) 

92 

93 # Vulnerabilities args 

94 parser.add_argument( 

95 '--remove-CVSS-values-without-vector', 

96 action='store_const', 

97 const='cmd-arg-entered', 

98 help=( 

99 'If vector is not present in CVSS ScoreSet,\n' 

100 'the convertor removes the whole ScoreSet instead of producing an error.' 

101 ), 

102 ) 

103 

104 parser.add_argument( 

105 '--default-CVSS3-version', 

106 dest='default_CVSS3_version', 

107 help=( 

108 'Default version used for CVSS version 3, when the version cannot be derived from other sources.\n' 

109 f"Default value is '{FALLBACK_CVSS3_VERSION}'." 

110 ), 

111 ) 

112 

113 try: 

114 args = {k: v for k, v in vars(parser.parse_args(argv)).items() if v is not None} 

115 except SystemExit as err: 

116 return int(str(err)), [] 

117 

118 config = cfg.load() 

119 scoped_messages = cfg.boolify(config) 

120 for scope, message in scoped_messages: 120 ↛ 121line 120 didn't jump to line 121, because the loop on line 120 never started

121 scoped_log(scope, message) 

122 if scope >= logging.CRITICAL: 

123 return 1, [] 

124 

125 config.update(args) # Update and overwrite config file values with the ones from command line arguments 

126 for key in OVERWRITABLE_KEYS: # Boolean optional arguments that are also present in config need special treatment 

127 if config.get(key) == MAGIC_CMD_ARG_ENTERED: 

128 config[key] = True 

129 

130 if not pathlib.Path(config.get(INPUT_FILE_KEY, '')).is_file(): # type: ignore 

131 # Avoided type error using empty string as default, which fakes missing file per current dir 

132 scoped_log(logging.CRITICAL, f'Input file not found, check the path: {config.get(INPUT_FILE_KEY)}') 

133 return 1, [] 

134 

135 return config, [] 

136 

137 

138def process(configuration: ConfigType) -> int: 

139 """Visit the source and yield the requested transformed target.""" 

140 in_path = pathlib.Path(configuration[INPUT_FILE_KEY]) # type: ignore 

141 with open(in_path, 'r', encoding=ENCODING) as source: 

142 loaded = source.read() 

143 

144 csaf_dict: dict[str, object] = {'csaf_version': '2.0', 'incoming_blob': loaded} 

145 

146 out_path = advisor.derive_csaf_filename() 

147 scoped_messages = writer.write_csaf(csaf_dict, out_path) 

148 for scope, message in scoped_messages: 

149 scoped_log(scope, message) 

150 if scope >= logging.CRITICAL: 150 ↛ 151line 150 didn't jump to line 151, because the condition on line 150 was never true

151 return 1 

152 

153 return 0 

154 

155 

156def app(argv: Union[list[str], None] = None) -> int: 

157 """Delegate processing to functional module.""" 

158 argv = sys.argv[1:] if argv is None else argv 

159 configuration, scoped_messages = parse_request(argv) 

160 if isinstance(configuration, int): 

161 return 0 

162 return process(configuration)