Skip to content

Commit 9b29a08

Browse files
feat: add enterprise plugin system for ggshield
1 parent c103509 commit 9b29a08

36 files changed

+4435
-8
lines changed

.importlinter

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,36 @@ include_external_packages = True
77
[importlinter:contract:ggshield-layers]
88
name = ggshield-layers
99
type = layers
10-
layers =
10+
layers =
1111
ggshield.__main__
12-
ggshield.cmd.auth | ggshield.cmd.config | ggshield.cmd.hmsl | ggshield.cmd.honeytoken | ggshield.cmd.install | ggshield.cmd.quota | ggshield.cmd.secret | ggshield.cmd.status | ggshield.cmd.utils
12+
ggshield.cmd.auth | ggshield.cmd.config | ggshield.cmd.hmsl | ggshield.cmd.honeytoken | ggshield.cmd.install | ggshield.cmd.plugin | ggshield.cmd.quota | ggshield.cmd.secret | ggshield.cmd.status | ggshield.cmd.utils
1313
ggshield.verticals.auth | ggshield.verticals.hmsl | ggshield.verticals.secret
1414
ggshield.core
1515
click | ggshield.utils | pygitguardian
16-
ignore_imports =
16+
ignore_imports =
1717
ggshield.cmd.** -> ggshield.cmd.utils.*
1818
ggshield.utils.click.** -> click
1919
unmatched_ignore_imports_alerting = warn
2020

2121
[importlinter:contract:verticals-cmd-transversals]
2222
name = verticals-cmd-transversals
2323
type = forbidden
24-
source_modules =
24+
source_modules =
2525
ggshield.cmd.auth
2626
ggshield.cmd.config
2727
ggshield.cmd.hmsl
2828
ggshield.cmd.honeytoken
2929
ggshield.cmd.install
30+
ggshield.cmd.plugin
3031
ggshield.cmd.quota
3132
ggshield.cmd.secret
3233
ggshield.cmd.status
3334
ggshield.cmd.utils
34-
forbidden_modules =
35+
forbidden_modules =
3536
ggshield.verticals.auth
3637
ggshield.verticals.hmsl
3738
ggshield.verticals.secret
38-
ignore_imports =
39+
ignore_imports =
3940
ggshield.cmd.auth.** -> ggshield.verticals.auth
4041
ggshield.cmd.auth.** -> ggshield.verticals.auth.**
4142
ggshield.cmd.auth.** -> ggshield.verticals.hmsl.**
@@ -47,6 +48,8 @@ ignore_imports =
4748
ggshield.cmd.honeytoken.** -> ggshield.verticals.honeytoken.**
4849
ggshield.cmd.install.** -> ggshield.verticals.install
4950
ggshield.cmd.install.** -> ggshield.verticals.install.**
51+
ggshield.cmd.plugin.** -> ggshield.verticals.plugin
52+
ggshield.cmd.plugin.** -> ggshield.verticals.plugin.**
5053
ggshield.cmd.quota.** -> ggshield.verticals.quota
5154
ggshield.cmd.quota.** -> ggshield.verticals.quota.**
5255
ggshield.cmd.secret.** -> ggshield.verticals.secret
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<!--
2+
A new scriv changelog fragment.
3+
4+
Uncomment the section that is right (remove the HTML comment wrapper).
5+
For top level release notes, leave all the headers commented out.
6+
-->
7+
8+
<!--
9+
### Removed
10+
11+
- A bullet item for the Removed category.
12+
13+
-->
14+
15+
### Added
16+
17+
- Add enterprise plugin system for ggshield, allowing organizations to install and manage plugins from GitGuardian.
18+
19+
<!--
20+
### Changed
21+
22+
- A bullet item for the Changed category.
23+
24+
-->
25+
<!--
26+
### Deprecated
27+
28+
- A bullet item for the Deprecated category.
29+
30+
-->
31+
<!--
32+
### Fixed
33+
34+
- A bullet item for the Fixed category.
35+
36+
-->
37+
<!--
38+
### Security
39+
40+
- A bullet item for the Security category.
41+
42+
-->

