Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85803179
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
38 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lilybuild/lilybuild/ci_steps.py b/lilybuild/lilybuild/ci_steps.py
index fda1496..fcbf9f1 100644
--- a/lilybuild/lilybuild/ci_steps.py
+++ b/lilybuild/lilybuild/ci_steps.py
@@ -1,488 +1,491 @@
from buildbot.plugins import *
from buildbot.process import buildstep, logobserver
from buildbot.interfaces import IRenderable
from twisted.internet import defer
from .ci_syntax import ci_file
from .ci_syntax import rules as ci_rules
from .helpers import rsync_rules_from_artifacts, get_job_script, normalize_image
from .phorge import SendCoverageToPhorge
import re
import sys
import json
SAFETAR_EXEC = '/lilybuild/lilybuild/safetar.py'
COVERAGE_EXEC = '/lilybuild/lilybuild/coverage.py'
def on_success(step):
return step.build.results == util.SUCCESS
def on_always(_step):
return True
def fill_list(*args):
return list(args)
class RunCIJobStep(steps.BuildStep):
# 200 MiB
artifact_max_size = 200 * 1024 * 1024
default_image = 'alpine'
master_job_artifact_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/builds/%(prop:lilybuild_root_build_id)s/jobs/%(kw:job)s/artifacts'
artifact_file_name = 'artifacts.tar'
master_job_artifact_file_name_pattern = master_job_artifact_dir_pattern + '/' + artifact_file_name
reports_file_name = 'reports.tar'
master_reports_file_name_pattern = master_job_artifact_dir_pattern + '/' + reports_file_name
master_pages_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/pages'
phorge_coverage_file_name = 'coverage-phorge.json'
def __init__(
self,
lbc,
src_relative=None,
src_dir=None,
storage_dir=None,
repo_id=None,
result_relative=None,
result_dir=None,
artifact_stage_relative=None,
artifact_stage_dir=None,
job_prop=None,
artifact_link_base=None,
**kwargs):
self.lbc = lbc
self.src_relative = src_relative
self.src_dir = src_dir
self.work_root_dir = kwargs['workdir']
self.script_dir = 'script'
self.storage_dir = storage_dir
self.repo_id = repo_id
self.artifact_stage_relative = artifact_stage_relative
self.artifact_stage_dir = artifact_stage_dir
self.result_relative = result_relative
self.result_dir = result_dir
self.artifact_link_base = artifact_link_base
super().__init__(name='Run step', **kwargs)
@defer.inlineCallbacks
def run(self):
job_prop = self.getProperty('lilybuild_job_prop')
job = ci_file.CIJob.from_prop(job_prop)
job_index = self.getProperty('lilybuild_job_index')
variables = yield self.get_ci_variables(job)
should_run = True
if len(job.rules):
should_run = False
for r in job.rules:
try:
rule_str = r.get('if')
if not rule_str:
continue
res = ci_rules.evaluate_rule(ci_rules.parse_rule(rule_str), variables)
if not res:
continue
when = r.get('when', 'on_success')
if when == 'never':
should_run = False
else:
should_run = True
break
except SyntaxError:
self.addCompleteLog('error', f'Rule "{rule_str}" has syntax errors')
except:
pass
if should_run:
next_steps = self.job_to_steps(job, job_index, variables)
self.build.addStepsAfterCurrentStep(next_steps)
return util.SUCCESS
else:
self.addCompleteLog('info', 'Job skipped by a rule')
return util.SKIPPED
@defer.inlineCallbacks
def get_ci_variables(self, job):
res = {}
res.update(job.get_predefined_ci_variables())
res.update(self.getProperty('lilybuild_pipeline_vars'))
res['CI_JOB_IMAGE'] = job.image or self.default_image
res['CI_JOB_URL'] = yield self.build.getUrl()
res['CI_JOB_ID'] = self.build.buildid
res['CI_PROJECT_DIR'] = '/build'
try:
repo = self.lbc.repos[self.getProperty('lilybuild_repo_id')]
variables = yield repo['variables_getter'](self.build)
res_vars = {}
for var in variables:
value = variables[var]
if IRenderable.providedBy(value):
value = yield self.build.render(value)
res_vars[var] = value
res.update(res_vars)
except Exception as e:
self.addCompleteLog('exception', f'{e}')
return res
def get_upload_artifacts_jobs(self, short_name, artifact_type, artifact_name, base_dir, paths, exclude, master_pattern, job_index, doStepIf=on_success, has_pages=False):
archive_artifact_step = steps.ShellCommand(
name=f'Archive artifacts: {short_name}',
command=[
SAFETAR_EXEC,
],
initialStdin=json.dumps({
'op': 'create',
'archive_file': artifact_name,
'base_dir': base_dir,
'content': paths,
'items_to_exclude': exclude,
+ 'compression': 'gz',
}),
workdir=self.work_root_dir,
doStepIf=doStepIf,
)
masterdest = util.Interpolate(
master_pattern,
st=self.storage_dir,
job=job_index,
doStepIf=doStepIf,
)
parent_build_id = self.getProperty('lilybuild_pipeline_vars')['CI_PIPELINE_ID']
artifact_url = f'{self.artifact_link_base}/plugins/lilybuild_artifacts/builds/{parent_build_id}/jobs/{job_index}/artifacts/{artifact_type}' if self.artifact_link_base else None
upload_artifact_step = steps.FileUpload(
workersrc=artifact_name,
maxsize=self.artifact_max_size,
name=f'Upload artifacts: {short_name}',
masterdest=masterdest,
workdir=self.work_root_dir,
url=artifact_url,
doStepIf=doStepIf,
)
r = [archive_artifact_step, upload_artifact_step]
if has_pages:
r.append(steps.MasterShellCommand(
command=util.Transform(fill_list,
sys.executable,
'-m', 'lilybuild.pages',
util.Interpolate(
self.master_pages_dir_pattern,
st=self.storage_dir,
),
masterdest,
),
name='Deploy pages',
logEnviron=False,
doStepIf=doStepIf,
))
return r
def job_to_steps(self, job, job_index, variables):
script_name = self.script_dir + '/run.sh'
source_step = self.lbc.create_source_step()
script_step = steps.StringDownload(
get_job_script(variables, job),
name='Set up script',
workerdest=script_name,
workdir=self.work_root_dir,
doStepIf=on_success,
)
chmod_step = steps.ShellCommand(
name='Make script executable',
command=['chmod', '+x', script_name],
workdir=self.work_root_dir,
doStepIf=on_success,
)
artifact_steps = []
dep_job_indices = self.getProperty('lilybuild_dependency_job_indices')
if dep_job_indices:
for i in dep_job_indices:
# The steps may not run or may not have an artifact even if it runs
download_job = steps.FileDownload(
mastersrc=util.Interpolate(
self.master_job_artifact_file_name_pattern,
st=self.storage_dir,
job=i,
),
maxsize=self.artifact_max_size,
name=f'Download artifacts from job #{i}',
workerdest=self.artifact_file_name,
workdir=self.work_root_dir,
doStepIf=on_success,
haltOnFailure=False,
flunkOnFailure=False,
+ # https://github.com/buildbot/buildbot/issues/3709
+ blocksize=256 * 1024,
)
unarchive_job = steps.ShellCommand(
name=f'Unarchive artifacts from job #{i}',
command=[
SAFETAR_EXEC,
],
initialStdin=json.dumps({
'op': 'extract',
'archive_file': self.artifact_file_name,
'target_dir': self.src_relative,
}),
workdir=self.work_root_dir,
doStepIf=on_success,
haltOnFailure=False,
flunkOnFailure=False,
)
artifact_steps += [download_job, unarchive_job]
run_step = steps.ShellCommand(
name='Run script in container',
command=[
'/lilybuild/podman-helper',
normalize_image(job.image or self.default_image),
self.src_relative,
self.script_dir,
self.result_relative,
],
workdir=self.work_root_dir,
doStepIf=on_success,
)
clean_script_step = steps.ShellCommand(
name='Clean script dir',
command=[
'rm',
'-rf',
self.script_dir,
],
workdir=self.work_root_dir,
alwaysRun=True,
)
steps_to_run = [source_step, script_step, chmod_step] + artifact_steps + [run_step, clean_script_step]
if 'paths' in job.artifacts:
steps_to_run += self.get_upload_artifacts_jobs(
'files',
'archive',
self.artifact_file_name,
self.result_relative,
job.artifacts.get('paths', []),
job.artifacts.get('exclude', []),
self.master_job_artifact_file_name_pattern,
job_index,
has_pages=job.is_pages()
)
if job.has_supported_coverage_report():
steps_to_run += [steps.ShellCommand(
name='Process reports',
command=[
COVERAGE_EXEC,
],
initialStdin=json.dumps({
'source_dir': self.src_relative,
'result_dir': self.result_relative,
'untrusted_coverage_file': job.artifacts['reports']['coverage_report']['path'],
'output_dir': self.artifact_stage_relative,
}),
workdir=self.work_root_dir,
doStepIf=on_always,
)] + self.get_upload_artifacts_jobs(
'reports',
'reports',
self.reports_file_name,
self.artifact_stage_relative,
['*'],
[],
self.master_reports_file_name_pattern,
job_index,
doStepIf=on_always
) + [SendCoverageToPhorge(
self.lbc,
self.artifact_stage_relative + '/' + self.phorge_coverage_file_name,
workdir=self.work_root_dir,
)]
clean_stage_dir_again_step = steps.ShellCommand(
name='Clean stage, result and artifact',
command=[
'rm',
'-rf',
self.result_relative,
self.artifact_file_name,
self.artifact_stage_relative,
self.reports_file_name,
],
workdir=self.work_root_dir,
alwaysRun=True,
)
steps_to_run.append(clean_stage_dir_again_step)
return steps_to_run
class TriggerMultipleJobsStep(steps.Trigger):
properties_to_keep = [
'branch',
'revision',
'repository',
'harbormaster_build_target_phid',
'harbormaster_variable_buildable.diff',
'harbormaster_variable_repository.staging.ref',
'harbormaster_variable_repository.staging.uri',
'harbormaster_variable_repository.uri',
'lilybuild_repo',
'lilybuild_repo_id',
'lilybuild_pipeline_vars',
]
def __init__(self, lbc, jobs_with_data, **kwargs):
self.lbc = lbc
self.jobs = jobs_with_data
super().__init__(schedulerNames=[self.lbc.triggerable_scheduler_name], **kwargs)
def getSchedulersAndProperties(self):
ret = []
common_properties = {
'lilybuild_root_build_id': self.build.buildid,
}
for prop in self.properties_to_keep:
if self.hasProperty(prop):
common_properties[prop] = self.getProperty(prop)
for (job, i, dep_job_indices) in self.jobs:
properties = common_properties.copy()
properties['lilybuild_job_prop'] = job.to_prop()
properties['lilybuild_job_index'] = i
properties['virtual_builder_name'] = 'lilybuild-job - ' + common_properties['lilybuild_repo'] + ' - ' + job.name
properties['lilybuild_dependency_job_indices'] = dep_job_indices
ret.append({
'sched_name': self.lbc.triggerable_scheduler_name,
'props_to_set': properties,
'unimportant': False,
})
return ret
class AnalyzeCIFileCommand(buildstep.ShellMixin, steps.BuildStep):
ci_def_file = '.gitlab-ci.yml'
build_target_prop_name = 'harbormaster_build_target_phid'
def __init__(
self,
lbc,
src_relative=None,
src_dir=None,
storage_dir=None,
repo_id=None,
result_relative=None,
result_dir=None,
artifact_stage_relative=None,
artifact_stage_dir=None,
**kwargs):
kwargs['name'] = 'Analyze CI file'
kwargs['command'] = ['cat', self.ci_def_file]
self.lbc = lbc
self.src_relative = src_relative
self.src_dir = src_dir
self.work_root_dir = kwargs['workdir']
self.script_dir = 'script'
self.storage_dir = storage_dir
self.repo_id = repo_id
self.artifact_stage_relative = artifact_stage_relative
self.artifact_stage_dir = artifact_stage_dir
self.result_relative = result_relative
self.result_dir = result_dir
kwargs['workdir'] = self.src_dir
kwargs = self.setupShellMixin(kwargs)
super().__init__(**kwargs)
self.observer = logobserver.BufferLogObserver()
self.addLogObserver('stdio', self.observer)
def stage_to_step(self, stage_name, stage_jobs, job_name_to_index_map, ci_file):
jobs_with_data = []
for job in stage_jobs:
dep_job_names = [
jn
for jn in ci_file.get_jobs_to_pull_artifacts_from(job.name)
if ci_file.jobs[jn].has_artifacts_archive()
]
dep_job_indices = [job_name_to_index_map[jn] for jn in dep_job_names]
jobs_with_data.append((job, job_name_to_index_map[job.name], dep_job_indices))
trigger = TriggerMultipleJobsStep(
name=stage_name,
lbc=self.lbc,
jobs_with_data=jobs_with_data,
waitForFinish=True,
doStepIf=on_success,
)
return trigger
def get_steps(self, stdout):
f = ci_file.CIFile(stdout)
stages = f.get_grouped_jobs()
jobs = [job for (stage, js) in f.get_grouped_jobs() for job in js]
job_names = [job.name for job in jobs]
job_name_to_index_map = {}
for (i, j) in enumerate(jobs):
job_name_to_index_map[j.name] = i
steps = [self.stage_to_step(stage_name, stage_jobs, job_name_to_index_map, f) for (stage_name, stage_jobs) in stages]
print('steps:', steps)
return steps
def get_is_phorge(self):
return not not self.getProperty(self.build_target_prop_name)
def get_ref_and_type(self):
ref_type = 'branch'
ref = self.getProperty('branch')
if self.getProperty('category') == 'tag':
ref_type = 'tag'
if ref is not None:
m = re.match(r'^refs/(heads|tags)/(.+)$', ref)
if m:
ref = m.group(2)
return (ref, ref_type)
@defer.inlineCallbacks
def get_pipeline_ci_vars(self):
url = yield self.build.getUrl()
res = {
'CI_PIPELINE_ID': self.build.buildid,
'CI_PIPELINE_IID': self.build.buildid,
'CI_PIPELINE_URL': url,
'CI_PROJECT_ID': self.getProperty('lilybuild_repo_id'),
'CI_CONFIG_PATH': self.ci_def_file,
}
if not self.get_is_phorge():
res['CI_COMMIT_SHA'] = self.getProperty('got_revision')
res['CI_COMMIT_SHORT_SHA'] = res['CI_COMMIT_SHA'][:8]
(ref, ref_type) = self.get_ref_and_type()
res['CI_COMMIT_REF_NAME'] = ref
res['CI_COMMIT_REF_SLUG'] = ci_file.ci_slugify(ref)
res['CI_COMMIT_REF_PROTECTED'] = 'false'
if ref_type == 'tag':
res['CI_COMMIT_TAG'] = ref
elif ref_type == 'branch':
res['CI_COMMIT_BRANCH'] = ref
return res
@defer.inlineCallbacks
def run(self):
# run './build.sh --list-stages' to generate the list of stages
cmd = yield self.makeRemoteShellCommand()
yield self.runCommand(cmd)
# if the command passes extract the list of stages
result = cmd.results()
if result == util.SUCCESS:
pipeline_vars = yield self.get_pipeline_ci_vars()
self.setProperty('lilybuild_pipeline_vars', pipeline_vars, self.__class__.__name__)
# create a ShellCommand for each stage and add them to the build
self.build.addStepsAfterCurrentStep(self.get_steps(self.observer.getStdout()))
return result
diff --git a/lilybuild/lilybuild/safetar.py b/lilybuild/lilybuild/safetar.py
index a7f4a1c..902c4a4 100755
--- a/lilybuild/lilybuild/safetar.py
+++ b/lilybuild/lilybuild/safetar.py
@@ -1,239 +1,244 @@
#!/usr/bin/env python3
import os
import glob
import re
try:
import tarfile
tarfile.FilterError
except AttributeError:
import backports.tarfile as tarfile
try:
glob_translate = glob.translate
except AttributeError:
# Taken from python 3.13 library
def py313_fnmatch_translate(pat, STAR, QUESTION_MARK):
res = []
add = res.append
i, n = 0, len(pat)
while i < n:
c = pat[i]
i = i+1
if c == '*':
# compress consecutive `*` into one
if (not res) or res[-1] is not STAR:
add(STAR)
elif c == '?':
add(QUESTION_MARK)
elif c == '[':
j = i
if j < n and pat[j] == '!':
j = j+1
if j < n and pat[j] == ']':
j = j+1
while j < n and pat[j] != ']':
j = j+1
if j >= n:
add('\\[')
else:
stuff = pat[i:j]
if '-' not in stuff:
stuff = stuff.replace('\\', r'\\')
else:
chunks = []
k = i+2 if pat[i] == '!' else i+1
while True:
k = pat.find('-', k, j)
if k < 0:
break
chunks.append(pat[i:k])
i = k+1
k = k+3
chunk = pat[i:j]
if chunk:
chunks.append(chunk)
else:
chunks[-1] += '-'
# Remove empty ranges -- invalid in RE.
for k in range(len(chunks)-1, 0, -1):
if chunks[k-1][-1] > chunks[k][0]:
chunks[k-1] = chunks[k-1][:-1] + chunks[k][1:]
del chunks[k]
# Escape backslashes and hyphens for set difference (--).
# Hyphens that create ranges shouldn't be escaped.
stuff = '-'.join(s.replace('\\', r'\\').replace('-', r'\-')
for s in chunks)
# Escape set operations (&&, ~~ and ||).
stuff = re.sub(r'([&~|])', r'\\\1', stuff)
i = j+1
if not stuff:
# Empty range: never match.
add('(?!)')
elif stuff == '!':
# Negated empty range: match any character.
add('.')
else:
if stuff[0] == '!':
stuff = '^' + stuff[1:]
elif stuff[0] in ('^', '['):
stuff = '\\' + stuff
add(f'[{stuff}]')
else:
add(re.escape(c))
assert i == n
return res
def py313_translate(pat, *, recursive=False, include_hidden=False, seps=None):
"""Translate a pathname with shell wildcards to a regular expression.
If `recursive` is true, the pattern segment '**' will match any number of
path segments.
If `include_hidden` is true, wildcards can match path segments beginning
with a dot ('.').
If a sequence of separator characters is given to `seps`, they will be
used to split the pattern into segments and match path separators. If not
given, os.path.sep and os.path.altsep (where available) are used.
"""
if not seps:
if os.path.altsep:
seps = (os.path.sep, os.path.altsep)
else:
seps = os.path.sep
escaped_seps = ''.join(map(re.escape, seps))
any_sep = f'[{escaped_seps}]' if len(seps) > 1 else escaped_seps
not_sep = f'[^{escaped_seps}]'
if include_hidden:
one_last_segment = f'{not_sep}+'
one_segment = f'{one_last_segment}{any_sep}'
any_segments = f'(?:.+{any_sep})?'
any_last_segments = '.*'
else:
one_last_segment = f'[^{escaped_seps}.]{not_sep}*'
one_segment = f'{one_last_segment}{any_sep}'
any_segments = f'(?:{one_segment})*'
any_last_segments = f'{any_segments}(?:{one_last_segment})?'
results = []
parts = re.split(any_sep, pat)
last_part_idx = len(parts) - 1
for idx, part in enumerate(parts):
if part == '*':
results.append(one_segment if idx < last_part_idx else one_last_segment)
elif recursive and part == '**':
if idx < last_part_idx:
if parts[idx + 1] != '**':
results.append(any_segments)
else:
results.append(any_last_segments)
else:
if part:
if not include_hidden and part[0] in '*?':
results.append(r'(?!\.)')
results.extend(py313_fnmatch_translate(part, f'{not_sep}*', not_sep))
if idx < last_part_idx:
results.append(any_sep)
res = ''.join(results)
return fr'(?s:{res})\Z'
glob_translate = py313_translate
def extract(target_dir, archive_file):
with tarfile.open(archive_file) as tf:
tf.errorlevel = 1
tf.extractall(target_dir, filter='data')
class ArchiveFilter:
def __init__(
self,
base_dir,
limit_bytes=None,
items_to_exclude=None
):
self.base_dir = base_dir
self.total_bytes_added = 0
self.limit_bytes = limit_bytes or 100*1024*1024 # 100 MiB
self.exclude_re = [re.compile(glob_translate(i, recursive=True, include_hidden=True)) for i in (items_to_exclude or [])]
def __call__(self, member):
member.name = os.path.relpath('/' + member.name, self.base_dir)
filtered_member = tarfile.data_filter(member, self.base_dir)
if not filtered_member:
return None
if self.total_bytes_added + member.size > self.limit_bytes:
self.total_bytes_added = self.limit_bytes + 1
raise RuntimeError('Limit exceeded')
name = member.name
for r in self.exclude_re:
if r.match(name):
return None
if member.isdir() and r.match(name + '/'):
return None
self.total_bytes_added += member.size
# Assume that data_filter does not do anything else to it besides rejecting
# Any remaining (permissions) will be stripped when the archive is extracted
# Directly using filtered_member will cause errors in further processing,
# as the data_filter seems intended only for extraction.
return member
-def create(archive_file, base_dir, content, limit_bytes, items_to_exclude):
+def create(archive_file, base_dir, content, limit_bytes, items_to_exclude, compression=None):
base_dir = os.path.abspath(base_dir)
+ open_mode = 'w'
+ if compression == 'gz':
+ open_mode = 'w:gz'
+
try:
- with tarfile.open(archive_file, 'w') as tf:
+ with tarfile.open(archive_file, open_mode) as tf:
tf.errorlevel = 1
archive_filter = ArchiveFilter(
base_dir,
limit_bytes,
items_to_exclude=items_to_exclude
)
for g in content:
# iglob is important because once we found one file, we
# add it, and if it does not pass the data filter,
# then we are done with the whole archive. Using glob
# will make it hang here, resulting in DoS.
for f in glob.iglob(g, root_dir=base_dir, recursive=True):
# Specifying 'xxx/**' as the glob will probably make
# this called multiple times on the parent and child
# dirs, so best to avoid it. However, we aren't
# good enough to sanitize this.
tf.add(os.path.join(base_dir, f), filter=archive_filter)
except tarfile.FilterError:
# To prevent exploiting '/**', '../../../**' globs etc., we
# cannot allow the filename to be exposed
raise RuntimeError('Did not pass the data_filter')
if __name__ == '__main__':
import sys
import json
if len(sys.argv) > 1:
a = json.loads(sys.argv[1])
else:
a = json.loads(sys.stdin.read())
op = a.get('op')
if op == 'create':
# Do not remove this try-catch, or it will print out the original
# FilterError, resulting in at least one file name being exposed.
# The file name in the filter error should not be exposed,
# or it allows the attacker to view arbitrary directory structure
# inside the whole worker.
try:
create(
a['archive_file'],
a['base_dir'],
a['content'],
a.get('limit_bytes'),
- a.get('items_to_exclude')
+ a.get('items_to_exclude'),
+ a.get('compression'),
)
except RuntimeError as e:
print('Cannot create archive:', e)
sys.exit(1)
elif op == 'extract':
# Does not need a try-catch, because only the things inside the archive
# or the target dir can be exposed.
extract(a['target_dir'], a['archive_file'])
else:
print('Unknown operation:', op)
sys.exit(1)
diff --git a/lilybuild/lilybuild/tests/safetar_test_worker.py b/lilybuild/lilybuild/tests/safetar_test_worker.py
index 95496f6..68fa518 100644
--- a/lilybuild/lilybuild/tests/safetar_test_worker.py
+++ b/lilybuild/lilybuild/tests/safetar_test_worker.py
@@ -1,208 +1,223 @@
import unittest
import tempfile
import os
import stat
from lilybuild.safetar import (
create, extract
)
try:
import tarfile
tarfile.FilterError
except AttributeError:
import backports.tarfile as tarfile
def make_artifact_dir(root_dir):
os.makedirs(os.path.join(root_dir, 'public'))
with open(os.path.join(root_dir, 'public', 'a'), 'w') as f:
print('test', file=f)
os.makedirs(os.path.join(root_dir, 'other'))
with open(os.path.join(root_dir, 'other', 'a'), 'w') as f:
print('another test', file=f)
def make_artifact_dir_link(root_dir):
os.makedirs(os.path.join(root_dir, 'public'))
with open(os.path.join(root_dir, 'public', 'a'), 'w') as f:
print('test', file=f)
os.makedirs(os.path.join(root_dir, 'other'))
os.symlink('../public/a', os.path.join(root_dir, 'other', 'a'))
def make_bad_artifact_archive(root_dir):
os.makedirs(os.path.join(root_dir, 'public'))
with open(os.path.join(root_dir, 'public', 'a'), 'w') as f:
print('test', file=f)
os.makedirs(os.path.join(root_dir, 'other'))
with open(os.path.join(root_dir, 'other', 'a'), 'w') as f:
print('should not be there', file=f)
archive = os.path.join(root_dir, 'artifacts.tar')
with tarfile.open(archive, 'w') as f:
f.add(os.path.join(root_dir, 'public'), 'public')
f.add(os.path.join(root_dir, 'other'), '../../../other')
return archive
class SafetarTest(unittest.TestCase):
def test_create(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other/a')
target = os.path.join(dir_name, 'extracts')
os.makedirs(target)
extract(target, archive)
+ def test_create_compression(self):
+ with tempfile.TemporaryDirectory() as dir_name:
+ make_artifact_dir(dir_name)
+ archive = os.path.join(dir_name, 'artifacts.tar')
+ create(archive, dir_name, ['public', 'other'], None, None, 'gz')
+ with tarfile.open(archive, 'r:gz') as f:
+ f.getmember('public/a')
+ f.getmember('other/a')
+ with tarfile.open(archive, 'r') as f:
+ f.getmember('public/a')
+ f.getmember('other/a')
+ target = os.path.join(dir_name, 'extracts')
+ os.makedirs(target)
+ extract(target, archive)
+
def test_create_glob(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['*'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['p*/a'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
with self.assertRaises(KeyError):
f.getmember('other')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['**/a'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other/a')
def test_create_glob_not_exploitable(self):
# This tests for any traversal attack
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, dir_name, ['/**'], None, None)
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, os.path.join(dir_name, 'public'), ['../**'], None, None)
def test_create_out_of_scope(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, os.path.join(dir_name, 'public'), ['.', '../other'], None, None)
with self.assertRaises(RuntimeError):
create(archive, os.path.join(dir_name, 'public'), ['/home'], None, None)
def test_create_good_link(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir_link(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other/a')
def test_create_bad_link(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir_link(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, os.path.join(dir_name, 'other'), ['.'], None, None)
def test_create_limited(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, dir_name, ['public', 'other'], 8, None)
def test_create_filtered(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['other/a'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other')
with self.assertRaises(KeyError):
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['other'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
with self.assertRaises(KeyError):
f.getmember('other')
with self.assertRaises(KeyError):
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['other/'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
with self.assertRaises(KeyError):
f.getmember('other')
with self.assertRaises(KeyError):
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['other/a/'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other')
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['oth'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other')
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['**/a'])
with tarfile.open(archive, 'r') as f:
f.getmember('public')
f.getmember('other')
with self.assertRaises(KeyError):
f.getmember('public/a')
with self.assertRaises(KeyError):
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['a'])
with tarfile.open(archive, 'r') as f:
f.getmember('public')
f.getmember('other')
f.getmember('public/a')
f.getmember('other/a')
def test_extract_bad(self):
with tempfile.TemporaryDirectory() as root_dir:
archive_file = make_bad_artifact_archive(root_dir)
with self.assertRaises(tarfile.FilterError):
extract(root_dir, archive_file)
if __name__ == '__main__':
unittest.main()
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Oct 9, 2:32 AM (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1784399
Default Alt Text
(38 KB)
Attached To
Mode
rB lilybuild
Attached
Detach File
Event Timeline
Log In to Comment