Skip to content

Commit b21c5f8

Browse files
authored
Merge pull request #248 from Yelp/easy-plugin-development
Easy plugin development
2 parents c6d8cac + 4656c42 commit b21c5f8

22 files changed

+368
-355
lines changed

Diff for: CONTRIBUTING.md

+8-12
Original file line numberDiff line numberDiff line change
@@ -78,23 +78,19 @@ There are many examples of existing plugins to reference, under
7878
Be sure to write comments about **why** your particular regex was crafted
7979
as it is!
8080

81-
3. Register your plugin
82-
83-
Once your plugin is written and tested, you need to register it so that
84-
it can be disabled if other users don't need it. Be sure to add it to
85-
`detect_secrets.core.usage.PluginOptions` as a new option for users to
86-
use.
87-
88-
Check out the following PRs for examples:
89-
- https://github.com/Yelp/detect-secrets/pull/74/files
90-
- https://github.com/Yelp/detect-secrets/pull/157/files
91-
92-
4. Update documentation
81+
3. Update documentation
9382

9483
Be sure to add your changes to the `README.md` and `CHANGELOG.md` so that
9584
it will be easier for maintainers to bump the version and for other
9685
downstream consumers to get the latest information about plugins available.
9786

87+
### Tips
88+
89+
- There should be a total of three modified files in a minimal new plugin: the
90+
plugin file, it's corresponding test, and an updated README.
91+
- If your plugin uses customizable options (e.g. entropy limit in `HighEntropyStrings`)
92+
be sure to add default options to the plugin's `default_options`.
93+
9894
## Running Tests
9995

10096
### Running the Entire Test Suite

Diff for: detect_secrets/core/baseline.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def initialize(
6565
elif os.path.isfile(element):
6666
files_to_scan.append(element)
6767
else:
68-
log.error('detect-secrets: ' + element + ': No such file or directory')
68+
log.error('detect-secrets: %s: No such file or directory', element)
6969

7070
if not files_to_scan:
7171
return output

Diff for: detect_secrets/core/usage.py

+37-65
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections import namedtuple
55

66
from detect_secrets import VERSION
7+
from detect_secrets.plugins.common.util import import_plugins
78

89

910
def add_exclude_lines_argument(parser):
@@ -279,7 +280,6 @@ class PluginDescriptor(
279280
],
280281
),
281282
):
282-
283283
def __new__(cls, related_args=None, **kwargs):
284284
if not related_args:
285285
related_args = []
@@ -290,74 +290,46 @@ def __new__(cls, related_args=None, **kwargs):
290290
**kwargs
291291
)
292292

293+
@classmethod
294+
def from_plugin_class(cls, plugin, name):
295+
"""
296+
:type plugin: Type[TypeVar('Plugin', bound=BasePlugin)]
297+
:type name: str
298+
"""
299+
related_args = None
300+
if plugin.default_options:
301+
related_args = []
302+
for arg_name, value in plugin.default_options.items():
303+
related_args.append((
304+
'--{}'.format(arg_name.replace('_', '-')),
305+
value,
306+
))
307+
308+
return cls(
309+
classname=name,
310+
disable_flag_text='--{}'.format(plugin.disable_flag_text),
311+
disable_help_text=cls.get_disabled_help_text(plugin),
312+
related_args=related_args,
313+
)
314+
315+
@staticmethod
316+
def get_disabled_help_text(plugin):
317+
for line in plugin.__doc__.splitlines():
318+
line = line.strip().lstrip()
319+
if line:
320+
break
321+
else:
322+
raise NotImplementedError('Plugins must declare a docstring.')
323+
324+
line = line[0].lower() + line[1:]
325+
return 'Disables {}'.format(line)
326+
293327

294328
class PluginOptions(object):
295329

