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:
James E. Blair committed 2026-08-18 14:57:07 -07:00
1 parent e1936136fa
commit abf7f2e4a8
8 files changed
+124 -8

No files matched your search

+24
View File
@@ -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
+6
View File
@@ -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.
+24
View File
@@ -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'
+9
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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