forked from mozilla-releng/scriptworker
-
Notifications
You must be signed in to change notification settings - Fork 0
/
verify.py
2199 lines (1805 loc) · 82.1 KB
/
verify.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Chain of Trust artifact verification.
Attributes:
DECISION_TASK_TYPES (tuple): the decision task types.
PARENT_TASK_TYPES (tuple): the parent task types.
log (logging.Logger): the log object for this module.
"""
import aiohttp
import argparse
import asyncio
from copy import deepcopy
import dictdiffer
from frozendict import frozendict
import jsone
import logging
import os
import pprint
import sys
import tempfile
from urllib.parse import urlparse
from scriptworker.artifacts import (
download_artifacts,
get_artifact_url,
get_optional_artifacts_per_task_id,
get_single_upstream_artifact_full_path,
)
from scriptworker.config import read_worker_creds, apply_product_config
from scriptworker.constants import DEFAULT_CONFIG
from scriptworker.context import Context
from scriptworker.ed25519 import ed25519_public_key_from_string, verify_ed25519_signature
from scriptworker.exceptions import CoTError, BaseDownloadError, ScriptWorkerEd25519Error
from scriptworker.github import (
GitHubRepository,
extract_github_repo_owner_and_name,
extract_github_repo_full_name,
)
from scriptworker.log import contextual_log_handler
from scriptworker.task import (
get_action_callback_name,
get_project,
get_and_check_tasks_for,
get_commit_message,
get_decision_task_id,
get_parent_task_id,
get_pull_request_number,
get_push_date_time,
get_repo,
get_repo_scope,
get_revision,
get_branch,
get_triggered_by,
get_task_id,
get_worker_pool_id,
is_try_or_pull_request,
is_action,
)
from scriptworker.utils import (
add_enumerable_item_to_dict,
get_hash,
get_loggable_url,
get_results_and_future_exceptions,
format_json,
load_json_or_yaml,
load_json_or_yaml_from_url,
makedirs,
match_url_path_callback,
match_url_regex,
raise_future_exceptions,
read_from_file,
remove_empty_keys,
rm,
write_to_file,
)
from scriptworker.version import __version_string__
from taskcluster.exceptions import TaskclusterFailure
from taskcluster.aio import Queue
log = logging.getLogger(__name__)
DECISION_TASK_TYPES = ('decision', )
PARENT_TASK_TYPES = ('decision', 'action')
# ChainOfTrust {{{1
class ChainOfTrust(object):
"""The master Chain of Trust, tracking all the various ``LinkOfTrust``s.
Attributes:
context (scriptworker.context.Context): the scriptworker context
decision_task_id (str): the task_id of self.task's decision task
parent_task_id (str): the task_id of self.task's parent task
links (list): the list of ``LinkOfTrust``s
name (str): the name of the task (e.g., signing)
task_id (str): the taskId of the task
task_type (str): the task type of the task (e.g., decision, build)
worker_impl (str): the taskcluster worker class (e.g., docker-worker) of the task
"""
def __init__(self, context, name, task_id=None):
"""Initialize ChainOfTrust.
Args:
context (scriptworker.context.Context): the scriptworker context
name (str): the name of the task (e.g., signing)
task_id (str, optional): the task_id of the task. If None, use
``get_task_id(context.claim_task)``. Defaults to None.
"""
self.name = name
self.context = context
self.task_id = task_id or get_task_id(context.claim_task)
self.task = context.task
self.task_type = guess_task_type(name, self.task)
self.worker_impl = guess_worker_impl(self) # this should be scriptworker
self.decision_task_id = get_decision_task_id(self.task)
self.parent_task_id = get_parent_task_id(self.task)
self.links = []
def dependent_task_ids(self):
"""Get all ``task_id``s for all ``LinkOfTrust`` tasks.
Returns:
list: the list of ``task_id``s
"""
return [x.task_id for x in self.links]
async def is_try_or_pull_request(self):
"""Determine if any task in the chain is a try task.
Returns:
bool: True if a task is a try task.
"""
tasks = [asyncio.ensure_future(link.is_try_or_pull_request()) for link in self.links]
tasks.insert(0, asyncio.ensure_future(is_try_or_pull_request(self.context, self.task)))
conditions = await raise_future_exceptions(tasks)
return any(conditions)
def get_link(self, task_id):
"""Get a ``LinkOfTrust`` by task id.
Args:
task_id (str): the task id to find.
Returns:
LinkOfTrust: the link matching the task id.
Raises:
CoTError: if no ``LinkOfTrust`` matches.
"""
links = [x for x in self.links if x.task_id == task_id]
if len(links) != 1:
raise CoTError("No single Link matches task_id {}!\n{}".format(task_id, self.dependent_task_ids()))
return links[0]
def is_decision(self):
"""Determine if the chain is a decision task.
Returns:
bool: whether it is a decision task.
"""
return self.task_type in DECISION_TASK_TYPES
def has_restricted_scopes(self):
"""Determine if this task is requesting any restricted scopes.
Returns:
bool: whether this task requested restricted scopes.
"""
return any(
(scope in self.context.config['cot_restricted_scopes'])
for scope in self.task['scopes']
)
def get_all_links_in_chain(self):
"""Return all links in the chain of trust, including the target task.
By default, we're checking a task and all its dependencies back to the
tree, so the full chain is ``self.links`` + ``self``. However, we also
support checking the decision task itself. In that case, we populate
the decision task as a link in ``self.links``, and we don't need to add
another check for ``self``.
Returns:
list: of all ``LinkOfTrust``s to verify.
"""
if self.is_decision() and self.get_link(self.task_id):
return self.links
return [self] + self.links
# LinkOfTrust {{{1
class LinkOfTrust(object):
"""Each LinkOfTrust represents a task in the Chain of Trust and its status.
Attributes:
context (scriptworker.context.Context): the scriptworker context
decision_task_id (str): the task_id of self.task's decision task
parent_task_id (str): the task_id of self.task's parent task
is_try_or_pull_request (bool): whether the task is a try or a pull request task
name (str): the name of the task (e.g., signing.decision)
task_id (str): the taskId of the task
task_graph (dict): the task graph of the task, if this is a decision task
task_type (str): the task type of the task (e.g., decision, build)
worker_impl (str): the taskcluster worker class (e.g., docker-worker) of the task
"""
_task = None
_cot = None
_task_graph = None
status = None
def __init__(self, context, name, task_id):
"""Initialize ChainOfTrust.
Args:
context (scriptworker.context.Context): the scriptworker context
name (str): the name of the task (e.g., signing)
task_id (str): the task_id of the task
"""
self.name = name
self.context = context
self.task_id = task_id
def _set(self, prop_name, value):
prev = getattr(self, prop_name)
if prev is not None:
raise CoTError(
"LinkOfTrust {}: re-setting {} to {} when it is already set to {}!".format(
str(self.name), prop_name, value, prev
)
)
return setattr(self, prop_name, value)
@property
def task(self):
"""dict: the task definition.
When set, we also set ``self.decision_task_id``, ``self.parent_task_id``,
and ``self.worker_impl`` based on the task definition.
"""
return self._task
@task.setter
def task(self, task):
self._set('_task', task)
self.task_type = guess_task_type(self.name, self.task)
self.decision_task_id = get_decision_task_id(self.task)
self.parent_task_id = get_parent_task_id(self.task)
self.worker_impl = guess_worker_impl(self)
async def is_try_or_pull_request(self):
"""bool: the task is either a try or a pull request one."""
return await is_try_or_pull_request(self.context, self.task)
@property
def cot(self):
"""dict: the chain of trust json body."""
return self._cot
@cot.setter
def cot(self, cot):
cot_task_id = cot.get('taskId')
if cot_task_id != self.task_id:
raise CoTError("Chain of Trust artifact taskId {} doesn't match task taskId {}!".format(cot_task_id, self.task_id))
self._set('_cot', cot)
@property
def task_graph(self):
"""dict: the decision task graph, if this is a decision task."""
return self._task_graph
@task_graph.setter
def task_graph(self, task_graph):
self._set('_task_graph', task_graph)
@property
def cot_dir(self):
"""str: the local path containing this link's artifacts."""
return self.get_artifact_full_path(path='.')
def get_artifact_full_path(self, path):
"""str: the full path where an artifact should be located."""
return get_single_upstream_artifact_full_path(self.context, self.task_id, path)
# raise_on_errors {{{1
def raise_on_errors(errors, level=logging.CRITICAL):
"""Raise a CoTError if errors.
Helper function because I had this code block everywhere.
Args:
errors (list): the error errors
level (int, optional): the log level to use. Defaults to logging.CRITICAL
Raises:
CoTError: if errors is non-empty
"""
if errors:
log.log(level, "\n".join(errors))
raise CoTError("\n".join(errors))
# guess_worker_impl {{{1
def guess_worker_impl(link):
"""Given a task, determine which worker implementation (e.g., docker-worker) it was run on.
* check for the `worker-implementation` tag
* docker-worker: ``task.payload.image`` is not None
* check for scopes beginning with the worker type name.
* generic-worker: ``task.payload.osGroups`` is not None
* generic-worker: ``task.payload.mounts`` is not None
Args:
link (LinkOfTrust or ChainOfTrust): the link to check.
Returns:
str: the worker type.
Raises:
CoTError: on inability to determine the worker implementation
"""
worker_impls = []
task = link.task
name = link.name
errors = []
if task['payload'].get("image"):
worker_impls.append("docker-worker")
if task['provisionerId'] in link.context.config['scriptworker_provisioners']:
worker_impls.append("scriptworker")
if get_worker_pool_id(task) in link.context.config['scriptworker_worker_pools']:
worker_impls.append("scriptworker")
if task['payload'].get("mounts") is not None:
worker_impls.append("generic-worker")
if task['payload'].get("osGroups") is not None:
worker_impls.append("generic-worker")
if task.get('tags', {}).get("worker-implementation", {}):
worker_impls.append(task['tags']['worker-implementation'])
for scope in task['scopes']:
if scope.startswith("docker-worker:"):
worker_impls.append("docker-worker")
if not worker_impls:
errors.append("guess_worker_impl: can't find a worker_impl for {}!\n{}".format(name, task))
if len(set(worker_impls)) > 1:
errors.append("guess_worker_impl: too many matches for {}: {}!\n{}".format(name, set(worker_impls), task))
raise_on_errors(errors)
log.debug("{} {} is {}".format(name, link.task_id, worker_impls[0]))
return worker_impls[0]
# get_valid_worker_impls {{{1
def get_valid_worker_impls():
"""Get the valid worker_impls, e.g. docker-worker.
No longer a constant, due to code ordering issues.
Returns:
frozendict: maps the valid worker_impls (e.g., docker-worker) to their
validation functions.
"""
# TODO support taskcluster worker
return frozendict({
'docker-worker': verify_docker_worker_task,
'generic-worker': verify_generic_worker_task,
'scriptworker': verify_scriptworker_task,
})
# guess_task_type {{{1
def guess_task_type(name, task_defn):
"""Guess the task type of the task.
Args:
name (str): the name of the task.
Returns:
str: the task_type.
Raises:
CoTError: on invalid task_type.
"""
parts = name.split(':')
task_type = parts[-1]
if task_type == 'parent':
if is_action(task_defn):
task_type = 'action'
else:
task_type = 'decision'
if task_type not in get_valid_task_types():
raise CoTError(
"Invalid task type for {}!".format(name)
)
return task_type
# get_valid_task_types {{{1
def get_valid_task_types():
"""Get the valid task types, e.g. signing.
No longer a constant, due to code ordering issues.
Returns:
frozendict: maps the valid task types (e.g., signing) to their validation functions.
"""
return frozendict({
'scriptworker': verify_scriptworker_task,
'balrog': verify_scriptworker_task,
'beetmover': verify_scriptworker_task,
'bouncer': verify_scriptworker_task,
'build': verify_build_task,
'l10n': verify_build_task,
'repackage': verify_build_task,
'action': verify_parent_task,
'decision': verify_parent_task,
'docker-image': verify_docker_image_task,
'pushapk': verify_scriptworker_task,
'pushsnap': verify_scriptworker_task,
'shipit': verify_scriptworker_task,
'signing': verify_scriptworker_task,
'partials': verify_partials_task,
})
# check_interactive_docker_worker {{{1
def check_interactive_docker_worker(link):
"""Given a task, make sure the task was not defined as interactive.
* ``task.payload.features.interactive`` must be absent or False.
* ``task.payload.env.TASKCLUSTER_INTERACTIVE`` must be absent or False.
Args:
link (LinkOfTrust): the task link we're checking.
Returns:
list: the list of error errors. Success is an empty list.
"""
errors = []
log.info("Checking for {} {} interactive docker-worker".format(link.name, link.task_id))
try:
if link.task['payload']['features'].get('interactive'):
errors.append("{} is interactive: task.payload.features.interactive!".format(link.name))
if link.task['payload']['env'].get('TASKCLUSTER_INTERACTIVE'):
errors.append("{} is interactive: task.payload.env.TASKCLUSTER_INTERACTIVE!".format(link.name))
except KeyError:
errors.append("check_interactive_docker_worker: {} task definition is malformed!".format(link.name))
return errors
# verify_docker_image_sha {{{1
def verify_docker_image_sha(chain, link):
"""Verify that built docker shas match the artifact.
Args:
chain (ChainOfTrust): the chain we're operating on.
link (LinkOfTrust): the task link we're checking.
Raises:
CoTError: on failure.
"""
cot = link.cot
task = link.task
errors = []
image = task['payload'].get('image')
if isinstance(image, dict) and image['type'] == 'task-image':
# Using pre-built image from docker-image task
docker_image_task_id = task['extra']['chainOfTrust']['inputs']['docker-image']
log.debug("Verifying {} {} against docker-image {}".format(
link.name, link.task_id, docker_image_task_id
))
if docker_image_task_id != image['taskId']:
errors.append("{} {} docker-image taskId isn't consistent!: {} vs {}".format(
link.name, link.task_id, docker_image_task_id,
task['payload']['image']['taskId']
))
else:
path = image['path']
# we need change the hash alg everywhere if we change, and recreate
# the docker images...
image_hash = cot['environment']['imageArtifactHash']
alg, sha = image_hash.split(':')
docker_image_link = chain.get_link(docker_image_task_id)
upstream_sha = docker_image_link.cot['artifacts'].get(path, {}).get(alg)
if upstream_sha is None:
errors.append("{} {} docker-image docker sha {} is missing! {}".format(
link.name, link.task_id, alg,
docker_image_link.cot['artifacts'][path]
))
elif upstream_sha != sha:
errors.append("{} {} docker-image docker sha doesn't match! {} {} vs {}".format(
link.name, link.task_id, alg, sha, upstream_sha
))
else:
log.debug("Found matching docker-image sha {}".format(upstream_sha))
elif isinstance(image, dict) and image['type'] == 'indexed-image':
# FIXME: Indexed image should be verified by CoT as well
if chain.has_restricted_scopes():
errors.append("Indexed images are not usable for tasks with restricted scopes.")
prebuilt_task_types = chain.context.config['prebuilt_docker_image_task_types']
if prebuilt_task_types != "any" and link.task_type not in prebuilt_task_types:
errors.append(
"Task type {} not allowed to use a indexed docker image!".format(
link.task_type
)
)
elif isinstance(image, dict):
errors.append("Unknown type of docker image {}.".format(image.get('type')))
else:
prebuilt_task_types = chain.context.config['prebuilt_docker_image_task_types']
if prebuilt_task_types != "any" and link.task_type not in prebuilt_task_types:
errors.append(
"Task type {} not allowed to use a prebuilt docker image!".format(
link.task_type
)
)
raise_on_errors(errors)
# find_sorted_task_dependencies {{{1
def find_sorted_task_dependencies(task, task_name, task_id):
"""Find the taskIds of the chain of trust dependencies of a given task.
Args:
task (dict): the task definition to inspect.
task_name (str): the name of the task, for logging and naming children.
task_id (str): the taskId of the task.
Returns:
list: tuples associating dependent task ``name`` to dependent task ``taskId``.
"""
log.info("find_sorted_task_dependencies {} {}".format(task_name, task_id))
cot_input_dependencies = [
_craft_dependency_tuple(task_name, task_type, task_id)
for task_type, task_id in task['extra'].get('chainOfTrust', {}).get('inputs', {}).items()
]
upstream_artifacts_dependencies = [
_craft_dependency_tuple(task_name, artifact_dict['taskType'], artifact_dict['taskId'])
for artifact_dict in task.get('payload', {}).get('upstreamArtifacts', [])
]
dependencies = [*cot_input_dependencies, *upstream_artifacts_dependencies]
dependencies = _sort_dependencies_by_name_then_task_id(dependencies)
parent_task_id = get_parent_task_id(task) or get_decision_task_id(task)
parent_task_type = 'parent'
# make sure we deal with the decision task first, or we may populate
# signing:build0:decision before signing:decision
parent_tuple = _craft_dependency_tuple(task_name, parent_task_type, parent_task_id)
dependencies.insert(0, parent_tuple)
log.info('found dependencies: {}'.format(dependencies))
return dependencies
def _craft_dependency_tuple(task_name, task_type, task_id):
return ('{}:{}'.format(task_name, task_type), task_id)
def _sort_dependencies_by_name_then_task_id(dependencies):
return sorted(dependencies, key=lambda dep: '{}_{}'.format(dep[0], dep[1]))
# build_task_dependencies {{{1
async def build_task_dependencies(chain, task, name, my_task_id):
"""Recursively build the task dependencies of a task.
Args:
chain (ChainOfTrust): the chain of trust to add to.
task (dict): the task definition to operate on.
name (str): the name of the task to operate on.
my_task_id (str): the taskId of the task to operate on.
Raises:
CoTError: on failure.
"""
log.info("build_task_dependencies {} {}".format(name, my_task_id))
if name.count(':') > chain.context.config['max_chain_length']:
raise CoTError("Too deep recursion!\n{}".format(name))
sorted_dependencies = find_sorted_task_dependencies(task, name, my_task_id)
for task_name, task_id in sorted_dependencies:
if task_id not in chain.dependent_task_ids():
link = LinkOfTrust(chain.context, task_name, task_id)
json_path = link.get_artifact_full_path('task.json')
try:
task_defn = await chain.context.queue.task(task_id)
link.task = task_defn
chain.links.append(link)
# write task json to disk
makedirs(os.path.dirname(json_path))
with open(json_path, 'w') as fh:
fh.write(format_json(task_defn))
await build_task_dependencies(chain, task_defn, task_name, task_id)
except TaskclusterFailure as exc:
raise CoTError(str(exc))
# download_cot {{{1
async def download_cot(chain):
"""Download the signed chain of trust artifacts.
Args:
chain (ChainOfTrust): the chain of trust to add to.
Raises:
BaseDownloadError: on failure.
"""
artifact_tasks = []
# only deal with chain.links, which are previously finished tasks with
# signed chain of trust artifacts. ``chain.task`` is the current running
# task, and will not have a signed chain of trust artifact yet.
for link in chain.links:
task_id = link.task_id
parent_dir = link.cot_dir
urls = []
unsigned_url = get_artifact_url(chain.context, task_id, 'public/chain-of-trust.json')
urls.append(unsigned_url)
if chain.context.config['verify_cot_signature']:
urls.append(
get_artifact_url(chain.context, task_id, 'public/chain-of-trust.json.sig')
)
artifact_tasks.append(
asyncio.ensure_future(
download_artifacts(
chain.context, urls, parent_dir=parent_dir,
valid_artifact_task_ids=[task_id]
)
)
)
artifacts_paths = await raise_future_exceptions(artifact_tasks)
for path in artifacts_paths:
sha = get_hash(path[0])
log.debug("{} downloaded; hash is {}".format(path[0], sha))
# download_cot_artifact {{{1
async def download_cot_artifact(chain, task_id, path):
"""Download an artifact and verify its SHA against the chain of trust.
Args:
chain (ChainOfTrust): the chain of trust object
task_id (str): the task ID to download from
path (str): the relative path to the artifact to download
Returns:
str: the full path of the downloaded artifact
Raises:
CoTError: on failure.
"""
link = chain.get_link(task_id)
log.debug("Verifying {} is in {} cot artifacts...".format(path, task_id))
if not link.cot:
log.warning('Chain of Trust for "{}" in {} does not exist. See above log for more details. \
Skipping download of this artifact'.format(path, task_id))
return
if path not in link.cot['artifacts']:
raise CoTError("path {} not in {} {} chain of trust artifacts!".format(path, link.name, link.task_id))
url = get_artifact_url(chain.context, task_id, path)
loggable_url = get_loggable_url(url)
log.info("Downloading Chain of Trust artifact:\n{}".format(loggable_url))
await download_artifacts(
chain.context, [url], parent_dir=link.cot_dir, valid_artifact_task_ids=[task_id]
)
full_path = link.get_artifact_full_path(path)
for alg, expected_sha in link.cot['artifacts'][path].items():
if alg not in chain.context.config['valid_hash_algorithms']:
raise CoTError("BAD HASH ALGORITHM: {}: {} {}!".format(link.name, alg, full_path))
real_sha = get_hash(full_path, hash_alg=alg)
if expected_sha != real_sha:
raise CoTError("BAD HASH on file {}: {}: Expected {} {}; got {}!".format(
full_path, link.name, alg, expected_sha, real_sha
))
log.debug("{} matches the expected {} {}".format(full_path, alg, expected_sha))
return full_path
# download_cot_artifacts {{{1
async def download_cot_artifacts(chain):
"""Call ``download_cot_artifact`` in parallel for each "upstreamArtifacts".
Optional artifacts are allowed to not be downloaded.
Args:
chain (ChainOfTrust): the chain of trust object
Returns:
list: list of full paths to downloaded artifacts. Failed optional artifacts
aren't returned
Raises:
CoTError: on chain of trust sha validation error, on a mandatory artifact
BaseDownloadError: on download error on a mandatory artifact
"""
upstream_artifacts = chain.task['payload'].get('upstreamArtifacts', [])
all_artifacts_per_task_id = get_all_artifacts_per_task_id(chain, upstream_artifacts)
mandatory_artifact_tasks = []
optional_artifact_tasks = []
for task_id, paths in all_artifacts_per_task_id.items():
for path in paths:
coroutine = asyncio.ensure_future(download_cot_artifact(chain, task_id, path))
if is_artifact_optional(chain, task_id, path):
optional_artifact_tasks.append(coroutine)
else:
mandatory_artifact_tasks.append(coroutine)
mandatory_artifacts_paths = await raise_future_exceptions(mandatory_artifact_tasks)
succeeded_optional_artifacts_paths, failed_optional_artifacts = \
await get_results_and_future_exceptions(optional_artifact_tasks)
if failed_optional_artifacts:
log.warning('Could not download {} artifacts: {}'.format(len(failed_optional_artifacts), failed_optional_artifacts))
return mandatory_artifacts_paths + succeeded_optional_artifacts_paths
def is_artifact_optional(chain, task_id, path):
"""Tells whether an artifact is flagged as optional or not.
Args:
chain (ChainOfTrust): the chain of trust object
task_id (str): the id of the aforementioned task
Returns:
bool: True if artifact is optional
"""
upstream_artifacts = chain.task['payload'].get('upstreamArtifacts', [])
optional_artifacts_per_task_id = get_optional_artifacts_per_task_id(upstream_artifacts)
return path in optional_artifacts_per_task_id.get(task_id, [])
def get_all_artifacts_per_task_id(chain, upstream_artifacts):
"""Return every artifact to download, including the Chain Of Trust Artifacts.
Args:
chain (ChainOfTrust): the chain of trust object
upstream_artifacts: the list of upstream artifact definitions
Returns:
dict: sorted list of paths to downloaded artifacts ordered by taskId
"""
all_artifacts_per_task_id = {}
for link in chain.links:
# Download task-graph.json for decision+action task cot verification
if link.task_type in PARENT_TASK_TYPES:
add_enumerable_item_to_dict(
dict_=all_artifacts_per_task_id, key=link.task_id, item='public/task-graph.json'
)
# Download actions.json for decision+action task cot verification
if link.task_type in DECISION_TASK_TYPES:
add_enumerable_item_to_dict(
dict_=all_artifacts_per_task_id, key=link.task_id, item='public/actions.json'
)
add_enumerable_item_to_dict(
dict_=all_artifacts_per_task_id, key=link.task_id, item='public/parameters.yml'
)
if upstream_artifacts:
for upstream_dict in upstream_artifacts:
add_enumerable_item_to_dict(
dict_=all_artifacts_per_task_id, key=upstream_dict['taskId'], item=upstream_dict['paths']
)
# Avoid duplicate paths per task_id
for task_id, paths in all_artifacts_per_task_id.items():
all_artifacts_per_task_id[task_id] = sorted(set(paths))
return all_artifacts_per_task_id
# verify_cot_signatures {{{1
def verify_link_ed25519_cot_signature(chain, link, unsigned_path, signature_path):
"""Verify the ed25519 signatures of the chain of trust artifacts populated in ``download_cot``.
Populate each link.cot with the chain of trust json body.
Args:
chain (ChainOfTrust): the chain of trust to add to.
Raises:
(CoTError, ScriptWorkerEd25519Error): on signature verification failure.
"""
if chain.context.config['verify_cot_signature']:
log.debug("Verifying the {} {} {} ed25519 chain of trust signature".format(
link.name, link.task_id, link.worker_impl
))
signature = read_from_file(signature_path, file_type='binary', exception=CoTError)
binary_contents = read_from_file(unsigned_path, file_type='binary', exception=CoTError)
errors = []
verify_key_seeds = chain.context.config['ed25519_public_keys'].get(link.worker_impl, [])
for seed in verify_key_seeds:
try:
verify_key = ed25519_public_key_from_string(seed)
verify_ed25519_signature(
verify_key, binary_contents, signature,
"{} {}: {} ed25519 cot signature doesn't verify against {}: %(exc)s".format(
link.name, link.task_id, link.worker_impl, seed
)
)
log.debug("{} {}: ed25519 cot signature verified.".format(link.name, link.task_id))
break
except ScriptWorkerEd25519Error as exc:
errors.append(str(exc))
else:
errors = errors or [
"{} {}: Unknown error verifying ed25519 cot signature. worker_impl {} verify_keys {}".format(
link.name, link.task_id, link.worker_impl,
verify_key_seeds
)
]
message = "\n".join(errors)
raise CoTError(message)
link.cot = load_json_or_yaml(
unsigned_path, is_path=True, exception=CoTError,
message="{} {}: Invalid unsigned cot json body! %(exc)s".format(link.name, link.task_id)
)
def verify_cot_signatures(chain):
"""Verify the signatures of the chain of trust artifacts populated in ``download_cot``.
Populate each link.cot with the chain of trust json body.
Args:
chain (ChainOfTrust): the chain of trust to add to.
Raises:
CoTError: on failure.
"""
for link in chain.links:
unsigned_path = link.get_artifact_full_path('public/chain-of-trust.json')
ed25519_signature_path = link.get_artifact_full_path('public/chain-of-trust.json.sig')
verify_link_ed25519_cot_signature(chain, link, unsigned_path, ed25519_signature_path)
# verify_task_in_task_graph {{{1
def verify_task_in_task_graph(task_link, graph_defn, level=logging.CRITICAL):
"""Verify a given task_link's task against a given graph task definition.
This is a helper function for ``verify_link_in_task_graph``; this is split
out so we can call it multiple times when we fuzzy match.
Args:
task_link (LinkOfTrust): the link to try to match
graph_defn (dict): the task definition from the task-graph.json to match
``task_link`` against
level (int, optional): the logging level to use on errors. Defaults to logging.CRITICAL
Raises:
CoTError: on failure
"""
ignore_keys = ("created", "deadline", "expires", "dependencies", "schedulerId")
errors = []
runtime_defn = deepcopy(task_link.task)
# dependencies
# Allow for the decision task ID in the dependencies; otherwise the runtime
# dependencies must be a subset of the graph dependencies.
bad_deps = set(runtime_defn['dependencies']) - set(graph_defn['task']['dependencies'])
# it's OK if a task depends on the decision task
bad_deps = bad_deps - {task_link.decision_task_id}
if bad_deps:
errors.append("{} {} dependencies don't line up!\n{}".format(
task_link.name, task_link.task_id, bad_deps
))
# payload - eliminate the 'expires' key from artifacts because the datestring
# will change
runtime_defn['payload'] = _take_expires_out_from_artifacts_in_payload(runtime_defn['payload'])
graph_defn['task']['payload'] = _take_expires_out_from_artifacts_in_payload(graph_defn['task']['payload'])
# test all non-ignored key/value pairs in the task defn
for key, value in graph_defn['task'].items():
if key in ignore_keys:
continue
if value != runtime_defn[key]:
errors.append("{} {} {} differs!\n graph: {}\n task: {}".format(
task_link.name, task_link.task_id, key,
format_json(value), format_json(runtime_defn[key])
))
raise_on_errors(errors, level=level)
def _take_expires_out_from_artifacts_in_payload(payload):
returned_payload = deepcopy(payload)
artifacts = returned_payload.get('artifacts', None)
if artifacts is None:
return returned_payload
elif type(artifacts) not in (dict, list):
raise CoTError('Unsupported type of artifacts. Found: "{}". Expected: dict, list or undefined. Payload: {}'.format(
type(artifacts), payload
))
artifacts_iterable = artifacts.values() if isinstance(artifacts, dict) else artifacts
for artifact_definition in artifacts_iterable:
if isinstance(artifact_definition, dict) and 'expires' in artifact_definition:
del(artifact_definition['expires'])
return returned_payload
# verify_link_in_task_graph {{{1
def verify_link_in_task_graph(chain, decision_link, task_link):
"""Compare the runtime task definition against the decision task graph.
Args:
chain (ChainOfTrust): the chain we're operating on.
decision_link (LinkOfTrust): the decision task link
task_link (LinkOfTrust): the task link we're testing
Raises:
CoTError: on failure.
"""
log.info("Verifying the {} {} task definition is part of the {} {} task graph...".format(
task_link.name, task_link.task_id, decision_link.name, decision_link.task_id
))
if task_link.task_id in decision_link.task_graph:
graph_defn = deepcopy(decision_link.task_graph[task_link.task_id])
verify_task_in_task_graph(task_link, graph_defn)
log.info("Found {} in the graph; it's a match".format(task_link.task_id))
return
raise_on_errors(["Can't find task {} {} in {} {} task-graph.json!".format(
task_link.name, task_link.task_id, decision_link.name, decision_link.task_id
)])
# get_pushlog_info {{{1
async def get_pushlog_info(decision_link):
"""Get pushlog info for a decision LinkOfTrust.
Args:
decision_link (LinkOfTrust): the decision link to get pushlog info about.
Returns:
dict: pushlog info.
"""
source_env_prefix = decision_link.context.config['source_env_prefix']
repo = get_repo(decision_link.task, source_env_prefix)
rev = get_revision(decision_link.task, source_env_prefix)
context = decision_link.context
pushlog_url = context.config['pushlog_url'].format(
repo=repo, revision=rev
)
log.info("Pushlog url {}".format(pushlog_url))
file_path = os.path.join(context.config["work_dir"], "{}_push_log.json".format(decision_link.name))
pushlog_info = await load_json_or_yaml_from_url(
context, pushlog_url, file_path, overwrite=False
)
if len(pushlog_info['pushes']) != 1:
log.warning("Pushlog error: expected a single push at {} but got {}!".format(
pushlog_url, pushlog_info['pushes']
))
return pushlog_info
# get_scm_level {{{1
async def get_scm_level(context, project):
"""Get the scm level for a project from ``projects.yml``.