Revert "Revert log redaction changes"
This reverts commit de6899c7c4.
This adds the log redaction stack back in one change so that we
can improve upon it.
Change-Id: I3a59a5b4fb18a15a6c89667e7ea434f3846a353a
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1722,7 +1722,14 @@ sensitive data must be provided to dependent jobs, the ``secret_data``
|
||||
attribute may be used instead, and the data will be provided via the
|
||||
same mechanism as job secrets, where the data are not written to disk
|
||||
in the work directory. Care must still be taken to avoid displaying
|
||||
or storing sensitive data within the job. For example:
|
||||
or storing sensitive data within the job.
|
||||
|
||||
If a job is configured with :attr:`job.secret-exposure` to redact or
|
||||
fail if a secret is exposed, any data returned using ``secret_data``
|
||||
will be automatically included in the list of secrets that Zuul
|
||||
searches for, starting with the next playbook.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
@@ -1923,6 +1930,36 @@ For example the following would skip retrying the build:
|
||||
zuul:
|
||||
retry: false
|
||||
|
||||
Adding redactions
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
If a job is configured with :attr:`job.secret-exposure` to redact or
|
||||
fail if a secret is exposed, the job may add additional confidental
|
||||
data for the detection system to use. This may be useful for jobs
|
||||
that generate or obtain a token from an external source. Confidential
|
||||
data may be registered with Zuul at the end of the playbook where it
|
||||
was obtained using *zuul_return*. Zuul will search for it as it does
|
||||
other secrets beginning with the next playbook (but not the playbook
|
||||
that registers the data).
|
||||
|
||||
Any secrets in secret_data are automatically added and do not need to
|
||||
be explicitly registered.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
tasks:
|
||||
- zuul_return:
|
||||
data:
|
||||
zuul:
|
||||
redactions:
|
||||
- my_secret_token
|
||||
|
||||
If *zuul_return* is invoked multiple times (e.g., via multiple
|
||||
playbooks), then the elements of **zuul.redactions** from each
|
||||
invocation will be appended.
|
||||
|
||||
.. _build_status:
|
||||
|
||||
Ansible Groups
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
|
||||
- name: Save output
|
||||
shell: |
|
||||
mv job-output.txt job-output-success-19887.txt
|
||||
mv job-output.json job-output-success-19887.json
|
||||
mv test-job-output.txt job-output-success-19887.txt
|
||||
mv test-job-output.json job-output-success-19887.json
|
||||
|
||||
# Streamer puts out a line like
|
||||
# [node1] Starting to log 916b2084-4bbb-80e5-248e-000000000016-1-node1 for task TASK: Print binary data
|
||||
@@ -71,8 +71,8 @@
|
||||
|
||||
- name: Save output
|
||||
shell: |
|
||||
mv job-output.txt job-output-success-19885.txt
|
||||
mv job-output.json job-output-success-19885.json
|
||||
mv test-job-output.txt job-output-success-19885.txt
|
||||
mv test-job-output.json job-output-success-19885.json
|
||||
|
||||
- name: Validate text outputs
|
||||
include_tasks: validate.yaml
|
||||
@@ -113,8 +113,8 @@
|
||||
|
||||
- name: Save output
|
||||
shell: |
|
||||
mv job-output.txt job-output-failure.txt
|
||||
mv job-output.json job-output-failure.json
|
||||
mv test-job-output.txt job-output-failure.txt
|
||||
mv test-job-output.json job-output-failure.json
|
||||
|
||||
- name: Validate output - failure shell task with exception
|
||||
shell: |
|
||||
|
||||
@@ -23,7 +23,18 @@ def main():
|
||||
output_txt_path = sys.argv[2]
|
||||
output_json_path = sys.argv[3]
|
||||
with open(output_json_path) as f:
|
||||
output_json = json.loads(f.read())
|
||||
# Our test logging configuration just outputs json blobs for
|
||||
# each playbook and does not assemble them into an array like
|
||||
# the log receiver does; therefore we must do that here.
|
||||
data = '['
|
||||
first = True
|
||||
for line in f:
|
||||
if not first and line == '{':
|
||||
line = ',' + line
|
||||
first = False
|
||||
data += line
|
||||
data += ']'
|
||||
output_json = json.loads(data)
|
||||
with open(output_txt_path) as f:
|
||||
output_txt = f.read()
|
||||
with open(console_path) as f:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Zuul can now optionally 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. See
|
||||
:attr:`job.secret-exposure`.
|
||||
+14
-2
@@ -847,7 +847,8 @@ class RecordingAnsibleJob(zuul.executor.server.AnsibleJob):
|
||||
|
||||
self.result, unreachable, error_detail = super(
|
||||
RecordingAnsibleJob, self).runPlaybooks(args)
|
||||
self.recordResult(self.result)
|
||||
if not self.executor_server._new_result_behavior:
|
||||
self.recordResult(self.result)
|
||||
return self.result, unreachable, error_detail
|
||||
|
||||
def runAnsible(self, cmd, timeout, playbook, ansible_version,
|
||||
@@ -901,7 +902,8 @@ class RecordingAnsibleJob(zuul.executor.server.AnsibleJob):
|
||||
super().resume()
|
||||
|
||||
def _send_aborted(self):
|
||||
self.recordResult('ABORTED')
|
||||
if not self.executor_server._new_result_behavior:
|
||||
self.recordResult('ABORTED')
|
||||
super()._send_aborted()
|
||||
|
||||
|
||||
@@ -1115,6 +1117,7 @@ class RecordingExecutorServer(zuul.executor.server.ExecutorServer):
|
||||
|
||||
_job_class = RecordingAnsibleJob
|
||||
_merger_api_class = TestingMergerApi
|
||||
_new_result_behavior = False
|
||||
|
||||
def __init__(self, *args, **kw):
|
||||
self._run_ansible = kw.pop('_run_ansible', False)
|
||||
@@ -1236,6 +1239,15 @@ class RecordingExecutorServer(zuul.executor.server.ExecutorServer):
|
||||
build.release()
|
||||
super(RecordingExecutorServer, self).stop()
|
||||
|
||||
def completeBuild(self, build_request, result_data):
|
||||
# TODO: change all existing tests to use this behavior since
|
||||
# it more accurately reflects what the scheduler sees.
|
||||
if self._new_result_behavior:
|
||||
job_worker = self.job_workers.get(build_request.uuid)
|
||||
job_worker.recordResult(result_data['result'])
|
||||
super(RecordingExecutorServer, self).completeBuild(
|
||||
build_request, result_data)
|
||||
|
||||
|
||||
class TestScheduler(zuul.scheduler.Scheduler):
|
||||
_merger_client_class = HoldableMergeClient
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
- hosts: all
|
||||
tasks:
|
||||
- debug:
|
||||
msg: "Username: {{ test_secret.username }}"
|
||||
|
||||
- hosts: all
|
||||
tasks:
|
||||
- debug:
|
||||
msg: "Password: {{ test_secret.password }}"
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
- hosts: all
|
||||
tasks:
|
||||
- set_fact:
|
||||
output: "Username: {{ test_secret.username }}"
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
- hosts: all
|
||||
tasks:
|
||||
- set_fact:
|
||||
output: "Password: {{ test_secret.complex_data.password }}"
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
- hosts: all
|
||||
tasks:
|
||||
- shell: |
|
||||
echo "Okay to expose this: test-password"
|
||||
- zuul_return:
|
||||
data:
|
||||
zuul:
|
||||
redactions:
|
||||
- zuul-return-redaction-1
|
||||
- zuul_return:
|
||||
data:
|
||||
zuul:
|
||||
redactions:
|
||||
- zuul-return-redaction-2
|
||||
secret_data:
|
||||
complex:
|
||||
data: zuul-return-redaction-3
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
- hosts: all
|
||||
tasks:
|
||||
- shell: |
|
||||
echo "Username: {{ test_secret.username }}"
|
||||
- shell: |
|
||||
echo "Password: {{ test_secret.complex_data.password }}"
|
||||
- shell: |
|
||||
echo "OIDC: {{ oidc_secret }}"
|
||||
- shell: |
|
||||
echo "Zuul return: zuul-return-redaction-1"
|
||||
- shell: |
|
||||
echo "Zuul return: zuul-return-redaction-2"
|
||||
- shell: |
|
||||
echo "Zuul return: zuul-return-redaction-3"
|
||||
@@ -97,6 +97,82 @@
|
||||
files:
|
||||
- postsyntax
|
||||
|
||||
- secret:
|
||||
name: test-secret
|
||||
data:
|
||||
username: test-username
|
||||
complex_data:
|
||||
password: !encrypted/pkcs1-oaep |
|
||||
BFhtdnm8uXx7kn79RFL/zJywmzLkT1GY78P3bOtp4WghUFWobkifSu7ZpaV4NeO0s71Y
|
||||
Usi1wGZZL0LveZjUN0t6OU1VZKSG8R5Ly7urjaSo1pPVIq5Rtt/H7W14Lecd+cUeKb4j
|
||||
oeusC9drN3AA8a4oykcVpt1wVqUnTbMGC9ARMCQP6eopcs1l7tzMseprW4RDNhIuz3CR
|
||||
gd0QBMPl6VDoFgBPB8vxtJw+3m0rqBYZCLZgCXekqlny8s2s92nJMuUABbJOEcDRarzi
|
||||
bDsSXsfJt1y+5n7yOURsC7lovMg4GF/vCl/0YMKjBO5bpv9EM5fToeKYyPGSKQoHOnCY
|
||||
ceb3cAVcv5UawcCic8XjhEhp4K7WPdYf2HVAC/qtxhbpjTxG4U5Q/SoppOJ60WqEkQvb
|
||||
Xs6n5Dvy7xmph6GWmU/bAv3eUK3pdD3xa2Ue1lHWz3U+rsYraI+AKYsMYx3RBlfAmCeC
|
||||
1ve2BXPrqnOo7G8tnUvfdYPbK4Aakk0ds/AVqFHEZN+S6hRBmBjLaRFWZ3QSO1NjbBxW
|
||||
naHKZYT7nkrJm8AMCgZU0ZArFLpaufKCeiK5ECSsDxic4FIsY1OkWT42qEUfL0Wd+150
|
||||
AKGNZpPJnnP3QYY4W/MWcKH/zdO400+zWN52WevbSqZy90tqKDJrBkMl1ydqbuw1E4ZH
|
||||
vIs=
|
||||
|
||||
- secret:
|
||||
name: oidc-secret
|
||||
oidc:
|
||||
|
||||
- job:
|
||||
name: expose-secrets-parent
|
||||
pre-run: playbooks/expose-secrets-parent.yaml
|
||||
|
||||
- job:
|
||||
name: expose-secrets-txt
|
||||
parent: expose-secrets-parent
|
||||
run: playbooks/expose-secrets-txt.yaml
|
||||
secrets:
|
||||
- name: test_secret
|
||||
secret: test-secret
|
||||
- name: oidc_secret
|
||||
secret: oidc-secret
|
||||
|
||||
- job:
|
||||
name: expose-secrets-json
|
||||
parent: expose-secrets-parent
|
||||
run:
|
||||
- playbooks/expose-secrets-json1.yaml
|
||||
- playbooks/expose-secrets-json2.yaml
|
||||
secrets:
|
||||
- name: test_secret
|
||||
secret: test-secret
|
||||
|
||||
- job:
|
||||
name: expose-secrets-txt-ignore
|
||||
parent: expose-secrets-txt
|
||||
secret-exposure: ignore
|
||||
|
||||
- job:
|
||||
name: expose-secrets-json-ignore
|
||||
parent: expose-secrets-json
|
||||
secret-exposure: ignore
|
||||
|
||||
- job:
|
||||
name: expose-secrets-txt-redact
|
||||
parent: expose-secrets-txt
|
||||
secret-exposure: redact
|
||||
|
||||
- job:
|
||||
name: expose-secrets-json-redact
|
||||
parent: expose-secrets-json
|
||||
secret-exposure: redact
|
||||
|
||||
- job:
|
||||
name: expose-secrets-txt-fail
|
||||
parent: expose-secrets-txt
|
||||
secret-exposure: fail
|
||||
|
||||
- job:
|
||||
name: expose-secrets-json-fail
|
||||
parent: expose-secrets-json
|
||||
secret-exposure: fail
|
||||
|
||||
- project:
|
||||
name: org/project
|
||||
check:
|
||||
@@ -138,3 +214,14 @@
|
||||
- pre-syntax
|
||||
- run-syntax
|
||||
- post-syntax
|
||||
|
||||
- project:
|
||||
name: org/project7
|
||||
check:
|
||||
jobs:
|
||||
- expose-secrets-txt-ignore
|
||||
- expose-secrets-json-ignore
|
||||
- expose-secrets-txt-redact
|
||||
- expose-secrets-json-redact
|
||||
- expose-secrets-txt-fail
|
||||
- expose-secrets-json-fail
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
test
|
||||
+1
@@ -11,3 +11,4 @@
|
||||
- org/project4
|
||||
- org/project5
|
||||
- org/project6
|
||||
- org/project7
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
- hosts: localhost
|
||||
tasks:
|
||||
- command: ln -s -f /etc/passwd "{{ zuul.executor.log_root }}/job-output.txt"
|
||||
# Pause the job for the side-effect of the executor writing to the
|
||||
# job-output.txt file.
|
||||
- zuul_return:
|
||||
data:
|
||||
zuul:
|
||||
pause: true
|
||||
@@ -1,20 +0,0 @@
|
||||
- pipeline:
|
||||
name: check
|
||||
manager: independent
|
||||
trigger:
|
||||
gerrit:
|
||||
- event: patchset-created
|
||||
- event: comment-added
|
||||
comment: '^(Patch Set [0-9]+:\n\n)?(?i:recheck)$'
|
||||
success:
|
||||
gerrit:
|
||||
Verified: 1
|
||||
failure:
|
||||
gerrit:
|
||||
Verified: -1
|
||||
|
||||
- job:
|
||||
name: base
|
||||
run: playbooks/run.yaml
|
||||
parent: null
|
||||
attempts: 1
|
||||
@@ -1,7 +0,0 @@
|
||||
- job:
|
||||
name: testjob
|
||||
|
||||
- project:
|
||||
check:
|
||||
jobs:
|
||||
- testjob
|
||||
@@ -1,8 +0,0 @@
|
||||
- tenant:
|
||||
name: tenant-one
|
||||
source:
|
||||
gerrit:
|
||||
config-projects:
|
||||
- common-config
|
||||
untrusted-projects:
|
||||
- org/project
|
||||
@@ -9691,22 +9691,23 @@ class TestSchedulerFailFast(ZuulTestCase):
|
||||
# Release the failing build first so it is the first
|
||||
# result event to be processed.
|
||||
job_workers = self.executor_server.job_workers.copy()
|
||||
released = self.executor_server.release('project-test1')
|
||||
for _ in iterate_timeout(10, 'project-test1 to be released'):
|
||||
if len(self.builds) == 1:
|
||||
break
|
||||
fake_build = released[0]
|
||||
job = job_workers.get(fake_build.build_request.uuid)
|
||||
job.wait()
|
||||
# Release successful build and wait for it to be gone,
|
||||
# so both result events are processed in the same iteration.
|
||||
released = self.executor_server.release('project-test2')
|
||||
for _ in iterate_timeout(10, 'project-test2 to be released'):
|
||||
if len(self.builds) == 0:
|
||||
break
|
||||
fake_build = released[0]
|
||||
job = job_workers.get(fake_build.build_request.uuid)
|
||||
job.wait()
|
||||
with self.scheds.first.sched.run_handler_lock:
|
||||
released = self.executor_server.release('project-test1')
|
||||
for _ in iterate_timeout(10, 'project-test1 to be released'):
|
||||
if len(self.builds) == 1:
|
||||
break
|
||||
fake_build = released[0]
|
||||
job = job_workers.get(fake_build.build_request.uuid)
|
||||
job.wait()
|
||||
# Release successful build and wait for it to be gone,
|
||||
# so both result events are processed in the same iteration.
|
||||
released = self.executor_server.release('project-test2')
|
||||
for _ in iterate_timeout(10, 'project-test2 to be released'):
|
||||
if len(self.builds) == 0:
|
||||
break
|
||||
fake_build = released[0]
|
||||
job = job_workers.get(fake_build.build_request.uuid)
|
||||
job.wait()
|
||||
|
||||
self.executor_server.hold_jobs_in_build = False
|
||||
self.fake_nodepool.unpause()
|
||||
|
||||
+148
-14
@@ -15,6 +15,7 @@
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import getpass
|
||||
import io
|
||||
import json
|
||||
import jwt
|
||||
@@ -38,6 +39,7 @@ import git
|
||||
import paramiko
|
||||
|
||||
import zuul.configloader
|
||||
from zuul.driver import bubblewrap
|
||||
from zuul.lib import yamlutil as yaml
|
||||
from zuul.model import MergeRequest, SEVERITY_WARNING
|
||||
from zuul.zk.blob_store import BlobStore
|
||||
@@ -3302,6 +3304,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'
|
||||
@@ -9163,6 +9189,8 @@ class TestSecretLeaks(AnsibleZuulTestCase):
|
||||
matches = []
|
||||
for (dirpath, dirnames, filenames) in os.walk(path):
|
||||
for filename in filenames:
|
||||
if filename == 'log.socket':
|
||||
continue
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
with open(filepath, 'rb') as f:
|
||||
if content in f.read():
|
||||
@@ -9247,6 +9275,8 @@ class TestParseErrors(AnsibleZuulTestCase):
|
||||
matches = []
|
||||
for (dirpath, dirnames, filenames) in os.walk(path):
|
||||
for filename in filenames:
|
||||
if filename == 'log.socket':
|
||||
continue
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
with open(filepath, 'rb') as f:
|
||||
if content in f.read():
|
||||
@@ -9555,6 +9585,17 @@ class TestJobOutput(AnsibleZuulTestCase):
|
||||
with open(p) as f:
|
||||
return f.read()
|
||||
|
||||
def _getSecrets(self, job, pbtype):
|
||||
secrets = []
|
||||
build = self.getJobFromHistory(job)
|
||||
for pb in getattr(build.jobdir, pbtype):
|
||||
if pb.secrets_content:
|
||||
secrets.append(
|
||||
yamlutil.ansible_unsafe_load(pb.secrets_content))
|
||||
else:
|
||||
secrets.append({})
|
||||
return secrets
|
||||
|
||||
def test_job_output_split_streams(self):
|
||||
# Verify that command standard output appears in the job output,
|
||||
# and that failures in the final playbook get logged.
|
||||
@@ -9912,6 +9953,113 @@ class TestJobOutput(AnsibleZuulTestCase):
|
||||
result = j[1]['plays'][0]['play']['name']
|
||||
self.assertIn(expected_str, result)
|
||||
|
||||
def test_job_output_redact_secrets(self):
|
||||
# If we can test cgroups, exercise the cgroup code path in
|
||||
# this test
|
||||
if os.environ.get("ZUUL_TEST_CGROUPS", None):
|
||||
user = getpass.getuser()
|
||||
memory_limit = '2G'
|
||||
cgroup_manager = bubblewrap.CgroupManager(user, memory_limit)
|
||||
self.executor_server.execution_wrapper.setCgroupManager(
|
||||
cgroup_manager)
|
||||
self.executor_server.keep_jobdir = True
|
||||
self.executor_server._new_result_behavior = True
|
||||
A = self.fake_gerrit.addFakeChange('org/project7', 'master', 'A')
|
||||
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
|
||||
self.waitUntilSettled()
|
||||
|
||||
es = 'expose-secrets'
|
||||
self.assertHistory([
|
||||
dict(name=f'{es}-txt-ignore', result='SUCCESS', changes='1,1'),
|
||||
dict(name=f'{es}-json-ignore', result='SUCCESS', changes='1,1'),
|
||||
dict(name=f'{es}-txt-redact', result='SUCCESS', changes='1,1'),
|
||||
dict(name=f'{es}-json-redact', result='SUCCESS', changes='1,1'),
|
||||
dict(name=f'{es}-txt-fail',
|
||||
result='SECRET_EXPOSURE', changes='1,1'),
|
||||
dict(name=f'{es}-json-fail',
|
||||
result='SECRET_EXPOSURE', changes='1,1'),
|
||||
], ordered=False)
|
||||
|
||||
# Check Ignore
|
||||
job1 = self.getJobFromHistory(f'{es}-txt-ignore')
|
||||
job2 = self.getJobFromHistory(f'{es}-json-ignore')
|
||||
json_output = self._get_file(job2, 'work/logs/job-output.json')
|
||||
self.log.info(json_output)
|
||||
data = json.loads(json_output)
|
||||
self.assertEqual(3, len(data))
|
||||
self.assertEqual('Username: test-username',
|
||||
data[1]['plays'][0]['tasks'][0]
|
||||
['hosts']['test_node']['ansible_facts']['output'])
|
||||
# This is a secret exposed
|
||||
self.assertEqual('Password: test-password',
|
||||
data[2]['plays'][0]['tasks'][0]
|
||||
['hosts']['test_node']['ansible_facts']['output'])
|
||||
|
||||
job_output = self._get_file(job1, 'work/logs/job-output.txt')
|
||||
self.log.info(job_output)
|
||||
self.assertIn("Job console starting", job_output)
|
||||
self.assertIn("Username: test-username", job_output)
|
||||
self.assertIn("Okay to expose this: test-password", job_output)
|
||||
# This is a secret exposed
|
||||
self.assertIn("Password: test-password", job_output)
|
||||
|
||||
# Check Redact
|
||||
job1 = self.getJobFromHistory(f'{es}-txt-redact')
|
||||
job2 = self.getJobFromHistory(f'{es}-json-redact')
|
||||
json_output = self._get_file(job2, 'work/logs/job-output.json')
|
||||
self.log.info(json_output)
|
||||
data = json.loads(json_output)
|
||||
self.assertEqual(3, len(data))
|
||||
self.assertEqual('Username: test-username',
|
||||
data[1]['plays'][0]['tasks'][0]
|
||||
['hosts']['test_node']['ansible_facts']['output'])
|
||||
# This is a redaction
|
||||
self.assertEqual('[Zuul] Secret detected in output',
|
||||
data[2]['plays'][0]['play']['name'])
|
||||
|
||||
job_output = self._get_file(job1, 'work/logs/job-output.txt')
|
||||
self.log.info(job_output)
|
||||
self.assertIn("Job console starting", job_output)
|
||||
self.assertIn("Username: test-username", job_output)
|
||||
self.assertIn("Okay to expose this: test-password", job_output)
|
||||
# This is a redaction
|
||||
self.assertNotIn("Password:", job_output)
|
||||
self.assertNotIn("zuul-return-redaction-1", job_output)
|
||||
self.assertNotIn("zuul-return-redaction-2", job_output)
|
||||
self.assertNotIn("zuul-return-redaction-3", job_output)
|
||||
self.assertIn("[Zuul] Secret detected in output", job_output)
|
||||
|
||||
# We check the OIDC secret here
|
||||
secrets = self._getSecrets(
|
||||
f'{es}-txt-redact', 'playbooks'
|
||||
)[0]
|
||||
self.assertEqual(len(secrets), 2)
|
||||
token = secrets['oidc_secret'].value
|
||||
self.assertNotIn("OIDC:", job_output)
|
||||
self.assertNotIn(token, job_output)
|
||||
|
||||
# Check Fail
|
||||
job1 = self.getJobFromHistory(f'{es}-txt-fail')
|
||||
job2 = self.getJobFromHistory(f'{es}-json-fail')
|
||||
job_output = self._get_file(job2, 'work/logs/job-output.txt')
|
||||
self.log.info(job_output)
|
||||
json_output = self._get_file(job2, 'work/logs/job-output.json')
|
||||
self.log.info(json_output)
|
||||
data = json.loads(json_output)
|
||||
self.assertEqual(3, len(data))
|
||||
self.assertEqual('Username: test-username',
|
||||
data[1]['plays'][0]['tasks'][0]
|
||||
['hosts']['test_node']['ansible_facts']['output'])
|
||||
self.assertEqual('[Zuul] Secret detected in output',
|
||||
data[2]['plays'][0]['play']['name'])
|
||||
|
||||
job_output = self._get_file(job1, 'work/logs/job-output.txt')
|
||||
self.log.info(job_output)
|
||||
self.assertIn("Job console starting", job_output)
|
||||
self.assertIn("Username: test-username", job_output)
|
||||
self.assertIn("Okay to expose this: test-password", job_output)
|
||||
self.assertNotIn("Password:", job_output)
|
||||
|
||||
|
||||
class TestNoLog(AnsibleZuulTestCase):
|
||||
tenant_config_file = 'config/ansible-no-log/main.yaml'
|
||||
@@ -11619,20 +11767,6 @@ class TestConnectionVars(AnsibleZuulTestCase):
|
||||
# self.assertNotIn("/bin/du", job_output)
|
||||
|
||||
|
||||
class TestSymlinkEscape(AnsibleZuulTestCase):
|
||||
tenant_config_file = 'config/symlink-escape/main.yaml'
|
||||
|
||||
@okay_tracebacks('ValueError: Symlink detected')
|
||||
def test_symlink_escape(self):
|
||||
A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
|
||||
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
|
||||
self.waitUntilSettled()
|
||||
self.assertHistory([])
|
||||
self.assertEqual(A.reported, 1)
|
||||
self.assertIn("ERROR", A.messages[0])
|
||||
self.assertIn("Symlink detected", A.messages[0])
|
||||
|
||||
|
||||
class IncludeBranchesTestCase(ZuulTestCase):
|
||||
def _test_include_branches(self, history1, history2, history3, history4):
|
||||
self.create_branch('org/project', 'stable')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -104,6 +104,7 @@ class BuildsPageComponent extends React.Component {
|
||||
'EXCEPTION',
|
||||
'NO_HANDLE',
|
||||
'SNAPSHOT_FAILURE',
|
||||
'SECRET_EXPOSURE',
|
||||
],
|
||||
fuzzy: false,
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@ def merge_data(dict_a, dict_b):
|
||||
artifacts = merge_zuul_list(dict_a, dict_b, 'artifacts')
|
||||
file_comments = merge_file_comments(dict_a, dict_b)
|
||||
warnings = merge_zuul_list(dict_a, dict_b, 'warnings')
|
||||
redactions = merge_zuul_list(dict_a, dict_b, 'redactions')
|
||||
retry = dict_a.get('zuul', {}).get('retry')
|
||||
merge_dict(dict_a, dict_b)
|
||||
if artifacts:
|
||||
@@ -71,6 +72,8 @@ def merge_data(dict_a, dict_b):
|
||||
dict_b.setdefault("zuul", {})["file_comments"] = file_comments
|
||||
if warnings:
|
||||
dict_b.setdefault('zuul', {})['warnings'] = warnings
|
||||
if redactions:
|
||||
dict_b.setdefault('zuul', {})['redactions'] = redactions
|
||||
if retry:
|
||||
dict_b.setdefault('zuul', {})['retry'] = retry
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import copy
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import logging.config
|
||||
|
||||
from ansible.plugins.loader import PluginLoader
|
||||
from ansible.plugins.callback import CallbackBase
|
||||
@@ -57,13 +59,16 @@ class CallbackModule(CallbackBase):
|
||||
super(CallbackModule, self).__init__(display)
|
||||
self.results = []
|
||||
self.playbook = {}
|
||||
self._playbook_name = None
|
||||
logging_config = logconfig.load_job_config(
|
||||
os.environ['ZUUL_JOB_LOG_CONFIG'])
|
||||
|
||||
self.output_path = os.path.splitext(
|
||||
logging_config.job_output_file)[0] + '.json'
|
||||
if self._display.verbosity > 2:
|
||||
logging_config.setDebug()
|
||||
|
||||
self._playbook_name = None
|
||||
logging_config.apply()
|
||||
|
||||
self._logger = logging.getLogger('zuul.executor.ansible.json')
|
||||
|
||||
def _new_playbook(self, play):
|
||||
extra_vars = play._variable_manager._extra_vars
|
||||
@@ -168,25 +173,9 @@ class CallbackModule(CallbackBase):
|
||||
self.playbook['plays'] = self.results
|
||||
self.playbook['stats'] = summary
|
||||
|
||||
first_time = not os.path.exists(self.output_path)
|
||||
|
||||
if first_time:
|
||||
with open(self.output_path, 'w') as outfile:
|
||||
outfile.write('[\n\n]\n')
|
||||
|
||||
with open(self.output_path, 'r+') as outfile:
|
||||
self._append_playbook(outfile, first_time)
|
||||
|
||||
def _append_playbook(self, outfile, first_time):
|
||||
file_len = outfile.seek(0, os.SEEK_END)
|
||||
# Remove three bytes to eat the trailing newline written by the
|
||||
# json.dump. This puts the ',' on the end of lines.
|
||||
outfile.seek(file_len - 3)
|
||||
if not first_time:
|
||||
outfile.write(',\n')
|
||||
json.dump(self.playbook, outfile,
|
||||
indent=4, sort_keys=True, separators=(',', ': '))
|
||||
outfile.write('\n]\n')
|
||||
self._logger.info(
|
||||
json.dumps(self.playbook,
|
||||
indent=4, sort_keys=True, separators=(',', ': ')))
|
||||
|
||||
v2_runner_on_unreachable = v2_runner_on_ok
|
||||
|
||||
|
||||
@@ -346,7 +346,7 @@ class CallbackModule(default.CallbackModule):
|
||||
|
||||
logging_config.apply()
|
||||
|
||||
self._logger = logging.getLogger('zuul.executor.ansible')
|
||||
self._logger = logging.getLogger('zuul.executor.ansible.stream')
|
||||
self._result_logger = logging.getLogger(
|
||||
'zuul.executor.ansible.result')
|
||||
|
||||
@@ -379,7 +379,7 @@ class CallbackModule(default.CallbackModule):
|
||||
msg = msg.rstrip()
|
||||
if job:
|
||||
now = ts or datetime.datetime.now()
|
||||
self._logger.info("{now} | {msg}".format(now=now, msg=msg))
|
||||
self._logger.debug("{now} | {msg}".format(now=now, msg=msg))
|
||||
if executor:
|
||||
if debug:
|
||||
self._display.vvv(msg)
|
||||
|
||||
+46
-13
@@ -43,9 +43,11 @@ _DEFAULT_JOB_LOGGING_CONFIG = {
|
||||
},
|
||||
'jobfile': {
|
||||
# used by executor to emit log file
|
||||
'class': 'logging.FileHandler',
|
||||
'level': 'INFO',
|
||||
'class': 'logging.handlers.SysLogHandler',
|
||||
'level': 'DEBUG',
|
||||
'formatter': 'plain',
|
||||
'facility': 'local0',
|
||||
'socktype': 1, # SOCK_STREAM
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
@@ -53,10 +55,14 @@ _DEFAULT_JOB_LOGGING_CONFIG = {
|
||||
'handlers': ['result'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'zuul.executor.ansible': {
|
||||
'zuul.executor.ansible.json': {
|
||||
'handlers': ['jobfile'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'zuul.executor.ansible.stream': {
|
||||
'handlers': ['jobfile'],
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
'sqlalchemy.engine': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARN',
|
||||
@@ -186,24 +192,49 @@ class DictLoggingConfig(LoggingConfig, metaclass=abc.ABCMeta):
|
||||
|
||||
class JobLoggingConfig(DictLoggingConfig):
|
||||
|
||||
def __init__(self, config=None, job_output_file=None):
|
||||
def __init__(self, config=None, log_socket=None,
|
||||
test_log_output_text_file=None,
|
||||
test_log_output_json_file=None):
|
||||
if not config:
|
||||
config = copy.deepcopy(_DEFAULT_JOB_LOGGING_CONFIG)
|
||||
super(JobLoggingConfig, self).__init__(config=config)
|
||||
if job_output_file:
|
||||
self.job_output_file = job_output_file
|
||||
if log_socket:
|
||||
self.log_socket = log_socket
|
||||
if test_log_output_text_file:
|
||||
# This is used by the streaming output test
|
||||
self._config['handlers']['jobfile'] = {
|
||||
'class': 'logging.FileHandler',
|
||||
'level': 'DEBUG',
|
||||
'formatter': 'plain',
|
||||
'filename': test_log_output_text_file,
|
||||
}
|
||||
if test_log_output_json_file:
|
||||
# This is used by the streaming output test
|
||||
self._config['handlers']['jsonfile'] = {
|
||||
'class': 'logging.FileHandler',
|
||||
'level': 'INFO',
|
||||
'formatter': 'plain',
|
||||
'filename': test_log_output_json_file,
|
||||
}
|
||||
self._config['loggers']['zuul.executor.ansible.json'] = {
|
||||
'handlers': ['jsonfile'],
|
||||
'level': 'INFO',
|
||||
}
|
||||
|
||||
def setDebug(self):
|
||||
# Set the level for zuul.executor.ansible to DEBUG
|
||||
self._config['loggers']['zuul.executor.ansible']['level'] = 'DEBUG'
|
||||
self._config['loggers']['zuul.executor.ansible.stream']['level'] =\
|
||||
'DEBUG'
|
||||
self._config['loggers']['zuul.executor.ansible.json']['level'] =\
|
||||
'DEBUG'
|
||||
|
||||
@property
|
||||
def job_output_file(self) -> str:
|
||||
return self._config['handlers']['jobfile']['filename']
|
||||
def log_socket(self):
|
||||
return self._config['handlers']['jobfile']['address']
|
||||
|
||||
@job_output_file.setter
|
||||
def job_output_file(self, filename: str):
|
||||
self._config['handlers']['jobfile']['filename'] = filename
|
||||
@log_socket.setter
|
||||
def log_socket(self, filename):
|
||||
self._config['handlers']['jobfile']['address'] = filename
|
||||
|
||||
|
||||
class ServerLoggingConfig(DictLoggingConfig):
|
||||
@@ -257,5 +288,7 @@ if __name__ == '__main__':
|
||||
# Use this to emit a working logging output for testing zuul_stream
|
||||
# locally.
|
||||
config = JobLoggingConfig(
|
||||
job_output_file=os.path.abspath('job-output.txt'))
|
||||
test_log_output_text_file=os.path.abspath('test-job-output.txt'),
|
||||
test_log_output_json_file=os.path.abspath('test-job-output.json'),
|
||||
)
|
||||
config.writeJson('logging.json')
|
||||
|
||||
@@ -40,5 +40,15 @@ warning_data = {
|
||||
vs.Extra: object,
|
||||
}
|
||||
|
||||
redaction_data = {
|
||||
'zuul': {
|
||||
'log_url': str,
|
||||
'redactions': [str],
|
||||
vs.Extra: object,
|
||||
},
|
||||
vs.Extra: object,
|
||||
}
|
||||
|
||||
artifact_schema = vs.Schema(artifact_data)
|
||||
warning_schema = vs.Schema(warning_data)
|
||||
redaction_schema = vs.Schema(redaction_data)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+578
-318
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import urllib.parse
|
||||
|
||||
from zuul.ansible.schema import (
|
||||
artifact_schema,
|
||||
redaction_schema,
|
||||
warning_schema,
|
||||
)
|
||||
|
||||
@@ -67,3 +68,15 @@ def get_warnings_from_result_data(result_data, logger=None):
|
||||
if logger:
|
||||
logger.debug("Result data did not pass warnings schema "
|
||||
"validation: %s", result_data)
|
||||
|
||||
|
||||
def pop_redactions_from_result_data(result_data, logger=None):
|
||||
# This method removes the redactions so we don't keep them around
|
||||
# longer than necessary and accidentally leak them.
|
||||
if validate_schema(result_data, redaction_schema):
|
||||
return result_data.get('zuul', {}).pop('redactions', [])
|
||||
else:
|
||||
if logger:
|
||||
# We do not log the redaction values
|
||||
logger.debug("Result data did not pass redactions schema "
|
||||
"validation")
|
||||
|
||||
+38
-1
@@ -3534,6 +3534,26 @@ class Secret(ConfigObject):
|
||||
r.secret_data = self._decrypt(private_key, self.secret_data)
|
||||
return r
|
||||
|
||||
def _decryptForCollection(self, private_key, secret_data):
|
||||
# recursive function to decrypt data
|
||||
if hasattr(secret_data, 'decrypt'):
|
||||
yield secret_data.decrypt(private_key)
|
||||
|
||||
if isinstance(secret_data, (dict, types.MappingProxyType)):
|
||||
for k, v in secret_data.items():
|
||||
yield from self._decryptForCollection(private_key, v)
|
||||
|
||||
if isinstance(secret_data, (list, tuple)):
|
||||
for v in secret_data:
|
||||
yield from self._decryptForCollection(private_key, v)
|
||||
|
||||
def collectEncryptedValues(self, private_key):
|
||||
"""Return a list of the decrypted contents of encrypted values
|
||||
in this secret"""
|
||||
|
||||
return [x for x in self._decryptForCollection(
|
||||
private_key, self.secret_data)]
|
||||
|
||||
def serialize(self, layout):
|
||||
# The output of this method is used by the executor
|
||||
# Set the ttl for this tenant
|
||||
@@ -4025,6 +4045,7 @@ class FrozenJob(zkobject.ZKObject):
|
||||
'image_build_name',
|
||||
'include_vars',
|
||||
'type',
|
||||
'secret_exposure',
|
||||
)
|
||||
|
||||
job_data_attributes = ('artifact_data',
|
||||
@@ -4215,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:
|
||||
@@ -4522,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
|
||||
@@ -4612,6 +4637,7 @@ class Job(ConfigObject):
|
||||
image_build_name=None,
|
||||
type='regular',
|
||||
preserve_home_paths=(),
|
||||
secret_exposure='ignore',
|
||||
)
|
||||
|
||||
override_control = defaultdict(lambda: True)
|
||||
@@ -5454,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
|
||||
@@ -5505,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:
|
||||
|
||||
Reference in New Issue
Block a user