Skip to content

Commit 549b1b2

Browse files
Merge pull request #1109 from GitGuardian/alina/add-source-uuid-secret-scan
feat: add new parameter source_uuid to secret scan
2 parents 917c935 + 57d8f9e commit 549b1b2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2355
-979
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
### Changed
2+
3+
- `ggshield secret scan` now provides an `--source-uuid` option. When this option is set, it will create the incidents on the GIM
4+
dashboard on the corresponding source. Note that the token should have the scope `scan:create-incidents`.

ggshield/cmd/secret/scan/secret_scan_common_options.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from typing import Callable, List, Optional
23

34
import click
@@ -149,6 +150,26 @@ def _banlist_detectors_callback(
149150
)
150151

151152

153+
def _source_uuid_callback(
154+
ctx: click.Context, param: click.Parameter, value: Optional[str]
155+
) -> Optional[str]:
156+
if value is not None:
157+
try:
158+
uuid.UUID(value)
159+
except ValueError:
160+
raise click.BadParameter("source-uuid must be a valid UUID")
161+
return create_config_callback("secret", "source_uuid")(ctx, param, value)
162+
163+
164+
_source_uuid_option = click.option(
165+
"--source-uuid",
166+
help="Identifier of the custom source in GitGuardian. If used, incidents will be created and visible on the "
167+
"dashboard. Requires the 'scan:create-incidents' scope.",
168+
callback=_source_uuid_callback,
169+
default=None,
170+
)
171+
172+
152173
def add_secret_scan_common_options() -> Callable[[AnyFunction], AnyFunction]:
153174
def decorator(cmd: AnyFunction) -> AnyFunction:
154175
add_common_options()(cmd)
@@ -163,6 +184,7 @@ def decorator(cmd: AnyFunction) -> AnyFunction:
163184
_with_incident_details_option(cmd)
164185
instance_option(cmd)
165186
_all_secrets(cmd)
187+
_source_uuid_option(cmd)
166188
return cmd
167189

168190
return decorator

ggshield/core/client.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
import requests
55
import urllib3
66
from pygitguardian import GGClient, GGClientCallbacks
7+
from pygitguardian.models import APITokensResponse, Detail, TokenScope
78
from requests import Session
89