296330
all_plugins = [
297-
PluginDescriptor(
298-
classname='HexHighEntropyString',
299-
disable_flag_text='--no-hex-string-scan',
300-
disable_help_text='Disables scanning for hex high entropy strings',
301-
related_args=[
302-
('--hex-limit', 3),
303-
],
304-
),
305-
PluginDescriptor(
306-
classname='Base64HighEntropyString',
307-
disable_flag_text='--no-base64-string-scan',
308-
disable_help_text='Disables scanning for base64 high entropy strings',
309-
related_args=[
310-
('--base64-limit', 4.5),
311-
],
312-
),
313-
PluginDescriptor(
314-
classname='PrivateKeyDetector',
315-
disable_flag_text='--no-private-key-scan',
316-
disable_help_text='Disables scanning for private keys.',
317-
),
318-
PluginDescriptor(
319-
classname='BasicAuthDetector',
320-
disable_flag_text='--no-basic-auth-scan',
321-
disable_help_text='Disables scanning for Basic Auth formatted URIs.',
322-
),
323-
PluginDescriptor(
324-
classname='KeywordDetector',
325-
disable_flag_text='--no-keyword-scan',
326-
disable_help_text='Disables scanning for secret keywords.',
327-
related_args=[
328-
('--keyword-exclude', None),
329-
],
330-
),
331-
PluginDescriptor(
332-
classname='AWSKeyDetector',
333-
disable_flag_text='--no-aws-key-scan',
334-
disable_help_text='Disables scanning for AWS keys.',
335-
),
336-
PluginDescriptor(
337-
classname='SlackDetector',
338-
disable_flag_text='--no-slack-scan',
339-
disable_help_text='Disables scanning for Slack tokens.',
340-
),
341-
PluginDescriptor(
342-
classname='ArtifactoryDetector',
343-
disable_flag_text='--no-artifactory-scan',
344-
disable_help_text='Disable scanning for Artifactory credentials',
345-
),
346-
PluginDescriptor(
347-
classname='StripeDetector',
348-
disable_flag_text='--no-stripe-scan',
349-
disable_help_text='Disable scanning for Stripe keys',
350-
),
351-
PluginDescriptor(
352-
classname='MailchimpDetector',
353-
disable_flag_text='--no-mailchimp-scan',
354-
disable_help_text='Disable scanning for Mailchimp keys',
355-
),
356-
PluginDescriptor(
357-
classname='JwtTokenDetector',
358-
disable_flag_text='--no-jwt-scan',
359-
disable_help_text='Disable scanning for JWTs',
360-
),
331+
PluginDescriptor.from_plugin_class(plugin, name)
332+
for name, plugin in import_plugins().items()
361333
]
362334

363335
def __init__(self, parser):

Diff for: detect_secrets/plugins/artifactory.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77

88
class ArtifactoryDetector(RegexBasedDetector):
9-
9+
"""Scans for Artifactory credentials."""
1010
secret_type = 'Artifactory Credentials'
1111

