Make secret redaction optional with a job attribute
This allows jobs to specify the behavior that should occur when a secret is (or would be) exposed. Change-Id: I516fdeb1e5974707f3c577dabc3ec19dc9b8256b
This commit is contained in:
1 parent
e1936136fa
commit
abf7f2e4a8
8 files changed
+124
-8
No files matched your search
@@ -535,6 +535,30 @@ Here is an example of two job definitions:
|
||||
ssh_key:
|
||||
key: decrypted-secret-key-data
|
||||
|
||||
.. attr:: secret-exposure
|
||||
:default: ignore
|
||||
|
||||
Zuul can monitor the output of a build in real time and detect if
|
||||
one of the Zuul secrets has been exposed in either the build's
|
||||
streaming log or JSON log. It does not scan every output or log
|
||||
file, so its effectiveness is limited, but it may detect some
|
||||
common methods of accidental exposure.
|
||||
|
||||
.. value:: ignore
|
||||
|
||||
The default is not to scan for secrets in build output.
|
||||
|
||||
.. value:: redact
|
||||
|
||||
The line containing the secret is replaced with a message
|
||||
indicating a secret would have been exposed. In the JSON
|
||||
output, the entire playbook is replaced with a message.
|
||||
|
||||
.. value:: fail
|
||||
|
||||
In addition to the actions in `redact`, the build is failed
|
||||
and aborted immediately.
|
||||
|
||||
.. attr:: nodeset
|
||||
|
||||
The nodes which should be supplied to the job. This parameter
|
||||
|
||||
@@ -264,3 +264,9 @@ Version 37
|
||||
:Prior Zuul version: 14.0.0
|
||||
:Description: Upgrade to per-project branch cache.
|
||||
Affects schedulers and web.
|
||||
|
||||
Version 38
|
||||
----------
|
||||
:Prior Zuul version: 14.2.0
|
||||
:Description: Add secret_exposure to FrozenJob.
|
||||
Affects schedulers and executors.
|
||||
@@ -3302,6 +3302,30 @@ class TestInRepoConfig(ZuulTestCase):
|
||||
self.assertEqual(30, build.job.timeout)
|
||||
self.assertEqual(30, build.job.pre_timeout)
|
||||
|
||||
def test_secret_exposure(self):
|
||||
in_repo_conf = textwrap.dedent(
|
||||
"""
|
||||
- job:
|
||||
name: parent
|
||||
secret-exposure: fail
|
||||
- job:
|
||||
name: project-test1
|
||||
parent: parent
|
||||
secret-exposure: redact
|
||||
run: playbooks/project-test1.yaml
|
||||
- project:
|
||||
check:
|
||||
jobs: ['project-test1']
|
||||
""")
|
||||
file_dict = {'.zuul.yaml': in_repo_conf}
|
||||
A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
|
||||
files=file_dict)
|
||||
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
|
||||
self.waitUntilSettled()
|
||||
self.assertEqual(A.reported, 1)
|
||||
self.assertHistory([])
|
||||
self.assertIn('Unable to reset secret-exposure', A.messages[0])
|
||||
|
||||
|
||||
class TestInRepoConfigSOS(ZuulTestCase):
|
||||
config_file = 'zuul-connections-gerrit-and-github.conf'
|
||||
|
||||
@@ -407,6 +407,7 @@ class TestWeb(BaseTestWeb):
|
||||
'project': 'org/common-config'},
|
||||
'tags': [],
|
||||
'type': 'regular',
|
||||
'secret_exposure': 'ignore',
|
||||
'pre_timeout': None,
|
||||
'timeout': None,
|
||||
'post_timeout': None,
|
||||
@@ -496,6 +497,7 @@ class TestWeb(BaseTestWeb):
|
||||
'source_context': source_ctx,
|
||||
'tags': [],
|
||||
'type': 'regular',
|
||||
'secret_exposure': 'ignore',
|
||||
'pre_timeout': None,
|
||||
'timeout': None,
|
||||
'post_timeout': None,
|
||||
@@ -558,6 +560,7 @@ class TestWeb(BaseTestWeb):
|
||||
'source_context': source_ctx,
|
||||
'tags': [],
|
||||
'type': 'regular',
|
||||
'secret_exposure': 'ignore',
|
||||
'pre_timeout': None,
|
||||
'timeout': None,
|
||||
'post_timeout': None,
|
||||
@@ -613,6 +616,7 @@ class TestWeb(BaseTestWeb):
|
||||
'source_context': source_ctx,
|
||||
'tags': [],
|
||||
'type': 'regular',
|
||||
'secret_exposure': 'ignore',
|
||||
'pre_timeout': None,
|
||||
'timeout': None,
|
||||
'post_timeout': None,
|
||||
@@ -751,6 +755,7 @@ class TestWeb(BaseTestWeb):
|
||||
'project': 'common-config'},
|
||||
'tags': [],
|
||||
'type': 'regular',
|
||||
'secret_exposure': 'ignore',
|
||||
'pre_timeout': None,
|
||||
'timeout': None,
|
||||
'post_timeout': None,
|
||||
@@ -802,6 +807,7 @@ class TestWeb(BaseTestWeb):
|
||||
'project': 'common-config'},
|
||||
'tags': [],
|
||||
'type': 'regular',
|
||||
'secret_exposure': 'ignore',
|
||||
'pre_timeout': None,
|
||||
'timeout': None,
|
||||
'post_timeout': None,
|
||||
@@ -853,6 +859,7 @@ class TestWeb(BaseTestWeb):
|
||||
'project': 'common-config'},
|
||||
'tags': [],
|
||||
'type': 'regular',
|
||||
'secret_exposure': 'ignore',
|
||||
'pre_timeout': None,
|
||||
'timeout': None,
|
||||
'post_timeout': None,
|
||||
@@ -904,6 +911,7 @@ class TestWeb(BaseTestWeb):
|
||||
'project': 'common-config'},
|
||||
'tags': [],
|
||||
'type': 'regular',
|
||||
'secret_exposure': 'ignore',
|
||||
'pre_timeout': None,
|
||||
'timeout': None,
|
||||
'post_timeout': None,
|
||||
@@ -984,6 +992,7 @@ class TestWeb(BaseTestWeb):
|
||||
'project': 'common-config'},
|
||||
'tags': [],
|
||||
'type': 'regular',
|
||||
'secret_exposure': 'ignore',
|
||||
'pre_timeout': None,
|
||||
'timeout': None,
|
||||
'post_timeout': None,
|
||||
|
||||
@@ -831,6 +831,7 @@ class JobParser(object):
|
||||
'image-build-name': str,
|
||||
'type': vs.Any('regular', 'initializer', 'reporter'),
|
||||
'preserve-home-paths': to_list(str),
|
||||
'secret-exposure': vs.Any('ignore', 'redact', 'fail'),
|
||||
'attribute-control': {
|
||||
vs.Any(
|
||||
'requires',
|
||||
@@ -890,6 +891,7 @@ class JobParser(object):
|
||||
'deduplicate',
|
||||
'image-build-name',
|
||||
'type',
|
||||
'secret-exposure',
|
||||
]
|
||||
|
||||
attr_control_job_attr_map = {
|
||||
|
||||
+40
-6
@@ -558,7 +558,7 @@ class LogReceiver:
|
||||
TYPE_SECRETS = 3
|
||||
TYPE_SYNC = 4
|
||||
|
||||
def __init__(self, zuul_event_id, build, jobdir, stop_conn):
|
||||
def __init__(self, zuul_event_id, build, jobdir, stop_conn, behavior):
|
||||
self.jobdir = jobdir
|
||||
self.build = build
|
||||
self.log = get_annotated_logger(
|
||||
@@ -572,6 +572,7 @@ class LogReceiver:
|
||||
self.job_output_file = None
|
||||
self.stop_conn = stop_conn
|
||||
self.secrets = []
|
||||
self.behavior = behavior
|
||||
|
||||
def start(self):
|
||||
self.log.debug("Starting log receiver at %s", self.jobdir.log_socket)
|
||||
@@ -586,21 +587,38 @@ class LogReceiver:
|
||||
for secret in self.secrets:
|
||||
if secret in message:
|
||||
self.log.error("Secret detected in output")
|
||||
# TODO: stop job
|
||||
return True
|
||||
|
||||
def _addLineToJobOutput(self, message):
|
||||
# Only used within the subprocess
|
||||
if self._containsSecrets(message):
|
||||
return
|
||||
stop = False
|
||||
if self.behavior != 'ignore':
|
||||
if self._containsSecrets(message):
|
||||
message = "[Zuul] Secret detected in output"
|
||||
if self.behavior == 'fail':
|
||||
stop = True
|
||||
output = self.job_output_file
|
||||
output.write(message + '\n')
|
||||
output.flush()
|
||||
if stop:
|
||||
self._stopJob()
|
||||
return
|
||||
|
||||
def _addPlaybookToJson(self, message):
|
||||
# Only used within the subprocess
|
||||
if self._containsSecrets(message):
|
||||
return
|
||||
stop = False
|
||||
if self.behavior != 'ignore':
|
||||
if self._containsSecrets(message):
|
||||
try:
|
||||
message = self._makeFakePlaybook(
|
||||
message,
|
||||
"[Zuul] Secret detected in output"
|
||||
)
|
||||
except Exception:
|
||||
self.log.exception("Error creating fake playbook")
|
||||
return
|
||||
if self.behavior == 'fail':
|
||||
stop = True
|
||||
output = self.json_output_file
|
||||
file_len = output.seek(0, os.SEEK_END)
|
||||
if file_len == 0:
|
||||
@@ -614,6 +632,21 @@ class LogReceiver:
|
||||
output.write(message)
|
||||
output.write('\n]\n')
|
||||
output.flush()
|
||||
if stop:
|
||||
self._stopJob()
|
||||
return
|
||||
|
||||
def _makeFakePlaybook(self, data, message):
|
||||
playbook_info = json.loads(data)
|
||||
playbook_info['plays'] = [{
|
||||
'play': {
|
||||
'name': message
|
||||
},
|
||||
'tasks': []
|
||||
}]
|
||||
data = json.dumps(playbook_info,
|
||||
indent=4, sort_keys=True, separators=(',', ': '))
|
||||
return data
|
||||
|
||||
def _setSecrets(self, message):
|
||||
self.secrets = json.loads(message)
|
||||
@@ -1494,6 +1527,7 @@ class AnsibleJob(object):
|
||||
build=self.build_request.uuid,
|
||||
jobdir=self.jobdir,
|
||||
stop_conn=self.executor_server.stop_worker_parent_conn,
|
||||
behavior=self.job.secret_exposure,
|
||||
)
|
||||
self.log_receiver.start()
|
||||
|
||||
|
||||
+18
-1
@@ -4045,6 +4045,7 @@ class FrozenJob(zkobject.ZKObject):
|
||||
'image_build_name',
|
||||
'include_vars',
|
||||
'type',
|
||||
'secret_exposure',
|
||||
)
|
||||
|
||||
job_data_attributes = ('artifact_data',
|
||||
@@ -4235,6 +4236,9 @@ class FrozenJob(zkobject.ZKObject):
|
||||
data.setdefault('include_projects', None)
|
||||
data.setdefault('exclude_projects', None)
|
||||
|
||||
# MODEL_API <= 38
|
||||
data.setdefault('secret_exposure', 'ignore')
|
||||
|
||||
for job_data_key in self.job_data_attributes:
|
||||
job_data = data.pop(job_data_key, None)
|
||||
if job_data:
|
||||
@@ -4542,6 +4546,7 @@ class Job(ConfigObject):
|
||||
d['failure_output'] = self.failure_output
|
||||
d['image_build_name'] = self.image_build_name
|
||||
d['type'] = self.type
|
||||
d['secret_exposure'] = self.secret_exposure
|
||||
d['include_vars'] = list(map(lambda x: x.toDict(), self.include_vars))
|
||||
if self.isBase():
|
||||
d['parent'] = None
|
||||
@@ -4632,6 +4637,7 @@ class Job(ConfigObject):
|
||||
image_build_name=None,
|
||||
type='regular',
|
||||
preserve_home_paths=(),
|
||||
secret_exposure='ignore',
|
||||
)
|
||||
|
||||
override_control = defaultdict(lambda: True)
|
||||
@@ -5474,7 +5480,8 @@ class Job(ConfigObject):
|
||||
'required_projects', 'include_projects',
|
||||
'exclude_projects', 'allowed_projects',
|
||||
'semaphores', 'failure_output',
|
||||
'include_vars', 'preserve_home_paths']):
|
||||
'include_vars', 'preserve_home_paths',
|
||||
'secret_exposure']):
|
||||
setattr(self, k, other._get(k))
|
||||
|
||||
# Don't set final above so that we don't trip an error halfway
|
||||
@@ -5525,6 +5532,16 @@ class Job(ConfigObject):
|
||||
# Freeze the nodeset
|
||||
self.nodeset = self.getNodeset(layout)
|
||||
|
||||
# Once set, the secret-exposure setting may not be changed
|
||||
if secret_exposure := other._get('secret_exposure'):
|
||||
if (self._get('secret_exposure') and
|
||||
secret_exposure != self._get('secret_exposure')):
|
||||
raise JobConfigurationError(
|
||||
"Unable to reset secret-exposure attribute of job"
|
||||
" %s by job %s" % (
|
||||
repr(self), repr(other)))
|
||||
self.secret_exposure = secret_exposure
|
||||
|
||||
# Pass secrets to parents
|
||||
secrets_for_parents = [s for s in other.secrets if s.pass_to_parent]
|
||||
if secrets_for_parents:
|
||||
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
|
||||
# When making ZK schema changes, increment this and add a record to
|
||||
# doc/source/developer/model-changelog.rst
|
||||
MODEL_API = 37
|
||||
MODEL_API = 38
|
||||
Reference in new issue
Block a user