910
from .config import Config
1011
from .constants import DEFAULT_INSTANCE_URL
1112
from .errors import (
1213
APIKeyCheckError,
14+
MissingScopesError,
1315
ServiceUnavailableError,
1416
UnexpectedError,
1517
UnknownInstanceError,
@@ -87,10 +89,12 @@ def create_session(allow_self_signed: bool = False) -> Session:
8789
return session
8890

8991

90-
def check_client_api_key(client: GGClient) -> None:
92+
def check_client_api_key(client: GGClient, required_scopes: set[TokenScope]) -> None:
9193
"""
9294
Raises APIKeyCheckError if the API key configured for the client is not usable
9395
(either it is invalid or unset). Raises UnexpectedError if the API is down.
96+
97+
If required_scopes is not empty, also checks that the API key has the required scopes.
9498
"""
9599
try:
96100
response = client.read_metadata()
@@ -102,9 +106,8 @@ def check_client_api_key(client: GGClient) -> None:
102106

103107
if response is None:
104108
# None means success
105-
return
106-
107-
if response.status_code == 401:
109+
pass
110+
elif response.status_code == 401:
108111
raise APIKeyCheckError(client.base_uri, "Invalid API key.")
109112
elif response.status_code == 404:
110113
raise UnexpectedError(
@@ -118,3 +121,18 @@ def check_client_api_key(client: GGClient) -> None:
118121
raise UnexpectedError(
119122
f"GitGuardian server is not responding as expected.\nDetails: {response.detail}"
120123
)
124+
125+
# Check token scopes if required_scopes is not empty
126+
if required_scopes:
127+
response = client.api_tokens()
128+
129+
if not isinstance(response, (Detail, APITokensResponse)):
130+
raise UnexpectedError("Unexpected api_tokens response")
131+
elif isinstance(response, Detail):
132+
raise UnexpectedError(response.detail)
133+
134+
missing_scopes = required_scopes - set(
135+
TokenScope(scope) for scope in response.scopes
136+
)
137+
if missing_scopes:
138+
raise MissingScopesError(list(missing_scopes))

ggshield/core/config/user_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from dataclasses import field
44
from pathlib import Path
55
from typing import Any, Dict, List, Optional, Set, Tuple
6+
from uuid import UUID
67

78
import marshmallow_dataclass
89
from marshmallow import ValidationError
@@ -44,6 +45,7 @@ class SecretConfig(FilteredConfig):
4445
# if configuration key is left unset the dashboard's remediation message is used.
4546
all_secrets: bool = False
4647
prereceive_remediation_message: str = ""
48+
source_uuid: Optional[UUID] = None
4749

4850
def add_ignored_match(self, secret: IgnoredMatch) -> None:
4951
"""
@@ -70,6 +72,7 @@ def dump_for_monitoring(self) -> str:
7072
self.prereceive_remediation_message
7173
),
7274
"all_secrets": self.all_secrets,
75+
"source_uuid": self.source_uuid,
7376
}
7477
)
7578

ggshield/verticals/auth/oauth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ def check_existing_token(self) -> bool:
327327
# Check our API key is valid, if not forget it
328328
client = create_client_from_config(self.config)
329329
try:
330-
check_client_api_key(client)
330+
check_client_api_key(client, set())
331331
except APIKeyCheckError:
332332
# Forget the account
333333
logger.debug(

ggshield/verticals/secret/repo.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
from ggshield.core.text_utils import STYLE, format_text
2121
from ggshield.utils.git_shell import get_list_commit_SHA, is_git_dir
2222
from ggshield.utils.os import cd
23+
from ggshield.verticals.secret.secret_scanner import (
24+
get_required_token_scopes_from_config,
25+
)
2326

2427
from .output import SecretOutputHandler
2528
from .secret_scan_collection import Results, SecretScanCollection
@@ -176,7 +179,7 @@ def scan_commit_range(
176179
:param commit_list: List of commits sha to scan
177180
:param verbose: Display successful scan's message
178181
"""
179-
check_client_api_key(client)
182+
check_client_api_key(client, get_required_token_scopes_from_config(secret_config))
180183
max_documents = client.secret_scan_preferences.maximum_documents_per_scan
181184

182185
with ui.create_progress(len(commit_list) + int(include_staged)) as progress:

ggshield/verticals/secret/secret_scanner.py

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
from typing import Dict, Iterable, List, Optional, Union
88

99
from pygitguardian import GGClient
10-
from pygitguardian.models import APITokensResponse, Detail, MultiScanResult, TokenScope
10+
from pygitguardian.models import Detail, MultiScanResult, TokenScope
1111

1212
from ggshield.core import ui
1313
from ggshield.core.cache import Cache
1414
from ggshield.core.client import check_client_api_key
1515
from ggshield.core.config.user_config import SecretConfig
1616
from ggshield.core.constants import MAX_WORKERS
17-
from ggshield.core.errors import MissingScopesError, UnexpectedError, handle_api_error
17+
from ggshield.core.errors import handle_api_error
1818
from ggshield.core.scan import DecodeError, ScanContext, Scannable
1919
from ggshield.core.scanner_ui.scanner_ui import ScannerUI
2020
from ggshield.core.text_utils import pluralize
@@ -50,7 +50,8 @@ def __init__(
5050
check_api_key: Optional[bool] = True,
5151
):
5252
if check_api_key:
53-
check_client_api_key(client)
53+
scopes = get_required_token_scopes_from_config(secret_config)
54+
check_client_api_key(client, scopes)
5455

5556
self.client = client
5657
self.cache = cache
@@ -60,16 +61,6 @@ def __init__(
6061

6162
self.command_id = scan_context.command_id
6263

63-
if secret_config.with_incident_details:
64-
response = self.client.api_tokens()
65-
66-
if not isinstance(response, (Detail, APITokensResponse)):
67-
raise UnexpectedError("Unexpected api_tokens response")
68-
elif isinstance(response, Detail):
69-
raise UnexpectedError(response.detail)
70-
if TokenScope.INCIDENTS_READ not in response.scopes:
71-
raise MissingScopesError([TokenScope.INCIDENTS_READ])
72-
7364
def scan(
7465
self,
7566
files: Iterable[Scannable],
@@ -109,12 +100,21 @@ def _scan_chunk(
109100
for x in chunk
110101
]
111102

112-
return executor.submit(
113-
self.client.multi_content_scan,
114-
documents,
115-
self.headers,
116-
all_secrets=True,
117-
)
103+
# Use scan_and_create_incidents if source_uuid is provided, otherwise use multi_content_scan
104+
if self.secret_config.source_uuid:
105+
return executor.submit(
106+
self.client.scan_and_create_incidents,
107+
documents,
108+
self.secret_config.source_uuid,
109+
extra_headers=self.headers,
110+
)
111+
else:
112+
return executor.submit(
113+
self.client.multi_content_scan,
114+
documents,
115+
self.headers,
116+
all_secrets=True,
117+
)
118118

119119
def _start_scans(
120120
self,
@@ -230,6 +230,11 @@ def handle_scan_chunk_error(detail: Detail, chunk: List[Scannable]) -> None:
230230
handle_api_error(detail)
231231
details = None
232232

233+
# Handle source_uuid not found error specifically
234+
if "Source not found" in detail.detail:
235+
ui.display_error("The provided source was not found in GitGuardian.")
236+
return
237+
233238
ui.display_error("Scanning failed. Results may be incomplete.")
234239
try:
235240
# try to load as list of dicts to get per file details
@@ -251,5 +256,24 @@ def handle_scan_chunk_error(detail: Detail, chunk: List[Scannable]) -> None:
251256
# if the details had a request error
252257
filenames = "\n".join(f"- {file.filename}" for file in chunk)
253258
ui.display_error(f"The following chunk is affected:\n{filenames}")
254-
255259
ui.display_error(str(detail))
260+
261+
262+
def get_required_token_scopes_from_config(
263+
secret_config: SecretConfig,
264+
) -> set[TokenScope]:
265+
"""
266+
Get the required token scopes based on the secret configuration.
267+
268+
Args:
269+
secret_config: The secret configuration to analyze
270+
271+
Returns:
272+
A set of TokenScope values required for the given configuration
273+
"""
274+
scopes = {TokenScope.SCAN}
275+
if secret_config.with_incident_details:
276+
scopes.add(TokenScope.INCIDENTS_READ)
277+
if secret_config.source_uuid:
278+
scopes.add(TokenScope.SCAN_CREATE_INCIDENTS)
279+
return scopes

pdm.lock

Lines changed: 3 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ dependencies = [
4141
"marshmallow~=3.18.0",
4242
"marshmallow-dataclass~=8.5.8",
4343
"oauthlib~=3.2.1",
44-
"pygitguardian~=1.23.0",
44+
"pygitguardian @ git+https://github.com/GitGuardian/py-gitguardian.git",
4545
"pyjwt~=2.6.0",
4646
"python-dotenv~=0.21.0",
4747
"pyyaml~=6.0.1",

0 commit comments

Comments
 (0)