1212
denylist = [

Diff for: detect_secrets/plugins/aws.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,23 @@
1212

1313
import requests
1414

15+
from .base import classproperty
1516
from .base import RegexBasedDetector
1617
from detect_secrets.core.constants import VerifiedResult
1718

1819

1920
class AWSKeyDetector(RegexBasedDetector):
20-
21+
"""Scans for AWS keys."""
2122
secret_type = 'AWS Access Key'
2223

2324
denylist = (
2425
re.compile(r'AKIA[0-9A-Z]{16}'),
2526
)
2627

28+
@classproperty
29+
def disable_flag_text(cls):
30+
return 'no-aws-key-scan'
31+
2732
def verify(self, token, content):
2833
secret_access_key_candidates = get_secret_access_keys(content)
2934
if not secret_access_key_candidates:

Diff for: detect_secrets/plugins/base.py

+48-9
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,39 @@
1919
LINES_OF_CONTEXT = 5
2020

2121

22+
class classproperty(property):
23+
def __get__(self, cls, owner):
24+
return classmethod(self.fget).__get__(None, owner)()
25+
26+
2227
class BasePlugin(object):
23-
"""This is an abstract class to define Plugins API"""
28+
"""
29+
This is an abstract class to define Plugins API.
30+
31+
:type secret_type: str
32+
:param secret_type: uniquely identifies the type of secret found in the baseline.
33+
e.g. {
34+
"hashed_secret": <hash>,
35+
"line_number": 123,
36+
"type": <secret_type>,
37+
}
38+
39+
Be warned of modifying the `secret_type` once rolled out to clients since
40+
the hashed_secret uses this value to calculate a unique hash (and the baselines
41+
will no longer match).
2442
43+
:type disable_flag_text: str
44+
:param disable_flag_text: text used as an command line argument flag to disable
45+
this specific plugin scan. does not include the `--` prefix.
46+
47+
:type default_options: Dict[str, Any]
48+
:param default_options: configurable options to modify plugin behavior
49+
"""
2550
__metaclass__ = ABCMeta
2651

27-
secret_type = None
52+
@abstractproperty
53+
def secret_type(self):
54+
raise NotImplementedError
2855

2956
def __init__(self, exclude_lines_regex=None, should_verify=False, **kwargs):
3057
"""
@@ -33,15 +60,31 @@ def __init__(self, exclude_lines_regex=None, should_verify=False, **kwargs):
3360
3461
:type should_verify: bool
3562
"""
36-
if not self.secret_type:
37-
raise ValueError('Plugins need to declare a secret_type.')
38-
3963
self.exclude_lines_regex = None
4064
if exclude_lines_regex:
4165
self.exclude_lines_regex = re.compile(exclude_lines_regex)
4266

4367
self.should_verify = should_verify
4468

69+
@classproperty
70+
def disable_flag_text(cls):
71+
name = cls.__name__
72+
if name.endswith('Detector'):
73+
name = name[:-len('Detector')]
74+
75+
# turn camel case into hyphenated strings
76+
name_hyphen = ''
77+
for letter in name:
78+
if letter.upper() == letter and name_hyphen:
79+
name_hyphen += '-'
80+
name_hyphen += letter.lower()
81+
82+
return 'no-{}-scan'.format(name_hyphen)
83+
84+
@classproperty
85+
def default_options(cls):
86+
return {}
87+
4588
def analyze(self, file, filename):
4689
"""
4790
:param file: The File object itself.
@@ -213,10 +256,6 @@ class FooDetector(RegexBasedDetector):
213256
"""
214257
__metaclass__ = ABCMeta
215258

216-
@abstractproperty
217-
def secret_type(self):
218-
raise NotImplementedError
219-
220259
@abstractproperty
221260
def denylist(self):
222261
raise NotImplementedError

Diff for: detect_secrets/plugins/basic_auth.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616

1717
class BasicAuthDetector(RegexBasedDetector):
18-
18+
"""Scans for Basic Auth formatted URIs."""
1919
secret_type = 'Basic Auth Credentials'
2020

2121
denylist = [

Diff for: detect_secrets/plugins/common/initialize.py

+7-20
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
11
"""Intelligent initialization of plugins."""
2-
from ..artifactory import ArtifactoryDetector # noqa: F401
3-
from ..aws import AWSKeyDetector # noqa: F401
4-
from ..base import BasePlugin
5-
from ..basic_auth import BasicAuthDetector # noqa: F401
6-
from ..common.util import get_mapping_from_secret_type_to_class_name
7-
from ..high_entropy_strings import Base64HighEntropyString # noqa: F401
8-
from ..high_entropy_strings import HexHighEntropyString # noqa: F401
9-
from ..jwt import JwtTokenDetector # noqa: F401
10-
from ..keyword import KeywordDetector # noqa: F401
11-
from ..mailchimp import MailchimpDetector # noqa: F401
12-
from ..private_key import PrivateKeyDetector # noqa: F401
13-
from ..slack import SlackDetector # noqa: F401
14-
from ..stripe import StripeDetector # noqa: F401
2+
from .util import get_mapping_from_secret_type_to_class_name
3+
from .util import import_plugins
154
from detect_secrets.core.log import log
165
from detect_secrets.core.usage import PluginOptions
176

@@ -173,10 +162,10 @@ def from_plugin_classname(
173162
174163
:type should_verify_secrets: bool
175164
"""
176-
klass = globals()[plugin_classname]
177-
178-
# Make sure the instance is a BasePlugin type, before creating it.
179-
if not issubclass(klass, BasePlugin): # pragma: no cover
165+
try:
166+
klass = import_plugins()[plugin_classname]
167+
except KeyError:
168+
log.warning('No such plugin to initialize.')
180169
raise TypeError
181170

182171
try:
@@ -187,9 +176,7 @@ def from_plugin_classname(
187176
**kwargs
188177
)
189178
except TypeError:
190-
log.warning(
191-
'Unable to initialize plugin!',
192-
)
179+
log.warning('Unable to initialize plugin!')
193180
raise
194181

195182
return instance

0 commit comments

Comments
 (0)