diff --git a/.circleci/config.yml b/.circleci/config.yml index a4582715..aa8ee4b2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,290 +1,290 @@ -version: 2.1 - -orbs: - slack: circleci/slack@5.2.3 - kubernetes: circleci/kubernetes@1.3.1 - -jobs: - ensure_formatting: - docker: - - image: cimg/python:3.13 - working_directory: ~/repo - steps: - - checkout - - run: sudo apt-get update -qq && sudo apt install curl gettext-base - - run: - name: install dependencies - command: pip install black isort --user - - run: - name: confirm black version - command: black --version - - run: - name: run isort check - command: isort --profile black --check . - - run: - name: run black check - command: black --check . - - slack/notify: - event: fail - template: basic_fail_1 - linter: - docker: - - image: alpine/flake8 - working_directory: ~/repo - steps: - - checkout - - run: apk update && apk upgrade && apk --no-cache add curl gettext bash - - run: - name: flake8 - command: flake8 --ignore=E,W ~/repo - - slack/notify: - event: fail - template: basic_fail_1 - test: - working_directory: ~/openaev - docker: - - image: cimg/python:3.13 - parameters: - injector_name: - type: string - steps: - - checkout - - setup_remote_docker - - run: - working_directory: ~/openaev/<< parameters.injector_name >> - name: Check if injector exists - command: | - if [ ! -f pyproject.toml ]; then - echo "pyproject.toml not found, injector may not exist on this branch" - circleci-agent step halt - fi; - - run: # this is only to satisfy poetry; not used - working_directory: ~/ - name: Clone pyoaev - command: | - git clone -b main https://github.com/OpenAEV-Platform/client-python - - run: - working_directory: ~/openaev - name: Install poetry - command: pip install poetry==2.3.2 && poetry config installer.re-resolve false - - run: - name: Run injector tests - working_directory: ~/openaev/<< parameters.injector_name >> - command: | - PYOAEV_REF="main" - - echo "Running tests for injector: << parameters.injector_name >>" - poetry install - poetry run pip install --force-reinstall git+https://github.com/OpenAEV-Platform/client-python.git@${PYOAEV_REF} - poetry run python -m unittest - - build_docker_images: - working_directory: ~/openaev - docker: - - image: cimg/base:current-24.04 - parameters: - injector_name: - type: string - steps: - - checkout - - run: - working_directory: ~/openaev/<< parameters.injector_name >> - name: Check if injector exists - command: | - if [ ! -f pyproject.toml ]; then - echo "pyproject.toml not found, injector may not exist on this branch" - circleci-agent step halt - fi; - - run: - working_directory: ~/openaev - name: Set semantic version environment - command: | - export LATEST_SEMANTIC_VERSION=$(git tag --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) - export IS_LATEST=$([ "$CIRCLE_TAG" = "$LATEST_SEMANTIC_VERSION" ] && echo "true" || echo "false") - echo "IS_LATEST=$IS_LATEST" >> $BASH_ENV - - setup_remote_docker - - run: - name: Install environment - command: | - sudo apt-get update -qq - sudo apt install curl gettext-base - mkdir -p ~/openaev/images - - run: - working_directory: ~/openaev - name: Replace pyoaev requirement of stable version with latest release branch code - # We safely expand the CIRCLE_BRANCH variable because the CircleCI filters already enforce a strict release/x.x.x pattern using regex - command: | - if [ "${CIRCLE_BRANCH}" = "main" ]; then - find . -name pyproject.toml | xargs -I ___ sed "s|branch = 'main'|branch = '${CIRCLE_BRANCH}'|" -i ___; - fi; - - run: - name: Build injector Docker images - working_directory: ~/openaev/<< parameters.injector_name >> - command: | - echo "Building injector: << parameters.injector_name >>" - - if [[ "${CIRCLE_BRANCH}" == "main" ]]; then - docker build --pull --progress=plain --build-context injector_common=../injector_common -t openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} --build-arg PYOAEV_GIT_BRANCH_OVERRIDE="${CIRCLE_BRANCH}" . - else - docker build --pull --progress=plain --build-context injector_common=../injector_common -t openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} . - fi - - docker save -o ~/openaev/images/injector-<< parameters.injector_name >> openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} - - persist_to_workspace: - root: ~/openaev - paths: - - images - - slack/notify: - event: fail - template: basic_fail_1 - publish_images: - working_directory: ~/openaev - docker: - - image: cimg/base:current-24.04 - parameters: - injector_name: - type: string - steps: - - checkout - - run: - working_directory: ~/openaev/<< parameters.injector_name >> - name: Check if injector exists - command: | - if [ ! -f pyproject.toml ]; then - echo "pyproject.toml not found, injector may not exist on this branch" - circleci-agent step halt - fi; - - run: - working_directory: ~/openaev - name: Set semantic version environment - command: | - export LATEST_SEMANTIC_VERSION=$(git tag --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) - export IS_LATEST=$([ "$CIRCLE_TAG" = "$LATEST_SEMANTIC_VERSION" ] && echo "true" || echo "false") - echo "IS_LATEST=$IS_LATEST" >> $BASH_ENV - - attach_workspace: - at: . - - setup_remote_docker - - run: - working_directory: ~/openaev/images - name: Restore saved images and publish - command: | - if [ "${CIRCLE_TAG}" != "" ] - then - export IMAGETAG=${CIRCLE_TAG} - elif [ "${CIRCLE_BRANCH}" = "main" ] - then - export IMAGETAG="rolling" - else - echo "The tag supplied or branch is not 'main' (was: '${CIRCLE_BRANCH}')" - exit 1 - fi - echo "Image tag: ${IMAGETAG}" - - docker image load < injector-<< parameters.injector_name >> - docker tag openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} openaev/injector-<< parameters.injector_name >>:${IMAGETAG} - docker tag openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} openbas/injector-<< parameters.injector_name >>:${IMAGETAG} - - echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin - - docker push openaev/injector-<< parameters.injector_name >>:${IMAGETAG} - docker push openbas/injector-<< parameters.injector_name >>:${IMAGETAG} - - if [ "${IS_LATEST}" == "true" ] - then - docker tag openaev/injector-<< parameters.injector_name >>:${IMAGETAG} openaev/injector-<< parameters.injector_name >>:latest - docker tag openaev/injector-<< parameters.injector_name >>:${IMAGETAG} openbas/injector-<< parameters.injector_name >>:latest - docker push openaev/injector-<< parameters.injector_name >>:latest - docker push openbas/injector-<< parameters.injector_name >>:latest - fi - - slack/notify: - event: fail - template: basic_fail_1 - deploy_testing: - docker: - - image: cimg/base:current-24.04 - steps: - - checkout - - kubernetes/install-kubectl - - run: kubectl --server=https://api.staging.eu-west.filigran.io --token=$K8S_TOKEN -n customer-testing-oaev rollout restart deployment -l app=openaev-colljector - deploy_prerelease: - docker: - - image: cimg/base:current-24.04 - steps: - - checkout - - kubernetes/install-kubectl - - run: kubectl --server=https://api.staging.eu-west.filigran.io --token=$K8S_TOKEN_PRE_RELEASE -n customer-prerelease-oaev rollout restart deployment -l app=openaev-colljector - notify_rolling: - docker: - - image: cimg/base:current-24.04 - steps: - - run: sudo apt-get update -qq && sudo apt install curl gettext-base - - slack/notify: - event: pass - template: basic_success_1 - notify: - docker: - - image: cimg/base:current-24.04 - steps: - - run: sudo apt-get update -qq && sudo apt install curl gettext-base - - slack/notify: - event: pass - template: basic_success_1 -workflows: - version: 2 - openaev: - jobs: - - ensure_formatting: - filters: - tags: - only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ - - linter: - filters: - tags: - only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ - - test: - matrix: - parameters: - injector_name: ["ai-redteam", "http-query", "netexec", "nmap", "nuclei"] - filters: - tags: - only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ - - build_docker_images: - matrix: - parameters: - injector_name: [ "ai-redteam", "aws", "http-query", "netexec", "nmap", "nuclei", "shodan", "teams" ] - requires: - - ensure_formatting - - linter - - test - filters: - tags: - only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ - - publish_images: - matrix: - parameters: - injector_name: [ "ai-redteam", "aws", "http-query", "netexec", "nmap", "nuclei", "shodan", "teams" ] - requires: - - build_docker_images - filters: - tags: - only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ - branches: - only: - - main - - deploy_testing: - requires: - - publish_images - filters: - branches: - only: main - - notify_rolling: - requires: - - deploy_testing - - notify: - requires: - - publish_images - filters: - tags: - only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ - branches: - ignore: /.*/ +version: 2.1 + +orbs: + slack: circleci/slack@5.2.3 + kubernetes: circleci/kubernetes@1.3.1 + +jobs: + ensure_formatting: + docker: + - image: cimg/python:3.13 + working_directory: ~/repo + steps: + - checkout + - run: sudo apt-get update -qq && sudo apt install curl gettext-base + - run: + name: install dependencies + command: pip install black isort --user + - run: + name: confirm black version + command: black --version + - run: + name: run isort check + command: isort --profile black --check . + - run: + name: run black check + command: black --check . + - slack/notify: + event: fail + template: basic_fail_1 + linter: + docker: + - image: alpine/flake8 + working_directory: ~/repo + steps: + - checkout + - run: apk update && apk upgrade && apk --no-cache add curl gettext bash + - run: + name: flake8 + command: flake8 --ignore=E,W ~/repo + - slack/notify: + event: fail + template: basic_fail_1 + test: + working_directory: ~/openaev + docker: + - image: cimg/python:3.13 + parameters: + injector_name: + type: string + steps: + - checkout + - setup_remote_docker + - run: + working_directory: ~/openaev/<< parameters.injector_name >> + name: Check if injector exists + command: | + if [ ! -f pyproject.toml ]; then + echo "pyproject.toml not found, injector may not exist on this branch" + circleci-agent step halt + fi; + - run: # this is only to satisfy poetry; not used + working_directory: ~/ + name: Clone pyoaev + command: | + git clone -b main https://github.com/OpenAEV-Platform/client-python + - run: + working_directory: ~/openaev + name: Install poetry + command: pip install poetry==2.3.2 && poetry config installer.re-resolve false + - run: + name: Run injector tests + working_directory: ~/openaev/<< parameters.injector_name >> + command: | + PYOAEV_REF="main" + + echo "Running tests for injector: << parameters.injector_name >>" + poetry install + poetry run pip install --force-reinstall git+https://github.com/OpenAEV-Platform/client-python.git@${PYOAEV_REF} + poetry run python -m unittest + + build_docker_images: + working_directory: ~/openaev + docker: + - image: cimg/base:current-24.04 + parameters: + injector_name: + type: string + steps: + - checkout + - run: + working_directory: ~/openaev/<< parameters.injector_name >> + name: Check if injector exists + command: | + if [ ! -f pyproject.toml ]; then + echo "pyproject.toml not found, injector may not exist on this branch" + circleci-agent step halt + fi; + - run: + working_directory: ~/openaev + name: Set semantic version environment + command: | + export LATEST_SEMANTIC_VERSION=$(git tag --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + export IS_LATEST=$([ "$CIRCLE_TAG" = "$LATEST_SEMANTIC_VERSION" ] && echo "true" || echo "false") + echo "IS_LATEST=$IS_LATEST" >> $BASH_ENV + - setup_remote_docker + - run: + name: Install environment + command: | + sudo apt-get update -qq + sudo apt install curl gettext-base + mkdir -p ~/openaev/images + - run: + working_directory: ~/openaev + name: Replace pyoaev requirement of stable version with latest release branch code + # We safely expand the CIRCLE_BRANCH variable because the CircleCI filters already enforce a strict release/x.x.x pattern using regex + command: | + if [ "${CIRCLE_BRANCH}" = "main" ]; then + find . -name pyproject.toml | xargs -I ___ sed "s|branch = 'main'|branch = '${CIRCLE_BRANCH}'|" -i ___; + fi; + - run: + name: Build injector Docker images + working_directory: ~/openaev/<< parameters.injector_name >> + command: | + echo "Building injector: << parameters.injector_name >>" + + if [[ "${CIRCLE_BRANCH}" == "main" ]]; then + docker build --pull --progress=plain --build-context injector_common=../injector_common -t openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} --build-arg PYOAEV_GIT_BRANCH_OVERRIDE="${CIRCLE_BRANCH}" . + else + docker build --pull --progress=plain --build-context injector_common=../injector_common -t openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} . + fi + + docker save -o ~/openaev/images/injector-<< parameters.injector_name >> openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} + - persist_to_workspace: + root: ~/openaev + paths: + - images + - slack/notify: + event: fail + template: basic_fail_1 + publish_images: + working_directory: ~/openaev + docker: + - image: cimg/base:current-24.04 + parameters: + injector_name: + type: string + steps: + - checkout + - run: + working_directory: ~/openaev/<< parameters.injector_name >> + name: Check if injector exists + command: | + if [ ! -f pyproject.toml ]; then + echo "pyproject.toml not found, injector may not exist on this branch" + circleci-agent step halt + fi; + - run: + working_directory: ~/openaev + name: Set semantic version environment + command: | + export LATEST_SEMANTIC_VERSION=$(git tag --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + export IS_LATEST=$([ "$CIRCLE_TAG" = "$LATEST_SEMANTIC_VERSION" ] && echo "true" || echo "false") + echo "IS_LATEST=$IS_LATEST" >> $BASH_ENV + - attach_workspace: + at: . + - setup_remote_docker + - run: + working_directory: ~/openaev/images + name: Restore saved images and publish + command: | + if [ "${CIRCLE_TAG}" != "" ] + then + export IMAGETAG=${CIRCLE_TAG} + elif [ "${CIRCLE_BRANCH}" = "main" ] + then + export IMAGETAG="rolling" + else + echo "The tag supplied or branch is not 'main' (was: '${CIRCLE_BRANCH}')" + exit 1 + fi + echo "Image tag: ${IMAGETAG}" + + docker image load < injector-<< parameters.injector_name >> + docker tag openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} openaev/injector-<< parameters.injector_name >>:${IMAGETAG} + docker tag openaev/injector-<< parameters.injector_name >>:${CIRCLE_SHA1} openbas/injector-<< parameters.injector_name >>:${IMAGETAG} + + echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + + docker push openaev/injector-<< parameters.injector_name >>:${IMAGETAG} + docker push openbas/injector-<< parameters.injector_name >>:${IMAGETAG} + + if [ "${IS_LATEST}" == "true" ] + then + docker tag openaev/injector-<< parameters.injector_name >>:${IMAGETAG} openaev/injector-<< parameters.injector_name >>:latest + docker tag openaev/injector-<< parameters.injector_name >>:${IMAGETAG} openbas/injector-<< parameters.injector_name >>:latest + docker push openaev/injector-<< parameters.injector_name >>:latest + docker push openbas/injector-<< parameters.injector_name >>:latest + fi + - slack/notify: + event: fail + template: basic_fail_1 + deploy_testing: + docker: + - image: cimg/base:current-24.04 + steps: + - checkout + - kubernetes/install-kubectl + - run: kubectl --server=https://api.staging.eu-west.filigran.io --token=$K8S_TOKEN -n customer-testing-oaev rollout restart deployment -l app=openaev-colljector + deploy_prerelease: + docker: + - image: cimg/base:current-24.04 + steps: + - checkout + - kubernetes/install-kubectl + - run: kubectl --server=https://api.staging.eu-west.filigran.io --token=$K8S_TOKEN_PRE_RELEASE -n customer-prerelease-oaev rollout restart deployment -l app=openaev-colljector + notify_rolling: + docker: + - image: cimg/base:current-24.04 + steps: + - run: sudo apt-get update -qq && sudo apt install curl gettext-base + - slack/notify: + event: pass + template: basic_success_1 + notify: + docker: + - image: cimg/base:current-24.04 + steps: + - run: sudo apt-get update -qq && sudo apt install curl gettext-base + - slack/notify: + event: pass + template: basic_success_1 +workflows: + version: 2 + openaev: + jobs: + - ensure_formatting: + filters: + tags: + only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ + - linter: + filters: + tags: + only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ + - test: + matrix: + parameters: + injector_name: ["ai-redteam", "http-query", "netexec", "nmap", "nuclei", "gophish"] + filters: + tags: + only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ + - build_docker_images: + matrix: + parameters: + injector_name: [ "ai-redteam", "aws", "http-query", "netexec", "nmap", "nuclei", "shodan", "teams", "gophish" ] + requires: + - ensure_formatting + - linter + - test + filters: + tags: + only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ + - publish_images: + matrix: + parameters: + injector_name: [ "ai-redteam", "aws", "http-query", "netexec", "nmap", "nuclei", "shodan", "teams", "gophish" ] + requires: + - build_docker_images + filters: + tags: + only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ + branches: + only: + - main + - deploy_testing: + requires: + - publish_images + filters: + branches: + only: main + - notify_rolling: + requires: + - deploy_testing + - notify: + requires: + - publish_images + filters: + tags: + only: /[0-9]+(\.[0-9]+)+(\.[0-9]+)*/ + branches: + ignore: /.*/ diff --git a/gophish/.dockerignore b/gophish/.dockerignore new file mode 100644 index 00000000..68a127f7 --- /dev/null +++ b/gophish/.dockerignore @@ -0,0 +1,2 @@ +config.yml +**/__pycache__ diff --git a/gophish/.env.sample b/gophish/.env.sample new file mode 100644 index 00000000..478523ef --- /dev/null +++ b/gophish/.env.sample @@ -0,0 +1,20 @@ +# OPENAEV Environment Variables +# base URL to reach the OpenAEV server +# note this URL must be routable from inside the container +# so `localhost` will most likely not work +OPENAEV_URL=ChangeMe +# admin account API token from the OpenAEV server +OPENAEV_TOKEN=ChangeMe +OPENAEV_TENANT_ID=ChangeMe + +# INJECTOR Environment Variables +INJECTOR_ID=gophish--ChangeMe +INJECTOR_NAME=Gophish +INJECTOR_LOG_LEVEL=error + +# GOPHISH Environment Variables +GOPHISH_BASE_URL=https://localhost:3333 +GOPHISH_API_KEY=ChangeMe +# Verify the Gophish server TLS certificate (secure by default). +# Set to false only for self-signed or local development servers. +GOPHISH_VERIFY_TLS=true diff --git a/gophish/Dockerfile b/gophish/Dockerfile new file mode 100644 index 00000000..8a568881 --- /dev/null +++ b/gophish/Dockerfile @@ -0,0 +1,52 @@ +FROM python:3.13-alpine AS builder + +ENV PIP_VERSION=25.0.1 + +RUN apk update && apk upgrade && apk add git curl + +WORKDIR /opt/injector_common +COPY --from=injector_common ./ ./ + +WORKDIR / +RUN git clone https://github.com/OpenAEV-Platform/client-python + +RUN curl -sS https://bootstrap.pypa.io/get-pip.py -o get-pip.py && \ + python3 get-pip.py pip==${PIP_VERSION} && \ + rm get-pip.py + +RUN python3 -m pip install poetry==2.3.2 \ + && poetry config installer.re-resolve false \ + && poetry config virtualenvs.create false + +ARG installdir=/opt/injector +ADD . ${installdir} +WORKDIR ${installdir} +RUN poetry install && \ + python3 -m pip install --no-cache-dir pip==${PIP_VERSION} + +FROM python:3.13-alpine AS runner + +ENV PIP_VERSION=25.0.1 + +WORKDIR /opt/injector_common +COPY --from=injector_common ./ ./ + +ARG installdir=/opt/injector +WORKDIR ${installdir} +COPY --from=builder ${installdir} ${installdir} +COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages + +ARG PYOAEV_GIT_BRANCH_OVERRIDE + +RUN if [ -n "${PYOAEV_GIT_BRANCH_OVERRIDE}" ] ; then \ + echo "Forcing specific version of client-python" && \ + apk add --no-cache git curl && \ + curl -sS https://bootstrap.pypa.io/get-pip.py -o get-pip.py && \ + python3 get-pip.py pip==${PIP_VERSION} && \ + rm get-pip.py && \ + pip install pip3-autoremove && \ + pip-autoremove pyoaev -y && \ + pip install git+https://github.com/OpenAEV-Platform/client-python@${PYOAEV_GIT_BRANCH_OVERRIDE} ; \ + fi + +CMD ["python3", "-m", "gophish_injector.openaev_gophish"] diff --git a/gophish/README.md b/gophish/README.md new file mode 100644 index 00000000..db411869 --- /dev/null +++ b/gophish/README.md @@ -0,0 +1,30 @@ +# OpenAEV Gophish Injector + +Launches phishing simulation campaigns through a [Gophish](https://getgophish.com/) +server and reports open / click / credential-submission activity as +human-response expectations. + +For a self-contained option that needs no external server, see the native +`phishing` injector. + +## How it works + +- Gophish server connection (`GOPHISH_BASE_URL`, `GOPHISH_API_KEY`) is injector + configuration; per-campaign parameters (template, landing page, sending + profile, target group, URL) are contract fields. +- The injector creates and launches the campaign via `POST /api/campaigns/` and + reports the initial campaign id and stats. Open/click/submit progress is read + from the campaign `stats`. + +## Development + +```bash +poetry install +poetry run python -m unittest +``` + +## Icon + +`gophish_injector/img/icon-gophish.png` must follow the injector icon standard +(square 1:1, 512x512 PNG, solid opaque background, genuine Gophish artwork) - +see OpenAEV-Platform/injectors#305. diff --git a/gophish/config.yml.sample b/gophish/config.yml.sample new file mode 100644 index 00000000..368abc68 --- /dev/null +++ b/gophish/config.yml.sample @@ -0,0 +1,16 @@ +openaev: + url: 'http://localhost:3001' + token: 'ChangeMe' +# tenant_id: 'ChangeMe' + +injector: + id: 'ChangeMe' + name: 'Gophish' + log_level: 'error' + +gophish: + base_url: 'https://localhost:3333' + api_key: 'ChangeMe' + # Verify the Gophish server TLS certificate (secure by default). + # Set to false only for self-signed or local development servers. + verify_tls: true diff --git a/gophish/docker-compose.yml b/gophish/docker-compose.yml new file mode 100644 index 00000000..1b09d7c9 --- /dev/null +++ b/gophish/docker-compose.yml @@ -0,0 +1,14 @@ +services: + injector-gophish: + image: openaev/injector-gophish:latest + environment: + - OPENAEV_URL=${OPENAEV_URL} + - OPENAEV_TOKEN=${OPENAEV_TOKEN} + - OPENAEV_TENANT_ID=${OPENAEV_TENANT_ID} + - INJECTOR_ID=${INJECTOR_ID} + - INJECTOR_NAME=${INJECTOR_NAME} + - INJECTOR_LOG_LEVEL=${INJECTOR_LOG_LEVEL} + - GOPHISH_BASE_URL=${GOPHISH_BASE_URL} + - GOPHISH_API_KEY=${GOPHISH_API_KEY} + - GOPHISH_VERIFY_TLS=${GOPHISH_VERIFY_TLS} + restart: always diff --git a/gophish/gophish_injector/__init__.py b/gophish/gophish_injector/__init__.py new file mode 100644 index 00000000..02bb6f36 --- /dev/null +++ b/gophish/gophish_injector/__init__.py @@ -0,0 +1,2 @@ +# OpenAEV Gophish Phishing Injector +__version__ = "1.0.0" diff --git a/gophish/gophish_injector/client/__init__.py b/gophish/gophish_injector/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gophish/gophish_injector/client/gophish_client.py b/gophish/gophish_injector/client/gophish_client.py new file mode 100644 index 00000000..13295c2a --- /dev/null +++ b/gophish/gophish_injector/client/gophish_client.py @@ -0,0 +1,142 @@ +"""Thin client over the Gophish REST API.""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Dict, Optional + +import requests + + +@dataclass +class CampaignResult: + success: bool + message: str + campaign_id: int = 0 + stats: Dict[str, int] = field(default_factory=dict) + + +class GophishClient: + def __init__( + self, + base_url: str, + api_key: str, + verify_tls: bool = True, + timeout: int = 60, + logger=None, + ): + self.base_url = base_url.rstrip("/") + self.verify_tls = verify_tls + self.timeout = timeout + self.logger = logger + self._headers = {"Authorization": api_key} + + def create_campaign( + self, + name: str, + template_name: str, + page_name: str, + smtp_name: str, + group_name: str, + url: str, + launch_date: Optional[str] = None, + ) -> CampaignResult: + """Create and launch a campaign referencing existing Gophish objects. + + Gophish resolves the template, landing page, sending profile and target + group by name, so those objects must already exist on the server. + + ``launch_date`` is sent in ISO 8601 form. When omitted it defaults to + the current UTC time so the campaign launches immediately with an + explicit, unambiguous schedule instead of relying on the server clock. + """ + payload = { + "name": name, + "template": {"name": template_name}, + "page": {"name": page_name}, + "smtp": {"name": smtp_name}, + "url": url, + "groups": [{"name": group_name}], + "launch_date": launch_date + or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + try: + response = requests.post( + f"{self.base_url}/api/campaigns/", + json=payload, + headers=self._headers, + verify=self.verify_tls, + timeout=self.timeout, + ) + response.raise_for_status() + body = response.json() + except requests.HTTPError as exc: + return self._error_result("Gophish campaign creation failed", exc) + except requests.RequestException as exc: + return self._error_result("Gophish request error", exc) + + return CampaignResult( + success=True, + message=f"Launched Gophish campaign '{name}' (id {body.get('id')})", + campaign_id=int(body.get("id", 0)), + stats=self._extract_stats(body), + ) + + def get_campaign_stats(self, campaign_id: int) -> CampaignResult: + try: + response = requests.get( + f"{self.base_url}/api/campaigns/{campaign_id}", + headers=self._headers, + verify=self.verify_tls, + timeout=self.timeout, + ) + response.raise_for_status() + body = response.json() + except requests.HTTPError as exc: + return self._error_result("Gophish stats fetch failed", exc) + except requests.RequestException as exc: + return self._error_result("Gophish request error", exc) + + return CampaignResult( + success=True, + message=f"Fetched stats for campaign {campaign_id}", + campaign_id=campaign_id, + stats=self._extract_stats(body), + ) + + def _error_result( + self, prefix: str, exc: requests.RequestException + ) -> CampaignResult: + """Build a failure result, enriching HTTP errors with the response body. + + Gophish returns the actionable reason (missing template / page / group, + permission or 404 details) in the response body, so it is appended - + trimmed - to the message and logged at error level for operators. + """ + message = f"{prefix}: {exc}" + body = self._response_body(exc) + if body: + message = f"{message} - {body}" + if self.logger is not None: + self.logger.error(message) + return CampaignResult(False, message) + + @staticmethod + def _response_body(exc: requests.RequestException, limit: int = 500) -> str: + response = getattr(exc, "response", None) + if response is None: + return "" + text = (response.text or "").strip() + if len(text) > limit: + text = text[:limit] + "..." + return text + + @staticmethod + def _extract_stats(body: Dict) -> Dict[str, int]: + stats = body.get("stats") or {} + return { + "total": int(stats.get("total", 0)), + "sent": int(stats.get("sent", 0)), + "opened": int(stats.get("opened", 0)), + "clicked": int(stats.get("clicked", 0)), + "submitted_data": int(stats.get("submitted_data", 0)), + } diff --git a/gophish/gophish_injector/configuration/__init__.py b/gophish/gophish_injector/configuration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gophish/gophish_injector/configuration/config_loader.py b/gophish/gophish_injector/configuration/config_loader.py new file mode 100644 index 00000000..12a00010 --- /dev/null +++ b/gophish/gophish_injector/configuration/config_loader.py @@ -0,0 +1,29 @@ +from gophish_injector.configuration.gophish_config import GophishConfig +from gophish_injector.configuration.injector_config_override import ( + InjectorConfigOverride, +) +from gophish_injector.contracts_gophish import GophishContracts +from pydantic import Field +from pyoaev.configuration import ConfigLoaderOAEV, Configuration, SettingsLoader + + +class ConfigLoader(SettingsLoader): + openaev: ConfigLoaderOAEV = Field(default_factory=ConfigLoaderOAEV) + injector: InjectorConfigOverride = Field(default_factory=InjectorConfigOverride) + gophish: GophishConfig = Field(default_factory=GophishConfig) + + def to_daemon_config(self) -> Configuration: + return Configuration( + config_hints={ + "openaev_url": {"data": str(self.openaev.url)}, + "openaev_token": {"data": self.openaev.token}, + "openaev_tenant_id": {"data": self.openaev.tenant_id}, + "injector_id": {"data": self.injector.id}, + "injector_name": {"data": self.injector.name}, + "injector_type": {"data": "openaev_gophish"}, + "injector_contracts": {"data": GophishContracts.build_contract()}, + "injector_log_level": {"data": self.injector.log_level}, + "injector_icon_filepath": {"data": self.injector.icon_filepath}, + }, + config_base_model=self, + ) diff --git a/gophish/gophish_injector/configuration/gophish_config.py b/gophish/gophish_injector/configuration/gophish_config.py new file mode 100644 index 00000000..1178305e --- /dev/null +++ b/gophish/gophish_injector/configuration/gophish_config.py @@ -0,0 +1,21 @@ +from pydantic import Field, SecretStr +from pydantic_settings import BaseSettings + + +class GophishConfig(BaseSettings): + """Gophish server connection settings.""" + + base_url: str = Field( + default="https://localhost:3333", + description="Base URL of the Gophish admin server.", + ) + api_key: SecretStr = Field( + description="Gophish API key (Settings page).", + ) + verify_tls: bool = Field( + default=True, + description=( + "Verify the Gophish server TLS certificate. Secure by default; set " + "to false only for self-signed or local development servers." + ), + ) diff --git a/gophish/gophish_injector/configuration/injector_config_override.py b/gophish/gophish_injector/configuration/injector_config_override.py new file mode 100644 index 00000000..161661fa --- /dev/null +++ b/gophish/gophish_injector/configuration/injector_config_override.py @@ -0,0 +1,16 @@ +from pydantic import Field +from pyoaev.configuration import ConfigLoaderCollector + + +class InjectorConfigOverride(ConfigLoaderCollector): + id: str = Field( + description="A unique UUIDv4 identifier for this injector instance.", + ) + name: str = Field( + default="Gophish", + description="Name of the injector.", + ) + icon_filepath: str | None = Field( + default="gophish_injector/img/icon-gophish.png", + description="Path to the icon file", + ) diff --git a/gophish/gophish_injector/contracts_gophish.py b/gophish/gophish_injector/contracts_gophish.py new file mode 100644 index 00000000..7aa7f5bb --- /dev/null +++ b/gophish/gophish_injector/contracts_gophish.py @@ -0,0 +1,101 @@ +from typing import List + +from pyoaev.contracts import ContractBuilder +from pyoaev.contracts.contract_config import ( + Contract, + ContractCardinality, + ContractConfig, + ContractElement, + ContractExpectations, + ContractText, + Expectation, + ExpectationType, + SupportedLanguage, + prepare_contracts, +) +from pyoaev.security_domain.types import SecurityDomains + +TYPE = "openaev_gophish" + +GOPHISH_CAMPAIGN_CONTRACT = "b2e3f4a5-5f66-4b73-9a81-6d7e8f9a0b12" + + +class GophishContracts: + @staticmethod + def build_contract(): + contract_config = ContractConfig( + type=TYPE, + label={ + SupportedLanguage.en: "Gophish", + SupportedLanguage.fr: "Gophish", + }, + color_dark="#2b8a3e", + color_light="#2b8a3e", + expose=True, + ) + + campaign_name = ContractText( + key="campaign_name", label="Campaign name", mandatory=True + ) + template_name = ContractText( + key="template_name", label="Email template name", mandatory=True + ) + page_name = ContractText( + key="page_name", label="Landing page name", mandatory=True + ) + smtp_name = ContractText( + key="smtp_name", label="Sending profile (SMTP) name", mandatory=True + ) + group_name = ContractText( + key="group_name", label="Target group name", mandatory=True + ) + url = ContractText( + key="url", label="Phishing URL (recipient landing base URL)", mandatory=True + ) + + expectations = ContractExpectations( + key="expectations", + label="Expectations", + mandatory=False, + cardinality=ContractCardinality.Multiple, + predefinedExpectations=[ + Expectation( + expectation_type=ExpectationType.manual, + expectation_name="Human Response", + expectation_description="Recipient opened / clicked / submitted.", + expectation_score=100, + expectation_expectation_group=False, + ) + ], + ) + + fields: List[ContractElement] = ( + ContractBuilder() + .add_fields( + [ + campaign_name, + template_name, + page_name, + smtp_name, + group_name, + url, + expectations, + ] + ) + .build_fields() + ) + + contract = Contract( + contract_id=GOPHISH_CAMPAIGN_CONTRACT, + config=contract_config, + label={ + SupportedLanguage.en: "Gophish - Launch phishing campaign", + SupportedLanguage.fr: "Gophish - Lancer une campagne de phishing", + }, + fields=fields, + outputs=ContractBuilder().build_outputs(), + manual=False, + domains=[SecurityDomains.EMAIL_INFILTRATION.value], + ) + + return prepare_contracts([contract]) diff --git a/gophish/gophish_injector/img/README.md b/gophish/gophish_injector/img/README.md new file mode 100644 index 00000000..b91a2627 --- /dev/null +++ b/gophish/gophish_injector/img/README.md @@ -0,0 +1,10 @@ +# Icon asset required + +Place the genuine Gophish product icon here as `icon-gophish.png`. + +Per OpenAEV-Platform/injectors#305 the icon must be: + +- Square 1:1, 512x512 PNG +- Solid opaque background (no transparency) +- Centered mark with ~14% padding +- Genuine brand artwork only (never invented / upscaled from low-res) diff --git a/gophish/gophish_injector/openaev_gophish.py b/gophish/gophish_injector/openaev_gophish.py new file mode 100644 index 00000000..5e58b534 --- /dev/null +++ b/gophish/gophish_injector/openaev_gophish.py @@ -0,0 +1,130 @@ +import json +import time +from typing import Dict + +from gophish_injector.client.gophish_client import GophishClient +from gophish_injector.configuration.config_loader import ConfigLoader +from gophish_injector.contracts_gophish import GOPHISH_CAMPAIGN_CONTRACT +from pyoaev.helpers import OpenAEVConfigHelper, OpenAEVInjectorHelper + +from injector_common.data_helpers import DataHelpers +from injector_common.dump_config import intercept_dump_argument + +ICON_PATH = "gophish_injector/img/icon-gophish.png" + +REQUIRED_CONTENT_FIELDS = ( + "campaign_name", + "template_name", + "page_name", + "smtp_name", + "group_name", + "url", +) + + +class OpenAEVGophish: + def __init__(self): + self.config_loader = ConfigLoader() + self.config = OpenAEVConfigHelper.from_configuration_object( + self.config_loader.to_daemon_config() + ) + intercept_dump_argument(self.config.get_config_obj()) + icon_bytes = self._load_icon() + self.helper = OpenAEVInjectorHelper(self.config, icon_bytes) + if not icon_bytes: + self.helper.injector_logger.warning( + "Icon file '%s' not found; registering the injector without an " + "icon (pending the icon standard, see #305)." % ICON_PATH + ) + gophish_conf = self.config_loader.gophish + self.client = GophishClient( + base_url=gophish_conf.base_url, + api_key=gophish_conf.api_key.get_secret_value(), + verify_tls=gophish_conf.verify_tls, + logger=self.helper.injector_logger, + ) + + @staticmethod + def _load_icon() -> bytes: + """Read the injector icon, tolerating a missing placeholder file. + + The genuine icon is tracked separately (see #305); until it lands the + injector must still start instead of crashing with FileNotFoundError. + """ + try: + with open(ICON_PATH, "rb") as icon_file: + return icon_file.read() + except FileNotFoundError: + return b"" + + @staticmethod + def _validate_content(content: Dict) -> None: + missing = [field for field in REQUIRED_CONTENT_FIELDS if not content.get(field)] + if missing: + raise ValueError( + "Missing required Gophish campaign fields: " + ", ".join(missing) + ) + + def process_message(self, data: Dict) -> None: + start = time.time() + inject_id = DataHelpers.get_inject_id(data) + self.helper.api.inject.execution_reception( + inject_id=inject_id, data={"tracking_total_count": 1} + ) + + try: + contract_id = DataHelpers.get_injector_contract_id(data) + if contract_id != GOPHISH_CAMPAIGN_CONTRACT: + raise ValueError( + "Unsupported injector contract '%s'; this injector only " + "handles the Gophish campaign contract." % contract_id + ) + content = DataHelpers.get_content(data) + self._validate_content(content) + result = self.client.create_campaign( + name=content.get("campaign_name"), + template_name=content.get("template_name"), + page_name=content.get("page_name"), + smtp_name=content.get("smtp_name"), + group_name=content.get("group_name"), + url=content.get("url"), + ) + + callback_data = { + "execution_message": result.message, + "execution_status": "SUCCESS" if result.success else "ERROR", + "execution_duration": int(time.time() - start), + "execution_action": "complete", + } + if result.success: + callback_data["execution_output_structured"] = json.dumps( + { + "campaign_id": result.campaign_id, + "stats": result.stats, + } + ) + self.helper.api.inject.execution_callback( + inject_id=inject_id, data=callback_data + ) + except Exception as e: + self.helper.injector_logger.error( + "Gophish injection failed", + {"inject_id": inject_id, "error": str(e)}, + ) + self.helper.api.inject.execution_callback( + inject_id=inject_id, + data={ + "execution_message": str(e), + "execution_status": "ERROR", + "execution_duration": int(time.time() - start), + "execution_action": "complete", + }, + ) + + def start(self): + self.helper.injector_logger.info("Starting Gophish injector...") + self.helper.listen(message_callback=self.process_message) + + +if __name__ == "__main__": + OpenAEVGophish().start() diff --git a/gophish/manifest-metadata.json b/gophish/manifest-metadata.json new file mode 100644 index 00000000..4fa0b10d --- /dev/null +++ b/gophish/manifest-metadata.json @@ -0,0 +1,18 @@ +{ + "title": "Gophish", + "slug": "openaev_gophish", + "description": "This injector launches phishing simulation campaigns through a Gophish server and reports open, click and credential-submission activity as human-response expectations.", + "short_description": "Run phishing campaigns with Gophish", + "use_cases": ["Technical"], + "verified": false, + "last_verified_date": "", + "playbook_supported": false, + "max_confidence_level": 80, + "support_version": "", + "subscription_link": "https://getgophish.com/", + "source_code": "", + "manager_supported": true, + "container_version": "rolling", + "container_image": "openaev/injector-gophish", + "container_type": "INJECTOR" +} diff --git a/gophish/pyproject.toml b/gophish/pyproject.toml new file mode 100644 index 00000000..80d37c3c --- /dev/null +++ b/gophish/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "openaev-gophish-injector" +version = "2.260710.0" +description = "An injector for running phishing campaigns via Gophish" +license = "Apache-2.0" +readme = "README.md" +authors = [ + { name = "Filigran", email = "contact@filigran.io" }, +] +requires-python = ">=3.11,<4.0" +dependencies = [ + "pyoaev (==2.260710.0); extra != 'dev'", + "injector_common @ ../injector_common", + "pydantic (>=2.11.3,<2.14.0)", + "pydantic-settings (>=2.11.0,<2.15.0)", + "requests~=2.33", +] + +[project.optional-dependencies] +dev = [ + "pyoaev @ ../../client-python", + "black (>=26.3.1,<26.4.0)", + "isort (>=8.0.1,<8.1.0)", + "ruff~=0.14.2", + "mypy~=1.18.2", + "pip_audit~=2.9.0", + "pre-commit (>=4.6.0,<4.7.0)", + "types-PyYAML~=6.0.12", + "types-requests~=2.32", + "coverage>=7.13.5", + "pytest (>=9.0.0,<9.1.0)", + "polyfactory~=2.22.2", +] + +[tool.poetry] +packages = [ + { include = "gophish_injector" }, +] + +[tool.poetry.dependencies] +pyoaev = [ + { markers = "extra != 'dev'", source = "PyPI" }, + { markers = "extra == 'dev'", develop = true }, +] + +[tool.poetry.dependencies.injector_common] +develop = true + +[tool.isort] +profile = "black" +known_local_folder = [ + "gophish_injector", +] + +[tool.cmw] +install-command = "poetry install" +config-dump-command = "poetry run python -m gophish_injector.openaev_gophish --dump-config-schema" +icon-path = "gophish_injector/img/icon-gophish.png" + +[build-system] +requires = [ + "poetry-core", +] +build-backend = "poetry.core.masonry.api" diff --git a/gophish/test/__init__.py b/gophish/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gophish/test/test_gophish.py b/gophish/test/test_gophish.py new file mode 100644 index 00000000..b2f16b1a --- /dev/null +++ b/gophish/test/test_gophish.py @@ -0,0 +1,117 @@ +from unittest import TestCase +from unittest.mock import MagicMock, patch + +import requests +from gophish_injector.client.gophish_client import GophishClient +from gophish_injector.contracts_gophish import ( + GOPHISH_CAMPAIGN_CONTRACT, + GophishContracts, +) + + +class ContractsTest(TestCase): + def test_build_contract(self): + contracts = GophishContracts.build_contract() + self.assertEqual(len(contracts), 1) + self.assertEqual(contracts[0]["contract_id"], GOPHISH_CAMPAIGN_CONTRACT) + + +class ClientTest(TestCase): + @patch("gophish_injector.client.gophish_client.requests.post") + def test_create_campaign_success(self, mock_post): + response = MagicMock() + response.json.return_value = { + "id": 7, + "stats": { + "total": 10, + "sent": 10, + "opened": 3, + "clicked": 1, + "submitted_data": 0, + }, + } + mock_post.return_value = response + + client = GophishClient("https://gophish:3333", "key") + result = client.create_campaign( + name="c", + template_name="t", + page_name="p", + smtp_name="s", + group_name="g", + url="http://phish", + ) + self.assertTrue(result.success) + self.assertEqual(result.campaign_id, 7) + self.assertEqual(result.stats["opened"], 3) + sent_payload = mock_post.call_args.kwargs["json"] + self.assertIn("launch_date", sent_payload) + self.assertTrue(sent_payload["launch_date"]) + self.assertEqual(sent_payload["groups"], [{"name": "g"}]) + + @patch("gophish_injector.client.gophish_client.requests.post") + def test_create_campaign_verify_tls_default(self, mock_post): + response = MagicMock() + response.json.return_value = {"id": 1, "stats": {}} + mock_post.return_value = response + + client = GophishClient("https://gophish:3333", "key") + client.create_campaign( + name="c", + template_name="t", + page_name="p", + smtp_name="s", + group_name="g", + url="http://phish", + ) + self.assertTrue(mock_post.call_args.kwargs["verify"]) + + @patch("gophish_injector.client.gophish_client.requests.get") + def test_get_stats(self, mock_get): + response = MagicMock() + response.json.return_value = {"stats": {"clicked": 5}} + mock_get.return_value = response + + client = GophishClient("https://gophish:3333", "key") + result = client.get_campaign_stats(7) + self.assertTrue(result.success) + self.assertEqual(result.stats["clicked"], 5) + + @patch("gophish_injector.client.gophish_client.requests.post") + def test_create_campaign_http_error_includes_body_and_logs(self, mock_post): + error_response = MagicMock() + error_response.text = "Template not found" + http_error = requests.HTTPError("400 Client Error") + http_error.response = error_response + response = MagicMock() + response.raise_for_status.side_effect = http_error + mock_post.return_value = response + + logger = MagicMock() + client = GophishClient("https://gophish:3333", "key", logger=logger) + result = client.create_campaign( + name="c", + template_name="t", + page_name="p", + smtp_name="s", + group_name="g", + url="http://phish", + ) + self.assertFalse(result.success) + self.assertIn("Template not found", result.message) + logger.error.assert_called_once() + + @patch("gophish_injector.client.gophish_client.requests.get") + def test_get_stats_http_error_includes_body(self, mock_get): + error_response = MagicMock() + error_response.text = "campaign not found" + http_error = requests.HTTPError("404 Client Error") + http_error.response = error_response + response = MagicMock() + response.raise_for_status.side_effect = http_error + mock_get.return_value = response + + client = GophishClient("https://gophish:3333", "key") + result = client.get_campaign_stats(7) + self.assertFalse(result.success) + self.assertIn("campaign not found", result.message) diff --git a/gophish/test/test_injector.py b/gophish/test/test_injector.py new file mode 100644 index 00000000..6f90d86d --- /dev/null +++ b/gophish/test/test_injector.py @@ -0,0 +1,122 @@ +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock, mock_open, patch + +import gophish_injector.openaev_gophish as mod +from gophish_injector.client.gophish_client import CampaignResult +from gophish_injector.contracts_gophish import GOPHISH_CAMPAIGN_CONTRACT + +BASE_ENV = { + "OPENAEV_URL": "http://localhost:3001", + "OPENAEV_TOKEN": "token", + "INJECTOR_ID": "gophish--test", + "GOPHISH_API_KEY": "gophish-key", +} + + +def make_injector(): + with patch.dict(os.environ, BASE_ENV, clear=False), patch.object( + mod, "OpenAEVInjectorHelper" + ), patch.object(mod, "OpenAEVConfigHelper"), patch.object( + mod, "intercept_dump_argument" + ), patch.object( + mod, "open", mock_open(read_data=b"icon"), create=True + ): + injector = mod.OpenAEVGophish() + injector.helper = MagicMock() + return injector + + +def _data(content): + return { + "injection": { + "inject_id": "i1", + "inject_injector_contract": { + "injector_contract_id": GOPHISH_CAMPAIGN_CONTRACT + }, + "inject_content": content, + } + } + + +CONTENT = { + "campaign_name": "c", + "template_name": "t", + "page_name": "p", + "smtp_name": "s", + "group_name": "g", + "url": "http://phish", +} + + +class ProcessMessageTest(TestCase): + def _callback(self, injector): + return injector.helper.api.inject.execution_callback.call_args.kwargs["data"] + + def test_success(self): + injector = make_injector() + injector.client = MagicMock() + injector.client.create_campaign.return_value = CampaignResult( + True, "launched", campaign_id=7, stats={"opened": 1} + ) + injector.process_message(_data(CONTENT)) + callback = self._callback(injector) + self.assertEqual(callback["execution_status"], "SUCCESS") + structured = json.loads(callback["execution_output_structured"]) + self.assertEqual(structured, {"campaign_id": 7, "stats": {"opened": 1}}) + + def test_failure(self): + injector = make_injector() + injector.client = MagicMock() + injector.client.create_campaign.return_value = CampaignResult( + False, "bad request" + ) + injector.process_message(_data(CONTENT)) + self.assertEqual(self._callback(injector)["execution_status"], "ERROR") + + def test_exception_is_logged(self): + injector = make_injector() + injector.client = MagicMock() + injector.client.create_campaign.side_effect = RuntimeError("boom") + injector.process_message(_data(CONTENT)) + self.assertEqual(self._callback(injector)["execution_status"], "ERROR") + injector.helper.injector_logger.error.assert_called_once() + + def test_missing_required_field(self): + injector = make_injector() + injector.client = MagicMock() + content = {key: value for key, value in CONTENT.items() if key != "url"} + injector.process_message(_data(content)) + callback = self._callback(injector) + self.assertEqual(callback["execution_status"], "ERROR") + self.assertIn("url", callback["execution_message"]) + injector.client.create_campaign.assert_not_called() + + def test_unsupported_contract(self): + injector = make_injector() + injector.client = MagicMock() + data = _data(CONTENT) + data["injection"]["inject_injector_contract"][ + "injector_contract_id" + ] = "00000000-0000-0000-0000-000000000000" + injector.process_message(data) + callback = self._callback(injector) + self.assertEqual(callback["execution_status"], "ERROR") + self.assertIn("Unsupported injector contract", callback["execution_message"]) + injector.client.create_campaign.assert_not_called() + + def test_start_listens(self): + injector = make_injector() + injector.start() + injector.helper.listen.assert_called_once() + + +class LoadIconTest(TestCase): + def test_missing_icon_returns_empty_bytes(self): + with patch.object(mod, "open", side_effect=FileNotFoundError, create=True): + self.assertEqual(mod.OpenAEVGophish._load_icon(), b"") + + def test_present_icon_returns_bytes(self): + with patch.object(mod, "open", mock_open(read_data=b"icon"), create=True): + self.assertEqual(mod.OpenAEVGophish._load_icon(), b"icon")