|
17 | 17 | PluginSourceType, |
18 | 18 | ) |
19 | 19 | from ggshield.core.plugin.downloader import ChecksumMismatchError, DownloadError |
| 20 | +from ggshield.core.plugin.signature import SignatureStatus, SignatureVerificationError |
20 | 21 |
|
21 | 22 |
|
22 | 23 | class TestPluginInstall: |
@@ -1054,3 +1055,221 @@ def test_install_checksum_mismatch( |
1054 | 1055 |
|
1055 | 1056 | assert result.exit_code == ExitCode.UNEXPECTED_ERROR |
1056 | 1057 | assert "Checksum verification failed" in result.output |
| 1058 | + |
| 1059 | + |
| 1060 | +class TestSignatureVerificationHandling: |
| 1061 | + """Tests for signature verification error handling in install commands.""" |
| 1062 | + |
| 1063 | + def test_gitguardian_install_signature_error(self, cli_fs_runner) -> None: |
| 1064 | + """ |
| 1065 | + GIVEN a plugin with invalid signature |
| 1066 | + WHEN installing from GitGuardian API |
| 1067 | + THEN signature error is shown with --allow-unsigned hint |
| 1068 | + """ |
| 1069 | + mock_catalog = PluginCatalog( |
| 1070 | + plan="Enterprise", |
| 1071 | + plugins=[ |
| 1072 | + PluginInfo( |
| 1073 | + name="tokenscanner", |
| 1074 | + display_name="Token Scanner", |
| 1075 | + description="Local secret scanning", |
| 1076 | + available=True, |
| 1077 | + latest_version="1.0.0", |
| 1078 | + reason=None, |
| 1079 | + ), |
| 1080 | + ], |
| 1081 | + features={}, |
| 1082 | + ) |
| 1083 | + |
| 1084 | + mock_download_info = PluginDownloadInfo( |
| 1085 | + download_url="https://example.com/plugin.whl", |
| 1086 | + filename="tokenscanner-1.0.0.whl", |
| 1087 | + sha256="abc123", |
| 1088 | + version="1.0.0", |
| 1089 | + expires_at="2099-12-31T23:59:59Z", |
| 1090 | + ) |
| 1091 | + |
| 1092 | + with ( |
| 1093 | + mock.patch( |
| 1094 | + "ggshield.cmd.plugin.install.create_client_from_config" |
| 1095 | + ) as mock_create_client, |
| 1096 | + mock.patch( |
| 1097 | + "ggshield.cmd.plugin.install.PluginAPIClient" |
| 1098 | + ) as mock_plugin_api_client_class, |
| 1099 | + mock.patch( |
| 1100 | + "ggshield.cmd.plugin.install.PluginDownloader" |
| 1101 | + ) as mock_downloader_class, |
| 1102 | + mock.patch( |
| 1103 | + "ggshield.cmd.plugin.install.EnterpriseConfig" |
| 1104 | + ) as mock_config_class, |
| 1105 | + ): |
| 1106 | + mock_client = mock.MagicMock() |
| 1107 | + mock_create_client.return_value = mock_client |
| 1108 | + |
| 1109 | + mock_plugin_api_client = mock.MagicMock() |
| 1110 | + mock_plugin_api_client.get_available_plugins.return_value = mock_catalog |
| 1111 | + mock_plugin_api_client.get_download_info.return_value = mock_download_info |
| 1112 | + mock_plugin_api_client_class.return_value = mock_plugin_api_client |
| 1113 | + |
| 1114 | + mock_downloader = mock.MagicMock() |
| 1115 | + mock_downloader.download_and_install.side_effect = ( |
| 1116 | + SignatureVerificationError( |
| 1117 | + SignatureStatus.INVALID, "no trusted identity matched" |
| 1118 | + ) |
| 1119 | + ) |
| 1120 | + mock_downloader_class.return_value = mock_downloader |
| 1121 | + |
| 1122 | + mock_config = mock.MagicMock() |
| 1123 | + mock_config.get_signature_mode.return_value = mock.MagicMock() |
| 1124 | + mock_config_class.load.return_value = mock_config |
| 1125 | + |
| 1126 | + result = cli_fs_runner.invoke(cli, ["plugin", "install", "tokenscanner"]) |
| 1127 | + |
| 1128 | + assert result.exit_code == ExitCode.UNEXPECTED_ERROR |
| 1129 | + assert "Signature verification failed" in result.output |
| 1130 | + assert "--allow-unsigned" in result.output |
| 1131 | + |
| 1132 | + def test_local_wheel_signature_error(self, cli_fs_runner, tmp_path: Path) -> None: |
| 1133 | + """ |
| 1134 | + GIVEN a local wheel with invalid signature |
| 1135 | + WHEN installing from local wheel |
| 1136 | + THEN signature error is shown with --allow-unsigned hint |
| 1137 | + """ |
| 1138 | + wheel_path = tmp_path / "plugin.whl" |
| 1139 | + wheel_path.touch() |
| 1140 | + |
| 1141 | + with ( |
| 1142 | + mock.patch( |
| 1143 | + "ggshield.cmd.plugin.install.PluginDownloader" |
| 1144 | + ) as mock_downloader_class, |
| 1145 | + mock.patch( |
| 1146 | + "ggshield.cmd.plugin.install.EnterpriseConfig" |
| 1147 | + ) as mock_config_class, |
| 1148 | + mock.patch( |
| 1149 | + "ggshield.cmd.plugin.install.detect_source_type", |
| 1150 | + return_value=PluginSourceType.LOCAL_FILE, |
| 1151 | + ), |
| 1152 | + ): |
| 1153 | + mock_downloader = mock.MagicMock() |
| 1154 | + mock_downloader.install_from_wheel.side_effect = SignatureVerificationError( |
| 1155 | + SignatureStatus.MISSING, "No bundle found" |
| 1156 | + ) |
| 1157 | + mock_downloader_class.return_value = mock_downloader |
| 1158 | + |
| 1159 | + mock_config = mock.MagicMock() |
| 1160 | + mock_config.get_signature_mode.return_value = mock.MagicMock() |
| 1161 | + mock_config_class.load.return_value = mock_config |
| 1162 | + |
| 1163 | + result = cli_fs_runner.invoke(cli, ["plugin", "install", str(wheel_path)]) |
| 1164 | + |
| 1165 | + assert result.exit_code == ExitCode.UNEXPECTED_ERROR |
| 1166 | + assert "Signature verification failed" in result.output |
| 1167 | + assert "--allow-unsigned" in result.output |
| 1168 | + |
| 1169 | + @pytest.mark.parametrize( |
| 1170 | + "source_type, method, cli_args", |
| 1171 | + [ |
| 1172 | + pytest.param( |
| 1173 | + PluginSourceType.URL, |
| 1174 | + "download_from_url", |
| 1175 | + ["plugin", "install", "https://example.com/plugin.whl"], |
| 1176 | + id="url", |
| 1177 | + ), |
| 1178 | + pytest.param( |
| 1179 | + PluginSourceType.GITHUB_RELEASE, |
| 1180 | + "download_from_github_release", |
| 1181 | + [ |
| 1182 | + "plugin", |
| 1183 | + "install", |
| 1184 | + "https://github.com/o/r/releases/download/v1/p.whl", |
| 1185 | + ], |
| 1186 | + id="github_release", |
| 1187 | + ), |
| 1188 | + pytest.param( |
| 1189 | + PluginSourceType.GITHUB_ARTIFACT, |
| 1190 | + "download_from_github_artifact", |
| 1191 | + [ |
| 1192 | + "plugin", |
| 1193 | + "install", |
| 1194 | + "https://github.com/o/r/actions/runs/1/artifacts/2", |
| 1195 | + ], |
| 1196 | + id="github_artifact", |
| 1197 | + ), |
| 1198 | + ], |
| 1199 | + ) |
| 1200 | + def test_signature_error_all_sources( |
| 1201 | + self, cli_fs_runner, source_type, method, cli_args |
| 1202 | + ) -> None: |
| 1203 | + """Test that SignatureVerificationError is handled for all source types.""" |
| 1204 | + with ( |
| 1205 | + mock.patch( |
| 1206 | + "ggshield.cmd.plugin.install.PluginDownloader" |
| 1207 | + ) as mock_downloader_class, |
| 1208 | + mock.patch( |
| 1209 | + "ggshield.cmd.plugin.install.EnterpriseConfig" |
| 1210 | + ) as mock_config_class, |
| 1211 | + mock.patch( |
| 1212 | + "ggshield.cmd.plugin.install.detect_source_type", |
| 1213 | + return_value=source_type, |
| 1214 | + ), |
| 1215 | + ): |
| 1216 | + mock_downloader = mock.MagicMock() |
| 1217 | + getattr(mock_downloader, method).side_effect = SignatureVerificationError( |
| 1218 | + SignatureStatus.INVALID, "bad signature" |
| 1219 | + ) |
| 1220 | + mock_downloader_class.return_value = mock_downloader |
| 1221 | + |
| 1222 | + mock_config = mock.MagicMock() |
| 1223 | + mock_config.get_signature_mode.return_value = mock.MagicMock() |
| 1224 | + mock_config_class.load.return_value = mock_config |
| 1225 | + |
| 1226 | + result = cli_fs_runner.invoke(cli, cli_args) |
| 1227 | + |
| 1228 | + assert result.exit_code == ExitCode.UNEXPECTED_ERROR |
| 1229 | + assert "Signature verification failed" in result.output |
| 1230 | + assert "--allow-unsigned" in result.output |
| 1231 | + |
| 1232 | + def test_allow_unsigned_flag(self, cli_fs_runner, tmp_path: Path) -> None: |
| 1233 | + """ |
| 1234 | + GIVEN --allow-unsigned flag |
| 1235 | + WHEN installing a plugin |
| 1236 | + THEN signature mode is set to WARN (not STRICT) |
| 1237 | + """ |
| 1238 | + wheel_path = tmp_path / "plugin.whl" |
| 1239 | + wheel_path.touch() |
| 1240 | + |
| 1241 | + with ( |
| 1242 | + mock.patch( |
| 1243 | + "ggshield.cmd.plugin.install.PluginDownloader" |
| 1244 | + ) as mock_downloader_class, |
| 1245 | + mock.patch( |
| 1246 | + "ggshield.cmd.plugin.install.EnterpriseConfig" |
| 1247 | + ) as mock_config_class, |
| 1248 | + mock.patch( |
| 1249 | + "ggshield.cmd.plugin.install.detect_source_type", |
| 1250 | + return_value=PluginSourceType.LOCAL_FILE, |
| 1251 | + ), |
| 1252 | + ): |
| 1253 | + mock_downloader = mock.MagicMock() |
| 1254 | + mock_downloader.install_from_wheel.return_value = ( |
| 1255 | + "plugin", |
| 1256 | + "1.0.0", |
| 1257 | + wheel_path, |
| 1258 | + ) |
| 1259 | + mock_downloader_class.return_value = mock_downloader |
| 1260 | + |
| 1261 | + mock_config = mock.MagicMock() |
| 1262 | + mock_config.get_signature_mode.return_value = mock.MagicMock() |
| 1263 | + mock_config_class.load.return_value = mock_config |
| 1264 | + |
| 1265 | + result = cli_fs_runner.invoke( |
| 1266 | + cli, |
| 1267 | + ["plugin", "install", str(wheel_path), "--allow-unsigned"], |
| 1268 | + catch_exceptions=False, |
| 1269 | + ) |
| 1270 | + |
| 1271 | + assert result.exit_code == ExitCode.SUCCESS |
| 1272 | + call_kwargs = mock_downloader.install_from_wheel.call_args[1] |
| 1273 | + from ggshield.core.plugin.signature import SignatureVerificationMode |
| 1274 | + |
| 1275 | + assert call_kwargs["signature_mode"] == SignatureVerificationMode.WARN |
0 commit comments