diff --git a/.github/workflows/publish_charm.yaml b/.github/workflows/publish_charm.yaml index e14e332..b9d8243 100644 --- a/.github/workflows/publish_charm.yaml +++ b/.github/workflows/publish_charm.yaml @@ -1,14 +1,91 @@ name: Publish to edge on: - workflow_dispatch: push: branches: - main + pull_request: jobs: - publish-to-edge: - uses: canonical/operator-workflows/.github/workflows/publish_charm.yaml@main - secrets: inherit - with: - channel: latest/edge + find-charms: + name: Find Charms + runs-on: ubuntu-latest + outputs: + charm-dirs: ${{ steps.charm-dirs.outputs.charm-dirs }} + steps: + - uses: actions/checkout@v4 + - id: charm-dirs + run: | + echo charm-dirs=`find -name charmcraft.yaml | xargs dirname | jq --raw-input --slurp 'split("\n") | map(select(. != ""))'` >> $GITHUB_OUTPUT + + publish-charm: + needs: [ find-charms ] + strategy: + matrix: + charm-dir: ${{ fromJSON(needs.find-charms.outputs.charm-dirs) }} + name: Publish Charm (${{ matrix.charm-dir }}) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: change directory + run: | + TEMP_DIR=$(mktemp -d) + cp -rp ./${{ matrix.charm-dir }}/. $TEMP_DIR + rm -rf .* * || : + cp -rp $TEMP_DIR/. . + rm -rf $TEMP_DIR + - name: setup lxd + uses: canonical/setup-lxd@v0.1.2 + - name: find rock + id: rock-dir + run: | + echo rock-dir=`dirname *rock/rockcraft.yaml` >> $GITHUB_OUTPUT + - name: build rock + id: rockcraft + run: | + sudo snap install --channel latest/stable --classic rockcraft + cd ${{ steps.rock-dir.outputs.rock-dir }} + rockcraft pack --verbosity trace + echo rock=`ls *.rock` >> $GITHUB_OUTPUT + - run: | + echo rockcraft pack: + echo ${{ steps.rockcraft.outputs.rock }} + - name: upload rock + run: | + cd ${{ steps.rock-dir.outputs.rock-dir }} + rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false oci-archive:${{ steps.rockcraft.outputs.rock }} docker-daemon:rock:latest + - name: build charm + id: charmcraft + run: | + sudo snap install --channel latest/stable --classic charmcraft + charmcraft pack --verbosity trace + echo charms=`ls *.charm` >> $GITHUB_OUTPUT + - run: | + echo charmcraft pack: + echo ${{ steps.charmcraft.outputs.charms }} + - id: charm-name + run: | + echo charm-name=`yq -r .name charmcraft.yaml` >> $GITHUB_OUTPUT + - run: | + sudo apt update && sudo apt install python3-yaml -y + - name: update upstream-source + shell: python + run: | + import yaml + + charmcraft_yaml = yaml.safe_load(open("charmcraft.yaml")) + resources = charmcraft_yaml["resources"] + resources[list(resources)[0]]["upstream-source"] = "rock:latest" + yaml.dump(charmcraft_yaml, open("charmcraft.yaml", "w"), sort_keys=False) + - run: | + echo upload charm ${{ steps.charm-name.outputs.charm-name }} + - run: | + cat charmcraft.yaml + - if: github.event_name == 'push' + name: publish charm + uses: canonical/charming-actions/upload-charm@2.6.3 + with: + credentials: ${{ secrets.CHARMHUB_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + built-charm-path: ${{ steps.charmcraft.outputs.charms }} + tag-prefix: ${{ steps.charm-name.outputs.charm-name }} diff --git a/.gitignore b/.gitignore index 8461f41..481504d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ __pycache__/ .vscode .mypy_cache *.egg-info/ -*/*.rock +*.rock diff --git a/.woke.yaml b/.woke.yaml index df1c258..75de6d3 100644 --- a/.woke.yaml +++ b/.woke.yaml @@ -1,2 +1,5 @@ ignore_files: - lib/charms/redis_k8s/v0/redis.py + - connectors/** + - scripts/** + - tests/unit/test_connectors.py diff --git a/charmcraft.yaml b/charmcraft.yaml index 75e8d61..da37575 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -53,6 +53,8 @@ requires: interface: ingress optional: false limit: 1 + opencti-connector: + interface: opencti_connector logging: interface: loki_push_api optional: true diff --git a/connector-template/charmcraft.yaml.j2 b/connector-template/charmcraft.yaml.j2 new file mode 100644 index 0000000..7d36b3b --- /dev/null +++ b/connector-template/charmcraft.yaml.j2 @@ -0,0 +1,46 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-{{ name }}-connector +title: OpenCTI {{ display_name_short }} Charm +summary: OpenCTI {{ display_name }} charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +{{ config | safe }} + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-{{ name }}-connector: + resource: opencti-{{ name }}-connector-image +resources: + opencti-{{ name }}-connector-image: + type: oci-image + description: OCI image for the OpenCTI {{ display_name }} connector. + +assumes: + - juju >= 3.4 diff --git a/connector-template/requirements.txt b/connector-template/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connector-template/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connector-template/rock/rockcraft.yaml.j2 b/connector-template/rock/rockcraft.yaml.j2 new file mode 100644 index 0000000..458d970 --- /dev/null +++ b/connector-template/rock/rockcraft.yaml.j2 @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-{{ name }}-connector +base: ubuntu@24.04 +version: &version '{{ version }}' +summary: OpenCTI {{ display_name }} Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + {{ name }}-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd {{ constant_to_kebab(connector_type) }}/{{ connector_name }} + cp -rp src $CRAFT_PART_INSTALL/opt/{{ install_location }} + {{ generate_entrypoint }} + cat entrypoint.sh | grep {{ install_location }} + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ diff --git a/connector-template/src/charm.py.j2 b/connector-template/src/charm.py.j2 new file mode 100644 index 0000000..10d7d6e --- /dev/null +++ b/connector-template/src/charm.py.j2 @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI {{ display_name }} connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class Opencti{{ kebab_to_pascal(name) }}ConnectorCharm(OpenctiConnectorCharm): + connector_type = "{{ connector_type }}" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + {{ charm_override | safe | indent(4) }} + +if __name__ == "__main__": + ops.main(Opencti{{ kebab_to_pascal(name) }}ConnectorCharm) diff --git a/connectors/abuseipdb_ipblacklist/charmcraft.yaml b/connectors/abuseipdb_ipblacklist/charmcraft.yaml new file mode 100644 index 0000000..ab6ace9 --- /dev/null +++ b/connectors/abuseipdb_ipblacklist/charmcraft.yaml @@ -0,0 +1,71 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-abuseipdb-ipblacklist-connector +title: OpenCTI abuseipdb ipblacklist Charm +summary: OpenCTI abuseipdb ipblacklist charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + abuseipdb-api-key: + description: Abuse IPDB API KEY + type: string + abuseipdb-interval: + description: interval between 2 collect itself + type: int + abuseipdb-limit: + description: limit number of result itself + type: int + abuseipdb-score: + description: AbuseIPDB Score Limitation + type: int + connector-scope: + type: string + description: connector scope + abuseipdb-url: + description: the Abuse IPDB URL + type: string + default: https://api.abuseipdb.com/api/v2/blacklist + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-abuseipdb-ipblacklist-connector: + resource: opencti-abuseipdb-ipblacklist-connector-image +resources: + opencti-abuseipdb-ipblacklist-connector-image: + type: oci-image + description: OCI image for the OpenCTI abuseipdb ipblacklist connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/abuseipdb_ipblacklist/lib/charms/opencti/v0/opencti_connector.py b/connectors/abuseipdb_ipblacklist/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/abuseipdb_ipblacklist/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/abuseipdb_ipblacklist/requirements.txt b/connectors/abuseipdb_ipblacklist/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/abuseipdb_ipblacklist/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/abuseipdb_ipblacklist/rock/rockcraft.yaml b/connectors/abuseipdb_ipblacklist/rock/rockcraft.yaml new file mode 100644 index 0000000..23d194f --- /dev/null +++ b/connectors/abuseipdb_ipblacklist/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-abuseipdb-ipblacklist-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI abuseipdb ipblacklist Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + abuseipdb-ipblacklist-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/abuseipdb-ipblacklist + cp -rp src $CRAFT_PART_INSTALL/opt/abuseipdb-ipblacklist + + cat entrypoint.sh | grep abuseipdb-ipblacklist + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/abuseipdb_ipblacklist/src/charm.py b/connectors/abuseipdb_ipblacklist/src/charm.py new file mode 100755 index 0000000..efa7e23 --- /dev/null +++ b/connectors/abuseipdb_ipblacklist/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI abuseipdb ipblacklist connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiAbuseipdbIpblacklistConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiAbuseipdbIpblacklistConnectorCharm) \ No newline at end of file diff --git a/connectors/alienvault/charmcraft.yaml b/connectors/alienvault/charmcraft.yaml new file mode 100644 index 0000000..ab55b1a --- /dev/null +++ b/connectors/alienvault/charmcraft.yaml @@ -0,0 +1,136 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-alienvault-connector +title: OpenCTI AlienVault Charm +summary: OpenCTI AlienVault charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + alienvault-base-url: + description: The base URL for the OTX DirectConnect API. + type: string + alienvault-excluded-pulse-indicator-types: + description: The Pulse indicator types that will be excluded from the import. + type: string + alienvault-guess-cve: + description: The Pulse tags are used to guess (checks whether tag matches (CVE-\d{4}-\d{4,7})) vulnerabilities. + type: boolean + alienvault-guess-malware: + description: The Pulse tags are used to guess (queries malwares in the OpenCTI) malwares related to the given Pulse. + type: boolean + alienvault-interval-sec: + description: alienvault interval seconds + type: int + alienvault-pulse-start-timestamp: + description: The Pulses modified after this timestamp will be imported. Timestamp in ISO 8601 format, UTC. + type: string + alienvault-report-status: + description: The status of imported reports in the OpenCTI. + type: string + alienvault-tlp: + description: The default TLP marking used if the Pulse does not define TLP. + type: string + connector-duration-period: + description: Determines the time interval between each launch of the connector in ISO 8601, ex:PT30M. + type: string + connector-scope: + type: string + description: connector scope + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + alienvault-api-key: + description: (optional) The OTX Key. + type: string + alienvault-create-indicators: + description: (optional) If true then indicators will be created from Pulse indicators and added to the report. + type: boolean + alienvault-create-observables: + description: (optional) If true then observables will be created from Pulse indicators and added to the report. + type: boolean + alienvault-default-x-opencti-score: + description: (optional) The default x_opencti_score to use for indicators. If a per indicator type score is not set, this is used. + type: int + alienvault-enable-attack-patterns-indicates: + description: (optional) If true then the relationshipsindicateswill be created between indicators and attack patterns. + type: boolean + alienvault-enable-relationships: + description: (optional) If true then the relationships will be created between SDOs. + type: boolean + alienvault-filter-indicators: + description: (optional) This boolean filters out indicators created before the latest pulse datetime, ensuring only recent indicators are processed. + type: boolean + alienvault-report-type: + description: (optional) The type of imported reports in the OpenCTI. + type: string + alienvault-x-opencti-score-cryptocurrency-wallet: + description: (optional) The x_opencti_score to use for Cryptocurrency Wallet indicators. If not set, the default value isdefault_x_opencti_score. + type: int + alienvault-x-opencti-score-domain: + description: (optional) The x_opencti_score to use for Domain indicators. If not set, the default value isdefault_x_opencti_score. + type: int + alienvault-x-opencti-score-email: + description: (optional) The x_opencti_score to use for Email indicators. If not set, the default value isdefault_x_opencti_score. + type: int + alienvault-x-opencti-score-file: + description: (optional) The x_opencti_score to use for StixFile indicators. If not set, the default value isdefault_x_opencti_score. + type: int + alienvault-x-opencti-score-hostname: + description: (optional) The x_opencti_score to use for Hostname indicators. If not set, the default value isdefault_x_opencti_score. + type: int + alienvault-x-opencti-score-ip: + description: (optional) The x_opencti_score to use for IP indicators. If not set, the default value isdefault_x_opencti_score. + type: int + alienvault-x-opencti-score-mutex: + description: (optional) The x_opencti_score to use for Mutex indicators. If not set, the default value isdefault_x_opencti_score. + type: int + alienvault-x-opencti-score-url: + description: (optional) The x_opencti_score to use for URL indicators. If not set, the default value isdefault_x_opencti_score. + type: int + connector-queue-threshold: + description: (optional) Used to determine the limit (RabbitMQ) in MB at which the connector must go into buffering mode. + type: int + connector-run-and-terminate: + description: (optional) Launch the connector once if set to True. Takes 2 available values:TrueorFalse. + type: boolean + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-alienvault-connector: + resource: opencti-alienvault-connector-image +resources: + opencti-alienvault-connector-image: + type: oci-image + description: OCI image for the OpenCTI AlienVault connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/alienvault/lib/charms/opencti/v0/opencti_connector.py b/connectors/alienvault/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/alienvault/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/alienvault/requirements.txt b/connectors/alienvault/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/alienvault/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/alienvault/rock/rockcraft.yaml b/connectors/alienvault/rock/rockcraft.yaml new file mode 100644 index 0000000..36293c4 --- /dev/null +++ b/connectors/alienvault/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-alienvault-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI AlienVault Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + alienvault-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/alienvault + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-alienvault + + cat entrypoint.sh | grep opencti-connector-alienvault + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/alienvault/src/charm.py b/connectors/alienvault/src/charm.py new file mode 100755 index 0000000..8fc443e --- /dev/null +++ b/connectors/alienvault/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI AlienVault connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiAlienvaultConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiAlienvaultConnectorCharm) \ No newline at end of file diff --git a/connectors/cisa_kev/charmcraft.yaml b/connectors/cisa_kev/charmcraft.yaml new file mode 100644 index 0000000..ab71156 --- /dev/null +++ b/connectors/cisa_kev/charmcraft.yaml @@ -0,0 +1,73 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-cisa-kev-connector +title: OpenCTI CISA KEV Charm +summary: OpenCTI CISA Known Exploited Vulnerabilities charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + cisa-catalog-url: + description: The URL that hosts the KEV Cataloghttps://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json + type: string + cisa-create-infrastructures: + description: Allows you to create or not create an infrastructure in opencti + type: boolean + cisa-tlp: + description: TLP for data coming from this connector + type: string + connector-duration-period: + description: Determines the time interval between each launch of the connector in ISO 8601, ex:P7D. + type: string + connector-scope: + type: string + description: connector scope + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + connector-queue-threshold: + description: (optional) Used to determine the limit (RabbitMQ) in MB at which the connector must go into buffering mode. + type: int + connector-run-and-terminate: + description: (optional) Launch the connector once if set to True. Takes 2 available values:TrueorFalse. + type: boolean + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-cisa-kev-connector: + resource: opencti-cisa-kev-connector-image +resources: + opencti-cisa-kev-connector-image: + type: oci-image + description: OCI image for the OpenCTI CISA Known Exploited Vulnerabilities connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/cisa_kev/lib/charms/opencti/v0/opencti_connector.py b/connectors/cisa_kev/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/cisa_kev/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/cisa_kev/requirements.txt b/connectors/cisa_kev/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/cisa_kev/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/cisa_kev/rock/rockcraft.yaml b/connectors/cisa_kev/rock/rockcraft.yaml new file mode 100644 index 0000000..9727ef9 --- /dev/null +++ b/connectors/cisa_kev/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-cisa-kev-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI CISA Known Exploited Vulnerabilities Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + cisa-kev-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/cisa-known-exploited-vulnerabilities + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-cisa-known-exploited-vulnerabilities + + cat entrypoint.sh | grep opencti-connector-cisa-known-exploited-vulnerabilities + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/cisa_kev/src/charm.py b/connectors/cisa_kev/src/charm.py new file mode 100755 index 0000000..74d77ca --- /dev/null +++ b/connectors/cisa_kev/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI CISA Known Exploited Vulnerabilities connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiCisaKevConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiCisaKevConnectorCharm) \ No newline at end of file diff --git a/connectors/crowdstrike/charmcraft.yaml b/connectors/crowdstrike/charmcraft.yaml new file mode 100644 index 0000000..735da02 --- /dev/null +++ b/connectors/crowdstrike/charmcraft.yaml @@ -0,0 +1,130 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-crowdstrike-connector +title: OpenCTI CrowdStrike Charm +summary: OpenCTI CrowdStrike charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-duration-period: + description: 'Determines the time interval between each launch of the connector in ISO 8601, ex: .' + type: string + crowdstrike-actor-start-timestamp: + description: The Actors created after this timestamp will be imported. Timestamp in UNIX Epoch time, UTC. + type: int + crowdstrike-client-id: + description: The CrowdStrike API client ID. + type: string + crowdstrike-client-secret: + description: The CrowdStrike API client secret. + type: string + crowdstrike-create-indicators: + description: If true then indicators will be created from the CrowdStrike indicators. + type: string + crowdstrike-create-observables: + description: If true then observables will be created from the CrowdStrike indicators. + type: string + crowdstrike-indicator-exclude-types: + description: The types of Indicators excluded from the import. The types are defined by the CrowdStrike. + type: string + crowdstrike-indicator-start-timestamp: + description: The Indicators published after this timestamp will be imported. Timestamp in UNIX Epoch time, UTC. + type: int + crowdstrike-report-guess-malware: + description: The Report tags are used to guess (queries malwares in the OpenCTI) malwares related to the given Report. + type: string + crowdstrike-report-include-types: + description: The types of Reports included in the import. The types are defined by the CrowdStrike. + type: string + crowdstrike-report-start-timestamp: + description: The Reports created after this timestamp will be imported. Timestamp in UNIX Epoch time, UTC. + type: int + crowdstrike-report-status: + description: The status of imported reports in the OpenCTI. + type: string + crowdstrike-report-target-industries: + description: The reports to be imported must contain this industry/sector. The industry's names are defined by the CrowdStrike. + type: string + crowdstrike-report-type: + description: The type of imported reports in the OpenCTI. + type: string + crowdstrike-scopes: + description: The scopes defines what data will be imported from the CrowdStrike. + type: string + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + connector-queue-threshold: + description: (optional) Used to determine the limit (RabbitMQ) in MB at which the connector must go into buffering mode. + type: int + connector-run-and-terminate: + description: (optional) Launch the connector once if set to True. Takes 2 available values:TrueorFalse. + type: string + crowdstrike-base-url: + description: (optional) The base URL for the CrowdStrike APIs. + type: string + crowdstrike-indicator-high-score: + description: (optional) If any of the low score labels are found on the indicator then this value is used as a score. + type: int + crowdstrike-indicator-high-score-labels: + description: (optional) The labels used to determine the low score indicators. + type: string + crowdstrike-indicator-low-score: + description: (optional) If any of the low score labels are found on the indicator then this value is used as a score. + type: int + crowdstrike-indicator-low-score-labels: + description: (optional) The labels used to determine the low score indicators. + type: string + crowdstrike-indicator-medium-score: + description: (optional) If any of the low score labels are found on the indicator then this value is used as a score. + type: int + crowdstrike-indicator-medium-score-labels: + description: (optional) The labels used to determine the low score indicators. + type: string + crowdstrike-indicator-unwanted-labels: + description: (optional) Indicators to be excluded from import based on the labels affixed to them. + type: string + crowdstrike-tlp: + description: (optional) The TLP marking used for the imported objects in the OpenCTI. + type: string + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-crowdstrike-connector: + resource: opencti-crowdstrike-connector-image +resources: + opencti-crowdstrike-connector-image: + type: oci-image + description: OCI image for the OpenCTI CrowdStrike connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/crowdstrike/lib/charms/opencti/v0/opencti_connector.py b/connectors/crowdstrike/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/crowdstrike/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/crowdstrike/requirements.txt b/connectors/crowdstrike/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/crowdstrike/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/crowdstrike/rock/rockcraft.yaml b/connectors/crowdstrike/rock/rockcraft.yaml new file mode 100644 index 0000000..5811190 --- /dev/null +++ b/connectors/crowdstrike/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-crowdstrike-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI CrowdStrike Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + crowdstrike-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/crowdstrike + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-crowdstrike + + cat entrypoint.sh | grep opencti-connector-crowdstrike + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/crowdstrike/src/charm.py b/connectors/crowdstrike/src/charm.py new file mode 100755 index 0000000..b20cec9 --- /dev/null +++ b/connectors/crowdstrike/src/charm.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI CrowdStrike connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiCrowdstrikeConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + def _gen_env(self) -> dict[str, str]: + env = super()._gen_env() + env["CONNECTOR_SCOPE"] = "crowdstrike" + return env + + +if __name__ == "__main__": + ops.main(OpenctiCrowdstrikeConnectorCharm) \ No newline at end of file diff --git a/connectors/cyber_campaign/charmcraft.yaml b/connectors/cyber_campaign/charmcraft.yaml new file mode 100644 index 0000000..6f7e8e9 --- /dev/null +++ b/connectors/cyber_campaign/charmcraft.yaml @@ -0,0 +1,66 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-cyber-campaign-connector +title: OpenCTI APT & Cyber Campaign Charm +summary: OpenCTI APT & Cybercriminals Campaign Collection charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-log-level: + description: The log level for the connector. + type: string + connector-run-and-terminate: + description: Whether the connector should run and terminate after execution. + type: boolean + connector-scope: + description: The data scope of the connector. + type: string + cyber-monitor-from-year: + description: The starting year for monitoring cyber campaigns. + type: int + cyber-monitor-interval: + description: The interval in days, must be strictly greater than 1. + type: int + cyber-monitor-github-token: + description: (optional) If not provided, rate limit will be very low. + type: string + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-cyber-campaign-connector: + resource: opencti-cyber-campaign-connector-image +resources: + opencti-cyber-campaign-connector-image: + type: oci-image + description: OCI image for the OpenCTI APT & Cybercriminals Campaign Collection connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/cyber_campaign/lib/charms/opencti/v0/opencti_connector.py b/connectors/cyber_campaign/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/cyber_campaign/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/cyber_campaign/requirements.txt b/connectors/cyber_campaign/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/cyber_campaign/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/cyber_campaign/rock/rockcraft.yaml b/connectors/cyber_campaign/rock/rockcraft.yaml new file mode 100644 index 0000000..7f3a9a8 --- /dev/null +++ b/connectors/cyber_campaign/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-cyber-campaign-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI APT & Cybercriminals Campaign Collection Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + cyber-campaign-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/cyber-campaign-collection + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-cyber-campaign-collection + + cat entrypoint.sh | grep opencti-connector-cyber-campaign-collection + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/cyber_campaign/src/charm.py b/connectors/cyber_campaign/src/charm.py new file mode 100755 index 0000000..7f566bb --- /dev/null +++ b/connectors/cyber_campaign/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI APT & Cybercriminals Campaign Collection connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiCyberCampaignConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiCyberCampaignConnectorCharm) \ No newline at end of file diff --git a/connectors/export_file_csv/charmcraft.yaml b/connectors/export_file_csv/charmcraft.yaml new file mode 100644 index 0000000..50d2051 --- /dev/null +++ b/connectors/export_file_csv/charmcraft.yaml @@ -0,0 +1,61 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-export-file-csv-connector +title: OpenCTI Export CSV File Charm +summary: OpenCTI Export CSV File charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-scope: + type: string + description: connector scope + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + connector-confidence-level: + type: int + description: (optional) the confidence level of the connector. + export-file-csv-delimiter: + type: string + description: (optional) the delimiter of the exported CSV file. + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-export-file-csv-connector: + resource: opencti-export-file-csv-connector-image +resources: + opencti-export-file-csv-connector-image: + type: oci-image + description: OCI image for the OpenCTI Export CSV File connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/export_file_csv/lib/charms/opencti/v0/opencti_connector.py b/connectors/export_file_csv/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/export_file_csv/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/export_file_csv/requirements.txt b/connectors/export_file_csv/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/export_file_csv/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/export_file_csv/rock/rockcraft.yaml b/connectors/export_file_csv/rock/rockcraft.yaml new file mode 100644 index 0000000..35ba37d --- /dev/null +++ b/connectors/export_file_csv/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-export-file-csv-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI Export CSV File Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + export-file-csv-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd internal-export-file/export-file-csv + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-export-file-csv + + cat entrypoint.sh | grep opencti-connector-export-file-csv + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/export_file_csv/src/charm.py b/connectors/export_file_csv/src/charm.py new file mode 100755 index 0000000..29408bf --- /dev/null +++ b/connectors/export_file_csv/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI Export CSV File connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiExportFileCsvConnectorCharm(OpenctiConnectorCharm): + connector_type = "INTERNAL_EXPORT_FILE" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiExportFileCsvConnectorCharm) \ No newline at end of file diff --git a/connectors/export_file_stix/charmcraft.yaml b/connectors/export_file_stix/charmcraft.yaml new file mode 100644 index 0000000..1fd4cca --- /dev/null +++ b/connectors/export_file_stix/charmcraft.yaml @@ -0,0 +1,58 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-export-file-stix-connector +title: OpenCTI Export STIX File Charm +summary: OpenCTI Export STIX File charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-scope: + type: string + description: connector scope + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + connector-confidence-level: + type: int + description: (optional) the confidence level of the connector. + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-export-file-stix-connector: + resource: opencti-export-file-stix-connector-image +resources: + opencti-export-file-stix-connector-image: + type: oci-image + description: OCI image for the OpenCTI Export STIX File connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/export_file_stix/lib/charms/opencti/v0/opencti_connector.py b/connectors/export_file_stix/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/export_file_stix/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/export_file_stix/requirements.txt b/connectors/export_file_stix/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/export_file_stix/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/export_file_stix/rock/rockcraft.yaml b/connectors/export_file_stix/rock/rockcraft.yaml new file mode 100644 index 0000000..b4b3e50 --- /dev/null +++ b/connectors/export_file_stix/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-export-file-stix-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI Export STIX File Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + export-file-stix-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd internal-export-file/export-file-stix + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-export-file-stix + + cat entrypoint.sh | grep opencti-connector-export-file-stix + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/export_file_stix/src/charm.py b/connectors/export_file_stix/src/charm.py new file mode 100755 index 0000000..e8fd1a4 --- /dev/null +++ b/connectors/export_file_stix/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI Export STIX File connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiExportFileStixConnectorCharm(OpenctiConnectorCharm): + connector_type = "INTERNAL_EXPORT_FILE" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiExportFileStixConnectorCharm) \ No newline at end of file diff --git a/connectors/export_file_txt/charmcraft.yaml b/connectors/export_file_txt/charmcraft.yaml new file mode 100644 index 0000000..0a44569 --- /dev/null +++ b/connectors/export_file_txt/charmcraft.yaml @@ -0,0 +1,58 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-export-file-txt-connector +title: OpenCTI Export TXT File Charm +summary: OpenCTI Export TXT File charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-scope: + type: string + description: connector scope + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + connector-confidence-level: + type: int + description: (optional) the confidence level of the connector. + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-export-file-txt-connector: + resource: opencti-export-file-txt-connector-image +resources: + opencti-export-file-txt-connector-image: + type: oci-image + description: OCI image for the OpenCTI Export TXT File connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/export_file_txt/lib/charms/opencti/v0/opencti_connector.py b/connectors/export_file_txt/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/export_file_txt/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/export_file_txt/requirements.txt b/connectors/export_file_txt/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/export_file_txt/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/export_file_txt/rock/rockcraft.yaml b/connectors/export_file_txt/rock/rockcraft.yaml new file mode 100644 index 0000000..728e0c6 --- /dev/null +++ b/connectors/export_file_txt/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-export-file-txt-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI Export TXT File Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + export-file-txt-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd internal-export-file/export-file-txt + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-export-file-txt + + cat entrypoint.sh | grep opencti-connector-export-file-txt + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/export_file_txt/src/charm.py b/connectors/export_file_txt/src/charm.py new file mode 100755 index 0000000..5a545e6 --- /dev/null +++ b/connectors/export_file_txt/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI Export TXT File connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiExportFileTxtConnectorCharm(OpenctiConnectorCharm): + connector_type = "INTERNAL_EXPORT_FILE" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiExportFileTxtConnectorCharm) \ No newline at end of file diff --git a/connectors/import_document/charmcraft.yaml b/connectors/import_document/charmcraft.yaml new file mode 100644 index 0000000..5df5c92 --- /dev/null +++ b/connectors/import_document/charmcraft.yaml @@ -0,0 +1,70 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-import-document-connector +title: OpenCTI Document Import Charm +summary: OpenCTI Document Import charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-auto: + description: enable/disable auto import of report file + type: boolean + connector-confidence-level: + description: connector confidence level, from 0 (unknown) to 100 (fully trusted). + type: int + connector-only-contextual: + description: true only extract data related to an entity (a report, a threat actor, etc.) + type: boolean + connector-scope: + description: connector scope + type: string + connector-validate-before-import: + description: validate any bundle before import. + type: boolean + import-document-create-indicator: + description: import document create indicator + type: boolean + connector-log-level: + description: log level for this connector. + type: string + default: info + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-import-document-connector: + resource: opencti-import-document-connector-image +resources: + opencti-import-document-connector-image: + type: oci-image + description: OCI image for the OpenCTI Document Import connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/import_document/lib/charms/opencti/v0/opencti_connector.py b/connectors/import_document/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/import_document/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/import_document/requirements.txt b/connectors/import_document/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/import_document/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/import_document/rock/rockcraft.yaml b/connectors/import_document/rock/rockcraft.yaml new file mode 100644 index 0000000..cf33d30 --- /dev/null +++ b/connectors/import_document/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-import-document-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI Document Import Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + import-document-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd internal-import-file/import-document + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-import-document + + cat entrypoint.sh | grep opencti-connector-import-document + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/import_document/src/charm.py b/connectors/import_document/src/charm.py new file mode 100755 index 0000000..117bcc4 --- /dev/null +++ b/connectors/import_document/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI Document Import connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiImportDocumentConnectorCharm(OpenctiConnectorCharm): + connector_type = "INTERNAL_IMPORT_FILE" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiImportDocumentConnectorCharm) \ No newline at end of file diff --git a/connectors/import_file_stix/charmcraft.yaml b/connectors/import_file_stix/charmcraft.yaml new file mode 100644 index 0000000..174cbc4 --- /dev/null +++ b/connectors/import_file_stix/charmcraft.yaml @@ -0,0 +1,64 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-import-file-stix-connector +title: OpenCTI Import File Stix Charm +summary: OpenCTI Import File Stix charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-auto: + description: enable/disable auto-import of file + type: boolean + connector-confidence-level: + description: from 0 (Unknown) to 100 (Fully trusted) + type: int + connector-scope: + description: connector scope + type: string + connector-validate-before-import: + description: validate any bundle before import + type: boolean + connector-log-level: + description: logging level of the connector + type: string + default: info + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-import-file-stix-connector: + resource: opencti-import-file-stix-connector-image +resources: + opencti-import-file-stix-connector-image: + type: oci-image + description: OCI image for the OpenCTI Import File Stix connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/import_file_stix/lib/charms/opencti/v0/opencti_connector.py b/connectors/import_file_stix/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/import_file_stix/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/import_file_stix/requirements.txt b/connectors/import_file_stix/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/import_file_stix/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/import_file_stix/rock/rockcraft.yaml b/connectors/import_file_stix/rock/rockcraft.yaml new file mode 100644 index 0000000..d4fc4b7 --- /dev/null +++ b/connectors/import_file_stix/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-import-file-stix-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI Import File Stix Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + import-file-stix-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd internal-import-file/import-file-stix + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-import-file-stix + + cat entrypoint.sh | grep opencti-connector-import-file-stix + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/import_file_stix/src/charm.py b/connectors/import_file_stix/src/charm.py new file mode 100755 index 0000000..b0ecb66 --- /dev/null +++ b/connectors/import_file_stix/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI Import File Stix connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiImportFileStixConnectorCharm(OpenctiConnectorCharm): + connector_type = "INTERNAL_IMPORT_FILE" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiImportFileStixConnectorCharm) \ No newline at end of file diff --git a/connectors/malwarebazaar/charmcraft.yaml b/connectors/malwarebazaar/charmcraft.yaml new file mode 100644 index 0000000..5b41f7d --- /dev/null +++ b/connectors/malwarebazaar/charmcraft.yaml @@ -0,0 +1,69 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-malwarebazaar-connector +title: OpenCTI MalwareBazaar Charm +summary: OpenCTI MalwareBazaar Recent Additions charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-log-level: + description: The log level for the connector + type: string + malwarebazaar-recent-additions-api-url: + description: The API URL + type: string + malwarebazaar-recent-additions-cooldown-seconds: + description: Time to wait in seconds between subsequent requests + type: int + malwarebazaar-recent-additions-labels-color: + description: Color to use for labels + type: string + malwarebazaar-recent-additions-include-reporters: + description: (optional) Only download files uploaded by these reporters. (Comma separated) + type: string + malwarebazaar-recent-additions-include-tags: + description: (optional) Only download files if any tag matches. (Comma separated) + type: string + malwarebazaar-recent-additions-labels: + description: (optional) Labels to apply to uploaded Artifacts. (Comma separated) + type: string + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-malwarebazaar-connector: + resource: opencti-malwarebazaar-connector-image +resources: + opencti-malwarebazaar-connector-image: + type: oci-image + description: OCI image for the OpenCTI MalwareBazaar Recent Additions connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/malwarebazaar/lib/charms/opencti/v0/opencti_connector.py b/connectors/malwarebazaar/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/malwarebazaar/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/malwarebazaar/requirements.txt b/connectors/malwarebazaar/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/malwarebazaar/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/malwarebazaar/rock/rockcraft.yaml b/connectors/malwarebazaar/rock/rockcraft.yaml new file mode 100644 index 0000000..d979d77 --- /dev/null +++ b/connectors/malwarebazaar/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-malwarebazaar-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI MalwareBazaar Recent Additions Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + malwarebazaar-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/malwarebazaar-recent-additions + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-malwarebazaar-recent-additions + + cat entrypoint.sh | grep opencti-connector-malwarebazaar-recent-additions + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/malwarebazaar/src/charm.py b/connectors/malwarebazaar/src/charm.py new file mode 100755 index 0000000..a18bf10 --- /dev/null +++ b/connectors/malwarebazaar/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI MalwareBazaar Recent Additions connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiMalwarebazaarConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiMalwarebazaarConnectorCharm) \ No newline at end of file diff --git a/connectors/misp_feed/charmcraft.yaml b/connectors/misp_feed/charmcraft.yaml new file mode 100644 index 0000000..dd6039b --- /dev/null +++ b/connectors/misp_feed/charmcraft.yaml @@ -0,0 +1,130 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-misp-feed-connector +title: OpenCTI MISP Source Charm +summary: OpenCTI MISP Source charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-scope: + type: string + description: connector scope + misp-feed-interval: + type: int + description: misp feed interval in minutes + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + aws-access-key-id: + description: (optional) Access key used to access the bucket + type: string + aws-endpoint-url: + description: (optional) URL to specify for compatibility with other S3 buckets (MinIO) + type: string + aws-secret-access-key: + description: (optional) Secret key used to access the bucket + type: string + connector-run-and-terminate: + type: boolean + description: (optional) Launch the connector once if set to True + misp-bucket-name: + description: (optional) Bucket Name where the MISP's files are stored + type: string + misp-bucket-prefix: + description: (optional) Used to filter imports + type: string + misp-create-tags-as-labels: + description: (optional) Whether to convert tags into labels. + type: boolean + misp-feed-author-from-tags: + description: (optional) Whether to infer authors from tags. + type: boolean + misp-feed-create-indicators: + description: (optional) Whether to create indicators from the MISP feed. + type: boolean + misp-feed-create-object-observables: + description: (optional) Whether to create object observables. + type: boolean + misp-feed-create-observables: + description: (optional) Whether to create observables from the MISP feed. + type: boolean + misp-feed-create-reports: + description: (optional) Whether to create reports from MISP feed data. + type: boolean + misp-feed-create-tags-as-labels: + type: boolean + description: (optional) create tags as labels (sanitize MISP tag to OpenCTI labels) + misp-feed-guess-threat-from-tags: + description: (optional) Whether to infer threats from tags. + type: boolean + misp-feed-import-from-date: + description: (optional) Start date for importing data from the MISP feed. + type: string + misp-feed-import-to-ids-no-score: + description: (optional) Import data without a score to IDS. + type: boolean + misp-feed-import-unsupported-observables-as-text: + description: (optional) Import unsupported observables as plain text. + type: boolean + misp-feed-import-unsupported-observables-as-text-transparent: + description: (optional) Whether to import unsupported observables transparently as text. + type: boolean + misp-feed-import-with-attachments: + description: (optional) Whether to import attachments from the feed. + type: boolean + misp-feed-markings-from-tags: + description: (optional) Whether to infer markings from tags. + type: boolean + misp-feed-report-type: + description: (optional) The type of reports to create from the MISP feed. + type: string + misp-feed-source-type: + description: (optional) Source type for the MISP feed (url or s3). + type: string + misp-feed-ssl-verify: + description: (optional) Whether to verify SSL certificates for the feed URL. + type: boolean + misp-feed-url: + description: (optional) The URL of the MISP feed (required ifsource_typeisurl). + type: string + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-misp-feed-connector: + resource: opencti-misp-feed-connector-image +resources: + opencti-misp-feed-connector-image: + type: oci-image + description: OCI image for the OpenCTI MISP Source connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/misp_feed/lib/charms/opencti/v0/opencti_connector.py b/connectors/misp_feed/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/misp_feed/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/misp_feed/requirements.txt b/connectors/misp_feed/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/misp_feed/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/misp_feed/rock/rockcraft.yaml b/connectors/misp_feed/rock/rockcraft.yaml new file mode 100644 index 0000000..be535e2 --- /dev/null +++ b/connectors/misp_feed/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-misp-feed-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI MISP Source Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + misp-feed-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/misp-feed + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-misp-feed + + cat entrypoint.sh | grep opencti-connector-misp-feed + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/misp_feed/src/charm.py b/connectors/misp_feed/src/charm.py new file mode 100755 index 0000000..0185907 --- /dev/null +++ b/connectors/misp_feed/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI MISP Source connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiMispFeedConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiMispFeedConnectorCharm) \ No newline at end of file diff --git a/connectors/mitre/charmcraft.yaml b/connectors/mitre/charmcraft.yaml new file mode 100644 index 0000000..47cdcd8 --- /dev/null +++ b/connectors/mitre/charmcraft.yaml @@ -0,0 +1,76 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-mitre-connector +title: OpenCTI MITRE Datasets Charm +summary: OpenCTI MITRE Datasets charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-scope: + type: string + description: connector scope + mitre-interval: + description: Number of the days between each MITRE datasets collection. + type: int + mitre-remove-statement-marking: + description: Remove the statement MITRE marking definition. + type: boolean + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + connector-run-and-terminate: + type: boolean + description: (optional) Launch the connector once if set to True + mitre-capec-file-url: + description: (optional) Resource URL + type: string + mitre-enterprise-file-url: + description: (optional) Resource URL + type: string + mitre-ics-attack-file-url: + description: (optional) Resource URL + type: string + mitre-mobile-attack-file-url: + description: (optional) Resource URL + type: string + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-mitre-connector: + resource: opencti-mitre-connector-image +resources: + opencti-mitre-connector-image: + type: oci-image + description: OCI image for the OpenCTI MITRE Datasets connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/mitre/lib/charms/opencti/v0/opencti_connector.py b/connectors/mitre/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/mitre/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/mitre/requirements.txt b/connectors/mitre/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/mitre/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/mitre/rock/rockcraft.yaml b/connectors/mitre/rock/rockcraft.yaml new file mode 100644 index 0000000..516388c --- /dev/null +++ b/connectors/mitre/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-mitre-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI MITRE Datasets Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + mitre-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/mitre + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-mitre + echo 'cd /opt/opencti-connector-mitre; python3 connector.py' > entrypoint.sh + cat entrypoint.sh | grep opencti-connector-mitre + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/mitre/src/charm.py b/connectors/mitre/src/charm.py new file mode 100755 index 0000000..601871c --- /dev/null +++ b/connectors/mitre/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI MITRE Datasets connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiMitreConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiMitreConnectorCharm) \ No newline at end of file diff --git a/connectors/sekoia/charmcraft.yaml b/connectors/sekoia/charmcraft.yaml new file mode 100644 index 0000000..469a34f --- /dev/null +++ b/connectors/sekoia/charmcraft.yaml @@ -0,0 +1,71 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-sekoia-connector +title: OpenCTI Sekoia.io Charm +summary: OpenCTI Sekoia.io charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-scope: + type: string + description: connector scope + sekoia-api-key: + description: Sekoia API key + type: string + sekoia-collection: + description: Sekoia collection + type: string + sekoia-create-observables: + description: create observables from indicators + type: boolean + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + sekoia-base-url: + description: Sekoia base url + type: string + default: https://api.sekoia.io + sekoia-start-date: + description: (optional) the date to start consuming data from. Maybe in the formats YYYY-MM-DD or YYYY-MM-DDT00:00:00 + type: string + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-sekoia-connector: + resource: opencti-sekoia-connector-image +resources: + opencti-sekoia-connector-image: + type: oci-image + description: OCI image for the OpenCTI Sekoia.io connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/sekoia/lib/charms/opencti/v0/opencti_connector.py b/connectors/sekoia/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/sekoia/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/sekoia/requirements.txt b/connectors/sekoia/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/sekoia/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/sekoia/rock/rockcraft.yaml b/connectors/sekoia/rock/rockcraft.yaml new file mode 100644 index 0000000..9dedd57 --- /dev/null +++ b/connectors/sekoia/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-sekoia-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI Sekoia.io Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + sekoia-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/sekoia + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-sekoia + echo 'cd /opt/opencti-connector-sekoia; python3 sekoia.py' > entrypoint.sh + cat entrypoint.sh | grep opencti-connector-sekoia + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/sekoia/src/charm.py b/connectors/sekoia/src/charm.py new file mode 100755 index 0000000..57d7a94 --- /dev/null +++ b/connectors/sekoia/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI Sekoia.io connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiSekoiaConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiSekoiaConnectorCharm) \ No newline at end of file diff --git a/connectors/urlscan/charmcraft.yaml b/connectors/urlscan/charmcraft.yaml new file mode 100644 index 0000000..fa64334 --- /dev/null +++ b/connectors/urlscan/charmcraft.yaml @@ -0,0 +1,87 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-urlscan-connector +title: OpenCTI Urlscan.io Charm +summary: OpenCTI Urlscan.io charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-confidence-level: + description: The default confidence level for created relationships (0 -> 100). + type: int + connector-log-level: + description: The log level for this connector, could be `debug`, `info`, `warn` or `error` (less verbose). + type: string + connector-update-existing-data: + description: If an entity already exists, update its attributes with information provided by this connector. + type: boolean + urlscan-api-key: + description: The Urlscan client secret. + type: string + urlscan-url: + description: The Urlscan URL. + type: string + connector-create-indicators: + description: (optional) Create indicators for each observable processed. + type: boolean + connector-interval: + description: (optional) An interval (in seconds) for data gathering from Urlscan. + type: int + connector-labels: + description: (optional) Comma delimited list of labels to apply to each observable. + type: string + connector-lookback: + description: (optional) How far to look back in days if the connector has never run or the last run is older than this value. Default is 3. You should not go above 7. + type: int + connector-tlp: + description: (optional) The TLP to apply to any indicators and observables, this could be `white`,`green`,`amber` or `red` + type: string + urlscan-default-x-opencti-score: + description: (optional) The default x_opencti_score to use across observable/indicator types. Default is 50. + type: int + urlscan-x-opencti-score-domain: + description: (optional) The x_opencti_score to use across Domain-Name observable and indicators. Defaults to default score. + type: int + urlscan-x-opencti-score-url: + description: (optional) The x_opencti_score to use across Url observable and indicators. Defaults to default score. + type: integer + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-urlscan-connector: + resource: opencti-urlscan-connector-image +resources: + opencti-urlscan-connector-image: + type: oci-image + description: OCI image for the OpenCTI Urlscan.io connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/urlscan/lib/charms/opencti/v0/opencti_connector.py b/connectors/urlscan/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/urlscan/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/urlscan/requirements.txt b/connectors/urlscan/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/urlscan/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/urlscan/rock/rockcraft.yaml b/connectors/urlscan/rock/rockcraft.yaml new file mode 100644 index 0000000..696dd1a --- /dev/null +++ b/connectors/urlscan/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-urlscan-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI Urlscan.io Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + urlscan-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/urlscan + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-urlscan + + cat entrypoint.sh | grep opencti-connector-urlscan + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/urlscan/src/charm.py b/connectors/urlscan/src/charm.py new file mode 100755 index 0000000..849301b --- /dev/null +++ b/connectors/urlscan/src/charm.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI Urlscan.io connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiUrlscanConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + def _gen_env(self) -> dict[str, str]: + env = super()._gen_env() + env["CONNECTOR_SCOPE"] = "threatmatch" + return env + + +if __name__ == "__main__": + ops.main(OpenctiUrlscanConnectorCharm) \ No newline at end of file diff --git a/connectors/urlscan_enrichment/charmcraft.yaml b/connectors/urlscan_enrichment/charmcraft.yaml new file mode 100644 index 0000000..f1b11c3 --- /dev/null +++ b/connectors/urlscan_enrichment/charmcraft.yaml @@ -0,0 +1,79 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-urlscan-enrichment-connector +title: OpenCTI URLScan Enrichment Charm +summary: OpenCTI URLScan Enrichment charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-auto: + type: boolean + description: connector auto + connector-scope: + type: string + description: connector scope + urlscan-enrichment-api-base-url: + description: URLScan Base Url + type: string + urlscan-enrichment-api-key: + description: URLScan API Key + type: string + urlscan-enrichment-import-screenshot: + description: Allows or not the import of the screenshot of the scan submitted in URLScan to OpenCTI. + type: boolean + urlscan-enrichment-max-tlp: + description: Do not send any data to URLScan if the TLP of the observable is greater than MAX_TLP + type: string + urlscan-enrichment-search-filtered-by-date: + description: Allows you to filter by date available:>now-1h,>now-1d,>now-1y,[2022 TO 2023],[2022/01/01 TO 2023/12/01] + type: string + urlscan-enrichment-visibility: + description: URLScan offers several levels of visibility for submitted scans:public,unlisted,private + type: string + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + connector-run-and-terminate: + description: (optional) Launch the connector once if set to True. Takes 2 available values:TrueorFalse + type: boolean + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-urlscan-enrichment-connector: + resource: opencti-urlscan-enrichment-connector-image +resources: + opencti-urlscan-enrichment-connector-image: + type: oci-image + description: OCI image for the OpenCTI URLScan Enrichment connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/urlscan_enrichment/lib/charms/opencti/v0/opencti_connector.py b/connectors/urlscan_enrichment/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/urlscan_enrichment/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/urlscan_enrichment/requirements.txt b/connectors/urlscan_enrichment/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/urlscan_enrichment/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/urlscan_enrichment/rock/rockcraft.yaml b/connectors/urlscan_enrichment/rock/rockcraft.yaml new file mode 100644 index 0000000..4dc4230 --- /dev/null +++ b/connectors/urlscan_enrichment/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-urlscan-enrichment-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI URLScan Enrichment Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + urlscan-enrichment-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd internal-enrichment/urlscan-enrichment + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-urlscan-enrichment + + cat entrypoint.sh | grep opencti-connector-urlscan-enrichment + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/urlscan_enrichment/src/charm.py b/connectors/urlscan_enrichment/src/charm.py new file mode 100755 index 0000000..8bf43fa --- /dev/null +++ b/connectors/urlscan_enrichment/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI URLScan Enrichment connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiUrlscanEnrichmentConnectorCharm(OpenctiConnectorCharm): + connector_type = "INTERNAL_ENRICHMENT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiUrlscanEnrichmentConnectorCharm) \ No newline at end of file diff --git a/connectors/virustotal_livehunt/charmcraft.yaml b/connectors/virustotal_livehunt/charmcraft.yaml new file mode 100644 index 0000000..d86c369 --- /dev/null +++ b/connectors/virustotal_livehunt/charmcraft.yaml @@ -0,0 +1,94 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-virustotal-livehunt-connector +title: OpenCTI VirusTotal Livehunt Charm +summary: OpenCTI VirusTotal Livehunt Notifications charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-scope: + type: string + description: connector scope + virustotal-livehunt-notifications-api-key: + description: Private API Key + type: string + virustotal-livehunt-notifications-create-alert: + description: Set to true to create alerts + type: boolean + virustotal-livehunt-notifications-create-file: + description: Set to true to create file object linked to the alerts + type: boolean + virustotal-livehunt-notifications-create-yara-rule: + description: Set to true to create yara rule linked to the alert and the file + type: boolean + virustotal-livehunt-notifications-delete-notification: + description: Set to true to remove livehunt notifications + type: boolean + virustotal-livehunt-notifications-filter-with-tag: + description: Filter livehunt notifications with this tag + type: string + virustotal-livehunt-notifications-interval-sec: + description: Time to wait in seconds between subsequent requests + type: int + virustotal-livehunt-notifications-max-age-days: + description: Only create the alert if the first submission of the file is not older than `max_age_days` + type: int + virustotal-livehunt-notifications-upload-artifact: + description: Set to true to upload the file to opencti + type: boolean + connector-log-level: + type: string + description: determines the verbosity of the logs. Options are debug, info, warn, or error + default: info + virustotal-livehunt-notifications-extensions: + description: (optional) Comma separated filter to only download files matching these extensions + type: string + virustotal-livehunt-notifications-max-file-size: + description: (optional) Don't download files larger than this many bytes + type: int + virustotal-livehunt-notifications-min-file-size: + description: (optional) Don't download files smaller than this many bytes + type: int + virustotal-livehunt-notifications-min-positives: + description: (optional) Don't download files with less than this many vendors marking malicious + type: int + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-virustotal-livehunt-connector: + resource: opencti-virustotal-livehunt-connector-image +resources: + opencti-virustotal-livehunt-connector-image: + type: oci-image + description: OCI image for the OpenCTI VirusTotal Livehunt Notifications connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/virustotal_livehunt/lib/charms/opencti/v0/opencti_connector.py b/connectors/virustotal_livehunt/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/virustotal_livehunt/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/virustotal_livehunt/requirements.txt b/connectors/virustotal_livehunt/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/virustotal_livehunt/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/virustotal_livehunt/rock/rockcraft.yaml b/connectors/virustotal_livehunt/rock/rockcraft.yaml new file mode 100644 index 0000000..3b0c010 --- /dev/null +++ b/connectors/virustotal_livehunt/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-virustotal-livehunt-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI VirusTotal Livehunt Notifications Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + virustotal-livehunt-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/virustotal-livehunt-notifications + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-virustotal-livehunt-notifications + + cat entrypoint.sh | grep opencti-connector-virustotal-livehunt-notifications + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/virustotal_livehunt/src/charm.py b/connectors/virustotal_livehunt/src/charm.py new file mode 100755 index 0000000..458420c --- /dev/null +++ b/connectors/virustotal_livehunt/src/charm.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI VirusTotal Livehunt Notifications connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiVirustotalLivehuntConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + @property + def boolean_style(self) -> str: + return "python" + + +if __name__ == "__main__": + ops.main(OpenctiVirustotalLivehuntConnectorCharm) \ No newline at end of file diff --git a/connectors/vxvault/charmcraft.yaml b/connectors/vxvault/charmcraft.yaml new file mode 100644 index 0000000..0325382 --- /dev/null +++ b/connectors/vxvault/charmcraft.yaml @@ -0,0 +1,68 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-vxvault-connector +title: OpenCTI VXVault Charm +summary: OpenCTI VXVault charm. +links: + documentation: https://discourse.charmhub.io + issues: https://github.com/canonical/opencti-operator/issues + source: https://github.com/canonical/opencti-operator + contact: https://launchpad.net/~canonical-is-devops + +description: | + A [Juju](https://juju.is/) [charm](https://juju.is/docs/olm/charmed-operators) + for deploying and managing the [OpenCTI Connectors](https://docs.opencti.io/latest/deployment/connectors/) + for the OpenCTI charm. + + This charm simplifies the configuration and maintenance of OpenCTI Connectors + across a range of environments, organize your cyber threat intelligence to + enhance and disseminate actionable insights. + +config: + options: + connector-scope: + description: connector scope + type: string + vxvault-create-indicators: + description: vxvault create indicators + type: boolean + vxvault-interval: + description: In days, must be strictly greater than 1 + type: int + vxvault-ssl-verify: + description: Whether to verify SSL certificates + type: boolean + default: true + vxvault-url: + description: vxvault url + type: string + default: https://vxvault.net/URL_List.php + connector-log-level: + description: (optional) The log level of the connector + type: string + + +provides: + opencti-connector: + interface: opencti_connector + limit: 1 + +type: charm +base: ubuntu@24.04 +build-base: ubuntu@24.04 +platforms: + amd64: +parts: + charm: {} + +containers: + opencti-vxvault-connector: + resource: opencti-vxvault-connector-image +resources: + opencti-vxvault-connector-image: + type: oci-image + description: OCI image for the OpenCTI VXVault connector. + +assumes: + - juju >= 3.4 \ No newline at end of file diff --git a/connectors/vxvault/lib/charms/opencti/v0/opencti_connector.py b/connectors/vxvault/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/connectors/vxvault/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/connectors/vxvault/requirements.txt b/connectors/vxvault/requirements.txt new file mode 100644 index 0000000..95534d0 --- /dev/null +++ b/connectors/vxvault/requirements.txt @@ -0,0 +1 @@ +ops \ No newline at end of file diff --git a/connectors/vxvault/rock/rockcraft.yaml b/connectors/vxvault/rock/rockcraft.yaml new file mode 100644 index 0000000..e7d52ff --- /dev/null +++ b/connectors/vxvault/rock/rockcraft.yaml @@ -0,0 +1,39 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +name: opencti-vxvault-connector +base: ubuntu@24.04 +version: &version '6.4.5' +summary: OpenCTI VXVault Connector +description: >- + OpenCTI connectors are the cornerstone of the OpenCTI platform and + allow organizations to easily ingest, enrich or export data. +platforms: + amd64: + +parts: + vxvault-connector: + source: https://github.com/OpenCTI-Platform/connectors.git + source-type: git + source-tag: *version + source-depth: 1 + plugin: nil + build-packages: + - python3-pip + stage-packages: + - python3-dev + - libmagic1 + - libffi-dev + override-build: | + craftctl default + ls -lah + mkdir -p $CRAFT_PART_INSTALL/opt + cd external-import/vxvault + cp -rp src $CRAFT_PART_INSTALL/opt/opencti-connector-vxvault + + cat entrypoint.sh | grep opencti-connector-vxvault + mkdir -p $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages + pip install \ + --target $CRAFT_PART_INSTALL/usr/local/lib/python3.12/dist-packages \ + -r $(find -name requirements.txt) + cp entrypoint.sh $CRAFT_PART_INSTALL/ \ No newline at end of file diff --git a/connectors/vxvault/src/charm.py b/connectors/vxvault/src/charm.py new file mode 100755 index 0000000..3eaf16b --- /dev/null +++ b/connectors/vxvault/src/charm.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI VXVault connector charm the service.""" + +import pathlib + +import ops + +from charms.opencti.v0.opencti_connector import OpenctiConnectorCharm + + +class OpenctiVxvaultConnectorCharm(OpenctiConnectorCharm): + connector_type = "EXTERNAL_IMPORT" + + @property + def charm_dir(self) -> pathlib.Path: + return pathlib.Path(__file__).parent.parent.absolute() + + + +if __name__ == "__main__": + ops.main(OpenctiVxvaultConnectorCharm) \ No newline at end of file diff --git a/lib/charms/opencti/v0/opencti_connector.py b/lib/charms/opencti/v0/opencti_connector.py new file mode 100644 index 0000000..463ff98 --- /dev/null +++ b/lib/charms/opencti/v0/opencti_connector.py @@ -0,0 +1,237 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI connector charm library.""" + +# The unique Charmhub library identifier, never change it +LIBID = "312661b5c30e4aeba8767706f3974899" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +import abc +import os +import pathlib +import urllib.parse +import uuid + +import ops +import yaml + + +class NotReady(Exception): + """The OpenCTI connector is not ready.""" + + +class OpenctiConnectorCharm(ops.CharmBase, abc.ABC): + """OpenCTI connector base charm.""" + + @property + @abc.abstractmethod + def connector_type(self) -> str: + """The OpenCTI connector type. + + Can be either "EXTERNAL_IMPORT", "INTERNAL_ENRICHMENT", "INTERNAL_IMPORT_FILE", + "INTERNAL_EXPORT_FILE" or "STREAM". + + Returns: the connector type. + """ + pass + + @property + @abc.abstractmethod + def charm_dir(self) -> pathlib.Path: + """Return the charm directory (the one with charmcraft.yaml in it).""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, self._reconcile) + self.framework.observe(self.on["opencti-connector"].relation_changed, self._reconcile) + self.framework.observe(self.on.secret_changed, self._reconcile) + self.framework.observe(self.on.upgrade_charm, self._reconcile) + self.framework.observe(self.on[self._charm_name].pebble_ready, self._reconcile) + + @property + def boolean_style(self) -> str: + """Dictate how boolean-typed configurations should translate to environment variable values. + + The style should be either "json" for true/false or "python" for True/False. + + Returns: "json" or "python" + """ + return "json" + + @property + def _charm_name(self): + """Get charm name. + + Returns: + The charm name. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "metadata.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["name"] + raise RuntimeError("charm metadata doesn't exist") + + def _config_metadata(self) -> dict: + """Get charm configuration metadata. + + Returns: + The charm configuration metadata. + + Raises: + RuntimeError: If charm metadata file doesn't exist. + """ + config_file = self.charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["options"] + config_file = self.charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"]["options"] + raise RuntimeError("charm configuration metadata doesn't exist") + + def kebab_to_constant(self, name: str) -> str: + """Convert kebab case to constant case + + Args: + name: Kebab case name. + + Returns: + Input in constant case. + """ + return name.replace("-", "_").upper() + + def _check_config(self) -> None: + """Check if required charm configurations are ready. + + Raises: + NotReady: If some charm configurations isn't ready. + """ + missing = [] + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None and not config_meta["description"].strip().startswith("(optional)"): + missing.append(config) + if missing: + raise NotReady("missing configurations: {}".format(", ".join(missing))) + + def _check_integration(self) -> None: + """Check if required charm integrations are ready. + + Raises: + NotReady: If some charm integrations isn't ready. + """ + integration = self.model.get_relation("opencti-connector") + if integration is None: + raise NotReady("missing opencti-connector integration") + + def _reconcile(self, _) -> None: + """Reconcile the charm.""" + try: + if self.app.planned_units() != 1: + self.unit.status = ops.BlockedStatus( + "connector charm cannot have multiple units, " + "scale down using the `juju scale` command" + ) + return + self._check_config() + self._check_integration() + self._reconcile_integration() + self._reconcile_connector() + self.unit.status = ops.ActiveStatus() + except NotReady as exc: + self.unit.status = ops.WaitingStatus(str(exc)) + + def _reconcile_integration(self) -> None: + """Reconcile the charm integrations.""" + if self.unit.is_leader(): + integration = self.model.get_relation("opencti-connector") + data = integration.data[self.app] + data.update( + { + "connector_charm_name": self._charm_name, + "connector_type": self.connector_type, + } + ) + if "connector_id" not in data: + data["connector_id"] = str(uuid.uuid4()) + + def _gen_env(self) -> dict[str, str]: + """Generate environment variables for the opencti connector service. + + Returns: + Environment variables. + """ + integration = self.model.get_relation("opencti-connector") + integration_data = integration.data[integration.app] + opencti_url, opencti_token_id = ( + integration_data.get("opencti_url"), + integration_data.get("opencti_token"), + ) + if not opencti_url or not opencti_token_id: + raise NotReady("waiting for opencti-connector integration") + opencti_token_secret = self.model.get_secret(id=opencti_token_id) + opencti_token = opencti_token_secret.get_content(refresh=True)["token"] + environment = { + "OPENCTI_URL": opencti_url, + "OPENCTI_TOKEN": opencti_token, + "CONNECTOR_ID": integration.data[self.app]["connector_id"], + "CONNECTOR_NAME": self.app.name, + "CONNECTOR_TYPE": self.connector_type, + } + for config, config_meta in self._config_metadata().items(): + value = self.config.get(config) + if value is None: + continue + if self.boolean_style == "json" and isinstance(value, bool): + environment[self.kebab_to_constant(config)] = str(value).lower() + else: + environment[self.kebab_to_constant(config)] = str(value) + http_proxy = os.environ.get("JUJU_CHARM_HTTP_PROXY") + https_proxy = os.environ.get("JUJU_CHARM_HTTPS_PROXY") + no_proxy = os.environ.get("JUJU_CHARM_NO_PROXY") + if http_proxy: + environment["HTTP_PROXY"] = http_proxy + environment["http_proxy"] = http_proxy + if https_proxy: + environment["HTTPS_PROXY"] = https_proxy + environment["https_proxy"] = https_proxy + no_proxy_list = no_proxy.split(",") if no_proxy else [] + if http_proxy or https_proxy: + opencti_host = urllib.parse.urlparse(opencti_url).hostname + no_proxy_list.append(opencti_host) + environment["NO_PROXY"] = https_proxy + environment["no_proxy"] = https_proxy + return environment + + def _reconcile_connector(self) -> None: + """Reconcile connector service.""" + container = self.unit.get_container(self._charm_name) + container.add_layer( + "connector", + layer=ops.pebble.LayerDict( + summary=self._charm_name, + description=self._charm_name, + services={ + "connector": { + "startup": "enabled", + "on-failure": "restart", + "override": "replace", + "command": "bash /entrypoint.sh", + "environment": self._gen_env(), + }, + }, + ), + combine=True, + ) + container.replan() diff --git a/pyproject.toml b/pyproject.toml index 3cc6d51..2d3c16e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ branch = true # Formatting tools configuration [tool.black] line-length = 99 -target-version = ["py38"] +target-version = ["py312"] [tool.coverage.report] show_missing = true diff --git a/scripts/gen_connector_charm.py b/scripts/gen_connector_charm.py new file mode 100644 index 0000000..008be9d --- /dev/null +++ b/scripts/gen_connector_charm.py @@ -0,0 +1,910 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +import operator +import pathlib +import textwrap +from typing import Callable + +import jinja2 +import requests +import markdown +import yaml + +from bs4 import BeautifulSoup + +_CONNECTOR_GENERATORS = {} + + +def connector_generator(name: str) -> Callable: + def decorator(func: Callable) -> Callable: + _CONNECTOR_GENERATORS[name] = func + return func + + return decorator + + +DEFAULT_CONFIG = { + "connector-scope": {"type": "string", "description": "connector scope"}, + "connector-log-level": { + "type": "string", + "description": "determines the verbosity of the logs. Options are debug, info, warn, or error", + "default": "info", + }, +} + +CHARM_MANAGED_ENV = { + "OPENCTI_URL", + "OPENCTI_TOKEN", + "CONNECTOR_ID", + "CONNECTOR_NAME", +} + + +def constant_to_kebab(string: str) -> str: + return string.replace("_", "-").lower() + + +def kebab_to_pascal(string: str) -> str: + words = string.split("-") + return "".join(word.capitalize() for word in words) + + +def kebab_to_snake(string: str) -> str: + return string.replace("-", "_") + + +def extract_tables(doc) -> list[dict[str, str]]: + html = markdown.markdown(doc, extensions=["tables"]) + soup = BeautifulSoup(html, "html.parser") + tables = soup.find_all("table") + rows = [] + for table in tables: + headers = [th.get_text(strip=True) for th in table.find_all("th")] + for row in table.find_all("tr"): + cells = row.find_all(["td"]) + if len(cells) == len(headers): + row_data = { + headers[i].lower(): cells[i].get_text(strip=True) for i in range(len(headers)) + } + rows.append(row_data) + return rows + + +def extract_template_configs(doc_url: str) -> dict: + response = requests.get(doc_url, timeout=10) + response.raise_for_status() + rows = extract_tables(response.text) + result = {} + for row in rows: + name = row["docker environment variable"] + if name in CHARM_MANAGED_ENV: + continue + is_mandatory = row["mandatory"].lower() + assert is_mandatory in ("yes", "no") + is_mandatory = is_mandatory == "yes" + description = row["description"] + if not is_mandatory: + description = "(optional) " + description + config_type = "string" + if row["default"].lower() in ("true", "false"): + config_type = "boolean" + if row["default"].isdigit(): + config_type = "int" + result[constant_to_kebab(name)] = { + "description": description, + "type": config_type, + } + result.update(DEFAULT_CONFIG) + return result + + +def sort_config(options): + mandatory = { + k: v + for k, v in options.items() + if not v["description"].startswith("(optional)") and "default" not in v + } + mandatory_with_default = { + k: v + for k, v in options.items() + if not v["description"].startswith("(optional)") and "default" in v + } + optional = { + k: v for k, v in options.items() if k not in mandatory and k not in mandatory_with_default + } + sorted_options = {} + for k, v in sorted(mandatory.items(), key=operator.itemgetter(0)): + sorted_options[k] = v + for k, v in sorted(mandatory_with_default.items(), key=operator.itemgetter(0)): + sorted_options[k] = v + for k, v in sorted(optional.items(), key=operator.itemgetter(0)): + sorted_options[k] = v + return sorted_options + + +def render_template( + *, + name, + connector_type, + version, + display_name, + config, + output_dir, + connector_name: str | None = None, + display_name_short: str | None = None, + charm_override: str = "", + generate_entrypoint: str = "", + install_location: str | None = None, + template_dir: pathlib.Path = pathlib.Path("connector-template"), +): + if "_" in name or name.lower() != name: + raise ValueError(f"connector name should be in kebab case: {name}") + connector_name = connector_name or name + display_name_short = display_name_short or display_name + output_dir.mkdir(exist_ok=True) + for source in template_dir.glob("**/*"): + file = source.relative_to(template_dir) + if "__pycache__" in str(file): + continue + output = output_dir / file + if source.is_dir(): + output.mkdir(exist_ok=True) + continue + if not str(file).endswith(".j2"): + output.write_bytes(source.read_bytes()) + continue + output = pathlib.Path(str(output).removesuffix(".j2")) + template = jinja2.Template(source.read_text()) + template.globals["kebab_to_pascal"] = kebab_to_pascal + template.globals["constant_to_kebab"] = constant_to_kebab + output.write_text( + template.render( + name=name, + connector_name=connector_name, + connector_type=connector_type, + version=version, + display_name=display_name, + display_name_short=( + display_name if display_name_short is None else display_name_short + ), + config=yaml.safe_dump( + {"config": {"options": sort_config(config)}}, width=99999, sort_keys=False + ), + charm_override=charm_override, + install_location=( + install_location if install_location else f"opencti-connector-{connector_name}" + ), + generate_entrypoint=generate_entrypoint, + ), + encoding="utf-8", + ) + (output_dir / "lib/charms/opencti/v0").mkdir(parents=True, exist_ok=True) + (output_dir / "lib/charms/opencti/v0/opencti_connector.py").write_bytes( + pathlib.Path("lib/charms/opencti/v0/opencti_connector.py").read_bytes() + ) + (output_dir / "src/charm.py").chmod(0o755) + + +@connector_generator("abuseipdb-ipblacklist") +def generate_abuseipdb_ipblacklist_connector(location: pathlib.Path, version: str): + """Generate opencti abuseipdb-ipblacklist connector + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/abuseipdb-ipblacklist + """ + render_template( + name="abuseipdb-ipblacklist", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="abuseipdb ipblacklist", + output_dir=location, + config={ + **DEFAULT_CONFIG, + "abuseipdb-url": { + "description": "the Abuse IPDB URL", + "type": "string", + "default": "https://api.abuseipdb.com/api/v2/blacklist", + }, + "abuseipdb-api-key": { + "description": "Abuse IPDB API KEY", + "type": "string", + }, + "abuseipdb-score": { + "description": "AbuseIPDB Score Limitation", + "type": "int", + }, + "abuseipdb-limit": { + "description": "limit number of result itself", + "type": "int", + }, + "abuseipdb-interval": { + "description": "interval between 2 collect itself", + "type": "int", + }, + }, + install_location="abuseipdb-ipblacklist", + ) + + +@connector_generator("alienvault") +def generate_alienvault_connector(location: pathlib.Path, version: str): + """Generate opencti alienvault connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/alienvault + """ + config = extract_template_configs( + "https://raw.githubusercontent.com/OpenCTI-Platform/connectors" + f"/refs/tags/{version}/external-import/alienvault/README.md" + ) + config["alienvault-interval-sec"] = { + "description": "alienvault interval seconds", + "type": "int", + } + render_template( + name="alienvault", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="AlienVault", + output_dir=location, + config=config, + ) + + +@connector_generator("cisa-kev") +def generate_cisa_known_exploited_vulnerabilities_connector(location: pathlib.Path, version: str): + """Generate opencti cisa-known-exploited-vulnerabilities (cisa-kev) connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/cisa-known-exploited-vulnerabilities + """ + config = extract_template_configs( + "https://raw.githubusercontent.com/OpenCTI-Platform/connectors" + f"/refs/tags/{version}/external-import/cisa-known-exploited-vulnerabilities/README.md" + ) + config["cisa-create-infrastructures"]["type"] = "boolean" + render_template( + name="cisa-kev", + connector_name="cisa-known-exploited-vulnerabilities", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="CISA Known Exploited Vulnerabilities", + display_name_short="CISA KEV", + output_dir=location, + config=config, + ) + + +def extract_crowdstrike_configs(doc_url: str) -> dict: + response = requests.get(doc_url, timeout=10) + response.raise_for_status() + rows = extract_tables(response.text) + result = {} + for row in rows: + name = row["docker environment variable"] + if name in CHARM_MANAGED_ENV: + continue + is_mandatory = row["mandatory"].lower() + assert is_mandatory in ("yes", "no") + is_mandatory = is_mandatory == "yes" + description = row["description"] + if not is_mandatory: + description = "(optional) " + description + config_type = "int" if (row["example"].isdigit() or row["default"].isdigit()) else "string" + result[constant_to_kebab(name)] = { + "description": description, + "type": config_type, + } + result.update(DEFAULT_CONFIG) + del result["connector-scope"] + return result + + +@connector_generator("crowdstrike") +def gen_crowdstrike_connector(location: pathlib.Path, version: str): + """Generate opencti crowdstrike connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/crowdstrike + """ + render_template( + name="crowdstrike", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="CrowdStrike", + config=extract_crowdstrike_configs( + "https://raw.githubusercontent.com/OpenCTI-Platform/connectors" + f"/refs/tags/{version}/external-import/crowdstrike/README.md" + ), + output_dir=location, + charm_override=textwrap.dedent( + """\ + def _gen_env(self) -> dict[str, str]: + env = super()._gen_env() + env["CONNECTOR_SCOPE"] = "crowdstrike" + return env + """ + ), + ) + + +@connector_generator("cyber-campaign") +def gen_cyber_campaign_collection_connector(location: pathlib.Path, version: str): + """Generate opencti cyber-campaign-collection (cyber-campaign) connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/cyber-campaign-collection + """ + render_template( + name="cyber-campaign", + connector_name="cyber-campaign-collection", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="APT & Cybercriminals Campaign Collection", + display_name_short="APT & Cyber Campaign", + output_dir=location, + config={ + "connector-scope": { + "description": "The data scope of the connector.", + "type": "string", + }, + "connector-run-and-terminate": { + "description": "Whether the connector should run and terminate after execution.", + "type": "boolean", + }, + "connector-log-level": { + "description": "The log level for the connector.", + "type": "string", + }, + "cyber-monitor-github-token": { + "description": "(optional) If not provided, rate limit will be very low.", + "type": "string", + }, + "cyber-monitor-from-year": { + "description": "The starting year for monitoring cyber campaigns.", + "type": "int", + }, + "cyber-monitor-interval": { + "description": "The interval in days, must be strictly greater than 1.", + "type": "int", + }, + }, + ) + + +_FILE_EXPORTER_CONFIGS = { + **DEFAULT_CONFIG, + "connector-confidence-level": { + "type": "int", + "description": "(optional) the confidence level of the connector.", + }, +} + + +@connector_generator("export-file-csv") +def gen_export_file_csv_connector(location: pathlib.Path, version: str): + """Generate opencti export-file-csv connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/internal-export-file/export-file-csv + """ + render_template( + name="export-file-csv", + connector_type="INTERNAL_EXPORT_FILE", + version=version, + display_name="Export CSV File", + output_dir=location, + config={ + **_FILE_EXPORTER_CONFIGS, + "export-file-csv-delimiter": { + "type": "string", + "description": "(optional) the delimiter of the exported CSV file.", + }, + }, + ) + + +@connector_generator("export-file-stix") +def gen_export_file_stix_connector(location: pathlib.Path, version: str): + """Generate opencti export-file-stix connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/internal-export-file/export-file-stix + """ + render_template( + name="export-file-stix", + connector_type="INTERNAL_EXPORT_FILE", + version=version, + display_name="Export STIX File", + output_dir=location, + config=_FILE_EXPORTER_CONFIGS, + ) + + +@connector_generator("export-file-txt") +def gen_export_file_txt_connector(location: pathlib.Path, version: str): + """Generate opencti export-file-txt connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/internal-export-file/export-file-txt + """ + render_template( + name="export-file-txt", + connector_type="INTERNAL_EXPORT_FILE", + version=version, + display_name="Export TXT File", + output_dir=location, + config=_FILE_EXPORTER_CONFIGS, + ) + + +@connector_generator("import-document") +def gen_import_document(location: pathlib.Path, version: str): + """Generate opencti import-document connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/internal-import-file/import-document + """ + render_template( + name="import-document", + connector_type="INTERNAL_IMPORT_FILE", + version=version, + display_name="Document Import", + output_dir=location, + config={ + "connector-only-contextual": { + "description": "true only extract data related to an entity (a report, a threat actor, etc.)", + "type": "boolean", + }, + "connector-auto": { + "description": "enable/disable auto import of report file", + "type": "boolean", + }, + "connector-scope": { + "description": "connector scope", + "type": "string", + }, + "connector-confidence-level": { + "description": "connector confidence level, from 0 (unknown) to 100 (fully trusted).", + "type": "int", + }, + "connector-log-level": { + "description": "log level for this connector.", + "type": "string", + "default": "info", + }, + "connector-validate-before-import": { + "description": "validate any bundle before import.", + "type": "boolean", + }, + "import-document-create-indicator": { + "description": "import document create indicator", + "type": "boolean", + }, + }, + ) + + +@connector_generator("import-file-stix") +def gen_import_file_stix_connector(location: pathlib.Path, version: str): + """Generate opencti import-file-stix connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/internal-import-file/import-file-stix + """ + render_template( + name="import-file-stix", + connector_type="INTERNAL_IMPORT_FILE", + version=version, + display_name="Import File Stix", + output_dir=location, + config={ + "connector-validate-before-import": { + "description": "validate any bundle before import", + "type": "boolean", + }, + "connector-scope": { + "description": "connector scope", + "type": "string", + }, + "connector-auto": { + "description": "enable/disable auto-import of file", + "type": "boolean", + }, + "connector-confidence-level": { + "description": "from 0 (Unknown) to 100 (Fully trusted)", + "type": "int", + }, + "connector-log-level": { + "description": "logging level of the connector", + "type": "string", + "default": "info", + }, + }, + ) + + +@connector_generator("malwarebazaar") +def gen_malwarebazaar_recent_additions_connector(location: pathlib.Path, version: str): + """Generate opencti malwarebazaar-recent-additions (malwarebazaar) connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/malwarebazaar-recent-additions + """ + render_template( + name="malwarebazaar", + connector_name="malwarebazaar-recent-additions", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="MalwareBazaar Recent Additions", + display_name_short="MalwareBazaar", + output_dir=location, + config={ + "connector-log-level": { + "description": "The log level for the connector", + "type": "string", + }, + "malwarebazaar-recent-additions-api-url": { + "description": "The API URL", + "type": "string", + }, + "malwarebazaar-recent-additions-cooldown-seconds": { + "description": "Time to wait in seconds between subsequent requests", + "type": "int", + }, + "malwarebazaar-recent-additions-include-tags": { + "description": "(optional) Only download files if any tag matches. (Comma separated)", + "type": "string", + }, + "malwarebazaar-recent-additions-include-reporters": { + "description": "(optional) Only download files uploaded by these reporters. (Comma separated)", + "type": "string", + }, + "malwarebazaar-recent-additions-labels": { + "description": "(optional) Labels to apply to uploaded Artifacts. (Comma separated)", + "type": "string", + }, + "malwarebazaar-recent-additions-labels-color": { + "description": "Color to use for labels", + "type": "string", + }, + }, + ) + + +@connector_generator("misp-feed") +def gen_misp_feed_connector(location: pathlib.Path, version: str): + """Generate opencti misp-feed connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/misp-feed + """ + config = extract_template_configs( + "https://raw.githubusercontent.com/OpenCTI-Platform/connectors" + f"/refs/tags/{version}/external-import/misp-feed/README.md" + ) + del config["connector-type"] + config["misp-feed-create-indicators"]["type"] = "boolean" + config["misp-feed-create-observables"]["type"] = "boolean" + config["misp-feed-import-to-ids-no-score"]["type"] = "boolean" + config["connector-run-and-terminate"] = { + "type": "boolean", + "description": "(optional) Launch the connector once if set to True", + } + config["misp-feed-interval"] = {"type": "int", "description": "misp feed interval in minutes"} + config["misp-feed-create-tags-as-labels"] = { + "type": "boolean", + "description": "(optional) create tags as labels (sanitize MISP tag to OpenCTI labels)", + } + render_template( + name="misp-feed", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="MISP Source", + output_dir=location, + config=config, + ) + + +@connector_generator("mitre") +def gen_mitre_connector(location: pathlib.Path, version: str): + """Generate opencti mitre connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/mitre + """ + render_template( + name="mitre", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="MITRE Datasets", + output_dir=location, + config={ + "connector-run-and-terminate": { + "type": "boolean", + "description": "(optional) Launch the connector once if set to True", + }, + "mitre-interval": { + "description": "Number of the days between each MITRE datasets collection.", + "type": "int", + }, + "mitre-remove-statement-marking": { + "description": "Remove the statement MITRE marking definition.", + "type": "boolean", + }, + "mitre-enterprise-file-url": { + "description": "(optional) Resource URL", + "type": "string", + }, + "mitre-mobile-attack-file-url": { + "description": "(optional) Resource URL", + "type": "string", + }, + "mitre-ics-attack-file-url": { + "description": "(optional) Resource URL", + "type": "string", + }, + "mitre-capec-file-url": { + "description": "(optional) Resource URL", + "type": "string", + }, + **DEFAULT_CONFIG, + }, + generate_entrypoint="echo 'cd /opt/opencti-connector-mitre; python3 connector.py' > entrypoint.sh", + ) + + +@connector_generator("sekoia") +def gen_sekoia_connector(location: pathlib.Path, version: str): + render_template( + name="sekoia", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="Sekoia.io", + output_dir=location, + config={ + **DEFAULT_CONFIG, + "sekoia-base-url": { + "description": "Sekoia base url", + "type": "string", + "default": "https://api.sekoia.io", + }, + "sekoia-api-key": { + "description": "Sekoia API key", + "type": "string", + }, + "sekoia-collection": { + "description": "Sekoia collection", + "type": "string", + }, + "sekoia-start-date": { + "description": "(optional) the date to start consuming data from. Maybe in the formats YYYY-MM-DD or YYYY-MM-DDT00:00:00", + "type": "string", + }, + "sekoia-create-observables": { + "description": "create observables from indicators", + "type": "boolean", + }, + }, + generate_entrypoint="echo 'cd /opt/opencti-connector-sekoia; python3 sekoia.py' > entrypoint.sh", + ) + + +@connector_generator("urlscan") +def genc_urlscan_connector(location: pathlib.Path, version: str): + """Generate opencti urlscan connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/urlscan + """ + render_template( + name="urlscan", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="Urlscan.io", + output_dir=location, + config={ + "connector-confidence-level": { + "description": "The default confidence level for created relationships (0 -> 100).", + "type": "int", + }, + "connector-update-existing-data": { + "description": "If an entity already exists, update its attributes with information provided by this connector.", + "type": "boolean", + }, + "connector-log-level": { + "description": "The log level for this connector, could be `debug`, `info`, `warn` or `error` (less verbose).", + "type": "string", + }, + "connector-create-indicators": { + "description": "(optional) Create indicators for each observable processed.", + "type": "boolean", + }, + "connector-tlp": { + "description": "(optional) The TLP to apply to any indicators and observables, this could be `white`,`green`,`amber` or `red`", + "type": "string", + }, + "connector-labels": { + "description": "(optional) Comma delimited list of labels to apply to each observable.", + "type": "string", + }, + "connector-interval": { + "description": "(optional) An interval (in seconds) for data gathering from Urlscan.", + "type": "int", + }, + "connector-lookback": { + "description": "(optional) How far to look back in days if the connector has never run or the last run is older than this value. Default is 3. You should not go above 7.", + "type": "int", + }, + "urlscan-url": { + "description": "The Urlscan URL.", + "type": "string", + }, + "urlscan-api-key": { + "description": "The Urlscan client secret.", + "type": "string", + }, + "urlscan-default-x-opencti-score": { + "description": "(optional) The default x_opencti_score to use across observable/indicator types. Default is 50.", + "type": "int", + }, + "urlscan-x-opencti-score-domain": { + "description": "(optional) The x_opencti_score to use across Domain-Name observable and indicators. Defaults to default score.", + "type": "int", + }, + "urlscan-x-opencti-score-url": { + "description": "(optional) The x_opencti_score to use across Url observable and indicators. Defaults to default score.", + "type": "integer", + }, + }, + charm_override=textwrap.dedent( + """\ + def _gen_env(self) -> dict[str, str]: + env = super()._gen_env() + env["CONNECTOR_SCOPE"] = "threatmatch" + return env + """ + ), + ) + + +@connector_generator("urlscan-enrichment") +def gen_urlscan_enrichment_connector(location: pathlib.Path, version: str): + """Generate opencti urlscan-enrichment connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/internal-enrichment/urlscan-enrichment + """ + config = extract_template_configs( + "https://raw.githubusercontent.com/OpenCTI-Platform/connectors" + f"/refs/tags/{version}/internal-enrichment/urlscan-enrichment/README.md" + ) + config["connector-auto"] = {"type": "boolean", "description": "connector auto"} + render_template( + name="urlscan-enrichment", + connector_type="INTERNAL_ENRICHMENT", + version=version, + display_name="URLScan Enrichment", + output_dir=location, + config=config, + ) + + +@connector_generator("virustotal-livehunt") +def gen_virustotal_livehunt_notifications_connector(location: pathlib.Path, version: str): + """Generate opencti virustotal-livehunt-notifications (virustotal-livehunt) connector. + + https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/virustotal-livehunt-notifications + """ + render_template( + name="virustotal-livehunt", + connector_name="virustotal-livehunt-notifications", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="VirusTotal Livehunt Notifications", + display_name_short="VirusTotal Livehunt", + output_dir=location, + config={ + **DEFAULT_CONFIG, + "virustotal-livehunt-notifications-api-key": { + "description": "Private API Key", + "type": "string", + }, + "virustotal-livehunt-notifications-interval-sec": { + "description": "Time to wait in seconds between subsequent requests", + "type": "int", + }, + "virustotal-livehunt-notifications-create-alert": { + "description": "Set to true to create alerts", + "type": "boolean", + }, + "virustotal-livehunt-notifications-extensions": { + "description": "(optional) Comma separated filter to only download files matching these extensions", + "type": "string", + }, + "virustotal-livehunt-notifications-min-file-size": { + "description": "(optional) Don't download files smaller than this many bytes", + "type": "int", + }, + "virustotal-livehunt-notifications-max-file-size": { + "description": "(optional) Don't download files larger than this many bytes", + "type": "int", + }, + "virustotal-livehunt-notifications-max-age-days": { + "description": "Only create the alert if the first submission of the file is not older than `max_age_days`", + "type": "int", + }, + "virustotal-livehunt-notifications-min-positives": { + "description": "(optional) Don't download files with less than this many vendors marking malicious", + "type": "int", + }, + "virustotal-livehunt-notifications-create-file": { + "description": "Set to true to create file object linked to the alerts", + "type": "boolean", + }, + "virustotal-livehunt-notifications-upload-artifact": { + "description": "Set to true to upload the file to opencti", + "type": "boolean", + }, + "virustotal-livehunt-notifications-create-yara-rule": { + "description": "Set to true to create yara rule linked to the alert and the file", + "type": "boolean", + }, + "virustotal-livehunt-notifications-delete-notification": { + "description": "Set to true to remove livehunt notifications", + "type": "boolean", + }, + "virustotal-livehunt-notifications-filter-with-tag": { + "description": "Filter livehunt notifications with this tag", + "type": "string", + }, + }, + charm_override=textwrap.dedent( + """\ + @property + def boolean_style(self) -> str: + return "python" + """ + ), + ) + + +@connector_generator("vxvault") +def gen_vxvault_connector(location: pathlib, version: str) -> None: + render_template( + name="vxvault", + connector_type="EXTERNAL_IMPORT", + version=version, + display_name="VXVault", + output_dir=location, + config={ + "connector-scope": { + "description": "connector scope", + "type": "string", + }, + "connector-log-level": { + "description": "(optional) The log level of the connector", + "type": "string", + }, + "vxvault-url": { + "description": "vxvault url", + "type": "string", + "default": "https://vxvault.net/URL_List.php", + }, + "vxvault-create-indicators": { + "description": "vxvault create indicators", + "type": "boolean", + }, + "vxvault-interval": { + "description": "In days, must be strictly greater than 1", + "type": "int", + }, + "vxvault-ssl-verify": { + "description": "Whether to verify SSL certificates", + "type": "boolean", + "default": True, + }, + }, + ) + + +def render(connector: str, version: str): + location = ( + pathlib.Path(__file__).resolve().parent.parent / "connectors" / kebab_to_snake(connector) + ) + _CONNECTOR_GENERATORS[connector](location=location, version=version) + + +def render_all(version): + for connector in _CONNECTOR_GENERATORS: + render(connector, version) + + +if __name__ == "__main__": + render_all("6.4.5") diff --git a/src/charm.py b/src/charm.py index eee8d6e..151ab11 100755 --- a/src/charm.py +++ b/src/charm.py @@ -25,6 +25,8 @@ from charms.redis_k8s.v0.redis import RedisRelationCharmEvents, RedisRequires from charms.traefik_k8s.v2.ingress import IngressPerAppRequirer +import opencti + logger = logging.getLogger(__name__) @@ -236,6 +238,7 @@ def _reconcile(self, _: ops.EventBase) -> None: """Run charm reconcile function and catch all exceptions.""" try: self._reconcile_platform() + self._reconcile_connector() self.unit.status = ops.ActiveStatus() except (MissingIntegration, MissingConfig, InvalidIntegration, InvalidConfig) as exc: self.unit.status = ops.BlockedStatus(str(exc)) @@ -243,7 +246,7 @@ def _reconcile(self, _: ops.EventBase) -> None: self.unit.status = ops.WaitingStatus(str(exc)) def _reconcile_platform(self) -> None: - """Run charm reconcile function. + """Run charm reconcile function for OpenCTI platform and workers. Raises: PlatformNotReady: failed to start the OpenCTI platform at this moment @@ -672,6 +675,73 @@ def _dump_integration(self, name: str) -> str: dump["unit-data"] = {unit.name: dict(integration.data[unit]) for unit in units} return json.dumps(dump) + def _reconcile_connector(self) -> None: + """Run charm reconcile function for OpenCTI connectors.""" + client = opencti.OpenctiClient( + url="http://localhost:8080", + api_token=self._get_peer_secret(_PEER_SECRET_ADMIN_TOKEN_SECRET_FIELD), + ) + integrations = self.model.relations["opencti-connector"] + current_using_users = set() + for integration in integrations: + if integration.app is None: + continue + user = self._setup_connector_integration_and_user(client, integration) + if user: + current_using_users.add(user) + for opencti_user in client.list_users(): + if opencti_user.name not in current_using_users and opencti_user.name.startswith( + "charm-connector-" + ): + client.set_account_status(opencti_user.id, "Inactive") + + def _setup_connector_integration_and_user( + self, client: opencti.OpenctiClient, integration: ops.Relation + ) -> str | None: + """Set up the connector integration and connector user. + + Args: + client: the OpenCTI client. + integration: the opencti-connector integration object. + + Returns: + name of the opencti user created for this integration, None if no user is needed. + """ + integration_data = integration.data[integration.app] + connector_charm_name, connector_type = ( + integration_data.get("connector_charm_name"), + integration_data.get("connector_type"), + ) + if not connector_charm_name or not connector_type: + return None + opencti_url = f"http://{self.app.name}-endpoints.{self.model.name}.svc:8080" + integration.data[self.app]["opencti_url"] = opencti_url + connector_user = f"charm-connector-{connector_charm_name.replace('_', '-').lower()}" + users = {u.name: u for u in client.list_users()} + groups = {g.name: g for g in client.list_groups()} + if connector_user not in users: + group_id = ( + groups["Administrators"] + if connector_type.replace("-", "_").upper() == "INTERNAL_EXPORT_FILE" + else groups["Connectors"] + ).id + client.create_user(name=connector_user, groups=[group_id]) + users = {u.name: u for u in client.list_users()} + else: + if users[connector_user].account_status == "Inactive": + client.set_account_status(users[connector_user].id, "Active") + api_token = users[connector_user].api_token + opencti_token_id = integration.data[self.app].get("opencti_token") + if not opencti_token_id: + secret = self.app.add_secret(content={"token": api_token}) + secret.grant(integration) + integration.data[self.app]["opencti_token"] = typing.cast(str, secret.id) + if opencti_token_id: + secret = self.model.get_secret(id=opencti_token_id) + if secret.get_content(refresh=True)["token"] != api_token: + secret.set_content({"token": api_token}) + return connector_user + if __name__ == "__main__": # pragma: nocover ops.main(OpenCTICharm) diff --git a/src/opencti.py b/src/opencti.py new file mode 100644 index 0000000..a304349 --- /dev/null +++ b/src/opencti.py @@ -0,0 +1,467 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""OpenCTI API client.""" + +import secrets +import textwrap +import typing +import urllib.parse + +import requests + + +class OpenctiUser(typing.NamedTuple): + """Opencti user. + + Attributes: + id: opencti user id + name: opencti username + user_email: opencti user email + account_status: opencti account status + api_token: opencti user api token + """ + + id: str + name: str + user_email: str + account_status: str + api_token: str + + +class OpenctiGroup(typing.NamedTuple): + """Opencti group. + + Attributes: + id: opencti group id + name: opencti group name + """ + + id: str + name: str + + +class GraphqlError(Exception): + """GraphQL error.""" + + +class OpenctiClient: + """Opencti API client.""" + + def __init__(self, url: str, api_token: str) -> None: + """Construct the Opencti client. + + Args: + url: URL of the Opencti API. + api_token: Opencti API token. + """ + self._query_url = urllib.parse.urljoin(url, "graphql") + self._api_token = api_token + self._cached_users: list[OpenctiUser] | None = None + self._cached_groups: list[OpenctiGroup] | None = None + + def _graphql( + self, + query_id: str, + query: str, + variables: dict | None = None, + ) -> dict: + """Call the OpenCTI GraphQL endpoint. + + Args: + query_id: GraphQL id. + query: GraphQL query. + variables: GraphQL variables. + + Returns: + data in GraphQL response. + + Raises: + GraphqlError: errors returned in GraphQL response. + """ + variables = variables or {} + response = requests.post( + self._query_url, + json={"id": query_id, "query": query, "variables": variables}, + headers={"Authorization": f"Bearer {self._api_token}"}, + timeout=10, + ) + response.raise_for_status() + result = response.json() + if "errors" in result: + raise GraphqlError(result["errors"]) + return result["data"] + + def list_users(self) -> list[OpenctiUser]: + """List OpenCTI users. + + Returns: + list of OpenctiUser objects. + """ + if self._cached_users is not None: + return self._cached_users + query = textwrap.dedent( + """\ + query ListUsers { + users { + edges { + node { + id + name + user_email + account_status + api_token + } + } + } + } + """ + ) + data = self._graphql("ListUsers", query=query) + users = [] + for user in data["users"]["edges"]: + node = user["node"] + users.append( + OpenctiUser( + id=node["id"], + name=node["name"], + user_email=node["user_email"], + account_status=node["account_status"], + api_token=node["api_token"], + ) + ) + self._cached_users = users + return users + + def create_user( + self, + name: str, + user_email: str | None = None, + groups: list[str] | None = None, + ) -> None: + """Create a OpenCTI user. + + Args: + name: User name. + user_email: User's email address. + groups: User's groups. + """ + self._cached_users = None + if user_email is None: + user_email = f"{name}@opencti.local" + if groups is None: + groups = [] + query = textwrap.dedent( + """\ + mutation UserCreationMutation( + $input: UserAddInput! + ) { + userAdd(input: $input) { + ...UserLine_node + id + } + } + fragment UserLine_node on User { + id + name + user_email + firstname + external + lastname + effective_confidence_level { + max_confidence + } + otp_activated + created_at + } + """ + ) + variables = { + "input": { + "name": name, + "user_email": user_email, + "firstname": "", + "lastname": "", + "description": "", + "password": secrets.token_urlsafe(32), + "account_status": "Active", + "account_lock_after_date": None, + "objectOrganization": [], + "groups": groups, + "user_confidence_level": None, + } + } + self._graphql("UserCreationMutation", query=query, variables=variables) + + def list_groups(self) -> list[OpenctiGroup]: + """List OpenCTI groups. + + Returns: + list of OpenctiGroup objects. + """ + if self._cached_groups is not None: + return self._cached_groups + query = textwrap.dedent( + """\ + query ListGroups { + groups { + edges { + node { + id + name + } + } + } + } + """ + ) + data = self._graphql("ListGroups", query=query) + groups = [] + for group in data["groups"]["edges"]: + group = group["node"] + groups.append(OpenctiGroup(id=group["id"], name=group["name"])) + self._cached_groups = groups + return groups + + def set_account_status( + self, + user_id: str, + status: typing.Literal["Active", "Inactive"], + ) -> None: + """Set Opencti account status. + + Args: + user_id: Opencti user id. + status: Opencti account status. + """ + self._cached_users = None + query = textwrap.dedent( + """ + mutation UserEditionOverviewFieldPatchMutation( + $id: ID! + $input: [EditInput]! + ) { + userEdit(id: $id) { + fieldPatch(input: $input) { + ...UserEditionOverview_user + ...UserEdition_user + id + } + } + } + fragment UserEditionGroups_user_2AtC8h on User { + id + objectOrganization(orderBy: name, orderMode: asc) { + edges { + node { + id + name + grantable_groups { + id + name + group_confidence_level { + max_confidence + } + } + } + } + } + roles(orderBy: name, orderMode: asc) { + id + name + } + groups(orderBy: name, orderMode: asc) { + edges { + node { + id + name + } + } + } + effective_confidence_level { + max_confidence + source { + type + object { + __typename + ... on User { + entity_type + id + name + } + ... on Group { + entity_type + id + name + } + } + } + } + } + fragment UserEditionOrganizationsAdmin_user_Z483F on User { + id + user_email + objectOrganization(orderBy: name, orderMode: asc) { + edges { + node { + id + name + description + authorized_authorities + } + } + } + } + fragment UserEditionOverview_user on User { + id + name + description + external + user_email + firstname + lastname + language + theme + api_token + otp_activated + stateless_session + otp_qr + account_status + account_lock_after_date + roles(orderBy: name, orderMode: asc) { + id + name + } + objectOrganization(orderBy: name, orderMode: asc) { + edges { + node { + id + name + } + } + } + groups(orderBy: name, orderMode: asc) { + edges { + node { + id + name + } + } + } + } + fragment UserEditionOverview_user_2AtC8h on User { + id + name + description + external + user_email + firstname + lastname + language + theme + api_token + otp_activated + stateless_session + otp_qr + account_status + account_lock_after_date + roles(orderBy: name, orderMode: asc) { + id + name + } + objectOrganization(orderBy: name, orderMode: asc) { + edges { + node { + id + name + } + } + } + groups(orderBy: name, orderMode: asc) { + edges { + node { + id + name + } + } + } + } + fragment UserEditionPassword_user on User { + id + } + fragment UserEdition_user on User { + id + external + user_confidence_level { + max_confidence + overrides { + max_confidence + entity_type + } + } + effective_confidence_level { + max_confidence + overrides { + max_confidence + entity_type + source { + type + object { + __typename + ... on User { + entity_type + id + name + } + ... on Group { + entity_type + id + name + } + } + } + } + source { + type + object { + __typename + ... on User { + entity_type + id + name + } + ... on Group { + entity_type + id + name + } + } + } + } + groups(orderBy: name, orderMode: asc) { + edges { + node { + id + name + } + } + } + ...UserEditionOverview_user_2AtC8h + ...UserEditionPassword_user + ...UserEditionGroups_user_2AtC8h + ...UserEditionOrganizationsAdmin_user_Z483F + editContext { + name + focusOn + } + } + """ + ) + self._graphql( + query_id="UserEditionOverviewFieldPatchMutation", + query=query, + variables={ + "id": user_id, + "input": {"key": "account_status", "value": status}, + }, + ) diff --git a/tests/conftest.py b/tests/conftest.py index adeaee9..38f4ca9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,30 @@ """Fixtures for charm tests.""" +import pathlib + +import pytest +import yaml + + +def list_connectors() -> list[str]: + """Return a list of opencti connector charms. + + Returns: + list: list of opencti connector charms. + """ + connectors = [] + for file in pathlib.Path("connectors").glob("**/charmcraft.yaml"): + charmcraft_yaml = yaml.safe_load(file.read_text()) + connectors.append(charmcraft_yaml["name"]) + return connectors + + +@pytest.fixture(scope="session", name="connectors") +def connectors_fixture() -> list[str]: + """Return a list of opencti connector charms.""" + return list_connectors() + def pytest_addoption(parser): """Parse additional pytest options. @@ -10,6 +34,8 @@ def pytest_addoption(parser): Args: parser: Pytest parser. """ - parser.addoption("--charm-file", action="store") + parser.addoption("--charm-file", action="append") parser.addoption("--opencti-image", action="store") parser.addoption("--machine-controller", action="store", default="localhost") + for connector in list_connectors(): + parser.addoption(f"--{connector}-image", action="store") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 18d3ae3..5c370fa 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -5,6 +5,7 @@ import json import logging +import pathlib import secrets import typing @@ -53,7 +54,7 @@ async def machine_controller_fixture( @pytest_asyncio.fixture(scope="module", name="machine_model") async def machine_model_fixture( - machine_controller: Controller, machine_controller_name: str + machine_controller: Controller, machine_controller_name: str, pytestconfig ) -> typing.AsyncGenerator[Model, None]: """The machine model for OpenSearch charm.""" machine_model_name = f"test-opencti-deps-{secrets.token_hex(2)}" @@ -62,7 +63,8 @@ async def machine_model_fixture( await model.set_config(MACHINE_MODEL_CONFIG) yield model await model.disconnect() - await machine_controller.destroy_models(model.uuid) + if not pytestconfig.getoption("--keep-models"): + await machine_controller.destroy_models(model.uuid) @pytest_asyncio.fixture(name="get_unit_ips", scope="module") @@ -99,3 +101,39 @@ async def machine_charm_dependencies_fixture(machine_model: Model): rabbitmq_server = await machine_model.deploy("rabbitmq-server", channel="3.9/stable") await machine_model.create_offer(f"{rabbitmq_server.name}:amqp", "amqp") await machine_model.wait_for_idle(timeout=1200) + + +@pytest.fixture(name="opencti_charm", scope="module") +def opencti_charm_fixture(pytestconfig) -> dict[str, str]: + """Opencti charm file.""" + charm_files = pytestconfig.getoption("--charm-file") + assert charm_files + for charm_file in charm_files: + if "connector" not in pathlib.Path(charm_file).name: + return charm_file + raise ValueError("opencti charm file not provided") + + +@pytest.fixture(name="opencti_connector_charms", scope="module") +def opencti_connector_charms_fixture(connectors, pytestconfig) -> dict[str, str]: + """Get opencti connector charm files.""" + charms = {} + charm_files = pytestconfig.getoption("--charm-file") + for charm_file in charm_files: + name = pathlib.Path(charm_file).name.split("_")[0] + if name in connectors: + charms[name] = charm_file + logger.info("load opencti connector charms: %s", charms) + return charms + + +@pytest.fixture(name="opencti_connector_images", scope="module") +def opencti_connector_images_fixture(connectors, pytestconfig) -> dict[str, str]: + """Get opencti connector charm images.""" + images = {} + for connector in connectors: + image = pytestconfig.getoption(f"--{connector}-image") + if image: + images[connector] = image + logger.info("load opencti connector images: %s", images) + return images diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 8fc9ece..02f2bb5 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -16,6 +16,8 @@ import yaml from juju.model import Model +from opencti import OpenctiClient + @pytest.mark.abort_on_fail @pytest.mark.usefixtures("machine_charm_dependencies") @@ -25,6 +27,7 @@ async def test_deploy_charm( machine_model: Model, machine_controller_name: str, get_unit_ips, + opencti_charm, ): """ arrange: deploy dependencies of the OpenCTI charm. @@ -63,7 +66,7 @@ async def test_deploy_charm( ) await action.wait() opencti = await model.deploy( - f"./{pytestconfig.getoption('--charm-file')}", + f"./{opencti_charm}", resources={ "opencti-image": pytestconfig.getoption("--opencti-image"), }, @@ -128,3 +131,79 @@ async def test_opencti_workers(get_unit_ips, ops_test): ) worker_count = resp.json()["data"]["rabbitMQMetrics"]["consumers"] assert worker_count == str(3) + + +async def test_opencti_client(get_unit_ips, ops_test): + """ + arrange: deploy the OpenCTI charm. + act: use the OpenCTI client to create some users. + assert: users are created normally. + """ + _, stdout, _ = await ops_test.juju( + "ssh", "--container", "opencti", "opencti/0", "pebble", "plan" + ) + plan = yaml.safe_load(stdout) + api_token = plan["services"]["platform"]["environment"]["APP__ADMIN__TOKEN"] + client = OpenctiClient( + url=f"http://{(await get_unit_ips('opencti'))[0]}:8080", api_token=api_token + ) + assert {u.name for u in client.list_users()} == {"admin"} + assert {g.name for g in client.list_groups()} == {"Administrators", "Connectors", "Default"} + client.create_user(name="testing") + user = {u.name: u for u in client.list_users()}["testing"] + client.set_account_status(user.id, "Inactive") + user = {u.name: u for u in client.list_users()}["testing"] + assert user.account_status == "Inactive" + + +async def test_opencti_connectors( + get_unit_ips, ops_test, model, opencti_connector_charms, opencti_connector_images +): + """ + arrange: deploy the OpenCTI charm and OpenCTI connector charm. + act: integrate the OpenCTI connector charm with the OpenCTI charm. + assert: OpenCTI connector should register itself inside the OpenCTI platform + """ + connector = "opencti-export-file-stix-connector" + charm = opencti_connector_charms[connector] + image = opencti_connector_images[connector] + connector_charm = await model.deploy( + f"./{charm}", + resources={ + f"{connector}-image": image, + }, + config={"connector-scope": "application/json"}, + ) + await model.integrate(connector_charm.name, "opencti") + await model.wait_for_idle(status="active") + query = { + "id": "WorkersStatusQuery", + "query": textwrap.dedent( + """\ + query ConnectorsStatusQuery { + ...ConnectorsStatus_data + } + fragment ConnectorsStatus_data on Query { + connectors { + name + active + } + } + """ + ), + "variables": {}, + } + _, stdout, _ = await ops_test.juju( + "ssh", "--container", "opencti", "opencti/0", "pebble", "plan" + ) + plan = yaml.safe_load(stdout) + api_token = plan["services"]["platform"]["environment"]["APP__ADMIN__TOKEN"] + resp = requests.post( + f"http://{(await get_unit_ips('opencti'))[0]}:8080/graphql", + json=query, + headers={"Authorization": f"Bearer {api_token}"}, + timeout=5, + ) + connectors = {c["name"]: c for c in resp.json()["data"]["connectors"]} + assert connector in connectors + assert connectors[connector]["active"] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index a51531b..cbbab79 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,12 +3,15 @@ """Fixtures for charm unit tests.""" +import typing import unittest.mock from unittest.mock import MagicMock import pytest +import opencti import src.charm +from opencti import OpenctiGroup, OpenctiUser @pytest.fixture(scope="function", autouse=True) @@ -17,9 +20,96 @@ def juju_version(monkeypatch): monkeypatch.setenv("JUJU_VERSION", "3.3.0") -@pytest.fixture(scope="function") +@pytest.fixture(scope="function", autouse=True) def patch_is_platform_healthy(): """Patch OpenCTICharm.is_platform_healthy function.""" mock = MagicMock(return_value=True) with unittest.mock.patch.object(src.charm.OpenCTICharm, "_is_platform_healthy", mock): yield mock + + +@pytest.fixture(scope="function", autouse=True) +def patch_opencti_client(): + """Patch OpenctiClient class.""" + with unittest.mock.patch.object(opencti, "OpenctiClient", OpenctiClientMock): + yield OpenctiClientMock() + + +class OpenctiClientMock: + """A mock for OpenctiClient.""" + + _users = [ + { + "id": "88ec0c6a-13ce-5e39-b486-354fe4a7084f", + "name": "admin", + "user_email": "admin@example.com", + "account_status": "Active", + "api_token": "a614ebcb-d597-4011-a626-c5302959efa6", + }, + ] + _groups = [ + {"name": "Administrators", "id": "35c94569-bbdd-4535-9def-26b781359a5b"}, + {"name": "Connectors", "id": "f4fb5f8d-91f5-441e-8ef9-93c283476110"}, + {"name": "Default", "id": "1e257543-6bfb-46f2-a25f-5d50bb0819bd"}, + ] + + def __init__(self, *_args, **_kwargs): + """Initialize OpenctiClientMock.""" + + def list_users(self) -> list[OpenctiUser]: + """List OpenCTI users. + + Returns: + A list of OpenctiUser objects. + """ + return [OpenctiUser(**u) for u in self._users] + + def list_groups(self) -> list[OpenctiGroup]: + """List OpenCTI groups. + + Returns: + A list of OpenctiGroup objects. + """ + return [OpenctiGroup(**g) for g in self._groups] + + def create_user( + self, + name: str, + user_email: str | None = None, + groups: list[str] | None = None, # pylint: disable=unused-argument + ) -> None: + """Create a user. + + Args: + name: The name of the user. + user_email: The email address of the user. + groups: The groups associated with the user. + """ + new_user = { + "name": name, + "id": "00000000-0000-0000-0000-000000000000", + "user_email": user_email or f"{name}@opencti.local", + "account_status": "Active", + "api_token": "00000000-0000-0000-0000-000000000000", + } + self._users.append(new_user) + + def set_account_status( + self, + user_id: str, + status: typing.Literal["Active", "Inactive"], + ) -> None: + """Set OpenCTI account status. + + Args: + user_id: The ID of the user. + status: The status of the user. + + Raises: + RuntimeError: If user doesn't exist. + """ + for user in self._users: + if user["id"] == user_id: + user["account_status"] = status + return + raise RuntimeError(f"Unknown user id: {user_id}") diff --git a/tests/unit/state.py b/tests/unit/state.py index 09dbf8f..e07769e 100644 --- a/tests/unit/state.py +++ b/tests/unit/state.py @@ -256,6 +256,7 @@ def build(self) -> ops.testing.State: Returns: ops.testing.State """ return ops.testing.State( + model=ops.testing.Model("test-opencti"), leader=self._leader, containers=[ ops.testing.Container( # type: ignore @@ -267,3 +268,69 @@ def build(self) -> ops.testing.State: secrets=self._secrets, config=self._config, ) + + +class ConnectorStateBuilder: + """ops.testing.State builder for connector tests.""" + + def __init__(self, container_name: str) -> None: + """Initialize the state builder. + + Args: + container_name: name of the container. + """ + self._integrations: list[ops.testing.RelationBase] = [] + self._config: dict[str, str | int | float | bool] = {} + self._secrets: list[ops.testing.Secret] = [] + self._container_name = container_name + + def add_opencti_connector_integration(self) -> "ConnectorStateBuilder": + """Add opencti-connector integration. + + Returns: self + """ + secret = ops.testing.Secret( + tracked_content={"token": "00000000-0000-0000-0000-000000000000"} + ) + integration = ops.testing.Relation( + remote_app_name="opencti", + endpoint="opencti-connector", + remote_app_data={ + "opencti_token": secret.id, + "opencti_url": "http://opencti-endpoints.test-opencti-connector.svc:8080", + }, + ) + self._secrets.append(secret) + self._integrations.append(integration) + return self + + def set_config(self, name: str, value: str) -> "ConnectorStateBuilder": + """Set charm config. + + Args: + name: config name. + value: config value. + + Returns: self + """ + self._config[name] = value + return self + + def build(self) -> ops.testing.State: + """Build state. + + Returns: ops.testing.State + """ + return ops.testing.State( + model=ops.testing.Model("test-opencti-connector"), + leader=True, + containers=[ + ops.testing.Container( # type: ignore + name=self._container_name, + can_connect=True, + ) + ], + relations=self._integrations, + secrets=self._secrets, + config=self._config, + ) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 4b50673..08168a2 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -348,3 +348,37 @@ def test_redis_library_workaround(): state_out = ctx.run(ctx.on.config_changed(), state_in) assert state_out.unit_status.name == "blocked" assert state_out.unit_status.message == "invalid redis integration" + + +def test_opencti_connector(patch_opencti_client): + """ + arrange: provide the charm with the required integrations and configurations. + act: simulate a config-changed event. + assert: opencti charm should configure opencti users properly for the connector. + """ + ctx = ops.testing.Context(OpenCTICharm) + opencti_connector_integration = ops.testing.Relation( + endpoint="opencti-connector", + remote_app_data={ + "connector_type": "INTERNAL_EXPORT_FILE", + "connector_charm_name": "test", + }, + ) + state_in = ( + StateBuilder() + .add_required_integrations() + .add_required_configs() + .add_integration(opencti_connector_integration) + .build() + ) + state_out = ctx.run(ctx.on.config_changed(), state_in) + users = {u.name: u for u in patch_opencti_client.list_users()} + assert "charm-connector-test" in users + integration_out = state_out.get_relation(opencti_connector_integration.id) + assert ( + integration_out.local_app_data["opencti_url"] # type: ignore + == "http://opencti-endpoints.test-opencti.svc:8080" + ) + secret_id = integration_out.local_app_data["opencti_token"] # type: ignore + secret = state_out.get_secret(id=secret_id) + assert secret.tracked_content == {"token": "00000000-0000-0000-0000-000000000000"} diff --git a/tests/unit/test_connectors.py b/tests/unit/test_connectors.py new file mode 100644 index 0000000..fbe128f --- /dev/null +++ b/tests/unit/test_connectors.py @@ -0,0 +1,693 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# Learn more about testing at: https://juju.is/docs/sdk/testing + +"""Unit tests for connectors.""" +import importlib + +import ops.testing +import pytest + +from tests.unit.state import ConnectorStateBuilder + + +def _kebab_to_pascal(string: str) -> str: + """Convert names from kebab case to pascal case.""" + words = string.split("-") + return "".join(word.capitalize() for word in words) + + +def _kebab_to_snake(string: str) -> str: + """Convert names from kebab case to snake case.""" + return string.replace("-", "_") + + +_CONNECTOR_TEST_PARAMS = [] + + +def _add_connector_test_params( + *, + name: str, + connector_name: str, + charm_config: dict[str, str | int | bool], + environment: dict[str, str], +) -> None: + """Add a connector test parameter.""" + _CONNECTOR_TEST_PARAMS.append(pytest.param(connector_name, charm_config, environment, id=name)) + + +_add_connector_test_params( + name="abuseipdb-ipblacklist", + connector_name="abuseipdb-ipblacklist", + charm_config={ + "connector-scope": "abuseipdb", + "connector-log-level": "error", + "abuseipdb-url": "https://api.abuseipdb.com/api/v2/blacklist", + "abuseipdb-api-key": "ChangeMe", + "abuseipdb-score": 100, + "abuseipdb-limit": 10000, + "abuseipdb-interval": 2, + }, + environment={ + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "CONNECTOR_NAME": "opencti-abuseipdb-ipblacklist-connector", + "CONNECTOR_SCOPE": "abuseipdb", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "ABUSEIPDB_URL": "https://api.abuseipdb.com/api/v2/blacklist", + "ABUSEIPDB_API_KEY": "ChangeMe", + "ABUSEIPDB_SCORE": "100", + "ABUSEIPDB_LIMIT": "10000", + "ABUSEIPDB_INTERVAL": "2", + }, +) + +_add_connector_test_params( + name="alienvault", + connector_name="alienvault", + charm_config={ + "connector-scope": "alienvault", + "connector-log-level": "error", + "connector-duration-period": "PT30M", + "alienvault-base-url": "https://otx.alienvault.com", + "alienvault-api-key": "ChangeMe", + "alienvault-tlp": "White", + "alienvault-create-observables": True, + "alienvault-create-indicators": True, + "alienvault-pulse-start-timestamp": "2022-05-01T00:00:00", + "alienvault-report-type": "threat-report", + "alienvault-report-status": "New", + "alienvault-guess-malware": False, + "alienvault-guess-cve": False, + "alienvault-excluded-pulse-indicator-types": "FileHash-MD5,FileHash-SHA1", + "alienvault-enable-relationships": True, + "alienvault-enable-attack-patterns-indicates": False, + "alienvault-interval-sec": 1800, + "alienvault-default-x-opencti-score": 50, + "alienvault-x-opencti-score-ip": 60, + "alienvault-x-opencti-score-domain": 70, + "alienvault-x-opencti-score-hostname": 75, + "alienvault-x-opencti-score-email": 70, + "alienvault-x-opencti-score-file": 85, + "alienvault-x-opencti-score-url": 80, + "alienvault-x-opencti-score-mutex": 60, + "alienvault-x-opencti-score-cryptocurrency-wallet": 80, + }, + environment={ + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "CONNECTOR_NAME": "opencti-alienvault-connector", + "CONNECTOR_SCOPE": "alienvault", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_DURATION_PERIOD": "PT30M", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "ALIENVAULT_BASE_URL": "https://otx.alienvault.com", + "ALIENVAULT_API_KEY": "ChangeMe", + "ALIENVAULT_TLP": "White", + "ALIENVAULT_CREATE_OBSERVABLES": "true", + "ALIENVAULT_CREATE_INDICATORS": "true", + "ALIENVAULT_PULSE_START_TIMESTAMP": "2022-05-01T00:00:00", + "ALIENVAULT_REPORT_TYPE": "threat-report", + "ALIENVAULT_REPORT_STATUS": "New", + "ALIENVAULT_GUESS_MALWARE": "false", + "ALIENVAULT_GUESS_CVE": "false", + "ALIENVAULT_EXCLUDED_PULSE_INDICATOR_TYPES": "FileHash-MD5,FileHash-SHA1", + "ALIENVAULT_ENABLE_RELATIONSHIPS": "true", + "ALIENVAULT_ENABLE_ATTACK_PATTERNS_INDICATES": "false", + "ALIENVAULT_INTERVAL_SEC": "1800", + "ALIENVAULT_DEFAULT_X_OPENCTI_SCORE": "50", + "ALIENVAULT_X_OPENCTI_SCORE_IP": "60", + "ALIENVAULT_X_OPENCTI_SCORE_DOMAIN": "70", + "ALIENVAULT_X_OPENCTI_SCORE_HOSTNAME": "75", + "ALIENVAULT_X_OPENCTI_SCORE_EMAIL": "70", + "ALIENVAULT_X_OPENCTI_SCORE_FILE": "85", + "ALIENVAULT_X_OPENCTI_SCORE_URL": "80", + "ALIENVAULT_X_OPENCTI_SCORE_MUTEX": "60", + "ALIENVAULT_X_OPENCTI_SCORE_CRYPTOCURRENCY_WALLET": "80", + }, +) + +_add_connector_test_params( + name="cisa-kev", + connector_name="cisa-kev", + charm_config={ + "connector-scope": "cisa", + "connector-run-and-terminate": False, + "connector-log-level": "error", + "connector-duration-period": "P2D", + "cisa-catalog-url": ( + "https://www.cisa.gov/sites/default/files/feeds/" + "known_exploited_vulnerabilities.json" + ), + "cisa-create-infrastructures": False, + "cisa-tlp": "TLP:CLEAR", + }, + environment={ + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "CONNECTOR_NAME": "opencti-cisa-kev-connector", + "CONNECTOR_SCOPE": "cisa", + "CONNECTOR_RUN_AND_TERMINATE": "false", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_DURATION_PERIOD": "P2D", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "CISA_CATALOG_URL": ( + "https://www.cisa.gov/sites/default/files/feeds/" + "known_exploited_vulnerabilities.json" + ), + "CISA_CREATE_INFRASTRUCTURES": "false", + "CISA_TLP": "TLP:CLEAR", + }, +) + +_add_connector_test_params( + name="crowdstrike", + connector_name="crowdstrike", + charm_config={ + "connector-log-level": "error", + "connector-duration-period": "PT30M", + "crowdstrike-base-url": "https://api.crowdstrike.com", + "crowdstrike-client-id": "ChangeMe", + "crowdstrike-client-secret": "ChangeMe", + "crowdstrike-tlp": "Amber", + "crowdstrike-create-observables": "true", + "crowdstrike-create-indicators": "true", + "crowdstrike-scopes": "actor,report,indicator,yara_master", + "crowdstrike-actor-start-timestamp": 0, + "crowdstrike-report-start-timestamp": 0, + "crowdstrike-report-status": "New", + "crowdstrike-report-include-types": "notice,tipper,intelligence report,periodic report", + "crowdstrike-report-type": "threat-report", + "crowdstrike-report-target-industries": "", + "crowdstrike-report-guess-malware": "false", + "crowdstrike-indicator-start-timestamp": 0, + "crowdstrike-indicator-exclude-types": "hash_ion,hash_md5,hash_sha1", + # undocumented configuration + # "crowdstrike-default-x-opencti-score": 50, + "crowdstrike-indicator-low-score": 40, + "crowdstrike-indicator-low-score-labels": "MaliciousConfidence/Low", + "crowdstrike-indicator-medium-score": 60, + "crowdstrike-indicator-medium-score-labels": "MaliciousConfidence/Medium", + "crowdstrike-indicator-high-score": 80, + "crowdstrike-indicator-high-score-labels": "MaliciousConfidence/High", + "crowdstrike-indicator-unwanted-labels": "", + }, + environment={ + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "CONNECTOR_NAME": "opencti-crowdstrike-connector", + "CONNECTOR_SCOPE": "crowdstrike", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "CONNECTOR_DURATION_PERIOD": "PT30M", + "CROWDSTRIKE_BASE_URL": "https://api.crowdstrike.com", + "CROWDSTRIKE_CLIENT_ID": "ChangeMe", + "CROWDSTRIKE_CLIENT_SECRET": "ChangeMe", + "CROWDSTRIKE_TLP": "Amber", + "CROWDSTRIKE_CREATE_OBSERVABLES": "true", + "CROWDSTRIKE_CREATE_INDICATORS": "true", + "CROWDSTRIKE_SCOPES": "actor,report,indicator,yara_master", + "CROWDSTRIKE_ACTOR_START_TIMESTAMP": "0", + "CROWDSTRIKE_REPORT_START_TIMESTAMP": "0", + "CROWDSTRIKE_REPORT_STATUS": "New", + "CROWDSTRIKE_REPORT_INCLUDE_TYPES": "notice,tipper,intelligence report,periodic report", + "CROWDSTRIKE_REPORT_TYPE": "threat-report", + "CROWDSTRIKE_REPORT_TARGET_INDUSTRIES": "", + "CROWDSTRIKE_REPORT_GUESS_MALWARE": "false", + "CROWDSTRIKE_INDICATOR_START_TIMESTAMP": "0", + "CROWDSTRIKE_INDICATOR_EXCLUDE_TYPES": "hash_ion,hash_md5,hash_sha1", + # "CROWDSTRIKE_DEFAULT_X_OPENCTI_SCORE": "50", + "CROWDSTRIKE_INDICATOR_LOW_SCORE": "40", + "CROWDSTRIKE_INDICATOR_LOW_SCORE_LABELS": "MaliciousConfidence/Low", + "CROWDSTRIKE_INDICATOR_MEDIUM_SCORE": "60", + "CROWDSTRIKE_INDICATOR_MEDIUM_SCORE_LABELS": "MaliciousConfidence/Medium", + "CROWDSTRIKE_INDICATOR_HIGH_SCORE": "80", + "CROWDSTRIKE_INDICATOR_HIGH_SCORE_LABELS": "MaliciousConfidence/High", + "CROWDSTRIKE_INDICATOR_UNWANTED_LABELS": "", + }, +) + + +_add_connector_test_params( + name="cyber-campaign", + connector_name="cyber-campaign", + charm_config={ + "connector-scope": "report", + "connector-run-and-terminate": False, + "connector-log-level": "error", + "cyber-monitor-github-token": "", + "cyber-monitor-from-year": 2018, + "cyber-monitor-interval": 4, + }, + environment={ + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "CONNECTOR_NAME": "opencti-cyber-campaign-connector", + "CONNECTOR_SCOPE": "report", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "CONNECTOR_RUN_AND_TERMINATE": "false", + "CONNECTOR_LOG_LEVEL": "error", + "CYBER_MONITOR_GITHUB_TOKEN": "", + "CYBER_MONITOR_FROM_YEAR": "2018", + "CYBER_MONITOR_INTERVAL": "4", + }, +) + +_add_connector_test_params( + name="export-file-csv", + connector_name="export-file-csv", + charm_config={ + "connector-scope": "text/csv", + "connector-confidence-level": 100, + "connector-log-level": "error", + "export-file-csv-delimiter": ";", + }, + environment={ + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "CONNECTOR_NAME": "opencti-export-file-csv-connector", + "CONNECTOR_SCOPE": "text/csv", + "CONNECTOR_TYPE": "INTERNAL_EXPORT_FILE", + "CONNECTOR_CONFIDENCE_LEVEL": "100", + "CONNECTOR_LOG_LEVEL": "error", + "EXPORT_FILE_CSV_DELIMITER": ";", + }, +) + +_add_connector_test_params( + name="export-file-stix", + connector_name="export-file-stix", + charm_config={ + "connector-scope": "application/vnd.oasis.stix+json", + "connector-confidence-level": 100, + "connector-log-level": "error", + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-export-file-stix-connector", + "CONNECTOR_SCOPE": "application/vnd.oasis.stix+json", + "CONNECTOR_CONFIDENCE_LEVEL": "100", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "INTERNAL_EXPORT_FILE", + }, +) + +_add_connector_test_params( + name="export-file-stix-minimal", + connector_name="export-file-stix", + charm_config={"connector-scope": "application/json"}, + environment={ + "CONNECTOR_LOG_LEVEL": "info", + "CONNECTOR_NAME": "opencti-export-file-stix-connector", + "CONNECTOR_SCOPE": "application/json", + "CONNECTOR_TYPE": "INTERNAL_EXPORT_FILE", + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + }, +) + +_add_connector_test_params( + name="export-file-txt", + connector_name="export-file-txt", + charm_config={ + "connector-scope": "text/plain", + "connector-confidence-level": 100, + "connector-log-level": "error", + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-export-file-txt-connector", + "CONNECTOR_TYPE": "INTERNAL_EXPORT_FILE", + "CONNECTOR_SCOPE": "text/plain", + "CONNECTOR_CONFIDENCE_LEVEL": "100", + "CONNECTOR_LOG_LEVEL": "error", + }, +) + +_add_connector_test_params( + name="import-document", + connector_name="import-document", + charm_config={ + "connector-validate-before-import": True, + "connector-scope": "application/pdf,text/plain,text/html,text/markdown", + "connector-auto": False, + "connector-only-contextual": False, + "connector-confidence-level": 100, + "connector-log-level": "error", + "import-document-create-indicator": False, + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-import-document-connector", + "CONNECTOR_VALIDATE_BEFORE_IMPORT": "true", + "CONNECTOR_SCOPE": "application/pdf,text/plain,text/html,text/markdown", + "CONNECTOR_AUTO": "false", + "CONNECTOR_ONLY_CONTEXTUAL": "false", + "CONNECTOR_CONFIDENCE_LEVEL": "100", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "INTERNAL_IMPORT_FILE", + "IMPORT_DOCUMENT_CREATE_INDICATOR": "false", + }, +) + +_add_connector_test_params( + name="import-file-stix", + connector_name="import-file-stix", + charm_config={ + "connector-validate-before-import": True, + "connector-scope": "application/json,application/xml", + "connector-auto": False, + "connector-confidence-level": 15, + "connector-log-level": "error", + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-import-file-stix-connector", + "CONNECTOR_VALIDATE_BEFORE_IMPORT": "true", + "CONNECTOR_SCOPE": "application/json,application/xml", + "CONNECTOR_AUTO": "false", + "CONNECTOR_CONFIDENCE_LEVEL": "15", + "CONNECTOR_TYPE": "INTERNAL_IMPORT_FILE", + "CONNECTOR_LOG_LEVEL": "error", + }, +) + +_add_connector_test_params( + name="malwarebazaar", + connector_name="malwarebazaar", + charm_config={ + "connector-log-level": "error", + "malwarebazaar-recent-additions-api-url": "https://mb-api.abuse.ch/api/v1/", + "malwarebazaar-recent-additions-cooldown-seconds": 300, + "malwarebazaar-recent-additions-include-tags": "exe,dll,docm,docx,doc,xls,xlsx,xlsm,js", + "malwarebazaar-recent-additions-include-reporters": "", + "malwarebazaar-recent-additions-labels": "malware-bazaar", + "malwarebazaar-recent-additions-labels-color": "#54483b", + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-malwarebazaar-connector", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "MALWAREBAZAAR_RECENT_ADDITIONS_API_URL": "https://mb-api.abuse.ch/api/v1/", + "MALWAREBAZAAR_RECENT_ADDITIONS_COOLDOWN_SECONDS": "300", + "MALWAREBAZAAR_RECENT_ADDITIONS_INCLUDE_TAGS": "exe,dll,docm,docx,doc,xls,xlsx,xlsm,js", + "MALWAREBAZAAR_RECENT_ADDITIONS_INCLUDE_REPORTERS": "", + "MALWAREBAZAAR_RECENT_ADDITIONS_LABELS": "malware-bazaar", + "MALWAREBAZAAR_RECENT_ADDITIONS_LABELS_COLOR": "#54483b", + }, +) + +_add_connector_test_params( + name="misp-feed", + connector_name="misp-feed", + charm_config={ + "connector-scope": "misp-feed", + "connector-run-and-terminate": False, + "connector-log-level": "error", + "misp-feed-url": "https://changeme.com/misp-feed", + "misp-feed-ssl-verify": True, + "misp-feed-import-from-date": "2000-01-01", + "misp-feed-create-reports": True, + "misp-feed-report-type": "misp-event", + "misp-feed-create-indicators": True, + "misp-feed-create-observables": True, + "misp-feed-create-object-observables": True, + "misp-feed-create-tags-as-labels": True, + "misp-feed-guess-threat-from-tags": False, + "misp-feed-author-from-tags": False, + "misp-feed-import-to-ids-no-score": True, + "misp-feed-import-unsupported-observables-as-text": False, + "misp-feed-import-unsupported-observables-as-text-transparent": True, + "misp-feed-import-with-attachments": False, + "misp-feed-interval": 5, + "misp-feed-source-type": "url", + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-misp-feed-connector", + "CONNECTOR_SCOPE": "misp-feed", + "CONNECTOR_RUN_AND_TERMINATE": "false", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "MISP_FEED_URL": "https://changeme.com/misp-feed", + "MISP_FEED_SSL_VERIFY": "true", + "MISP_FEED_IMPORT_FROM_DATE": "2000-01-01", + "MISP_FEED_CREATE_REPORTS": "true", + "MISP_FEED_REPORT_TYPE": "misp-event", + "MISP_FEED_CREATE_INDICATORS": "true", + "MISP_FEED_CREATE_OBSERVABLES": "true", + "MISP_FEED_CREATE_OBJECT_OBSERVABLES": "true", + "MISP_FEED_CREATE_TAGS_AS_LABELS": "true", + "MISP_FEED_GUESS_THREAT_FROM_TAGS": "false", + "MISP_FEED_AUTHOR_FROM_TAGS": "false", + "MISP_FEED_IMPORT_TO_IDS_NO_SCORE": "true", + "MISP_FEED_IMPORT_UNSUPPORTED_OBSERVABLES_AS_TEXT": "false", + "MISP_FEED_IMPORT_UNSUPPORTED_OBSERVABLES_AS_TEXT_TRANSPARENT": "true", + "MISP_FEED_IMPORT_WITH_ATTACHMENTS": "false", + "MISP_FEED_INTERVAL": "5", + "MISP_FEED_SOURCE_TYPE": "url", + }, +) + +_add_connector_test_params( + name="mitre", + connector_name="mitre", + charm_config={ + "connector-scope": ( + "tool,report,malware,identity,campaign," + "intrusion-set,attack-pattern,course-of-action," + "x-mitre-data-source,x-mitre-data-component," + "x-mitre-matrix,x-mitre-tactic,x-mitre-collection" + ), + "connector-run-and-terminate": False, + "connector-log-level": "error", + "mitre-remove-statement-marking": True, + "mitre-interval": 7, + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-mitre-connector", + "CONNECTOR_SCOPE": ( + "tool,report,malware,identity,campaign,intrusion-set," + "attack-pattern,course-of-action,x-mitre-data-source," + "x-mitre-data-component,x-mitre-matrix,x-mitre-tactic,x-mitre-collection" + ), + "CONNECTOR_RUN_AND_TERMINATE": "false", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "MITRE_REMOVE_STATEMENT_MARKING": "true", + "MITRE_INTERVAL": "7", + }, +) + +_add_connector_test_params( + name="sekoia", + connector_name="sekoia", + charm_config={ + "connector-scope": ( + "identity,attack-pattern,course-of-action,intrusion-set," + "malware,tool,report,location,vulnerability,indicator," + "campaign,infrastructure,relationship" + ), + "connector-log-level": "error", + "sekoia-api-key": "ChangeMe", + "sekoia-collection": "d6092c37-d8d7-45c3-8aff-c4dc26030608", + "sekoia-start-date": "2022-01-01", + "sekoia-create-observables": True, + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-sekoia-connector", + "CONNECTOR_SCOPE": ( + "identity,attack-pattern,course-of-action,intrusion-set," + "malware,tool,report,location,vulnerability,indicator," + "campaign,infrastructure,relationship" + ), + "CONNECTOR_LOG_LEVEL": "error", + "SEKOIA_API_KEY": "ChangeMe", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "SEKOIA_BASE_URL": "https://api.sekoia.io", + "SEKOIA_COLLECTION": "d6092c37-d8d7-45c3-8aff-c4dc26030608", + "SEKOIA_START_DATE": "2022-01-01", + "SEKOIA_CREATE_OBSERVABLES": "true", + }, +) + +_add_connector_test_params( + name="urlscan", + connector_name="urlscan", + charm_config={ + "connector-log-level": "error", + "connector-confidence-level": 40, + "connector-create-indicators": True, + "connector-tlp": "white", + "connector-labels": "Phishing,Phishfeed", + "connector-interval": 86400, + "connector-lookback": 3, + "connector-update-existing-data": True, + "urlscan-url": "https://urlscan.io/api/v1/pro/phishfeed?format=json", + "urlscan-api-key": "", + "urlscan-default-x-opencti-score": 50, + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-urlscan-connector", + "CONNECTOR_SCOPE": "threatmatch", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_CONFIDENCE_LEVEL": "40", + "CONNECTOR_CREATE_INDICATORS": "true", + "CONNECTOR_TLP": "white", + "CONNECTOR_LABELS": "Phishing,Phishfeed", + "CONNECTOR_INTERVAL": "86400", + "CONNECTOR_LOOKBACK": "3", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "CONNECTOR_UPDATE_EXISTING_DATA": "true", + "URLSCAN_URL": "https://urlscan.io/api/v1/pro/phishfeed?format=json", + "URLSCAN_API_KEY": "", + "URLSCAN_DEFAULT_X_OPENCTI_SCORE": "50", + }, +) + +_add_connector_test_params( + name="urlscan-enrichment", + connector_name="urlscan-enrichment", + charm_config={ + "connector-scope": "url,ipv4-addr,ipv6-addr", + "connector-auto": False, + "connector-log-level": "error", + "urlscan-enrichment-api-key": "ChangeMe", + "urlscan-enrichment-api-base-url": "https://urlscan.io/api/v1/", + "urlscan-enrichment-import-screenshot": True, + "urlscan-enrichment-visibility": "public", + "urlscan-enrichment-search-filtered-by-date": ">now-1y", + "urlscan-enrichment-max-tlp": "TLP:AMBER", + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-urlscan-enrichment-connector", + "CONNECTOR_SCOPE": "url,ipv4-addr,ipv6-addr", + "CONNECTOR_AUTO": "false", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "INTERNAL_ENRICHMENT", + "URLSCAN_ENRICHMENT_API_KEY": "ChangeMe", + "URLSCAN_ENRICHMENT_API_BASE_URL": "https://urlscan.io/api/v1/", + "URLSCAN_ENRICHMENT_IMPORT_SCREENSHOT": "true", + "URLSCAN_ENRICHMENT_VISIBILITY": "public", + "URLSCAN_ENRICHMENT_SEARCH_FILTERED_BY_DATE": ">now-1y", + "URLSCAN_ENRICHMENT_MAX_TLP": "TLP:AMBER", + }, +) + +_add_connector_test_params( + name="virustotal-livehunt", + connector_name="virustotal-livehunt", + charm_config={ + "connector-scope": "StixFile,Indicator,Incident", + "connector-log-level": "error", + "virustotal-livehunt-notifications-api-key": "ChangeMe", + "virustotal-livehunt-notifications-interval-sec": 300, + "virustotal-livehunt-notifications-create-alert": True, + "virustotal-livehunt-notifications-extensions": "'exe,dll'", + "virustotal-livehunt-notifications-min-file-size": 1000, + "virustotal-livehunt-notifications-max-file-size": 52428800, + "virustotal-livehunt-notifications-max-age-days": 3, + "virustotal-livehunt-notifications-min-positives": 5, + "virustotal-livehunt-notifications-create-file": True, + "virustotal-livehunt-notifications-upload-artifact": False, + "virustotal-livehunt-notifications-create-yara-rule": True, + "virustotal-livehunt-notifications-delete-notification": False, + "virustotal-livehunt-notifications-filter-with-tag": '"mytag"', + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-virustotal-livehunt-connector", + "CONNECTOR_SCOPE": "StixFile,Indicator,Incident", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_API_KEY": "ChangeMe", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_INTERVAL_SEC": "300", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_CREATE_ALERT": "True", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_EXTENSIONS": "'exe,dll'", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_MIN_FILE_SIZE": "1000", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_MAX_FILE_SIZE": "52428800", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_MAX_AGE_DAYS": "3", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_MIN_POSITIVES": "5", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_CREATE_FILE": "True", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_UPLOAD_ARTIFACT": "False", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_CREATE_YARA_RULE": "True", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_DELETE_NOTIFICATION": "False", + "VIRUSTOTAL_LIVEHUNT_NOTIFICATIONS_FILTER_WITH_TAG": '"mytag"', + }, +) + + +_add_connector_test_params( + name="vxvault", + connector_name="vxvault", + charm_config={ + "connector-scope": "vxvault", + "connector-log-level": "error", + "vxvault-url": "https://vxvault.net/URL_List.php", + "vxvault-create-indicators": True, + "vxvault-interval": 3, + "vxvault-ssl-verify": False, + }, + environment={ + "OPENCTI_TOKEN": "00000000-0000-0000-0000-000000000000", + "OPENCTI_URL": "http://opencti-endpoints.test-opencti-connector.svc:8080", + "CONNECTOR_NAME": "opencti-vxvault-connector", + "CONNECTOR_SCOPE": "vxvault", + "CONNECTOR_LOG_LEVEL": "error", + "CONNECTOR_TYPE": "EXTERNAL_IMPORT", + "VXVAULT_URL": "https://vxvault.net/URL_List.php", + "VXVAULT_CREATE_INDICATORS": "true", + "VXVAULT_INTERVAL": "3", + "VXVAULT_SSL_VERIFY": "false", + }, +) + + +@pytest.mark.parametrize("connector_name, charm_config, environment", _CONNECTOR_TEST_PARAMS) +def test_connector_environment(connector_name, charm_config, environment): + """ + arrange: provide the connector charm with the required integrations and configurations. + act: simulate a config-changed event. + assert: the installed Pebble plan matches the expectation. + """ + name = f"opencti-{connector_name}-connector" + module = connector_name + charm_module = importlib.import_module(f"connectors.{_kebab_to_snake(module)}.src.charm") + charm_class = getattr(charm_module, _kebab_to_pascal(name) + "Charm") + ctx = ops.testing.Context(charm_class) + state_builder = ConnectorStateBuilder(name).add_opencti_connector_integration() + for config_key, config_value in charm_config.items(): + state_builder = state_builder.set_config(config_key, config_value) + state_in = state_builder.build() + state_out = ctx.run(ctx.on.config_changed(), state_in) + plan = state_out.get_container(name).plan.to_dict() + del plan["services"]["connector"]["environment"]["CONNECTOR_ID"] + assert plan == { + "services": { + "connector": { + "command": "bash /entrypoint.sh", + "environment": environment, + "on-failure": "restart", + "override": "replace", + "startup": "enabled", + } + } + } diff --git a/tox.ini b/tox.ini index adf84c1..25f59df 100644 --- a/tox.ini +++ b/tox.ini @@ -113,6 +113,7 @@ deps = pytest pytest-asyncio pytest-operator + PyYAML -r{toxinidir}/requirements.txt commands = pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs}