Skip to content

Commit 463cf5a

Browse files
feat(validation): add workflow image vetting (reanahub#739)
1 parent e04d1fd commit 463cf5a

5 files changed

Lines changed: 128 additions & 1 deletion

File tree

docs/openapi.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,14 @@
521521
"yadage"
522522
]
523523
},
524+
"vetted_container_images_allowlist": {
525+
"title": "List of allowed container images",
526+
"value": []
527+
},
528+
"vetted_container_images_enabled": {
529+
"title": "Whether container images are vetted or not",
530+
"value": "False"
531+
},
524532
"workspaces_available": {
525533
"title": "List of available workspaces",
526534
"value": [
@@ -810,6 +818,31 @@
810818
},
811819
"type": "object"
812820
},
821+
"vetted_container_images_allowlist": {
822+
"properties": {
823+
"title": {
824+
"type": "string"
825+
},
826+
"value": {
827+
"items": {
828+
"type": "string"
829+
},
830+
"type": "array"
831+
}
832+
},
833+
"type": "object"
834+
},
835+
"vetted_container_images_enabled": {
836+
"properties": {
837+
"title": {
838+
"type": "string"
839+
},
840+
"value": {
841+
"type": "boolean"
842+
}
843+
},
844+
"type": "object"
845+
},
813846
"workspaces_available": {
814847
"properties": {
815848
"title": {

reana_server/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,11 @@ def _get_rate_limit(env_variable: str, default: str) -> str:
466466
)
467467
"""Whether users can set custom interactive session images or not."""
468468

469+
REANA_VETTED_CONTAINER_IMAGES = json.loads(
470+
os.getenv("REANA_VETTED_CONTAINER_IMAGES", "{}")
471+
)
472+
"""Container images that are allowed to be used in workflows."""
473+
469474
# Kubernetes jobs timeout
470475
# ==================
471476
REANA_KUBERNETES_JOBS_TIMEOUT_LIMIT = os.getenv("REANA_KUBERNETES_JOBS_TIMEOUT_LIMIT")

reana_server/rest/info.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
REANA_INTERACTIVE_SESSION_MAX_INACTIVITY_PERIOD,
2828
REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS,
2929
REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS_CUSTOM_ALLOWED,
30+
REANA_VETTED_CONTAINER_IMAGES,
3031
DASK_ENABLED,
3132
DASK_AUTOSCALER_ENABLED,
3233
REANA_DASK_CLUSTER_DEFAULT_NUMBER_OF_WORKERS,
@@ -153,6 +154,22 @@ def info(user, **kwargs): # noqa
153154
type: string
154155
type: array
155156
type: object
157+
vetted_container_images_enabled:
158+
properties:
159+
title:
160+
type: string
161+
value:
162+
type: boolean
163+
type: object
164+
vetted_container_images_allowlist:
165+
properties:
166+
title:
167+
type: string
168+
value:
169+
items:
170+
type: string
171+
type: array
172+
type: object
156173
supported_workflow_engines:
157174
properties:
158175
title:
@@ -318,6 +335,14 @@ def info(user, **kwargs): # noqa
318335
"title": "Whether users are allowed to spawn custom interactive session images",
319336
"value": "False"
320337
},
338+
"vetted_container_images_enabled": {
339+
"title": "Whether container images are vetted or not",
340+
"value": "False"
341+
},
342+
"vetted_container_images_allowlist": {
343+
"title": "List of allowed container images",
344+
"value": []
345+
},
321346
"supported_workflow_engines": {
322347
"title": "List of supported workflow engines",
323348
"value": [
@@ -452,6 +477,14 @@ def info(user, **kwargs): # noqa
452477
]
453478
],
454479
),
480+
vetted_container_images_enabled=dict(
481+
title="Whether container images are vetted or not",
482+
value=REANA_VETTED_CONTAINER_IMAGES["enabled"],
483+
),
484+
vetted_container_images_allowlist=dict(
485+
title="List of allowed container images",
486+
value=REANA_VETTED_CONTAINER_IMAGES["allowlist"],
487+
),
455488
supported_workflow_engines=dict(
456489
title="List of supported workflow engines",
457490
value=["cwl", "serial", "snakemake", "yadage"],
@@ -534,6 +567,13 @@ class StringInfoValue(Schema):
534567
value = fields.String(allow_none=False)
535568

536569

570+
class BooleanInfoValue(Schema):
571+
"""Schema for a value represented by a string."""
572+
573+
title = fields.String()
574+
value = fields.Boolean(allow_none=False)
575+
576+
537577
class StringNullableInfoValue(Schema):
538578
"""Schema for a value represented by a nullable string."""
539579

@@ -558,6 +598,8 @@ class InfoSchema(Schema):
558598
kubernetes_max_memory_limit = fields.Nested(StringInfoValue)
559599
interactive_session_recommended_jupyter_images = fields.Nested(ListStringInfoValue)
560600
interactive_sessions_custom_image_allowed = fields.Nested(StringInfoValue)
601+
vetted_container_images_enabled = fields.Nested(BooleanInfoValue)
602+
vetted_container_images_allowlist = fields.Nested(ListStringInfoValue)
561603
supported_workflow_engines = fields.Nested(ListStringInfoValue)
562604
cwl_engine_tool = fields.Nested(StringInfoValue)
563605
cwl_engine_version = fields.Nested(StringInfoValue)

reana_server/validation.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
REANA_DASK_CLUSTER_MAX_SINGLE_WORKER_MEMORY,
3232
REANA_DASK_CLUSTER_DEFAULT_SINGLE_WORKER_THREADS,
3333
REANA_DASK_CLUSTER_MAX_SINGLE_WORKER_THREADS,
34+
REANA_VETTED_CONTAINER_IMAGES,
3435
)
3536
from reana_server import utils
3637