ggshield/__main__.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ggshield.cmd.hmsl import hmsl_group
1616
from ggshield.cmd.honeytoken import honeytoken_group
1717
from ggshield.cmd.install import install_cmd
18+
from ggshield.cmd.plugin import plugin_group
1819
from ggshield.cmd.quota import quota_cmd
1920
from ggshield.cmd.secret import secret_group
2021
from ggshield.cmd.secret.scan import scan_group
@@ -25,8 +26,11 @@
2526
from ggshield.core import check_updates, ui
2627
from ggshield.core.cache import Cache
2728
from ggshield.core.config import Config
29+
from ggshield.core.config.enterprise_config import EnterpriseConfig
2830
from ggshield.core.env_utils import load_dot_env
2931
from ggshield.core.errors import ExitCode
32+
from ggshield.core.plugin.loader import PluginLoader
33+
from ggshield.core.plugin.registry import PluginRegistry
3034
from ggshield.core.ui import ensure_level, log_utils
3135
from ggshield.core.ui.rich import RichGGShieldUI
3236
from ggshield.utils.click import RealPath
@@ -55,11 +59,36 @@ def exit_code(ctx: click.Context, exit_code: int, **kwargs: Any) -> int:
5559
return exit_code
5660

5761

62+
# Load plugins early so their commands can be discovered
63+
# This happens at module load time, before the CLI is invoked
64+
_plugin_registry: Optional[PluginRegistry] = None
65+
66+
67+
def _load_plugins() -> PluginRegistry:
68+
"""Load plugins at module level so commands are available."""
69+
global _plugin_registry
70+
if _plugin_registry is None:
71+
try:
72+
enterprise_config = EnterpriseConfig.load()
73+
plugin_loader = PluginLoader(enterprise_config)
74+
_plugin_registry = plugin_loader.load_enabled_plugins()
75+
except Exception as e:
76+
logger.debug("Failed to load plugins: %s", e)
77+
_plugin_registry = PluginRegistry()
78+
79+
# Make registry available to hooks module
80+
from ggshield.core.plugin.hooks import set_plugin_registry
81+
82+
set_plugin_registry(_plugin_registry)
83+
return _plugin_registry
84+
85+
5886
@click.group(
5987
context_settings={"help_option_names": ["-h", "--help"]},
6088
commands={
6189
"auth": auth_group,
6290
"config": config_group,
91+
"plugin": plugin_group,
6392
"secret": secret_group,
6493
"install": install_cmd,
6594
"quota": quota_cmd,
@@ -83,6 +112,7 @@ def cli(
83112
*,
84113
allow_self_signed: Optional[bool],
85114
insecure: Optional[bool],
115+
instance: Optional[str],
86116
config_path: Optional[Path],
87117
**kwargs: Any,
88118
) -> None:
@@ -104,11 +134,33 @@ def cli(
104134
if insecure or allow_self_signed:
105135
user_config.insecure = True
106136

137+
# Set the instance URL from command line if provided
138+
if instance:
139+
ctx_obj.config.cmdline_instance_name = instance
140+
107141
ctx_obj.config._dotenv_vars = load_dot_env()
108142

143+
# Use pre-loaded plugin registry
144+
ctx_obj.plugin_registry = _load_plugins()
145+
109146
_set_color(ctx)
110147

111148

149+
# Register plugin commands at module load time
150+
# This must happen after the cli group is defined
151+
def _register_plugin_commands() -> None:
152+
"""Register plugin commands with the CLI."""
153+
try:
154+
registry = _load_plugins()
155+
for cmd in registry.get_commands():
156+
cli.add_command(cmd)
157+
except Exception as e:
158+
logger.debug("Failed to register plugin commands: %s", e)
159+
160+
161+
_register_plugin_commands()
162+
163+
112164
def _set_color(ctx: click.Context):
113165
"""
114166
Helper function to override the default click default output color setting.

ggshield/cmd/plugin/__init__.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""
2+
Plugin commands for managing ggshield plugins.
3+
"""
4+
5+
from typing import Any
6+
7+
import click
8+
9+
from ggshield.cmd.plugin.install import install_cmd
10+
from ggshield.cmd.plugin.manage import disable_cmd, enable_cmd, uninstall_cmd
11+
from ggshield.cmd.plugin.plugin_list import list_cmd
12+
from ggshield.cmd.plugin.status import status_cmd
13+
from ggshield.cmd.plugin.update import update_cmd
14+
from ggshield.cmd.utils.common_options import add_common_options
15+
16+
17+
@click.group(
18+
commands={
19+
"install": install_cmd,
20+
"list": list_cmd,
21+
"status": status_cmd,
22+
"enable": enable_cmd,
23+
"disable": disable_cmd,
24+
"uninstall": uninstall_cmd,
25+
"update": update_cmd,
26+
}
27+
)
28+
@add_common_options()
29+
def plugin_group(**kwargs: Any) -> None:
30+
"""
31+
Manage ggshield plugins.
32+
33+
Plugins extend ggshield with additional capabilities like local secret
34+
detection. Use 'ggshield plugin status' to see available plugins
35+
for your GitGuardian account.
36+
37+
Examples:
38+
39+
# Check available plugins for your account
40+
ggshield plugin status
41+
42+
# Install a plugin
43+
ggshield plugin install tokenscanner
44+
45+
# List installed plugins
46+
ggshield plugin list
47+
48+
# Check for updates
49+
ggshield plugin update --check
50+
"""
51+
pass

ggshield/cmd/plugin/install.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
Plugin install command.
3+
"""
4+
5+
from typing import Any, Optional
6+
7+
import click
8+
9+
from ggshield.cmd.utils.common_options import add_common_options
10+
from ggshield.cmd.utils.context_obj import ContextObj
11+
from ggshield.core import ui
12+
from ggshield.core.client import create_client_from_config
13+
from ggshield.core.config.enterprise_config import EnterpriseConfig
14+
from ggshield.core.errors import ExitCode
15+
from ggshield.core.plugin.downloader import DownloadError, PluginDownloader
16+
from ggshield.core.plugin.entitlements import (
17+
EntitlementsClient,
18+
EntitlementsError,
19+
PluginNotAvailableError,
20+
)
21+
22+
23+
@click.command()
24+
@click.argument("plugin_name")
25+
@click.option(
26+
"--version",
27+
"version",
28+
default=None,
29+
help="Specific version to install (defaults to latest)",
30+
)
31+
@add_common_options()
32+
@click.pass_context
33+
def install_cmd(
34+
ctx: click.Context,
35+
plugin_name: str,
36+
version: Optional[str],
37+
**kwargs: Any,
38+
) -> None:
39+
"""
40+
Download and install a plugin from GitGuardian.
41+
42+
Install a specific plugin:
43+
44+
ggshield plugin install tokenscanner
45+
46+
Install a specific version:
47+
48+
ggshield plugin install tokenscanner --version 0.1.0
49+
50+
Plugins are downloaded from GitGuardian and installed locally.
51+
Requires authentication with a GitGuardian account.
52+
"""
53+
54+
ctx_obj = ContextObj.get(ctx)
55+
config = ctx_obj.config
56+
57+
# Fetch entitlements
58+
try:
59+
client = create_client_from_config(config)
60+
entitlements_client = EntitlementsClient(client)
61+
entitlements = entitlements_client.get_entitlements()
62+
except EntitlementsError as e:
63+
ui.display_error(str(e))
64+
ctx.exit(ExitCode.UNEXPECTED_ERROR)
65+
except Exception as e:
66+
ui.display_error(f"Failed to connect to GitGuardian: {e}")
67+
ctx.exit(ExitCode.UNEXPECTED_ERROR)
68+
69+
# Check if plugin is available
70+
available_plugins = {p.name: p for p in entitlements.plugins if p.available}
71+
72+
if plugin_name not in available_plugins:
73+
# Check if plugin exists but is not available
74+
unavailable = next(
75+
(p for p in entitlements.plugins if p.name == plugin_name), None
76+
)
77+
if unavailable:
78+
ui.display_error(
79+
f"Plugin '{plugin_name}' is not available for your account"
80+
)
81+
if unavailable.reason:
82+
ui.display_info(f"Reason: {unavailable.reason}")
83+
else:
84+
ui.display_error(f"Unknown plugin: {plugin_name}")
85+
ui.display_info("Use 'ggshield plugin status' to see available plugins")
86+
ctx.exit(ExitCode.USAGE_ERROR)
87+
88+
# Install the plugin
89+
downloader = PluginDownloader()
90+
enterprise_config = EnterpriseConfig.load()
91+
92+
ui.display_info(f"Installing {plugin_name}...")
93+
94+
try:
95+
# Get download info
96+
download_info = entitlements_client.get_plugin_download_info(
97+
plugin_name, version=version
98+
)
99+
100+
# Download and install
101+
downloader.download_and_install(download_info, plugin_name)
102+
103+
# Enable in config
104+
enterprise_config.enable_plugin(plugin_name, version=download_info.version)
105+
106+
# Save config
107+
enterprise_config.save()
108+
109+
ui.display_info(f"Installed {plugin_name} v{download_info.version}")
110+
111+
except PluginNotAvailableError as e:
112+
ui.display_error(f"Failed to install {plugin_name}: {e}")
113+
ctx.exit(ExitCode.UNEXPECTED_ERROR)
114+
except DownloadError as e:
115+
ui.display_error(f"Failed to install {plugin_name}: {e}")
116+
ctx.exit(ExitCode.UNEXPECTED_ERROR)
117+
except Exception as e:
118+
ui.display_error(f"Failed to install {plugin_name}: {e}")
119+
ctx.exit(ExitCode.UNEXPECTED_ERROR)

0 commit comments

Comments
 (0)