Skip to content

Commit bcaa289

Browse files
authored
Experimental extension for "secret" parameters. (#683)
* Experimental extension for "secret" parameters. These are string input parameters that represent sensitive information such as passwords. This feature deliberately obscures them by replacing them with a placeholder during workflow evaluation and then substituting the real value at the last moment. Staged files containing secrets are deleted when the tool completes.
1 parent 6afea6c commit bcaa289

File tree

9 files changed

+207
-17
lines changed

9 files changed

+207
-17
lines changed

cwltool/docker.py

+11-6
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .errors import WorkflowException
1818
from .job import ContainerCommandLineJob
1919
from .pathmapper import PathMapper, ensure_writable
20+
from .secrets import SecretStore
2021
from .utils import docker_windows_path_adjust, onWindows
2122

2223
_logger = logging.getLogger("cwltool")
@@ -132,8 +133,8 @@ def get_from_requirements(self, r, req, pull_image, dry_run=False):
132133

133134
return None
134135

135-
def add_volumes(self, pathmapper, runtime):
136-
# type: (PathMapper, List[Text]) -> None
136+
def add_volumes(self, pathmapper, runtime, secret_store=None):
137+
# type: (PathMapper, List[Text], SecretStore) -> None
137138

138139
host_outdir = self.outdir
139140
container_outdir = self.builder.outdir
@@ -170,13 +171,17 @@ def add_volumes(self, pathmapper, runtime):
170171
shutil.copytree(vol.resolved, host_outdir_tgt)
171172
ensure_writable(host_outdir_tgt)
172173
elif vol.type == "CreateFile":
174+
if secret_store:
175+
contents = secret_store.retrieve(vol.resolved)
176+
else:
177+
contents = vol.resolved
173178
if host_outdir_tgt:
174179
with open(host_outdir_tgt, "wb") as f:
175-
f.write(vol.resolved.encode("utf-8"))
180+
f.write(contents.encode("utf-8"))
176181
else:
177182
fd, createtmp = tempfile.mkstemp(dir=self.tmpdir)
178183
with os.fdopen(fd, "wb") as f:
179-
f.write(vol.resolved.encode("utf-8"))
184+
f.write(contents.encode("utf-8"))
180185
runtime.append(u"--volume=%s:%s:rw" % (
181186
docker_windows_path_adjust(createtmp),
182187
docker_windows_path_adjust(vol.target)))
@@ -196,9 +201,9 @@ def create_runtime(self, env, rm_container=True, record_container_id=False, cidf
196201
runtime.append(u"--volume=%s:%s:rw" % (
197202
docker_windows_path_adjust(os.path.realpath(self.tmpdir)), "/tmp"))
198203

199-
self.add_volumes(self.pathmapper, runtime)
204+
self.add_volumes(self.pathmapper, runtime, secret_store=kwargs.get("secret_store"))
200205
if self.generatemapper:
201-
self.add_volumes(self.generatemapper, runtime)
206+
self.add_volumes(self.generatemapper, runtime, secret_store=kwargs.get("secret_store"))
202207

203208
if user_space_docker_cmd:
204209
runtime = [x.replace(":ro", "") for x in runtime]

cwltool/extensions.yml

+20
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,23 @@ $graph:
3434
"_type": "@vocab"
3535
inplaceUpdate:
3636
type: boolean
37+
38+
- name: Secrets
39+
type: record
40+
inVocab: false
41+
extends: cwl:ProcessRequirement
42+
fields:
43+
class:
44+
type: string
45+
doc: "Always 'Secrets'"
46+
jsonldPredicate:
47+
"_id": "@type"
48+
"_type": "@vocab"
49+
secrets:
50+
type: string[]
51+
doc: |
52+
List one or more input parameters that are sensitive (such as passwords)
53+
which will be deliberately obscured from logging.
54+
jsonldPredicate:
55+
"_type": "@id"
56+
refScope: 0

cwltool/job.py

+24-6
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .pathmapper import PathMapper
2626
from .process import (UnsupportedRequirement, get_feature,
2727
stageFiles)
28+
from .secrets import SecretStore
2829
from .utils import bytes2str_in_dicts
2930
from .utils import copytree_with_merge, onWindows
3031

@@ -170,8 +171,8 @@ def _setup(self, kwargs): # type: (Dict) -> None
170171
_logger.debug(u"[job %s] initial work dir %s", self.name,
171172
json.dumps({p: self.generatemapper.mapper(p) for p in self.generatemapper.files()}, indent=4))
172173

173-
def _execute(self, runtime, env, rm_tmpdir=True, move_outputs="move"):
174-
# type: (List[Text], MutableMapping[Text, Text], bool, Text) -> None
174+
def _execute(self, runtime, env, rm_tmpdir=True, move_outputs="move", secret_store=None):
175+
# type: (List[Text], MutableMapping[Text, Text], bool, Text, SecretStore) -> None
175176

176177
scr, _ = get_feature(self, "ShellCommandRequirement")
177178

@@ -214,6 +215,10 @@ def _execute(self, runtime, env, rm_tmpdir=True, move_outputs="move"):
214215
stdout_path = absout
215216

216217
commands = [Text(x) for x in (runtime + self.command_line)]
218+
if secret_store:
219+
commands = secret_store.retrieve(commands)
220+
env = secret_store.retrieve(env)
221+
217222
job_script_contents = None # type: Text
218223
builder = getattr(self, "builder", None) # type: Builder
219224
if builder is not None:
@@ -269,6 +274,19 @@ def _execute(self, runtime, env, rm_tmpdir=True, move_outputs="move"):
269274
if _logger.isEnabledFor(logging.DEBUG):
270275
_logger.debug(u"[job %s] %s", self.name, json.dumps(outputs, indent=4))
271276

277+
if self.generatemapper and secret_store:
278+
# Delete any runtime-generated files containing secrets.
279+
for f, p in self.generatemapper.items():
280+
if p.type == "CreateFile":
281+
if secret_store.has_secret(p.resolved):
282+
host_outdir = self.outdir
283+
container_outdir = self.builder.outdir
284+
host_outdir_tgt = p.target
285+
if p.target.startswith(container_outdir+"/"):
286+
host_outdir_tgt = os.path.join(
287+
host_outdir, p.target[len(container_outdir)+1:])
288+
os.remove(host_outdir_tgt)
289+
272290
with job_output_lock:
273291
self.output_callback(outputs, processStatus)
274292

@@ -307,12 +325,12 @@ def run(self, pull_image=True, rm_container=True,
307325
if "SYSTEMROOT" not in env and "SYSTEMROOT" in os.environ:
308326
env["SYSTEMROOT"] = str(os.environ["SYSTEMROOT"]) if onWindows() else os.environ["SYSTEMROOT"]
309327

310-
stageFiles(self.pathmapper, ignoreWritable=True, symLink=True)
328+
stageFiles(self.pathmapper, ignoreWritable=True, symLink=True, secret_store=kwargs.get("secret_store"))
311329
if self.generatemapper:
312-
stageFiles(self.generatemapper, ignoreWritable=self.inplace_update, symLink=True)
330+
stageFiles(self.generatemapper, ignoreWritable=self.inplace_update, symLink=True, secret_store=kwargs.get("secret_store"))
313331
relink_initialworkdir(self.generatemapper, self.outdir, self.builder.outdir, inplace_update=self.inplace_update)
314332

315-
self._execute([], env, rm_tmpdir=rm_tmpdir, move_outputs=move_outputs)
333+
self._execute([], env, rm_tmpdir=rm_tmpdir, move_outputs=move_outputs, secret_store=kwargs.get("secret_store"))
316334

317335

318336
class ContainerCommandLineJob(JobBase):
@@ -382,7 +400,7 @@ def run(self, pull_image=True, rm_container=True,
382400
runtime = self.create_runtime(env, rm_container, record_container_id, cidfile_dir, cidfile_prefix, **kwargs)
383401
runtime.append(img_id)
384402

385-
self._execute(runtime, env, rm_tmpdir=rm_tmpdir, move_outputs=move_outputs)
403+
self._execute(runtime, env, rm_tmpdir=rm_tmpdir, move_outputs=move_outputs, secret_store=kwargs.get("secret_store"))
386404

387405

388406
def _job_popen(

cwltool/main.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from .process import (Process, normalizeFilesDirs,
3737
scandeps, shortname, use_custom_schema,
3838
use_standard_schema)
39+
from .secrets import SecretStore
3940
from .resolver import ga4gh_tool_registries, tool_resolver
4041
from .software_requirements import (DependenciesConfiguration,
4142
get_container_from_software_requirements)
@@ -154,10 +155,13 @@ def init_job_order(job_order_object, # type: MutableMapping[Text, Any]
154155
stdout=sys.stdout, # type: IO[Any]
155156
make_fs_access=None, # type: Callable[[Text], StdFsAccess]
156157
loader=None, # type: Loader
157-
input_basedir="" # type: Text
158+
input_basedir="", # type: Text
159+
secret_store=None # type: SecretStore
158160
):
159161
# (...) -> Tuple[Dict[Text, Any], Text]
160162

163+
secrets_req, _ = t.get_requirement("http://commonwl.org/cwltool#Secrets")
164+
161165
if not job_order_object:
162166
namemap = {} # type: Dict[Text, Text]
163167
records = [] # type: List[Text]
@@ -191,6 +195,9 @@ def init_job_order(job_order_object, # type: MutableMapping[Text, Any]
191195

192196
job_order_object.update({namemap[k]: v for k, v in cmd_line.items()})
193197

198+
if secrets_req:
199+
secret_store.store([shortname(sc) for sc in secrets_req["secrets"]], job_order_object)
200+
194201
if _logger.isEnabledFor(logging.DEBUG):
195202
_logger.debug(u"Parsed job order from command line: %s", json.dumps(job_order_object, indent=4))
196203
else:
@@ -245,6 +252,9 @@ def expand_formats(p):
245252
adjustDirObjs(job_order_object, trim_listing)
246253
normalizeFilesDirs(job_order_object)
247254

255+
if secrets_req:
256+
secret_store.store([shortname(sc) for sc in secrets_req["secrets"]], job_order_object)
257+
248258
if "cwl:tool" in job_order_object:
249259
del job_order_object["cwl:tool"]
250260
if "id" in job_order_object:
@@ -556,14 +566,17 @@ def main(argsl=None, # type: List[str]
556566
setattr(args, 'move_outputs', "copy")
557567
setattr(args, "tmp_outdir_prefix", args.cachedir)
558568

569+
secret_store = SecretStore()
570+
559571
try:
560572
job_order_object = init_job_order(job_order_object, args, tool,
561573
print_input_deps=args.print_input_deps,
562574
relative_deps=args.relative_deps,
563575
stdout=stdout,
564576
make_fs_access=make_fs_access,
565577
loader=jobloader,
566-
input_basedir=input_basedir)
578+
input_basedir=input_basedir,
579+
secret_store=secret_store)
567580
except SystemExit as e:
568581
return e.code
569582

@@ -585,6 +598,7 @@ def main(argsl=None, # type: List[str]
585598
makeTool=makeTool,
586599
select_resources=selectResources,
587600
make_fs_access=make_fs_access,
601+
secret_store=secret_store,
588602
**vars(args))
589603

590604
# This is the workflow output, it needs to be written

cwltool/process.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@
3636
from .pathmapper import (PathMapper, adjustDirObjs, get_listing,
3737
normalizeFilesDirs, visit_class, trim_listing,
3838
ensure_writable)
39+
from .secrets import SecretStore
3940
from .stdfsaccess import StdFsAccess
4041
from .utils import aslist, get_feature, copytree_with_merge, onWindows
4142

43+
4244
# if six.PY3:
4345
# AvroSchemaFromJSONData = avro.schema.SchemaFromJSONData
4446
# else:
@@ -207,8 +209,8 @@ def adjustFilesWithSecondary(rec, op, primary=None):
207209
adjustFilesWithSecondary(d, op, primary)
208210

209211

210-
def stageFiles(pm, stageFunc=None, ignoreWritable=False, symLink=True):
211-
# type: (PathMapper, Callable[..., Any], bool, bool) -> None
212+
def stageFiles(pm, stageFunc=None, ignoreWritable=False, symLink=True, secret_store=None):
213+
# type: (PathMapper, Callable[..., Any], bool, bool, SecretStore) -> None
212214
for f, p in pm.items():
213215
if not p.staged:
214216
continue
@@ -240,7 +242,10 @@ def stageFiles(pm, stageFunc=None, ignoreWritable=False, symLink=True):
240242
ensure_writable(p.target)
241243
elif p.type == "CreateFile":
242244
with open(p.target, "wb") as n:
243-
n.write(p.resolved.encode("utf-8"))
245+
if secret_store:
246+
n.write(secret_store.retrieve(p.resolved).encode("utf-8"))
247+
else:
248+
n.write(p.resolved.encode("utf-8"))
244249
ensure_writable(p.target)
245250

246251
def collectFilesAndDirs(obj, out):

cwltool/secrets.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import uuid
2+
import six
3+
from typing import (List, Dict, Any, Text, MutableMapping)
4+
5+
class SecretStore(object):
6+
def __init__(self):
7+
# type: () -> None
8+
self.secrets = {} # type: Dict[Text, Text]
9+
10+
def add(self, value):
11+
# type: (Text) -> Text
12+
if not isinstance(value, six.string_types):
13+
raise Exception("Secret store only accepts strings")
14+
15+
if value not in self.secrets:
16+
placeholder = "(secret-%s)" % Text(uuid.uuid4())
17+
self.secrets[placeholder] = value
18+
return placeholder
19+
else:
20+
return value
21+
22+
def store(self, secrets, job):
23+
# type: (List[Text], MutableMapping[Text, Any]) -> None
24+
for j in job:
25+
if j in secrets:
26+
job[j] = self.add(job[j])
27+
28+
def has_secret(self, value):
29+
# type: (Any) -> bool
30+
if isinstance(value, six.string_types):
31+
for k in self.secrets:
32+
if k in value:
33+
return True
34+
elif isinstance(value, dict):
35+
for v in value.values():
36+
if self.has_secret(v):
37+
return True
38+
elif isinstance(value, list):
39+
for v in value:
40+
if self.has_secret(v):
41+
return True
42+
return False
43+
44+
def retrieve(self, value):
45+
# type: (Any) -> Any
46+
if isinstance(value, six.string_types):
47+
for k,v in self.secrets.items():
48+
value = value.replace(k, v)
49+
elif isinstance(value, dict):
50+
return {k: self.retrieve(v) for k,v in value.items()}
51+
elif isinstance(value, list):
52+
return [self.retrieve(v) for k,v in enumerate(value)]
53+
return value

tests/test_secrets.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import absolute_import
2+
3+
import unittest
4+
5+
from cwltool.secrets import SecretStore
6+
from .util import get_data
7+
8+
class TestSecrets(unittest.TestCase):
9+
def test_secrets(self):
10+
secrets = SecretStore()
11+
job = {"foo": "bar",
12+
"baz": "quux"}
13+
secrets.store(["foo"], job)
14+
self.assertNotEquals(job["foo"], "bar")
15+
self.assertEquals(job["baz"], "quux")
16+
self.assertEquals(secrets.retrieve(job)["foo"], "bar")
17+
18+
hello = "hello %s" % job["foo"]
19+
self.assertTrue(secrets.has_secret(hello))
20+
self.assertNotEquals(hello, "hello bar")
21+
self.assertEquals(secrets.retrieve(hello), "hello bar")
22+
23+
hello2 = ["echo", "hello %s" % job["foo"]]
24+
self.assertTrue(secrets.has_secret(hello2))
25+
self.assertNotEquals(hello2, ["echo", "hello bar"])
26+
self.assertEquals(secrets.retrieve(hello2), ["echo", "hello bar"])
27+
28+
hello3 = {"foo": job["foo"]}
29+
print(hello3)
30+
self.assertTrue(secrets.has_secret(hello3))
31+
self.assertNotEquals(hello3, {"foo": "bar"})
32+
self.assertEquals(secrets.retrieve(hello3), {"foo": "bar"})
33+
34+
self.assertNotEquals(job["foo"], "bar")
35+
self.assertEquals(job["baz"], "quux")

tests/wf/secret_job.cwl

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
cwlVersion: v1.0
2+
class: CommandLineTool
3+
$namespaces:
4+
cwltool: http://commonwl.org/cwltool#
5+
hints:
6+
"cwltool:Secrets":
7+
secrets: [pw]
8+
requirements:
9+
InitialWorkDirRequirement:
10+
listing:
11+
- entryname: example.conf
12+
entry: |
13+
username: user
14+
password: $(inputs.pw)
15+
inputs:
16+
pw: string
17+
outputs:
18+
out: stdout
19+
arguments: [cat, example.conf]

tests/wf/secret_wf.cwl

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
cwlVersion: v1.0
2+
class: Workflow
3+
$namespaces:
4+
cwltool: http://commonwl.org/cwltool#
5+
hints:
6+
"cwltool:Secrets":
7+
secrets: [pw]
8+
DockerRequirement:
9+
dockerPull: debian:8
10+
inputs:
11+
pw: string
12+
outputs:
13+
out:
14+
type: File
15+
outputSource: step1/out
16+
steps:
17+
step1:
18+
in:
19+
pw: pw
20+
out: [out]
21+
run: secret_job.cwl

0 commit comments

Comments
 (0)