@@ -119,6 +120,23 @@ def validate_inputs(reana_yaml: Dict) -> None:
119120
)
120121

121122

123+
def validate_images(reana_yaml: Dict) -> None:
124+
"""Check whether the images used in the workflow are allowed or not.
125+
126+
:param reana_yaml: REANA specification.
127+
"""
128+
if not REANA_VETTED_CONTAINER_IMAGES["enabled"]:
129+
return
130+
131+
allowed_images = REANA_VETTED_CONTAINER_IMAGES["allowlist"]
132+
steps = reana_yaml["workflow"].get("specification", {}).get("steps", [])
133+
134+
for step in steps:
135+
image = step.get("environment", None)
136+
if image and image not in allowed_images:
137+
raise REANAValidationError(f"Image not allowed: {image}")
138+
139+
122140
def validate_workflow(reana_yaml: Dict, input_parameters: Dict) -> Dict:
123141
"""Validate REANA workflow specification by calling all the validation utilities.
124142
@@ -137,6 +155,7 @@ def validate_workflow(reana_yaml: Dict, input_parameters: Dict) -> Dict:
137155
validate_compute_backends(reana_yaml)
138156
validate_workspace_path(reana_yaml)
139157
validate_inputs(reana_yaml)
158+
validate_images(reana_yaml)
140159
return reana_yaml_warnings
141160

142161

tests/test_validation.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212

1313
from reana_commons.errors import REANAValidationError
1414

15-
from reana_server.validation import validate_inputs, validate_retention_rule
15+
from reana_server.validation import (
16+
validate_inputs,
17+
validate_images,
18+
validate_retention_rule,
19+
)
1620

1721

1822
@pytest.mark.parametrize(
@@ -30,6 +34,30 @@ def test_validate_inputs(paths, error):
3034
validate_inputs({"inputs": {"directories": paths}})
3135

3236

37+
@pytest.mark.parametrize(
38+
"config, images, error",
39+
[
40+
# Validation disabled, anything goes
41+
({"enabled": False, "allowlist": []}, ["docker.io/bitcoin-miner:1.2.3"], does_not_raise()),
42+
43+
# Allowed image
44+
({"enabled": True, "allowlist": ["docker.io/reanahub/reana-env-root6:6.18.04"]}, ["docker.io/reanahub/reana-env-root6:6.18.04"], does_not_raise()),
45+
46+
# Disallowed image
47+
({"enabled": True, "allowlist": ["docker.io/reanahub/reana-env-root6:6.18.04"]}, ["docker.io/bitcoin-miner:1.2.3"], pytest.raises(REANAValidationError, match="not allowed")),
48+
49+
# Mixed images
50+
({"enabled": True, "allowlist": ["docker.io/reanahub/reana-env-root6:6.18.04"]}, ["docker.io/reanahub/reana-env-root6:6.18.04", "docker.io/bitcoin-miner:1.2.3"], pytest.raises(REANAValidationError, match="not allowed")),
51+
],
52+
)
53+
def test_validate_images(config, images, error):
54+
with patch("reana_server.validation.REANA_VETTED_CONTAINER_IMAGES", config):
55+
with error:
56+
validate_images(
57+
{"workflow": {"specification": {"steps": [{"environment": image} for image in images]}}}
58+
)
59+
60+
3361
@pytest.mark.parametrize(
3462
"rule, days, error",
3563
[

0 commit comments

Comments
 (0)