From fd646d09d249b6d6a075c23d5453b1d96687f422 Mon Sep 17 00:00:00 2001 From: "Philipp Heil (zkdev)" Date: Thu, 9 Oct 2025 11:46:10 +0200 Subject: [PATCH 1/2] feat(odg): Add test evidence extension Signed-off-by: Philipp Heil (zkdev) Co-authored-by: Franziska Schallhorn --- artefact_enumerator.py | 17 ++ charts/extensions/Chart.yaml | 3 + .../test-evidence/templates/test-results.yaml | 123 ++++++++++ charts/extensions/values.yaml | 7 + odg/extensions_cfg.py | 20 ++ odg/extensions_cfg.yaml | 3 + odg/findings.py | 29 +++ odg/findings_cfg.yaml | 19 ++ odg/labels.py | 12 + odg/model.py | 26 +- odg/profiles.yaml | 1 + .../test_evidence_component_descriptor.yaml | 45 ++++ test/test_test_evidence_extension.py | 112 +++++++++ test_evidence_extension.py | 224 ++++++++++++++++++ 14 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 charts/extensions/charts/test-evidence/templates/test-results.yaml create mode 100644 test/resources/test_evidence_component_descriptor.yaml create mode 100644 test/test_test_evidence_extension.py create mode 100644 test_evidence_extension.py diff --git a/artefact_enumerator.py b/artefact_enumerator.py index bb492fc9..251568ff 100644 --- a/artefact_enumerator.py +++ b/artefact_enumerator.py @@ -359,6 +359,23 @@ def _process_compliance_snapshot_of_artefact( if uncommitted_backlog_item: uncommitted_backlog_items.append(uncommitted_backlog_item) + if ( + extensions_cfg.test_results + and extensions_cfg.test_results.enabled + and extensions_cfg.test_results.is_supported(artefact_kind=artefact.artefact_kind) + ): + compliance_snapshot, uncommitted_backlog_item = _create_backlog_item_for_extension( + finding_cfgs=finding_cfgs, + finding_types=(odg.model.Datatype.TEST_RESULT_FINDING,), + artefact=artefact, + compliance_snapshot=compliance_snapshot, + service=odg.extensions_cfg.Services.TEST_RESULT_FINDING, + interval_seconds=extensions_cfg.osid.interval, + now=now, + ) + if uncommitted_backlog_item: + uncommitted_backlog_items.append(uncommitted_backlog_item) + if ( extensions_cfg.osid and extensions_cfg.osid.enabled diff --git a/charts/extensions/Chart.yaml b/charts/extensions/Chart.yaml index c00b98a7..22b29999 100644 --- a/charts/extensions/Chart.yaml +++ b/charts/extensions/Chart.yaml @@ -44,3 +44,6 @@ dependencies: - name: odg-operator repository: file://charts/odg-operator condition: odg-operator.enabled + - name: test-evidence + repository: file://charts/test-evidence + condition: test-evidence.enabled diff --git a/charts/extensions/charts/test-evidence/templates/test-results.yaml b/charts/extensions/charts/test-evidence/templates/test-results.yaml new file mode 100644 index 00000000..37729176 --- /dev/null +++ b/charts/extensions/charts/test-evidence/templates/test-results.yaml @@ -0,0 +1,123 @@ +{{- $podName := "test-evidence" }} + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $podName }} + namespace: {{ .Values.target_namespace | default .Release.Namespace }} + {{- if default dict (.Values.deployment).annotations }} + annotations: + {{- range $annotation, $value := .Values.deployment.annotations }} + {{ $annotation }}: {{ $value }} + {{- end }} + {{- end }} +spec: + replicas: 0 # will be scaled automatically by backlog-controller + selector: + matchLabels: + app: {{ $podName }} + delivery-gear.gardener.cloud/service: testEvidence + template: + metadata: + labels: + app: {{ $podName }} + delivery-gear.gardener.cloud/service: testEvidence + spec: + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app: {{ $podName }} + terminationGracePeriodSeconds: 300 # 5 min + containers: + - name: {{ $podName }} + image: {{ include "image" .Values.image }} + imagePullPolicy: IfNotPresent + command: + - python3 + - -m + - test_evidence_extension + securityContext: + allowPrivilegeEscalation: false + env: + - name: SECRET_FACTORY_PATH + value: /secrets + - name: EXTENSIONS_CFG_PATH + value: /extensions_cfg/extensions_cfg + - name: FINDINGS_CFG_PATH + value: /findings_cfg/findings_cfg + - name: OCM_REPO_MAPPINGS_PATH + value: /ocm_repo_mappings/ocm_repo_mappings + - name: K8S_TARGET_NAMESPACE + value: {{ .Values.target_namespace | default .Release.Namespace }} + volumeMounts: + - name: github + mountPath: /secrets/github + - name: github-app + mountPath: /secrets/github-app + - name: kubernetes + mountPath: /secrets/kubernetes + - name: oci-registry + mountPath: /secrets/oci-registry + - name: extensions-cfg + mountPath: /extensions_cfg + - name: findings-cfg + mountPath: /findings_cfg + - name: ocm-repo-mappings + mountPath: /ocm_repo_mappings + readOnly: true + lifecycle: + preStop: # hook ensures that just created pods have at least enough time alive to add a termination signal handler + exec: + command: + - sleep + - "60" + resources: + requests: + memory: 100Mi + cpu: 250m + limits: + memory: 300Mi + cpu: 500m + volumes: + - name: github + secret: + secretName: secret-factory-github + optional: true + - name: github-app + secret: + secretName: secret-factory-github-app + optional: true + - name: kubernetes + secret: + secretName: secret-factory-kubernetes + optional: true # might use incluster config + - name: oci-registry + secret: + secretName: secret-factory-oci-registry + optional: true # oci authentication is optional + - name: extensions-cfg + configMap: + name: extensions-cfg + - name: findings-cfg + configMap: + name: findings-cfg + - name: ocm-repo-mappings + configMap: + name: ocm-repo-mappings +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-from-test-evidence + namespace: {{ .Values.target_namespace | default .Release.Namespace }} +spec: + podSelector: + matchLabels: + app: {{ $podName }} + policyTypes: + - Egress + egress: + - {} # Allows all egress traffic to any destination and port diff --git a/charts/extensions/values.yaml b/charts/extensions/values.yaml index 2617b786..8773186b 100644 --- a/charts/extensions/values.yaml +++ b/charts/extensions/values.yaml @@ -88,3 +88,10 @@ sast: image: repository: null tag: null +test-evidence: + deployment: + annotations: [] + enabled: false + image: + repository: null + tag: null diff --git a/odg/extensions_cfg.py b/odg/extensions_cfg.py index 1846d0a0..8e8f89c6 100644 --- a/odg/extensions_cfg.py +++ b/odg/extensions_cfg.py @@ -43,6 +43,7 @@ class Services(enum.StrEnum): PPMS = 'ppms' RESPONSIBLES = 'responsibles' SAST = 'sast' + TEST_EVIDENCE = 'testEvidence' ODG_OPERATOR = 'odg-operator' SBOM_GENERATOR = 'sbomGenerator' @@ -984,6 +985,24 @@ def is_supported( return True +@dataclasses.dataclass(kw_only=True) +class TestEvidenceConfig(BacklogItemMixins): + service: Services = Services.TEST_EVIDENCE + delivery_service_url: str + external_artefacts_require_tests: bool + interval: int = 60 * 60 * 24 # + on_unsupported: WarningVerbosities = WarningVerbosities.WARNING + + def is_supported( + self, + artefact_kind: odg.model.ArtefactKind, + ) -> bool: + if artefact_kind is not odg.model.ArtefactKind.RESOURCE: + return False + + return True + + @dataclasses.dataclass(kw_only=True) class OsId(BacklogItemMixins): ''' @@ -1119,6 +1138,7 @@ class ExtensionsConfiguration: responsibles: ResponsiblesConfig | None sast: SASTConfig | None sbom_generator: SBOMGeneratorConfig | None + test_evidence: TestEvidenceConfig | None backlog_controller: BacklogControllerConfig = dataclasses.field(default_factory=BacklogControllerConfig) # noqa: E501 @staticmethod diff --git a/odg/extensions_cfg.yaml b/odg/extensions_cfg.yaml index 155dd62b..c8f88827 100644 --- a/odg/extensions_cfg.yaml +++ b/odg/extensions_cfg.yaml @@ -105,3 +105,6 @@ sbom_generator: mappings: - prefix: '' group_id: 0 # must be set + +test_evidence: + external_artefacts_require_tests: False diff --git a/odg/findings.py b/odg/findings.py index b6ce79d6..bd53212b 100644 --- a/odg/findings.py +++ b/odg/findings.py @@ -135,6 +135,15 @@ class SASTFindingSelector: sub_types: list[str] +@dataclasses.dataclass +class TestEvidenceFindingSelector: + ''' + :param list[str] status: + List of regexes to determine matching test-evidence statuses. + ''' + status: list[str] + + @dataclasses.dataclass class VulnerabilityFindingSelector: ''' @@ -195,6 +204,7 @@ class FindingCategorisation: | SASTFindingSelector | VulnerabilityFindingSelector | OsIdFindingSelector + | TestEvidenceFindingSelector | None ) @@ -557,6 +567,8 @@ def _validate(self): self._validate_malware() case odg.model.Datatype.SAST_FINDING: self._validate_sast() + case odg.model.Datatype.TEST_EVIDENCE_FINDING: + self._validate_test_result() case odg.model.Datatype.VULNERABILITY_FINDING: self._validate_vulnerabilty() case odg.model.Datatype.INVENTORY_FINDING: @@ -647,6 +659,18 @@ def _validate_malware(self): e.add_note('\n'.join(violations)) raise e + def _validate_test_result(self): + violations = self._validate_categorisations( + expected_selector=TestEvidenceFindingSelector, + ) + + if not violations: + return + + e = ModelValidationError('test evidence violations found:') + e.add_note('\n'.join(violations)) + raise e + def _validate_sast(self): violations = self._validate_categorisations( expected_selector=SASTFindingSelector, @@ -930,3 +954,8 @@ def categorise_finding( for status in selector.status: if re.fullmatch(status, finding_property, re.IGNORECASE): return categorisation + + elif isinstance(selector, TestEvidenceFindingSelector): + for status in selector.status: + if re.fullmatch(status, finding_property, re.IGNORECASE): + return categorisation diff --git a/odg/findings_cfg.yaml b/odg/findings_cfg.yaml index 726d9fa3..9d8dcbe4 100644 --- a/odg/findings_cfg.yaml +++ b/odg/findings_cfg.yaml @@ -46,6 +46,25 @@ malware_names: - .* +- type: finding/test-evidence + issues: + enable_assignees: False + categorisations: + - id: NONE + display_name: No Tests required + value: 0 + allowed_processing_time: ~ + rescoring: manual + + - id: BLOCKER + display_name: No Test Evidence + value: 16 + allowed_processing_time: 0 + rescoring: manual + selector: + status: + - .* + - type: finding/sast issues: enable_issues: False diff --git a/odg/labels.py b/odg/labels.py index a011a9a4..8bc17419 100644 --- a/odg/labels.py +++ b/odg/labels.py @@ -76,6 +76,18 @@ class CveCategorisationLabel(Label): value: odg.cvss.CveCategorisation +@dataclasses.dataclass(frozen=True) +class TestScopeLabel(Label): + name = 'gardener.cloud/test-scope' + value: str # artefact-name + + +@dataclasses.dataclass(frozen=True) +class TestPolicyLabel(Label): + name = 'gardener.cloud/test-policy' + value: bool # is test required + + @functools.cache def _label_to_type() -> dict[str, Label]: own_module = sys.modules[__name__] diff --git a/odg/model.py b/odg/model.py index 1f20f593..bb7351ec 100644 --- a/odg/model.py +++ b/odg/model.py @@ -42,6 +42,7 @@ class Datatype(enum.StrEnum): OSID_FINDING = 'finding/osid' SAST_FINDING = 'finding/sast' VULNERABILITY_FINDING = 'finding/vulnerability' + TEST_EVIDENCE_FINDING = 'finding/test-evidence' # informational datatypes CRYPTO_ASSET = 'crypto_asset' @@ -62,6 +63,7 @@ def datasource(self) -> 'Datasource': Datatype.OSID_FINDING: Datasource.OSID, Datatype.SAST_FINDING: Datasource.SAST, Datatype.VULNERABILITY_FINDING: Datasource.BDBA, + Datatype.TEST_EVIDENCE_FINDING: Datasource.TEST_EVIDENCE, }[self] def display_name(self) -> str: @@ -83,6 +85,7 @@ class Datasource(enum.StrEnum): OSID = 'osid' RESPONSIBLES = 'responsibles' SAST = 'sast' + TEST_EVIDENCE = 'test-evidence' def datatypes(self) -> tuple[Datatype, ...]: return { @@ -126,6 +129,7 @@ def datatypes(self) -> tuple[Datatype, ...]: Datasource.SAST: ( Datatype.SAST_FINDING, ), + Datasource.TEST_EVIDENCE: (Datatype.TEST_EVIDENCE_FINDING) }.get(self, tuple()) @@ -177,13 +181,23 @@ class UserIdentity: ''' Collection of identities that refer to the same user ''' - identifiers: list[EmailAddress | GithubUser | MetaOrigin | PersonalName | UserIdentifierBase] + identifiers: list[ + EmailAddress + | GithubUser + | MetaOrigin + | PersonalName + | UserIdentifierBase + ] class SastStatus(enum.StrEnum): NO_LINTER = 'no-linter' +class TestStatus(enum.StrEnum): + NO_TEST_EVIDENCE = 'no-test-evidence' + + class SastSubType(enum.StrEnum): LOCAL_LINTING = 'local-linting' CENTRAL_LINTING = 'central-linting' @@ -473,6 +487,15 @@ def key(self) -> str: return _as_key(self.package_name, self.package_version, self.license.name) +@dataclasses.dataclass +class TestEvidenceMissingFinding(Finding): + test_status: TestStatus + + @property + def key(self) -> str: + return _as_key(self.test_status) + + @dataclasses.dataclass class VulnerabilityFinding(Finding, BDBAMixin): cve: str @@ -1426,6 +1449,7 @@ def key(self) -> str: | OsIdFinding | SastFinding | VulnerabilityFinding + | TestEvidenceMissingFinding ) InformationalModels = ( StructureInfo diff --git a/odg/profiles.yaml b/odg/profiles.yaml index 8d241e13..89fc4cd5 100644 --- a/odg/profiles.yaml +++ b/odg/profiles.yaml @@ -12,5 +12,6 @@ - finding/osid - finding/sast - finding/vulnerability + - finding/test-result special_component_ids: - 03e237b4-434e-4e1c-b786-5ceb6cc76c1c diff --git a/test/resources/test_evidence_component_descriptor.yaml b/test/resources/test_evidence_component_descriptor.yaml new file mode 100644 index 00000000..7c643652 --- /dev/null +++ b/test/resources/test_evidence_component_descriptor.yaml @@ -0,0 +1,45 @@ +component: + name: MyComponent + version: 1.2.3 + resources: + - name: test-evidence + relation: local + labels: + - name: gardener.cloud/purposes + value: + - test + - name: gardener.cloud/test-scope + value: build-result + version: foo + type: foo + + - name: build-result + relation: local + labels: + - name: gardener.cloud/test-policy + value: True + version: foo + type: foo + + - name: other-build-result + relation: local + labels: + - name: gardener.cloud/test-policy + value: True + version: foo + type: foo + + - name: build-result-not-requiring-tests + relation: local + labels: + - name: gardener.cloud/test-policy + value: False + version: foo + type: foo + + repositoryContexts: [] + provider: any + sources: [] + componentReferences: [] +meta: + schemaVersion: v2 \ No newline at end of file diff --git a/test/test_test_evidence_extension.py b/test/test_test_evidence_extension.py new file mode 100644 index 00000000..48e65250 --- /dev/null +++ b/test/test_test_evidence_extension.py @@ -0,0 +1,112 @@ +import enum +import os +import pathlib + +import dacite +import pytest +import yaml + +import ocm +import ocm.iter + +import odg.extensions_cfg +import test_evidence_extension as tee + + +parent_dir = pathlib.Path(__file__).parent +resource_dir = os.path.abspath( + os.path.join( + parent_dir, + 'resources', + ), +) + + +@pytest.fixture +def component() -> ocm.Component: + path = os.path.join( + resource_dir, + 'test_evidence_component_descriptor.yaml', + ) + with open(path, 'r') as f: + raw = yaml.safe_load(f) + + return dacite.from_dict( + data=raw, + data_class=ocm.ComponentDescriptor, + config=dacite.Config(cast=[enum.Enum]) + ).component + + +@pytest.fixture +def extensions_cfg() -> odg.extensions_cfg.TestEvidenceConfig: + return odg.extensions_cfg.TestEvidenceConfig( + delivery_service_url='foo', + external_artefacts_require_tests=False, + ) + + +def _find_resource_node( + component: ocm.Component, + resource_name: str, +) -> ocm.iter.ResourceNode | None: + for rnode in ocm.iter.iter( + component=component, + recursion_depth=0, + node_filter=ocm.iter.Filter.resources, + ): + rnode: ocm.iter.ResourceNode + if rnode.resource.name == resource_name: + return rnode + + raise ValueError(f'did not find resource for {resource_name=}') + + +def test_is_test_required( + component: ocm.Component, + extensions_cfg: odg.extensions_cfg.TestEvidenceConfig, +): + artefact_not_requiring_tests = _find_resource_node( + component=component, + resource_name='build-result-not-requiring-tests', + ) + assert tee.is_test_required( + artefact_node=artefact_not_requiring_tests, + extensions_cfg=extensions_cfg, + ) is False + + artefact_requiring_tests = _find_resource_node( + component=component, + resource_name='build-result', + ) + assert tee.is_test_required( + artefact_node=artefact_requiring_tests, + extensions_cfg=extensions_cfg, + ) is True + + +def test_test_evidence_collection( + component: ocm.Component, +): + test_evidences = list(tee.iter_test_evidence_resources( + component=component, + )) + assert len(test_evidences) == 1 + + +def test_artefact_test_coverage( + component: ocm.Component, +): + test_evidences = list(tee.iter_test_evidence_resources( + component=component, + )) + + assert tee.has_artefact_test_coverage( + artefact_name='build-result', + test_evidences=test_evidences, + ) == True + + assert tee.has_artefact_test_coverage( + artefact_name='other-build-result', + test_evidences=test_evidences, + ) == False diff --git a/test_evidence_extension.py b/test_evidence_extension.py new file mode 100644 index 00000000..e59ec309 --- /dev/null +++ b/test_evidence_extension.py @@ -0,0 +1,224 @@ +import collections.abc +import datetime +import functools +import logging + +import ci.log +import cnudie.retrieve +import delivery.client +import ocm +import ocm.iter + +import k8s.logging +import k8s.util +import odg.extensions_cfg +import odg.findings +import odg.labels +import odg.model +import odg.util +import paths + + +logger = logging.getLogger(__name__) +ci.log.configure_default_logging() +k8s.logging.configure_kubernetes_logging() +PURPOSE_LABEL_VALUE = 'test' + + +def is_test_required( + artefact_node: ocm.iter.ArtefactNode, + extensions_cfg: odg.extensions_cfg.TestEvidenceConfig, +) -> bool: + artefact = artefact_node.artefact + + # reuse artefact filter once it supports relation attribute + if ( + not extensions_cfg.external_artefacts_require_tests + and artefact_node.artefact.relation is ocm.ResourceRelation.EXTERNAL + ): + return False + + # TODO: use artefact filter once factored out + + test_policy_label = artefact.find_label(name=odg.labels.TestPolicyLabel.name) + if not test_policy_label: + return True # require all resources to provide test evidences by default + + test_policy_label: odg.labels.TestPolicyLabel = odg.labels.deserialise_label( + label=test_policy_label, + ) + return test_policy_label.value + + +def iter_test_evidence_resources( + component: ocm.Component, +) -> collections.abc.Generator[ocm.Resource, None, None]: + return ( + resource + for resource in component.resources + if ( + (label := resource.find_label(name=odg.labels.PurposeLabel.name)) + and PURPOSE_LABEL_VALUE in label.value + ) + ) + + +def has_artefact_test_coverage( + artefact_name: str, + test_evidences: collections.abc.Iterable[ocm.Resource], +) -> bool: + artefact_names_with_test_evidence = [] + for test_evidence in test_evidences: + if not (test_scope_label := test_evidence.find_label(name=odg.labels.TestScopeLabel.name)): + # if label is absent, assume tests are scoping *all* resources within this component + return True + + test_scope_label: ocm.Label + test_scope_label: odg.labels.TestScopeLabel = odg.labels.deserialise_label( + label=test_scope_label, + ) + artefact_names_with_test_evidence.append(test_scope_label.value) + + return artefact_name in artefact_names_with_test_evidence + + +def missing_test_evidence_finding( + artefact: odg.model.ComponentArtefactId, + artefact_node: ocm.iter.ArtefactNode, + sub_type: odg.model.TestStatus, + findings_cfg: odg.findings.Finding, + extensions_cfg: odg.extensions_cfg.TestEvidenceConfig, +) -> odg.model.ArtefactMetadata | None: + categorisation: odg.findings.FindingCategorisation = odg.findings.categorise_finding( + finding_cfg=findings_cfg, + finding_property=sub_type + ) + + if not is_test_required( + artefact_node=artefact_node, + extensions_cfg=extensions_cfg, + ): + return None + + test_evidences: collections.abc.Iterable[ocm.Resource] = iter_test_evidence_resources( + component=artefact_node.component, + ) + + if has_artefact_test_coverage( + artefact_name=artefact_node.artefact.name, + test_evidences=test_evidences, + ): + return None + + now = datetime.datetime.now() + return odg.model.ArtefactMetadata( + artefact=artefact, + meta=odg.model.Metadata( + datasource=odg.model.Datasource.TEST_EVIDENCE, + type=odg.model.Datatype.TEST_EVIDENCE_FINDING, + creation_date=now, + last_update=now, + ), + data=odg.model.TestEvidenceMissingFinding( + test_status=odg.model.TestStatus.NO_TEST_EVIDENCE, + severity=categorisation.id + ), + discovery_date=now.date(), + ) + + +def finding_artefact_metadata( + artefact: odg.model.ComponentArtefactId, + extensions_cfg: odg.extensions_cfg.TestEvidenceConfig, + findings_cfg: odg.findings.Finding, + component_descriptor_lookup: cnudie.retrieve.ComponentDescriptorLookupById, +) -> odg.model.ArtefactMetadata | None: + if not findings_cfg.matches(artefact): + logger.info(f'Findings are filtered out for {artefact=}, skipping...') + return + + artefact_node = k8s.util.get_ocm_node( + component_descriptor_lookup=component_descriptor_lookup, + artefact=artefact, + ) + + if not artefact_node: + logger.info(f'did not find {artefact=}, skipping...') + return + + if not extensions_cfg.is_supported(artefact_kind=artefact.artefact_kind): + if extensions_cfg.on_unsupported is odg.extensions_cfg.WarningVerbosities.FAIL: + raise TypeError(f'{artefact.artefact_kind} is not supported') + return + + return missing_test_evidence_finding( + component_resource_id=artefact, + resource_node=artefact_node, + sub_type=odg.model.TestStatus.NO_TEST_EVIDENCE, + findings_cfg=findings_cfg, + extensions_cfg=extensions_cfg, + ) + + +def scan( + artefact: odg.model.ComponentArtefactId, + extensions_cfg: odg.extensions_cfg.TestEvidenceConfig, + findings_cfg: odg.findings.Finding, + component_descriptor_lookup: cnudie.retrieve.ComponentDescriptorLookupById, + delivery_client: delivery.client.DeliveryServiceClient, + **kwargs, # odg wrapper passes more attributes than we need +): + now = datetime.datetime.now() + + delivery_client.update_metadata( + data=[ + odg.model.ArtefactMetadata( + artefact=artefact, + meta=odg.model.Metadata( + datasource=odg.model.Datasource.TEST_EVIDENCE, + type=odg.model.Datatype.ARTEFACT_SCAN_INFO, + creation_date=now, + last_update=now, + ), + data={}, + discovery_date=now.date(), + ), + finding_artefact_metadata( + artefact=artefact, + extensions_cfg=extensions_cfg, + findings_cfg=findings_cfg, + component_descriptor_lookup=component_descriptor_lookup + ) + ] + ) + + +def main(): + parsed_arguments = odg.util.parse_args() + + if not (findings_cfg_path := parsed_arguments.findings_cfg_path): + findings_cfg_path = paths.findings_cfg_path() + + findings_cfg = odg.findings.Finding.from_file( + path=findings_cfg_path, + finding_type=odg.model.Datatype.TEST_EVIDENCE_FINDING, + ) + + if not findings_cfg: + logger.info('Test evidence findings are disabled, exiting...') + return + + scan_callback = functools.partial( + scan, + findings_cfg=findings_cfg, + ) + + odg.util.process_backlog_items( + parsed_arguments=parsed_arguments, + service=odg.extensions_cfg.Services.TEST_EVIDENCE, + callback=scan_callback, + ) + + +if __name__ == '__main__': + main() From b79a5b343b401fc256633e85a1533c4ec3f5a987 Mon Sep 17 00:00:00 2001 From: "Philipp Heil (zkdev)" Date: Thu, 19 Feb 2026 16:53:22 +0100 Subject: [PATCH 2/2] docs: Add documentation on evidence-checker extension Signed-off-by: Philipp Heil (zkdev) Co-authored-by: Franziska Schallhorn --- docs/extensions/evidence-checker.jpg | Bin 0 -> 26202 bytes docs/extensions/evidence_checker.rst | 64 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 docs/extensions/evidence-checker.jpg create mode 100644 docs/extensions/evidence_checker.rst diff --git a/docs/extensions/evidence-checker.jpg b/docs/extensions/evidence-checker.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c106429d60dbdb8a53f889f8c22d67d3a13a4917 GIT binary patch literal 26202 zcmce-2Ut^0(=Z$rxkW&#B3(-8MS2INCo~DYOBDj42LvhJ(mM$ygsMR3MS4dmQbP;9 ziF9d7@9H1lay>rR|JLjOzJJb@b7ppDcPH7&oY~pgUo*eH0La0rAXUJP8vwu!`~~>6 za$^Rhq-6CBqN57ZRQcD6J^&sF9s>X{S8q>Zn*$}VfBYuy)}Ge? z(j5W*e$&AL05~iJ07y*$0IE>{;I7%f)ZuUciENMXRSbB!-0;^6fD6DL@CX0`xB_ee z0(eXq@E9Nj5dSp~Py*b#`5S)UZ{dM}kl;7mAtby_NOb4!U7|ZgM0ZKZNbVBfBPJpu zr69dWMovyaewT!jijtfPkCXrI2`qTlt#Es9KL7jKZg9bk$;y`{6mm`FP#IB+`{=`FlQ zqT7{KbxUI;3F|tQ@|8z|-5wnVH+cT6B75T-mXN1iaz$_PgTeqnS@{P|{wE_7y-~2z zgdy-^sM%9P2pn@r`cND6?A} z*R0Bcy^06_O@}L~uGUB8UYa2wbD4TDZf-W^p44oel>Pj?Vh}`K>I1=dVrJDWgH!RA zOO}1@{iJvx}x=Z7Tj6|PpdWyxLdX8Rk-F(1|*C! z?R)RYt6R@l#RU3!loyN3Lm?gop47vryL8WW(stb0Bthnd{S*+Gcyf6if0qt2#Dpe2 z_nY-n097krARXLjX72mfhz^;~-myZ1z_^N^k-HTEPoUByW$#NF8h)mnC#SQ0OkqiG zJ_@oW;XcjK;|`o~FPITas-Q$Vf}3NqyR`S{U?UaWJ_ZCt`|m7-Hd$rA4h!!%sM^`t z)fN^t2@D88f^Z^_9)ACGkUvL{E&`Yx4qk7S2B?VQwqE!oR4TM(#dqyxWuBzguFQ z@O(QVDw?Ydxl7B=tdl2QFfSso7HFOa0}!@sx2ZJM?S9f_U|=-Kyg?jxN8HR8WW88r=LCG<)X>=F_C70xMQ zGkjC(L%_-LNf1e45E{X4{`)0>w<xi3}8!xCnXozMEbsFx3J4unC zD@39^+d?>H3uE#ZK=ls!#f4SaWoOPa&bgI%ZK|Df#V3)PKjnpr^kv1&1xV zF!bawzPaj;ADq%7@3ercDyNQ3*iYq&oYHVS=4F2wK8xjCpnsVD=AAZB`8Z9}+^T(A z?%ZXf=oxl!c5>@Q#~GOH{lNZ^QuwluwjE4JU=x@)TgS%I9I#;`9di0M%gDh@%aBFY1Gbm zoULTHJ~8BFK-vEba6VpwYu*y14JvLtHW05%*TmwWBBFH3POC*1J3mPq*(~!JDjk*V zEi$dMQWEK?bI?RUtZuEcIl@mh{?64as!#d=a69p(p;~ER{M_0Q^R@bgJjm%~*HQ}> z8W54Vd3&b0?_A*1{QGydjXe^P2Du4Ty`|jF>5$Y&_!xa`r+(hLs=7-QyF=hegU9S^ z5-P9tGf7nzE4X4kfQ z7Bj$G@BeE+WEAB#JE=BBNOV1Eh_})I{|tK%SYBs9>XMN$D48)=`@>YzZH#Px-IOkA_r&6)v07$F{gFdh{Q@!) zr&UKcegGkfB6d%fAnqD`WjzE^@o04+|LNv4(p*259-Fp1RsP+CJj10WAMN(8&J#o1 zh`K)!sC0KoxHRhr!9kmI(bi#^14Gdiv<|wB zEAw{i?uhs)edX?3+f=f_ntoS$v$~fjdVlc@Kq+Q5lvHL(5&K}jE+x=UT%V2B^D8}9 z_I232%F4ruRQR|@>6*4Q3<5o`6cfx$Fx^B_Qn@88p zRqKk$-A4WC15GNuTsg9R^Y-d%ahiEpL@$Hb5@I_dZ*j2TW8c}(rHI)bFgL{(c}G?J z$KWdD{2PZ#kO!gpyDc!>Q5P<*MZ3?{I3MH_ivPR^D);T{PGd4y!O5sUN~syVDGn%@ul4FVoUH%{e}+c}s`L?=^;eLs8o0=MW~b38?u1 z*eq9FNd=<3s%B5=Wk_Qfc~tI)<{7HpCdH^Oe@fdzo0L1)cR>tsgYhG!)nw8ieSGMv zO{&f+oi?;EiD^dnN8Zk%O}OU4*HNU*-h5FiSGWLX!T^%8=U4PP)>mAsGsekt0pT}6 z_b>R_enz<6X0)plpU6j)4w#^b?Jcy*TDW;`WCG#`XXq7l3S?ecBK+cIk8^F8EQKtW z)!&4n(Neai7OLZ<`H?BW2nM!q7~uhRtbUO&9Z7XMhgyuK(Za-tOLmleJD6Q9G2tS^ zgV4#uM}E!WiOl~eeYrv1eXY7qJIt$WV7opU@`gS5nI4l8Pd;V)SYRqC5Lbor;SB*Lm0-NAp>ZN}#3%sxCi7=kaa< zNB_IJ3ule5U&J#Biq7O+mNQZ;>FW8hKxUz`(z2=#%IRZ2^L8($%3IdUWx3^TVU-w_ ziO?Kw&aEVyA5`^~m#MrLeRqq9jQ*s9(Bgt)GSFXuVw(?{uS;I1{8K^1pRE78rm-S+ z=ZIP33IFq`v4%!s!vua*_3rWv*(ijCds||IQGb>gtP`JnQ5rv6tgL$Y#PrB~hiqD3 zVC|wun!UcpVp-+;P$27*wNAo&Vd4wFpX$E=R|}eN7JHU7 z^(B=9#r7UWrNyF|-MHnTAe31=b6J2D#YK=>h+2aud5LF>!2lbd({bEvw=~8@r7( zxN_KKK?RDeFlW#G7SG3%Ac}H?+{6zSz|Ip-I^^?~;_aH1{6wE^#mou1@F(2g!r zqZ$O9v9~pK4{XT(G@l5!*noYzro*6eC=RA&0hY_^jLRsYXB26AAsBnUyz@~#} zxUAQ*SC#Le3hZxP`zhaQq1H}&rPSktt)#@+=>`Kzt8^^L$(4TFMtOwesc0w{Zq}PU z8Tv)Mj}SR~p0?OT66HtuA;>;XTQI@aP=zIJqHfYiU?e_b(Z^#J>#A>QkrALMN_5msvcvz0-YMsTW5{xv z&anJ(a0~5|*2@5Tj@~K>ZL7Agb*GU??Cm~;(Da%JKV{bOWoS|0_BIvvm;u%?Y{b%dNeHKua zXQVzV?*(`%Y{MLq4mNph4Esuv*jtcv1Ml+GNl>BDiO-Yj;SH3Zg@HHsf^9` z)h44eQ5LY*G{zgvajGDS+Y;}mp2QHvUtOQD^0o?IaqESfXF2-YcgM){j=oTZYC7>3 zqtJc?wUS{pcfrWO*x1;06Kc}}r=B&KsuVoVf9HS{0nzP^=CR6a zqyRauaB4H23Yr--U$g3hC#tN5pBOv=fB1t|l}dLR!UX+fd}Qhe2}8zc$o#FphI+;3)H@D1>j~aS;fU*{FNcrLsR)cX*8&kiW~x!?q~$jI2fgfJRe zT9krR{Kx;ri;3XRcmm#~T$Wj@D)?D>@VzVT#*(dn5={YL$9%Y1pWTb3AJP{-LJuK8 zjL>}S_+1zmJH5Jf>L^H;S-v_JJ7IgdkvJ}>tJuRd`7k8qex(#E!+l(@9*OkkwaPof zcuLGKfa73udyuLNKp_hJ45>&70&Bcs6}S^~M)vg&frDL+wi(#rJHknaViP~^yIi~j-;C_NnT6vdmbL9Q$(S)KFTb0z)?l zk^Kyk(~68>eFvDwg3Fz4&oU$WVUHFDB|%J-n}!>sheK!bEPvsg zt~-7;_hT&xC(I^5^4eQiG&>U&t<-_n>ZjM9%{m3jMLwy*%T;Vi3SuPb(v&sZMm(%* zmnr>cLR~|msl>@Uv6UA3V^g*+>uC#_)?0A+Ztac`qtaS(J`%|Wrr|!L3^@U5mz}Re zMS2Qs%$&6hTX=Q*eC6LIjA~hN|cfEiIl9ciI9^PPJnwJS*zFMmUFE07CTqN+qrW#hx-2&M||vy>zf>zXbjF4R6I~ z59er23R7_*-;g>4N#D#L4!#C>?DBpwdy-_}d{?$lkJj8zC|?{!=ZDG1)$-qE0|J#D zR8`GsX%VryS%O7upU2lppu4&)0YM^FFlE^9WT5{lIe*&!5!nVTE z)?VV-0oEsvxZG0~^t~GE1AUZk2A*?rP}Z!q8&`G$A($13L%xKwy=Z0ZFW-HD;PnUF zUx09TdmHAJ;9zGhl{j#>k4j4|54LEJP0s}M{Kt(8$LD=3<;RDbl6JMl@=h#w1wcF^ zAjEANy`O`RmkxjU{-mu+iJT6PTPznZVE&7C+K@ON z#czqsG?)_ljS8i?WA5Wz_cf3walz|vM)s9awE+lJ{?17JOL?g;=ZKfX1J|4{GxpDr z&dM(nCklO12gVuwu*0=c8LC98BV}&xNcT9GVvBR7LE-NoGkyVT6nDDRB0i8+?TIjx zn+`C!WpS4}r()J8zas8QlRJhH*%g97;^`u~@07tx3C$rtAU7`QZW`=Y;bY^elU6RJ zAIg}C@fQeYa5~U(`H|->(CQDm<4?w$CI8$|#GKdqxeQP7{GwN;?S+9e<$a3TjqqMu@-la{_p(+#W;?D{o0)2C&K@j#Tya3ayp?d8!V|hrd!&ji3cR{$(gdNj z=?wO#*i>`cLq`!&-kI8>*uEpc4d$~?O=8M9oTBBZswxHbuPP^U{(o3t8r*nNKw2Lmmu2(Ng#BBc>OPxIYQIbp;Agxv^Exnb7zwJU!KevO;u_ zx)i8eEmUPmpl8~xzM`G{Wsd{OKj-=25=XU$#AvxygxR9>{VY_KRaeF~DQHNWXU}e^ z+@OnS_H6Sooe-pGOsYH5D8JJuBt7fnUC%3k0g_ga$q0+|g#H3_`n89cAnE+pX~~c5 z7pEK3mHN1wGpNnFbAj(rhcc3%KW_=^85El^zwhrSu)1EfpAqH{YxanJ&>yQ!!b?&p zl0DmG6@z0D)DBd*>?MV2=$c#%ZQT=JvSrmR4b7I{C>uVFOA^nhc?1RCf)yb`M!~+L zmSVxsP7@?!Mu*QKh*W3~$ntrB_Vcas{Wxn)Z%MgZ2348vp939MmJr*c@U18VJp&~(y7c&Jp!XeElF&-GJzwL(UH2BE?38^MpBZ zRqkycD7qNqcoM_>^HQSEgL_t`eXkTfPgNn?nUFoVRq<8bs27Vxx@4)=wJP)BgXPLr z4;#oD@5OW^M5MLT%F3ScFwg{P z(K^8~KFS^LBT`a28b+{=i4-nZ_Pd8mY|`!hUZ%Ou6BU%*vq!q0a*S@D@IRnxrjyd_ zecHH{!DWV&)|;q!XvA;@uy_sSXjw8Nl^&<+crOLBTGw)sYCf1}7%DE8FVgy$Aj-CG0(d>azaBVmnJf>BTAXiVQ%2|(5XuHy1{;7Rg~$Rn3R$#kg3-+$rZORTPjp3Qi28pp|>l_aKhZ+nEcw&eAKQnAk` zwxN9J>?S6yKaz>v^qqWWA`|h&+w%xJc8>+4 zJKmVhLf00rgAAx6sus9z41@|!C`HV`OK=Tl4?5+BfA-lfeTvPOqarU;oP8I&ZO|`y z!Mo~WF8HE%zt;w0`_A(Hh`YJYolcLmnT?kGl9Ig0LQ1Uv)Mz+pxaNpDP@!zOvS=!A z9c-jqguM_-1nk?V=GBHjbRw;t1Km-iR8G#1dBe(jNZD+UJNOz zNh7aL9d$>ftvujszH)u1@L-{m8|>f=JMq+A2lfnRgSbV2xr<vW|{k2com!xD(j8m52)uduEzqooQ#UZd5JNAP5U3Xo5$-D9-FTWR* z%>?2@Ta=jAR1^E#dyuI-_WYPL^!}~e3S|Dl3lde`)_tx7(S{x;^$W9HM(;!~*Tj{p zy^69pGtJsPnA4r*^Gwhb@ikvs3S$czOBVjtdodVg^8>MNn4Rs##?mu~L|Y;@Syr4j z>BKO~P{;n7bpnu}LTG($^@+PfwfWlgFTjmmD<-rx(a%>j%E$H38pZtER4>?LQ~D7s z4yY|Pw~_O0H&VY_LL<%En~zGOw_`Shl>|TZ4hL}I>{PQgvLT6mRatVCkLUg8u{Mag z3%J-{s+}1*?6^jUtv1{t^)lwXn zKQ8DPloL4g3sCQG1YOMxYWNxds;*a|^cO%+N+dhDD_fxsce4a&>UJ8_*8ZO57l3Cp zw)E~?^Qx_1QKuKU)QRGu zFYf&B0p}|IJmFl}F#{oqB!sOy5$kwZ!TBlmiln?9!_B>Q5~r%%(1)5(#yzV$pif;r z<9q&0*m0K7=`ZQFGM^}|Ud;=*N0AGMGABKZ`>=mHJ<@IqC}$BsvG1U`2TM17x+q!E z;vlMGWU+E_dm@#PDpa00Uh_vm@QUnfaNEIo;ZM@-%GMUEQ$0c3lM)Y|jA%HLAA$z6)FE)B09bGh5dqM7bxr<{mu1(9DWB_GnO`05aEi9*u+* zBp@{$H~)q}Ku)4)GzE$Qu^)UE^*K1dNQ5|5L^Eivixj&mi5q0e6%}E%q1(*mTT9mx zu{rxTm~Uq>L^#)Gv&FDrVpyFvR#0}^}Jsjuj{LDb+B~Y>{s`RW~7Mxn~_V!g-EJ?C$B;a!8^F{wujHfXVgO1z*rkAE7aD<5DK{7Ch2CRJOYz3*$`HARlT2xj?kP0Ix|UUp(mS&qHgg;OPny)r$3cW3063h%pMu@3-B@Gcr-+p52u=sKrl1* zCCuX!SDuJD>$D{AwoZN5aFLO51_xT}I>p%)p;obdr^jVctI*!)xsI~lAvf@;5M zV$X%1jp+|$vGL6vxjgF2K+UyK6gXQdAy}rTT94IZG3vX8{*b!nyl)!r^Sh}ym2~Sg zMkbQQ;zX_Tt?W<#J zX6nb%jTEzjSEmr-)ZJ>eU=GD5%L1{bW>~EHLLzYA(+Fmq8qs~{>_}0v@O`N zAK}ftE?jBi_E{ zuSJMN36q%)Uk|&@#AI zxyQY9g=8qqmA_`|>uNIprhd}T{>B;s>4Y5bIGs|=a9&MHg;0e3Du4YX?{@L?Y18=^ zgO2fmvbD)M-pH5iIi6oW+O(fv>-_={o)~?$NLYLNPe%9c&ER5jCWFkDv;X_>){tI8y7gzQgL3FYAjDE|!RAPmUjJoH(;XbqRK}qR+p3(JyZG|sP>GF=?Mq+Z z!rE^~Ga=+(C|xxxUZ%I1ml&VJ8*{w78?&kix87@dogMI7|g)F1_~A|wg291mDB zitB*8R>o6w&B@7#rTJD|bH;HvLJh%pKHhh>zcoT_O$i zz4^zeZyEawkmTTRdqq)^-4^)RbA5u1-)4Seaa3>^+OzQ7c0y#c-$xTI$#`o-_HfAA z16uif0>gEM{tag$kMIA>L_REVE;;$!eVB=@qPwEiZ4P|k@pdkQe0;K_in~Swi>6!< zFf-K&D{G4umVZz>k(`bcf6A2p3&7TBsOFLp*5Ut1RR9E~Y!`YvBc<1a2Mq4`xW=ESn zT(dnTli!j)8jKHP>0yS%gtm@{CVM&*WC`kD7zZ812n^g~tn-+LZ`ey<>UsI>lHQjw z8R;e&99=%cP97H)&8v~IC!~lYo-t}0M6db{OV~!*A6M~j!B#8yW*`~B+AFXCDG%E( zKx@LBsk;JEZl2DR9cfo zeUUj&-Ic^T#d|1n29k8N>~_uOPWxw%#XT0noQMbmC>Ns-fSoRr{lGOu?Iy!JXMryw zH>Q`?L?32}wkB}5Ru&Y$o^2Bd3{6%`U}Qd$T)`sWe6zszg>Lu0y!wFD?x<~-`HtIe z2sNI`%u&rPbstF(P{x5TKMQ7slkbH6sCG#x5^7`In$ohrs$@O;fn@3Kvqt6liNXa6 zvvZzQit+QKea<&ikFF2xR@5)=NMF!?8=K#!cLPtfMC*dwn`=l?o3xtOf+|hmClv_k z<)WBLACTx0p$3>4gpzWlDcBF-Sllg7Lkb8NYbN{o0S7;$9k1*hf~+!g&kLF$v1q#t z$Ji0f?Y=)Jo}g6g=a_H96oJCJDTTrRz?S^G3aA9=zl_LP4kj(1n|jFJin?dhLGGzk z;m4h2I^F}aF>|saZB7sgPY&ZJga+mTDJUa1a&~tP2;=4pz6zDSAblSezY}1Y^bie~ zbJA(1;p?;)tf8$v2`~eJ=u+z_6Na5b5uP@a?fi9oSf+7P7$`f5UbqrroNnw=VUT$& z83Jy5AObQgHT45h;zHPIDPzZb*#P@0#OBU%V)0+i{V2CRv6k7g*YWXlQmVDC`WSBQ zet0z`a~>UtVL3OaJIuEYdVZoMK%zvLWO&|%I^CLF;OTZe&KJ0Qix zp`dhqCwUE|2;1s=TsbDcRm$QQpq80rQKD&``jsgZwZ1S>teVf*t-vid(*H7YK7Ltr zxtJU72#Kc-3N4H4;$QqHDQ`qI&##Irg0V)U~?9u$Zwt0mao%AXNHA{Ts1j(!AnN$B3fDFj@+=$_a{d){p?Y1g0sSjP+N=zs0l&!A1+e~^U=3RReilNhyD+w4f zDYVs7kreJc`K^j_FN`@pZvM^@T;wt<#ODYeI@wp_a|8$Oc-Avjb@>EB zc&kg=n_{m$u&wp$9n{GHQh zUt2AJ&*|GOm{88oVb5g~_{)dWWD5Cq;5)HoFRbkG?ikZkCM9)8*ab}3p05$TAc zPx>|=Ho{zz&wTe$?(2k~Y1QSQiyP?879HYgQsLO>Ay*SEaC~z*F;9xUO+L!I zCwi7XKIglzK=RQ*9I!u@(RgEV5nh=mjTSUr@w#Bw)z@JAkn-^SOTnaZUk+*Qtw{UuP;U&(|IVqv^ z*PoW5du~>tOto=ubYD%F$t%?kKY`7-;axpWQxiIKFP?FCz35)69(+4pO)9L3b}z0wW82u8<|1}kyZ(AJ||P|mJ}xo+FZ$&*T30)i@D?ITEbrUf!3QfApzb%maox4(g`a|P7V>KrkYXe zruwR?DHO~#XNi6P?|BDk3Cb$%KuOY9$^GS>ZS52Kpg;uVxo6h6%xFvP3oGJfl#1@Ev53(NPY@;<+Hq|_mvJo)osL(u5Vsqxfk z66tJoNr#qcB{$q*KVuKcz!c~v;@LE!!~ z{OPPeXtL1y=AYkSR?PvWhs(cDXZ=Yh@9gD8?0)r05|Sv_STG~=++~}?X_utX86{%h zD6S6K7iy&1J+79|PU0jE5lSTzfA=HLP3P!XVIBJDe&WCXq7*;gwtoHv@V2Jgq??l> zq%pINSwnecQWTjGKbfSRVPdU6pgcTtU4%s>l06x5@|E?|OxN7dhR%7K_ z7sHUKypI1*Mx&%nv^W;?g3qRNPF!_ThF>*w5gr0PVNs6RWa+hvPpPl=EsAV|=%D*7 z0Z!(NWHr3pyHzL9C?jF*k~x&su!A?1+ucATpO_d4lpPSpIht=!&htEVnU%KI3EZey z&pNL6V(NkC=BERx>QY+dRf5D_V3245D+VmWks(oHxcrViklopIx`PIi9DojctaH>h z9&6oz*iWA^q&0jYRNLAkrW*P|jD+h_k3)w?!-Ea(bF>5@f3clEQK^40*hW?5%EMuB z&0pW8222sx(A0z{fzUqkjVCZ! z4!%uK^w)^lIJ;yad$$Q^VfohfR}p!llbq+YxBP2lX1}MzpZ3w&UVk%}b^rAK&XB|T?^%B#+mc#&S%lWE9 zL`6|453#uHTJ!xvAH6L{U!z_t$bJ9^v|faDjo(EbJ=VWDp~F2092 z?GW6X?^$bSm@v7RLO8q??^Fn6C{#ev<2b}e_CS`*gWnCZx-a5o3CvopEe&(!L z0jqNJXy>`wKTxPHDa)uS?}YKpCeGI#(s_dP!N3;k(O-aEzu|gI2&!`=MDfyRT4Ley z=w?mGQ;)<>|HHsI`&ar0nD@o&TjO+*;~Sl+DHrUk{bGEP=OErUy8CLIh;MO5F&zWI z*%uQ$z9etf!`8%Ij6L-^o@ECKA*~=JSCshUjKv_*jl%VCfo2mJ*w z9~d8>7a#{5jqw6Gbasx?_3_%_A96*YFO==w3WwsA{K6I_3 zD|OPQRU4*GZ&EwUhBzX(p&39?0<+Tw<(G2yup=Vo0BB&5W@t_Bei>fG&XW`cX z4w(DqT{wFUI$_URYAZD{Rdv4KDXcL}G6|fl zY8T)!;<53nC#-dagOT%LUtah7CSS&gfE_-LW|a&V>C08N)j&3+(mhm~OOk3n9VlR5 z^(DdNaAp*7mKcOi+?ROtF;S)K-PX%$k_$>B!RJ$X>B@WY_-@gi0<(hpr8cuL+n=1`aw%Qz%%0VJ=f^^;GGhGcc}b-ard>Zx zq}9cM_wtjKos77}*jG|7ESF}|!lmlQ1x3obz4{HO?J4UFw^K|9Od~(ZfV4~vSooXA zAsAYZ_2SJ@ZLfazF8FJxs(ks%Mn}(%C-0BYy_76_Z+cpfFQC`abro4aRvYqyweda> zc-s`Y#d5l|PT*R%UcVl5tWZ67-uIJNTrZxcO1Pvdr8f9Vc~JwZu)l1-l;Gs^_;*Hr z^ywMy70;Cd=ocWFY|QfWrH;MTi!Fu2*F1TJl~^HX%{PP8l^;S+hNO|`QOYfUB}T2< zWvZI!{(<)4Ze*)JFZ7UcNdpa<8#7xpE_K%r; zTIbH-8)&tD4!@@q7&$V1zQ-st?!e6sb8P;AbA6*&CGuvR-+95pS?$uFEG)ikC|>TQzzaiVtTC z)cFWMAGnHrhjrE=hwW9tkwo8$C@QPdOD)7=M&m{H%Y88+PsObWhDs zj+a%zU@B8b);&-bfu^NBT?G|;*IP$V+tQ{scfm?dNqa!=ZAWT6W%}In+G7fx2SAmR z%C?EoCv}sZ(#at@z9f^8&;92G62h$3X0Ch^U4&#!Nv|U#LX20-2m5v~bz--9lrJiI zTs{G#_@uA2E}UIKCzAG~iOlZ+k1hx5w^zF=j4~7meOt`&0M)}W1lW1RW6n`zTlOR@Ca4xW(gjTX`LhXNJf zkourg-VoAj#vp}6$h!XfLs`%Mn7f07QRmfYBmUOeE9#_+KBsmkU4+@oI9paPi{beC znjYTWEVp~LXbvr^#9@}!_2SYo@U3%D9*tcVbSLNRmt#%xA`6K2jmc#8Vw#} zN%oTF5@@EYr)5ZTu32{Kc0he!9th5qur_4!u9{;JV!>X7?Q(K;XAG{ZeBqD=yxw&a z>2;Yj68@P|6UVZ-npZ2;4*`j+a$-Rj+Hkg?DnRqJj|T}flMhdInQzQHE5#KTgPmnd z%h2@9$OQvn6D2zjm@+L&BSw+BK0Af+t|^fxxw9e}Wf|@W>RGqjc&;^T-@g!>4tp^E z4Wz{uS39_&v6`R^(dsgdjH=^JVfJL>r8aGSK{sn+I64f=h3GEd-^J-xcyXE;=@D(1 z1ljh%aY_Q*(*gP1-9@<2UZtRt*3QS}|Gkev*Ai>%7dULf{CL0Egw4t(={}3k-Ts|L z71HA78q@Vpc|Z})5y-i4qH8z)VAFAX3T1T0cdt(h2T2BjbH+dYe`hEDNi&_v;zQ%K zVn&OYgw413yhh7E3*P<7`oC=|*zJ3;bpMK=5{o~K4Q^XIkFhG+xa>iO`9RPeq`a&b zLmb5eAm(o(Ae1~#;dxMkL?sS?#2NSa7vLA*$?p`)JUQHNocMqnI-;|Ec~8i2N#R53 zkkjYjuK1js>{sqT?(coiKQ%ZF!ka;^TaVw(8b8oUwzQ`9GYH5`p!49L*Y|6qvPL3P zSe&iRCAG?VpS~NGegI|z$68XGx+G6@{p{uI1E0(fB*Ow+Uu+U>jCie0d!Yq- z)#R@H(C`Tkw8!dGSuM5~tL9o*C^)Us ze?o1d+WL-~jWEc+&4OE?jz3W2nR&JcUu{FOs*|FYT5rP}7UqxF`py)oYn)P1 z5hmz7I1Z%7p)z6L%l05_h2G4<&Ql1hJsX=aru(dcbJDoehxrGK*m>OBEc|I1Ggnp9 z)LGB=q^B$be3yjnOXn6dB5*>H=+&3WbeVtOZ)(+08@=|yrZ`|Lfdb88aBe}#dXQ>} zZXr^4-)V1%U-y0WVw{Xr;i&5u15dl1d=BCxUdp2|Jug<}Y5mB`@@fs;1sM1DiQ0N) zq{uQmy_{aP*y0H`)+a`;mk(y$d)!2BWBT|w+;}rV42Dt7&r4$67G6bs`*eh*mw-*X zw+=`DAVF|X8qCyiC8}cB<5cGo-iWc-FKsHuYzP(kRYthr3e|K~%HJ0B@@pZ=ZMPe((QAoQKa!iHWzyL@63FM|GO}a6Cb5 zv__Qc6#qY+op)Fh+t!Eis3;bifYL-Dp-Plc6a*}Uk_4oKKmd^>7((blK|qg6uL%hd znhFWMcLY(S1_DU07Me&^0tf=?mvipD&%yhh`+Q~onXH|e*?VU8v&-7+_YUmgVgVr@ zcAJ>qP<%Gogb2k*98ciIY47O1douUwe#gMIGQe2q*XS3QdsuT2a?S(GEB~C&{)$A{ zYyFeFa3~9*Uq1DM-3Lu=C5}=2664_7VJ05bT4u2@ipZ5yhJ@ki(&q<=~^KivVtH)iR*j}ySllQF?;l!^%2v% z=ea}HrJR*+PpfX8LSXnTYVpULeqdJ>Hxv?I%Nu*_q+J8mtUIK>TL)0CuZ>o1xiNmZ4=qg!pJmzH`i zH{oZozP0JvyoHGqQckoc6-`z=wsI3x(sPdMBd#J>Z;dB>22=OMtAaVLmy+l4je|Mn ztF3QuqQQ}1lc*Do>+mnJ;eZ?9<%uT#_=o;tB-D~> z3NuM--o_qu%-Lqm#?ikl_~Ht`L70J1{ph(u7k~5N*o`N|oe(#zg$8^LP39-teUW2N z|CcKPocvuXmO2)4G0dZ8Hw|x(5<~}>p~gW2ISpQlpM#Hxx~j_OXDA7rwIp^P1CNSV zr9gRr98h1Feppyo9S`^Bzno2M977J&oO5Nft`qiSP2AlvLO12MbKYc<1&=Hk_<6pC zB@y3WR`31zv{q{3b`L>^jb|$*Qf@f5&U#AdAy8RB7~9d6m*Q#8OG2V28{#jBs9+l>Pn_g zP@F-1XFKp&vrLVtdHfO*iPqH{X>ZQxzm810RL~@)Ra&-7Jd9mTOU*co$-Mjy$O3Y<7`IIsa~EJW$p|{uxr&4ypw8 zieF-d?XcvMjy3OAXz}hRxkr5ZJw)=bt|$IlDg8 z`2j+bz7pd;qu9QVy1NH#Cfv}9pkY<&3m4G%XYI@!IV4ZAVXhJ{Z0$5GY&)|gQA84S za@KnQ5pn$VU~r~d$m~n|I|OmVhECoaQuA{5(0kYqg1HJ5F)#eA#vdaj8=VER#Q$xr z=^s_)muTrsht;Pt&RF<}3$dltk5&{!Y+(H1$0;6Jx5Q=P#uTfs zDKQ~*Gz7b7RUq@PTln|=ewLoNK<{#vrDpOpdDEi9^W2FOH{g%Q+gC!3o!?e-2lh=F zkm5h|AyMsVYB4K@W{QblY-R6p@xUY0_)ePj<+mFUFJ3!i6bO|?V^=Wgnx_E&#FaG)m|6|?@L5nnVAZ;Y$mesoEB8;t|L+xF%cdXJe9rg&cv4P z&IsoX8r0mfTd(DYaXd!aP!j{?3w>c~0@!9SqPC_=SSyA22A%@hn9xH{U84UEC2pz{?@)$xiY#6EGs#Nbuz(dy0o}i7#&#=HvI}& zZy4vb*6yM;XGKGSL`w(~X5!t$9@HCmZ+g{eS9s9D&-XHcjpaYuYaeR3%=eS6@rA`^ zqSH6-KZp*$Ba3pr5CK&gXw@byWOHGIwn7UvkpB}Xs zLayjvZn8~PV5goac7Bn;Yro*H0c%}%aI-o(KI z^_ns8%N{k0sA?df7D`EpqR47pgI=xLXuFxW)#e)ZDb@AqOqMz+EBqaC5g-&6)j!z7 zj?`-tA#h&#|KHjF>7jH~YT>ty2b0_wo98Ba0&BkGKR@&PaVKNj9Q}@2-%Z+B|CXj{ zCp#B&7j$0SXIh?v+tY$}x|xlS;{NuWB~(37QAoAY~rwY^|w#>g1VFapjA z;X0+P{o3`t;I0=w(hi_q;DP#U>R}h+{3&E1?bx#BkW?=)J)0OzNP#QUAY`7P-1S_! z4?j>myEDp?a-+2*P3H*PnM*~&y`$mru!mi~?&?_sY@3nE<=t(fbGc`C>XMB6Wo*q`aG$dz+QW$ABHLfQ1Xu9C!Oii&8AEF_;0TfPb zU#6}Zlpvl(+DSKqR1ytazBu=#2WB@4esNGd)w}zA=i(jb?$tXdd5gTcTa*OkP6XUx zH#LjWzOZzK%MQWZUdKAtdnwh-8xUNLW~{ zl41+BK8x;h>%CLen`L=vRU;kB@%8$qq@U5s~#F)+qMrn2Q-K zN&v`EmsQTlmy*$rctbPMaNnvo&r0RA6>s=L9;=2r_#s1Vp*iiO%0ej2)Na5<0%Xui zMBd>pXGkKi)J{+kX^~POZ-F_^;wry(#?>ea=Ha8TV9(>lvgZ@V=K*;}X|JQ3pCc_9 z2^;)Ek`@MyOg~<6cza1rbdO7m7gH1&XQkbK^a&!jpTj4J9w%lfSvU z3|gMuc{7PnD~r*Fd$yg7CTTq^f`xr|!}*6LORjYIU;Mz{K5jekW?0kx9)|b%v0Fe* zIoo+uLOUZY8g4>3=`FDm)GyYtn1{Vvd_A`+SXJ4NS3+a`&YLOWz)-&aiQa-V*YhRvN9<&=@icVez`2GeQthSx(go3hlP3K@@AbiM2_kpJ;#!xi9== zJH4y+JN-)w6FcyJ#zky*zhv}Y@Px%*E!yAz;)REMI&Y;8_5UeVQvZD3FOg!cz$tEd zYIM&y|5iz7mp+E8WI+VeOng%z6DXPX-J1euscSh7 z<2XRPBx)VcADgelnbRNszQDhx?kjl5SKVFfWQtvO1Tg{DH8GhjpFc~{pYSJ@2>KtD zy(Y5glM3|7F4XIN^EkJGMyPpcQUBGgkP_2O=ejvn%%Ye@0M@Eva)|A*G|2diKu!pUU=wCzGzc?^THz+tN_*~Ua;~l*~z_5PVqz$5Ksa9wDL4fSsaTi zTUbITt13~!^4|-M%D|c~$qN|k!3K*J%|~$)7BDNQ&`~ahstH^4VsV@2n$$o8Sp%j; z=8a-+Liq(94TgkYQlo#m?Q}`}EX02dt7vQcQLis#U?D6TjrgMTeoWIc<)mG&U2|K} zTD@1pyT*^xI*;UfL#XX|REIFGQifD16HA2hP}WAY17KK%NB1ifC)Pv?I#yhqrd3It zO>IZQy>1YILnj9+5EWG{fM0Lb)c98Ox+Kw*uSn78k$~-f`#I&P$>{D{LipssfER5< zhYW1X7nJbSB2p{qRg>NyWsmbRo`(aM&eTo+5Xak_x@*SHe37~m?1-^-(kPHnZ9a4c z2n)Qv@f^n7Hgy4t%Yo7bib(!VXW}dHkKMh^lx|He-z!%bQK8P(`)s>rr7|Z_=~k;T z;;)r+Ic6alAKIf_x?jIZaoai7xjKf}*W@=}2W)rS_5rA;%9{P?g9ST&puHT1e@B3fty73blEhzzffo>DfZUPXMnjSY;9cvaz8jR*py++Ehc zCg_^#3A>g6lhAen<}hkt=DGnqOieS@uhhJ(-{WrJj$IQqjE5`<7TdMDX(V((9-3Vj zqRShZ5dEMOFQk3%0rF(tm|gv-Zw=(q9ntaiQQ4lPoX#Lab68E3Ka4Thlecz#*Lr&* zIf{rZGCE$^495A@-T3{I?l~}i;YFxaL&n*N&*7ntgo^b$7vq>h+SgU*dE3b!0cl|6 zpjvKRSHhiQ#qotDxA)*SEU0WhaQIdHttM~**J!7LETr0~`gE>X$^Dy4c7e`>)8e*i z(|wagU*Dqmd$A#QV_tuDprB?Ep6wtt)AzDM)V| z#aHd}=>~e;$Ba%DnjL(>An$V|G`amb2lU+8ZS9-bDo{nKJj#6T1|y^GJrftBnTp>@ zXOd7X&nT=xR<{Tq`~~eKR=K*5D$c*Z$TbRm&-&ozi-oxiYrVM9k$4yD3KtlING0%y zb;CRlE?ax&Zf%BWK65Mr+!{*KMVRGz9GGkqnhLr&7*Nd*J`q#N!+lCSwnT@&g*G2U zLtYp1=c~3;9%6PWC0?88WUQtIjjx}b9qB5we!Qe>*qT(7#tEn6+|GoJUzipO+cJ%d zHBP`t>F`|KOfH}dHbgWzsiB~= zmh}9W5%!_~40Qg2A4U7T9gv{> ze(;CpmiM37Y>CPs;8Vs}ioE5>l)F`XfpF_N7~yt}`0RvJwGo8u4t?H%b;*rw}) z`Ay4sAE{L}AHVKPvF0;vF(*JiDFI%3UOuY&{P4Gfu1UDI z76s?-;RQDjul5+dnjre12x?VhA>F))U9?B<|~-Qg-W2T*t_| zTgU{iQ-^nWPM#q}w~OkMY)yIn^^RSxR}}vu;_lMQs)*_Dp>aF!Opz8Xa3fh4)#u^t zWlC3x28`Hvz>}N&pLTXkU_CZN255zRi6HO6DBX4FfpO+2#;u{rC?yCUi8)>{0kcFu z0+Bt?5FeiGQw4}x+xzwlpJ-OjwvU%wpGYPuC#6Q}PSayOFo>+Qm;@X5uJn*Trh|9i zaaZa94 zbm7Y#mLl>en@}j4O}UQG$=mVWZ<%PY>VO)5?*^Y5|M{nH{P}V?@1=H7tZ1a*=vK^!{!_H>dp!ihBo>3tTiSJT^4aFL!|~Q&8J%- zCMpF%oPPI!tIMyJ>5%m(*{QBhynXFfAH^aIHvTn)`N^Gc8Tx!ocSdmdL-O-F&TpI;AjO3@02D*X%z z`Hu<5me^orMr2U^Pqs539m1v`<^ipxI}+{h$9c0p%dpow`)_aapXR?Y<-b|6iCM5| zS-d(lc!i7Xv2Vk8|15$n(46hR|M5$;`F-t%7HRjd7LiTv>EGwa`ijUeUjoQv7JsjO zzZ?FO?HenoVev{(-rr>Pk1}vrLYXuc8%^X7