Skip to content

Commit 75bde61

Browse files
docs: add changelog entry for plugin signature verification
1 parent 80cc38f commit 75bde61

File tree

8 files changed

+569
-33
lines changed

8 files changed

+569
-33
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
### Added
2+
3+
- Add sigstore signature verification for plugin wheels, enforcing identity-based trust via OIDC. Plugins can be verified in STRICT, WARN, or DISABLED mode, configurable through enterprise settings or the `--allow-unsigned` flag. Note: `--allow-unsigned` is intentionally separate from the existing `--insecure` option, as they address different security concerns (content authenticity vs. transport security).
4+
5+
### Fixed
6+
7+
- Forward `signature_mode` through GitHub release and GitHub artifact download paths, ensuring signature verification is applied consistently across all install sources.

ggshield/__main__.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,25 +70,13 @@ def _load_plugins() -> PluginRegistry:
7070
"""Load plugins at module level so commands are available."""
7171
global _plugin_registry
7272
if _plugin_registry is None:
73-
# Suppress signature/loader loggers during startup to avoid noisy output
74-
# before logging is configured
75-
sig_logger = logging.getLogger("ggshield.core.plugin.signature")
76-
loader_logger = logging.getLogger("ggshield.core.plugin.loader")
77-
orig_sig_level = sig_logger.level
78-
orig_loader_level = loader_logger.level
79-
sig_logger.setLevel(logging.CRITICAL)
80-
loader_logger.setLevel(logging.CRITICAL)
81-
8273
try:
8374
enterprise_config = EnterpriseConfig.load()
8475
plugin_loader = PluginLoader(enterprise_config)
8576
_plugin_registry = plugin_loader.load_enabled_plugins()
8677
except Exception as e:
8778
_deferred_warnings.append(f"Failed to load plugins: {e}")
8879
_plugin_registry = PluginRegistry()
89-
finally:
90-
sig_logger.setLevel(orig_sig_level)
91-
loader_logger.setLevel(orig_loader_level)
9280

9381
# Make registry available to hooks module
9482
from ggshield.core.plugin.hooks import set_plugin_registry
@@ -286,14 +274,14 @@ def main(args: Optional[List[str]] = None) -> Any:
286274
287275
`args` is only used by unit-tests.
288276
"""
277+
log_utils.disable_logs()
278+
289279
_register_plugin_commands()
290280

291281
# Required by pyinstaller when forking.
292282
# See https://pyinstaller.org/en/latest/common-issues-and-pitfalls.html#multi-processing
293283
multiprocessing.freeze_support()
294284

295-
log_utils.disable_logs()
296-
297285
if not os.getenv("GG_PLAINTEXT_OUTPUT", False) and sys.stderr.isatty():
298286
ui.set_ui(RichGGShieldUI())
299287

ggshield/core/config/enterprise_config.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22
Enterprise configuration - plugin settings.
33
"""
44

5+
from __future__ import annotations
6+
57
from dataclasses import dataclass, field
68
from pathlib import Path
79
from typing import TYPE_CHECKING, Any, Dict, Optional
810

11+
from ggshield.core.config.utils import load_yaml_dict, save_yaml_dict
12+
from ggshield.core.dirs import get_config_dir
13+
914

1015
if TYPE_CHECKING:
1116
from ggshield.core.plugin.signature import SignatureVerificationMode
1217

13-
from ggshield.core.config.utils import load_yaml_dict, save_yaml_dict
14-
from ggshield.core.dirs import get_config_dir
18+
19+
_PLUGIN_SIGNATURE_MODE_KEY = "plugin_signature_mode"
20+
_DEFAULT_SIGNATURE_MODE = "strict"
1521

1622

1723
def get_enterprise_config_filepath() -> Path:
@@ -33,10 +39,10 @@ class EnterpriseConfig:
3339
"""Enterprise configuration stored in ~/.config/ggshield/enterprise_config.yaml"""
3440

3541
plugins: Dict[str, PluginConfig] = field(default_factory=dict)
36-
plugin_signature_mode: str = "strict"
42+
plugin_signature_mode: str = _DEFAULT_SIGNATURE_MODE
3743

3844
@classmethod
39-
def load(cls) -> "EnterpriseConfig":
45+
def load(cls) -> EnterpriseConfig:
4046
"""Load enterprise config from file."""
4147
config_path = get_enterprise_config_filepath()
4248
data = load_yaml_dict(config_path)
@@ -59,7 +65,9 @@ def load(cls) -> "EnterpriseConfig":
5965
else:
6066
plugins[name] = PluginConfig(enabled=True)
6167

62-
plugin_signature_mode = data.get("plugin_signature_mode", "strict")
68+
plugin_signature_mode = data.get(
69+
_PLUGIN_SIGNATURE_MODE_KEY, _DEFAULT_SIGNATURE_MODE
70+
)
6371

6472
return cls(plugins=plugins, plugin_signature_mode=plugin_signature_mode)
6573

@@ -79,7 +87,7 @@ def save(self) -> None:
7987
}
8088
}
8189

82-
data["plugin_signature_mode"] = self.plugin_signature_mode
90+
data[_PLUGIN_SIGNATURE_MODE_KEY] = self.plugin_signature_mode
8391

8492
# Remove None values for cleaner YAML
8593
for plugin_data in data["plugins"].values():
@@ -88,8 +96,10 @@ def save(self) -> None:
8896

8997
save_yaml_dict(data, config_path)
9098

91-
def get_signature_mode(self) -> "SignatureVerificationMode":
99+
def get_signature_mode(self) -> SignatureVerificationMode:
92100
"""Get the signature verification mode."""
101+
# Deferred import to avoid circular dependency:
102+
# enterprise_config -> plugin.__init__ -> loader -> enterprise_config
93103
from ggshield.core.plugin.signature import SignatureVerificationMode
94104

95105
try:

ggshield/core/plugin/downloader.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,6 @@
3333
logger = logging.getLogger(__name__)
3434

3535

36-
def parse_wheel_filename(filename: str) -> Optional[Tuple[str, str]]:
37-
"""Parse a wheel filename into (name, version).
38-
39-
Returns None if the filename doesn't match the wheel naming convention.
40-
"""
41-
match = re.match(r"^([A-Za-z0-9_.-]+?)-(\d+[^-]*)-", filename)
42-
if match:
43-
return match.group(1), match.group(2)
44-
return None
45-
46-
4736
def get_signature_label(manifest: Dict[str, Any]) -> Optional[str]:
4837
"""Get a human-readable signature status label from a manifest.
4938

tests/unit/cmd/plugin/test_install.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
PluginSourceType,
1818
)
1919
from ggshield.core.plugin.downloader import ChecksumMismatchError, DownloadError
20+
from ggshield.core.plugin.signature import SignatureStatus, SignatureVerificationError
2021

2122

2223
class TestPluginInstall:
@@ -1054,3 +1055,221 @@ def test_install_checksum_mismatch(
10541055

10551056
assert result.exit_code == ExitCode.UNEXPECTED_ERROR
10561057
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

Comments
 (0)