diff --git a/CHANGES.md b/CHANGES.md index 43f853e9..642e9946 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +# [0.13.0](https://github.com/ComplianceAsCode/auditree-arboretum/releases/tag/v0.11.0) + +- [ADDED] Kubernetes resources report added. + # [0.12.0](https://github.com/ComplianceAsCode/auditree-arboretum/releases/tag/v0.12.0) - [ADDED] Github org integrity teams fetcher functionality added to Github org integrity permissions fetcher. diff --git a/arboretum/__init__.py b/arboretum/__init__.py index 9d144f76..37576b30 100644 --- a/arboretum/__init__.py +++ b/arboretum/__init__.py @@ -14,4 +14,4 @@ # limitations under the License. """Arboretum - Checking your compliance & security posture, continuously.""" -__version__ = '0.12.0' +__version__ = '0.13.0' diff --git a/arboretum/kubernetes/README.md b/arboretum/kubernetes/README.md index b9a7b351..999f6558 100644 --- a/arboretum/kubernetes/README.md +++ b/arboretum/kubernetes/README.md @@ -114,6 +114,29 @@ list of clusters. TTL is set to 1 day. Checks coming soon... +## Reports + +### Compliance OSCAL Observations + +* Report: [compliance_oscal_observations][compliance-oscal-observations] +* Purpose: Create a JSON format report as a [NIST OSCAL Assessment Results][assessment-results] observations list from the kubernetes [OpenShift Compliance Operator][compliance-operator] data in the evidence locker. +* Behavior: + * A report is generated comprising a collection of observations, one for each [XCCDF][xccdf] rule/result pair discovered in the `cluster_resource.json` files with respect to the optional date range. Each observation may be enhanced in accordance with an optional `oscal_metadata.yaml` file. +* Data files required: + * `raw/kubernetes/cluster_resource.json`, created by the kubernetes provider [ClusterResourceFetcher][fetch-cluster-resource]. +* Data files optional: + * `raw/kubernetes/oscal_metadata.json`, planted by the kubernetes provider account administrator. +* Details/Config: + + ```shell + harvest reports arboretum --detail compliance_oscal_observations + ``` + +[compliance-oscal-observations]: reports/compliance_oscal_observations.py +[fetch-cluster-resource]: https://github.com/ComplianceAsCode/auditree-arboretum/blob/main/arboretum/kubernetes/fetchers/fetch_cluster_resource.py +[assessment-results]: https://pages.nist.gov/OSCAL/documentation/schema/assessment-results-layer/assessment-results/ +[xccdf]: https://csrc.nist.gov/projects/security-content-automation-protocol/specifications/xccdf +[compliance-operator]: https://github.com/openshift/compliance-operator/blob/master/README.md [auditree-framework]: https://github.com/ComplianceAsCode/auditree-framework [auditree-framework documentation]: https://complianceascode.github.io/auditree-framework/ [usage]: https://github.com/ComplianceAsCode/auditree-arboretum#usage diff --git a/arboretum/kubernetes/reports/compliance_oscal_observations.py b/arboretum/kubernetes/reports/compliance_oscal_observations.py new file mode 100644 index 00000000..2495d4c9 --- /dev/null +++ b/arboretum/kubernetes/reports/compliance_oscal_observations.py @@ -0,0 +1,228 @@ +# -*- mode:python; coding:utf-8 -*- +# Copyright (c) 2021 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +The Compliance OSCAL Observations report. + +A json report containing NIST OSCAL Assessment Results Observations generated +by processing Kubernetes stand-alone cluster resources evidence. The +XML within the cluster resources evidence is transformed to produce the JSON +report. If an optional OSCAL metadata file is specified, then the report is +enhanced accordingly. + +Provide the "start" and "end" optional configuration (--config) parameters +as a JSON string, in "YYYYMMDD" format to define a date range for the evidence +used to process the report. If omitted, the default value is the current date. + +--------------- +Example usages: +--------------- + +> harvest report my-repo arboretum compliance_oscal_observations + +> harvest report my-repo arboretum compliance_oscal_observations \ +--config '{ \ +"oscal_metadata":"raw/kubernetes/oscal_metadata.yaml" \ +}' + +> harvest report my-repo arboretum compliance_oscal_observations \ +--config '{ \ +"cluster_resource":"raw/kubernetes/cluster_resource.json", \ +"oscal_metadata":"raw/kubernetes/oscal_metadata.yaml", \ +"start":"20200901", \ +"end":"20201231" \ +}' + +-------------------- +oscal_metadata.yaml: +-------------------- + +The oscal_metadata.yaml file contains one or more mappings. Below is shown the +format of a single mapping. The items in angle brackets are to be replaced with +desired values for augmenting the produced OSCAL. + +The mapping whose matches the [metadata][name] in the cluster resources +evidence for the corresponding XML is used for augmenting the produced OSCAL. +If no match is found, no augmentation occurs. + +: + namespace: + subject-references: + component: + uuid-ref: + type: + title: + inventory-item: + uuid-ref: + type: + title: + properties: + target: + cluster-name: + cluster-type: + cluster-region: + +A sample oscal_metadata.yaml file with 2 mappings is shown below. + +ssg-ocp4-ds-cis-111.222.333.444-pod: + namespace: xccdf + subject-references: + component: + uuid-ref: 56666738-0f9a-4e38-9aac-c0fad00a5821 + type: component + title: Red Hat OpenShift Kubernetes + inventory-item: + uuid-ref: 46aADFAC-A1fd-4Cf0-a6aA-d1AfAb3e0d3e + type: inventory-item + title: Pod + properties: + target: kube-br7qsa3d0vceu2so1a90-roksopensca-0000026b.iks.mycorp + cluster-name: ROKS-OpenSCAP-1 + cluster-type: openshift + cluster-region: us-south +ssg-rhel7-ds-cis-111.222.333.444-pod: + namespace: xccdf + subject-references: + component: + uuid-ref: 89cfe7a7-ce6b-4699-aa7b-2f5739c72001 + type: component + title: RedHat Enterprise Linux 7.8 + inventory-item: + uuid-ref: 46aADFAC-A1fd-4Cf0-a6aA-d1AfAb3e0d3e + type: inventory-item + title: VM + properties: + target: kube-br7qsa3d0vceu2so1a90-roksopensca-0000026b.iks.mycorp + cluster-name: ROKS-OpenSCAP-1 + cluster-type: openshift + cluster-region: us-south +""" + +import json +from datetime import datetime, timedelta + +from harvest.reporter import BaseReporter + +from trestle.utils import osco + +import yaml + + +class ComplianceOscalObservations(BaseReporter): + """The compliance oscal observations class.""" + + @property + def report_filename(self): + """Return the report filename.""" + return 'compliance_oscal_observations.json' + + def generate_report(self): + """ + Generate the compliance oscal observations report content. + + :returns: stringified OSCAL json content + """ + # get required cluster resource path + path_cluster_resource = self.config.get( + 'cluster_resource', 'raw/kubernetes/cluster_resource.json' + ) + # get optional oscal_metadata path + path_oscal_metadata = self.config.get( + 'oscal_metadata', 'raw/kubernetes/oscal_metadata.yaml' + ) + # get start+end dates + start_dt = datetime.strptime( + self.config.get('start', datetime.today().strftime('%Y%m%d')), + '%Y%m%d' + ) + end_dt = datetime.strptime( + self.config.get('end', datetime.today().strftime('%Y%m%d')), + '%Y%m%d' + ) + if start_dt > end_dt: + raise ValueError('Cannot have start date before end date.') + current_dt = start_dt + previous = None + observation_list = [] + # examine each day's evidence, if any + while current_dt <= end_dt: + try: + cluster_resource = json.loads( + self.get_file_content(path_cluster_resource, current_dt) + ) + try: + oscal_metadata = yaml.load( + self.get_file_content(path_oscal_metadata, current_dt), + Loader=yaml.FullLoader + ) + # add locker info to oscal metadata + for key in oscal_metadata.keys(): + entry = oscal_metadata[key] + entry['locker'] = self.repo_url + except Exception: + oscal_metadata = None + # skip if no new evidence + if previous != cluster_resource: + previous = cluster_resource + # examine entries skipping those not relevant + for key in cluster_resource.keys(): + for group in cluster_resource[key]: + for cluster in cluster_resource[key][group]: + for resource in cluster.get('resources', []): + self._update_observations( + observation_list, + resource, + oscal_metadata + ) + except Exception: + pass + current_dt = current_dt + timedelta(days=1) + # create report + if len(observation_list) == 0: + raise RuntimeError('No report content.') + observation_dict = json.dumps( + {'observations': observation_list}, indent=2 + ) + report = str(observation_dict) + return report + + def _update_observations(self, observation_list, resource, oscal_metadata): + """Update observations list with additional observations.""" + if resource.get('kind') != 'ConfigMap': + return + if 'data' not in resource.keys(): + return + if 'results' not in resource['data'].keys(): + return + if 'metadata' not in resource.keys(): + return + if 'name' not in resource['metadata'].keys(): + return + # assemble osco data for transformation + data = {'results': resource['data']['results']} + osco_data = { + 'kind': resource['kind'], + 'data': data, + 'metadata': resource['metadata'] + } + # get OSCAL Observation objects + arp, analysis = osco.get_observations(osco_data, oscal_metadata) + # convert Observation objects into json + for observation_model in arp.observations: + observation_json = json.loads( + observation_model.json( + exclude_none=True, by_alias=True, indent=2 + ) + ) + observation_list.append(observation_json) diff --git a/setup.cfg b/setup.cfg index 6b826af2..72e2765f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,7 @@ packages = find: install_requires = auditree-framework>=1.2.3 auditree-harvest>=1.0.0 + compliance-trestle>=0.7.0 [options.packages.find] exclude = diff --git a/test/fixtures/compliance_oscal_metadata.yaml b/test/fixtures/compliance_oscal_metadata.yaml new file mode 100644 index 00000000..b80b8cf7 --- /dev/null +++ b/test/fixtures/compliance_oscal_metadata.yaml @@ -0,0 +1,35 @@ + +ssg-ocp4-ds-cis-10.321.456.999-pod: + namespace: xccdf + subject-references: + component: + uuid-ref: 56666738-0f9a-4e38-9aac-c0fad00a5821 + type: component + title: Red Hat OpenShift Kubernetes + inventory-item: + uuid-ref: 46aADFAC-A1fd-4Cf0-a6aA-d1AfAb3e0d3e + type: inventory-item + title: Pod + properties: + target: kube-br7qsa3d0vceu2so1a90-roksopensca-default-0000026b.iks.mycorp + cluster-name: ROKS-OpenSCAP-1 + cluster-type: openshift + cluster-region: us-south + +ssg-rhel7-ds-cis-111.222.333.444-pod: + namespace: xccdf + subject-references: + component: + uuid-ref: 89cfe7a7-ce6b-4699-aa7b-2f5739c72001 + type: component + title: RedHat Enterprise Linux 7.8 + inventory-item: + uuid-ref: 46aADFAC-A1fd-4Cf0-a6aA-d1AfAb3e0d3e + type: inventory-item + title: VM + properties: + target: kube-br7qsa3d0vceu2so1a90-roksopensca-default-0000026b.iks.mycorp + cluster-name: ROKS-OpenSCAP-1 + cluster-type: openshift + cluster-region: us-south + \ No newline at end of file diff --git a/test/fixtures/kubernetes_cluster_resource.json b/test/fixtures/kubernetes_cluster_resource.json new file mode 100644 index 00000000..1548fef7 --- /dev/null +++ b/test/fixtures/kubernetes_cluster_resource.json @@ -0,0 +1,38 @@ +{ + "iks": { + "demo2020": [ + { + "name": "compliance-dev-city10", + "region": "region2", + "type": "kubernetes", + "resources": [ + { + "apiVersion": "v1", + "data": { + "exit-code": "2", + "results": " OSCAP Scan Result kube-roksopensca-default-00000123.iks.abc /kubernetes-api-resources Webhook 5m notselected CCE-84209-6 pass pass fail notchecked No candidate or applicable check found. " + }, + "kind": "ConfigMap", + "metadata": { + "annotations": { + "compliance-remediations/processed": "", + "compliance.openshift.io/scan-error-msg": "", + "compliance.openshift.io/scan-result": "NON-COMPLIANT", + "openscap-scan-result/node": "10.321.456.999" + }, + "creationTimestamp": "2020-08-03T02:26:38Z", + "labels": { + "compliance-scan": "ssg-ocp4-ds-cis" + }, + "name": "ssg-ocp4-ds-cis-10.321.456.999-pod", + "namespace": "openshift-compliance", + "resourceVersion": "22693331", + "selfLink": "/api/v1/namespaces/openshift-compliance/configmaps/ssg-ocp4-ds-cis-10.321.456.999-pod", + "uid": "dba4a77e-5eab-4df8-8258-64305f6a1768" + } + } + ] + } + ] + } +} diff --git a/test/report_test/test_kubernetes_compliance_oscal_observarions.py b/test/report_test/test_kubernetes_compliance_oscal_observarions.py new file mode 100644 index 00000000..9e6b2f9a --- /dev/null +++ b/test/report_test/test_kubernetes_compliance_oscal_observarions.py @@ -0,0 +1,178 @@ +# -*- mode:python; coding:utf-8 -*- +# Copyright (c) 2021 IBM Corp. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Harvest compliance oscal observations report tests.""" + +import hashlib +import unittest +import uuid +from unittest.mock import Mock, patch + +import arboretum.kubernetes.reports.compliance_oscal_observations as co + +# mock uuid so that generated uuid's will match expected results +uuid_mock1 = Mock( + return_value=uuid.UUID('56666738-0f9a-4e38-9aac-c0fad00a5821') +) + + +class TestKubernetesComplianceOscalObservations(unittest.TestCase): + """Test ComplianceOscalObservations.""" + + def test_report_filename(self): + """Ensure check results summary filename property is set.""" + report = co.ComplianceOscalObservations( + 'https://repo', 'creds', 'branch', 'repo-path' + ) + self.assertEqual( + report.report_filename, 'compliance_oscal_observations.json' + ) + + def test_generate_report_exception_no_cluster_resource_content(self): + """Ensure proper handling of no cluster_resource content.""" + report = co.ComplianceOscalObservations( + 'https://repo', + 'creds', + 'branch', + 'repo-path', + cluster_resource='bogus' + ) + with self.assertRaises(RuntimeError) as e: + report.generate_report() + self.assertEqual(str(e.exception), 'No report content.') + + def test_generate_report_bad_dates(self): + """Ensure proper handling bad start and end dates.""" + report = co.ComplianceOscalObservations( + 'https://repo', + 'creds', + 'branch', + 'repo-path', + start='20210101', + end='20201231' + ) + with self.assertRaises(ValueError) as e: + report.generate_report() + self.assertEqual( + str(e.exception), 'Cannot have start date before end date.' + ) + + @patch(target='uuid.uuid4', new=uuid_mock1) + def test_generate_report(self): + """Ensure proper report creation.""" + with open('./test/fixtures/kubernetes_cluster_resource.json', + 'r') as f: + kubernetes_cluster_resource = f.read() + tgt = 'arboretum.kubernetes.reports.compliance_oscal_observations.' + tgt += 'ComplianceOscalObservations.get_file_content' + with patch(tgt, Mock(side_effect=[kubernetes_cluster_resource])): + report = co.ComplianceOscalObservations( + 'https://repo', 'creds', 'branch', 'repo-path' + ) + result = report.generate_report() + self.assertEqual( + 'ff4b5c1576e226aca4108b3a6a335969', + hashlib.md5(result.encode('utf-8')).hexdigest() + ) + + @patch(target='uuid.uuid4', new=uuid_mock1) + def test_generate_report_with_metadata(self): + """Ensure proper report creation with oscal metadata.""" + with open('./test/fixtures/compliance_oscal_metadata.yaml', 'r') as f: + compliance_oscal_metadata = f.read() + with open('./test/fixtures/kubernetes_cluster_resource.json', + 'r') as f: + kubernetes_cluster_resource = f.read() + tgt = 'arboretum.kubernetes.reports.compliance_oscal_observations.' + tgt += 'ComplianceOscalObservations.get_file_content' + with patch(tgt, + Mock(side_effect=[kubernetes_cluster_resource, + compliance_oscal_metadata])): + report = co.ComplianceOscalObservations( + 'https://github.mycorp.com/myuser/evidence-locker', + 'creds', + 'branch', + 'repo-path', + oscal_metadata='compliance_oscal_metadata.yaml' + ) + result = report.generate_report() + self.assertEqual( + '28f797591fcf61f0edb6f4ad17a0a98d', + hashlib.md5(result.encode('utf-8')).hexdigest() + ) + + def test_generate_report_no_resources(self): + """Test no 'resources' found.""" + with open('./test/fixtures/kubernetes_cluster_resource.json', + 'r') as f: + kubernetes_cluster_resource = f.read().replace( + 'resources', 'bogus' + ) + self._expect_no_report_content(kubernetes_cluster_resource) + + def test_generate_report_no_kind(self): + """Test no 'kind' found.""" + with open('./test/fixtures/kubernetes_cluster_resource.json', + 'r') as f: + kubernetes_cluster_resource = f.read().replace('kind', 'bogus') + self._expect_no_report_content(kubernetes_cluster_resource) + + def test_generate_report_no_configmap(self): + """Test no 'ConfigMap' found.""" + with open('./test/fixtures/kubernetes_cluster_resource.json', + 'r') as f: + kubernetes_cluster_resource = f.read().replace( + 'ConfigMap', 'bogus' + ) + self._expect_no_report_content(kubernetes_cluster_resource) + + def test_generate_report_no_data(self): + """Test no 'data' found.""" + with open('./test/fixtures/kubernetes_cluster_resource.json', + 'r') as f: + kubernetes_cluster_resource = f.read().replace('data', 'bogus') + self._expect_no_report_content(kubernetes_cluster_resource) + + def test_generate_report_no_results(self): + """Test no 'results' found.""" + with open('./test/fixtures/kubernetes_cluster_resource.json', + 'r') as f: + kubernetes_cluster_resource = f.read().replace('results', 'bogus') + self._expect_no_report_content(kubernetes_cluster_resource) + + def test_generate_report_no_metadata(self): + """Test no 'metadata' found.""" + with open('./test/fixtures/kubernetes_cluster_resource.json', + 'r') as f: + kubernetes_cluster_resource = f.read().replace('metadata', 'bogus') + self._expect_no_report_content(kubernetes_cluster_resource) + + def test_generate_report_no_name(self): + """Test no 'name' found.""" + with open('./test/fixtures/kubernetes_cluster_resource.json', + 'r') as f: + kubernetes_cluster_resource = f.read().replace('name', 'bogus') + self._expect_no_report_content(kubernetes_cluster_resource) + + def _expect_no_report_content(self, kubernetes_cluster_resource): + """Expect 'No report content.' exception.""" + tgt = 'arboretum.kubernetes.reports.compliance_oscal_observations.' + tgt += 'ComplianceOscalObservations.get_file_content' + with patch(tgt, Mock(side_effect=[kubernetes_cluster_resource])): + report = co.ComplianceOscalObservations( + 'https://repo', 'creds', 'branch', 'repo-path' + ) + with self.assertRaises(RuntimeError) as e: + report.generate_report() + self.assertEqual(str(e.exception), 'No report content.')