From 5b86b4711d562698d3e784c87fb6d426d468c6a8 Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Tue, 14 Jul 2026 11:32:29 +0200 Subject: [PATCH 1/7] feat(gophish): add Gophish phishing campaign injector (#311) Launches phishing campaigns through a Gophish server and reports open/click/submit stats as human-response expectations. Wires the injector into the CircleCI build, publish and test matrices. --- .circleci/config.yml | 580 +++++++++--------- gophish/.dockerignore | 2 + gophish/.env.sample | 17 + gophish/Dockerfile | 52 ++ gophish/README.md | 30 + gophish/config.yml.sample | 14 + gophish/docker-compose.yml | 13 + gophish/gophish_injector/__init__.py | 2 + gophish/gophish_injector/client/__init__.py | 0 .../gophish_injector/client/gophish_client.py | 107 ++++ .../configuration/__init__.py | 0 .../configuration/config_loader.py | 30 + .../configuration/gophish_config.py | 18 + .../configuration/injector_config_override.py | 16 + gophish/gophish_injector/contracts_gophish.py | 101 +++ gophish/gophish_injector/img/README.md | 10 + gophish/gophish_injector/openaev_gophish.py | 84 +++ gophish/manifest-metadata.json | 18 + gophish/pyproject.toml | 64 ++ gophish/test/__init__.py | 0 gophish/test/test_gophish.py | 56 ++ 21 files changed, 924 insertions(+), 290 deletions(-) create mode 100644 gophish/.dockerignore create mode 100644 gophish/.env.sample create mode 100644 gophish/Dockerfile create mode 100644 gophish/README.md create mode 100644 gophish/config.yml.sample create mode 100644 gophish/docker-compose.yml create mode 100644 gophish/gophish_injector/__init__.py create mode 100644 gophish/gophish_injector/client/__init__.py create mode 100644 gophish/gophish_injector/client/gophish_client.py create mode 100644 gophish/gophish_injector/configuration/__init__.py create mode 100644 gophish/gophish_injector/configuration/config_loader.py create mode 100644 gophish/gophish_injector/configuration/gophish_config.py create mode 100644 gophish/gophish_injector/configuration/injector_config_override.py create mode 100644 gophish/gophish_injector/contracts_gophish.py create mode 100644 gophish/gophish_injector/img/README.md create mode 100644 gophish/gophish_injector/openaev_gophish.py create mode 100644 gophish/manifest-metadata.json create mode 100644 gophish/pyproject.toml create mode 100644 gophish/test/__init__.py create mode 100644 gophish/test/test_gophish.py 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..d45ace19 --- /dev/null +++ b/gophish/.env.sample @@ -0,0 +1,17 @@ +# 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 diff --git a/gophish/Dockerfile b/gophish/Dockerfile new file mode 100644 index 00000000..a565a04e --- /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 [[ ${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..6198c341 --- /dev/null +++ b/gophish/config.yml.sample @@ -0,0 +1,14 @@ +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_tls: false diff --git a/gophish/docker-compose.yml b/gophish/docker-compose.yml new file mode 100644 index 00000000..cfca72e2 --- /dev/null +++ b/gophish/docker-compose.yml @@ -0,0 +1,13 @@ +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} + 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..018aceab --- /dev/null +++ b/gophish/gophish_injector/client/gophish_client.py @@ -0,0 +1,107 @@ +"""Thin client over the Gophish REST API.""" + +from dataclasses import dataclass, field +from typing import Dict + +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 = False, + 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, + ) -> 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. + """ + payload = { + "name": name, + "template": {"name": template_name}, + "page": {"name": page_name}, + "smtp": {"name": smtp_name}, + "url": url, + "groups": [{"name": group_name}], + } + 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 CampaignResult(False, f"Gophish campaign creation failed: {exc}") + except requests.RequestException as exc: + return CampaignResult(False, f"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 CampaignResult(False, f"Gophish stats fetch failed: {exc}") + except requests.RequestException as exc: + return CampaignResult(False, f"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), + ) + + @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..d4726913 --- /dev/null +++ b/gophish/gophish_injector/configuration/config_loader.py @@ -0,0 +1,30 @@ +from pydantic import Field +from pyoaev.configuration import ConfigLoaderOAEV, Configuration, SettingsLoader + +from gophish_injector.configuration.gophish_config import GophishConfig +from gophish_injector.configuration.injector_config_override import ( + InjectorConfigOverride, +) +from gophish_injector.contracts_gophish import GophishContracts + + +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..c4741c42 --- /dev/null +++ b/gophish/gophish_injector/configuration/gophish_config.py @@ -0,0 +1,18 @@ +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=False, + description="Verify the Gophish server TLS certificate.", + ) 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..c95df23e --- /dev/null +++ b/gophish/gophish_injector/openaev_gophish.py @@ -0,0 +1,84 @@ +import json +import time +from typing import Dict + +from injector_common.data_helpers import DataHelpers +from injector_common.dump_config import intercept_dump_argument +from pyoaev.helpers import OpenAEVConfigHelper, OpenAEVInjectorHelper + +from gophish_injector.client.gophish_client import GophishClient +from gophish_injector.configuration.config_loader import ConfigLoader + +ICON_PATH = "gophish_injector/img/icon-gophish.png" + + +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()) + with open(ICON_PATH, "rb") as icon_file: + icon_bytes = icon_file.read() + self.helper = OpenAEVInjectorHelper(self.config, icon_bytes) + 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, + ) + + 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: + content = DataHelpers.get_content(data) + 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": [str(result.campaign_id)], + "stats": [json.dumps(result.stats)], + } + ) + self.helper.api.inject.execution_callback( + inject_id=inject_id, data=callback_data + ) + except Exception as 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..1339fca1 --- /dev/null +++ b/gophish/test/test_gophish.py @@ -0,0 +1,56 @@ +from unittest import TestCase +from unittest.mock import MagicMock, patch + +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) + + @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) From 8fa95933150a1618e302e9d52edc327c40c657a5 Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Tue, 14 Jul 2026 11:41:06 +0200 Subject: [PATCH 2/7] style(gophish): sort imports for repo-root isort check (#311) --- gophish/gophish_injector/configuration/config_loader.py | 5 ++--- gophish/gophish_injector/openaev_gophish.py | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/gophish/gophish_injector/configuration/config_loader.py b/gophish/gophish_injector/configuration/config_loader.py index d4726913..12a00010 100644 --- a/gophish/gophish_injector/configuration/config_loader.py +++ b/gophish/gophish_injector/configuration/config_loader.py @@ -1,11 +1,10 @@ -from pydantic import Field -from pyoaev.configuration import ConfigLoaderOAEV, Configuration, SettingsLoader - 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): diff --git a/gophish/gophish_injector/openaev_gophish.py b/gophish/gophish_injector/openaev_gophish.py index c95df23e..ee0db6de 100644 --- a/gophish/gophish_injector/openaev_gophish.py +++ b/gophish/gophish_injector/openaev_gophish.py @@ -2,12 +2,12 @@ import time from typing import Dict -from injector_common.data_helpers import DataHelpers -from injector_common.dump_config import intercept_dump_argument -from pyoaev.helpers import OpenAEVConfigHelper, OpenAEVInjectorHelper - from gophish_injector.client.gophish_client import GophishClient from gophish_injector.configuration.config_loader import ConfigLoader +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" From a83982bcf9e91619b4c8d4733497161a1c2ffdba Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Tue, 14 Jul 2026 11:58:08 +0200 Subject: [PATCH 3/7] test(gophish): cover injector lifecycle and dispatch (#311) Adds coverage for __init__, process_message and start to satisfy the codecov patch gate. --- gophish/test/test_injector.py | 77 +++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 gophish/test/test_injector.py diff --git a/gophish/test/test_injector.py b/gophish/test/test_injector.py new file mode 100644 index 00000000..b4378bbd --- /dev/null +++ b/gophish/test/test_injector.py @@ -0,0 +1,77 @@ +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)) + self.assertEqual(self._callback(injector)["execution_status"], "SUCCESS") + + 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_start_listens(self): + injector = make_injector() + injector.start() + injector.helper.listen.assert_called_once() From f9f1aa1b00b2d7b6d9c881be434e71d3c282ab25 Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Tue, 14 Jul 2026 12:47:28 +0200 Subject: [PATCH 4/7] fix(gophish): harden startup, validate inputs, flatten output (#311) - Load the injector icon defensively so a missing placeholder PNG no longer raises FileNotFoundError at startup (real artwork tracked in #305); log a warning and register without an icon instead. - Validate the mandatory campaign contract fields before calling the Gophish API and fail with a clear message listing the missing fields, rather than silently forwarding None values. - Emit execution_output_structured as a plain JSON object (campaign_id and stats) instead of double-encoded JSON wrapped in one-element lists, matching the sibling injector convention. - Extend tests to cover the missing-field validation, the flattened structured output shape, and the graceful missing-icon fallback. --- gophish/gophish_injector/openaev_gophish.py | 43 +++++++++++++++++++-- gophish/test/test_injector.py | 26 ++++++++++++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/gophish/gophish_injector/openaev_gophish.py b/gophish/gophish_injector/openaev_gophish.py index ee0db6de..a3efee15 100644 --- a/gophish/gophish_injector/openaev_gophish.py +++ b/gophish/gophish_injector/openaev_gophish.py @@ -11,6 +11,15 @@ 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): @@ -19,9 +28,13 @@ def __init__(self): self.config_loader.to_daemon_config() ) intercept_dump_argument(self.config.get_config_obj()) - with open(ICON_PATH, "rb") as icon_file: - icon_bytes = icon_file.read() + 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, @@ -30,6 +43,27 @@ def __init__(self): 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) @@ -39,6 +73,7 @@ def process_message(self, data: Dict) -> None: try: 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"), @@ -57,8 +92,8 @@ def process_message(self, data: Dict) -> None: if result.success: callback_data["execution_output_structured"] = json.dumps( { - "campaign_id": [str(result.campaign_id)], - "stats": [json.dumps(result.stats)], + "campaign_id": result.campaign_id, + "stats": result.stats, } ) self.helper.api.inject.execution_callback( diff --git a/gophish/test/test_injector.py b/gophish/test/test_injector.py index b4378bbd..903f7c70 100644 --- a/gophish/test/test_injector.py +++ b/gophish/test/test_injector.py @@ -1,3 +1,4 @@ +import json import os from unittest import TestCase from unittest.mock import MagicMock, mock_open, patch @@ -60,7 +61,10 @@ def test_success(self): True, "launched", campaign_id=7, stats={"opened": 1} ) injector.process_message(_data(CONTENT)) - self.assertEqual(self._callback(injector)["execution_status"], "SUCCESS") + 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() @@ -71,7 +75,27 @@ def test_failure(self): injector.process_message(_data(CONTENT)) self.assertEqual(self._callback(injector)["execution_status"], "ERROR") + 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_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") From 28c7ebc227f4a6154e62d46e4015a78398176634 Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Tue, 14 Jul 2026 13:23:50 +0200 Subject: [PATCH 5/7] fix(gophish): use POSIX test in Dockerfile and expose verify_tls (#311) The client-python override block used the bash-specific [[ ... ]] test, but the image is python:3.13-alpine whose /bin/sh is BusyBox ash, so the PYOAEV_GIT_BRANCH_OVERRIDE branch could be silently skipped. Switch to a POSIX [ -n "..." ] test with the variable quoted. Also expose the existing GOPHISH_VERIFY_TLS setting in .env.sample and docker-compose.yml so the TLS-verification option is configurable via the documented deployment path. --- gophish/.env.sample | 1 + gophish/Dockerfile | 2 +- gophish/docker-compose.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/gophish/.env.sample b/gophish/.env.sample index d45ace19..67508905 100644 --- a/gophish/.env.sample +++ b/gophish/.env.sample @@ -15,3 +15,4 @@ INJECTOR_LOG_LEVEL=error # GOPHISH Environment Variables GOPHISH_BASE_URL=https://localhost:3333 GOPHISH_API_KEY=ChangeMe +GOPHISH_VERIFY_TLS=false diff --git a/gophish/Dockerfile b/gophish/Dockerfile index a565a04e..8a568881 100644 --- a/gophish/Dockerfile +++ b/gophish/Dockerfile @@ -38,7 +38,7 @@ COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/pytho ARG PYOAEV_GIT_BRANCH_OVERRIDE -RUN if [[ ${PYOAEV_GIT_BRANCH_OVERRIDE} ]] ; then \ +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 && \ diff --git a/gophish/docker-compose.yml b/gophish/docker-compose.yml index cfca72e2..1b09d7c9 100644 --- a/gophish/docker-compose.yml +++ b/gophish/docker-compose.yml @@ -10,4 +10,5 @@ services: - 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 From b69f981f01654ecf8f723909ea37e5fb59959aec Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Wed, 15 Jul 2026 18:02:51 +0200 Subject: [PATCH 6/7] fix(gophish): secure TLS default, contract guard, explicit launch date (#311) - Default verify_tls to true (secure by default) in GophishConfig and the client, documenting the opt-out for self-signed / local servers in .env.sample and config.yml.sample. Skipping TLS verification exposed the API key to interception. - Validate injector_contract_id in process_message and fail with a clear "unsupported contract" error before calling the Gophish API, so a misrouted or stale-contract injection no longer produces confusing campaign-creation failures. - Send an explicit ISO 8601 UTC launch_date on campaign creation (defaults to now) instead of relying on the server clock for immediate launch. - Add tests for the unsupported-contract path, the launch_date payload and the secure verify_tls default. --- gophish/.env.sample | 4 +++- gophish/config.yml.sample | 4 +++- .../gophish_injector/client/gophish_client.py | 12 +++++++++-- .../configuration/gophish_config.py | 7 +++++-- gophish/gophish_injector/openaev_gophish.py | 7 +++++++ gophish/test/test_gophish.py | 21 +++++++++++++++++++ gophish/test/test_injector.py | 13 ++++++++++++ 7 files changed, 62 insertions(+), 6 deletions(-) diff --git a/gophish/.env.sample b/gophish/.env.sample index 67508905..478523ef 100644 --- a/gophish/.env.sample +++ b/gophish/.env.sample @@ -15,4 +15,6 @@ INJECTOR_LOG_LEVEL=error # GOPHISH Environment Variables GOPHISH_BASE_URL=https://localhost:3333 GOPHISH_API_KEY=ChangeMe -GOPHISH_VERIFY_TLS=false +# 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/config.yml.sample b/gophish/config.yml.sample index 6198c341..368abc68 100644 --- a/gophish/config.yml.sample +++ b/gophish/config.yml.sample @@ -11,4 +11,6 @@ injector: gophish: base_url: 'https://localhost:3333' api_key: 'ChangeMe' - verify_tls: false + # 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/gophish_injector/client/gophish_client.py b/gophish/gophish_injector/client/gophish_client.py index 018aceab..ef9eae8c 100644 --- a/gophish/gophish_injector/client/gophish_client.py +++ b/gophish/gophish_injector/client/gophish_client.py @@ -1,7 +1,8 @@ """Thin client over the Gophish REST API.""" from dataclasses import dataclass, field -from typing import Dict +from datetime import datetime, timezone +from typing import Dict, Optional import requests @@ -19,7 +20,7 @@ def __init__( self, base_url: str, api_key: str, - verify_tls: bool = False, + verify_tls: bool = True, timeout: int = 60, logger=None, ): @@ -37,11 +38,16 @@ def create_campaign( 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, @@ -50,6 +56,8 @@ def create_campaign( "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( diff --git a/gophish/gophish_injector/configuration/gophish_config.py b/gophish/gophish_injector/configuration/gophish_config.py index c4741c42..1178305e 100644 --- a/gophish/gophish_injector/configuration/gophish_config.py +++ b/gophish/gophish_injector/configuration/gophish_config.py @@ -13,6 +13,9 @@ class GophishConfig(BaseSettings): description="Gophish API key (Settings page).", ) verify_tls: bool = Field( - default=False, - description="Verify the Gophish server TLS certificate.", + 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/openaev_gophish.py b/gophish/gophish_injector/openaev_gophish.py index a3efee15..32a2c304 100644 --- a/gophish/gophish_injector/openaev_gophish.py +++ b/gophish/gophish_injector/openaev_gophish.py @@ -4,6 +4,7 @@ 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 @@ -72,6 +73,12 @@ def process_message(self, data: Dict) -> None: ) 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( diff --git a/gophish/test/test_gophish.py b/gophish/test/test_gophish.py index 1339fca1..d511accc 100644 --- a/gophish/test/test_gophish.py +++ b/gophish/test/test_gophish.py @@ -43,6 +43,27 @@ def test_create_campaign_success(self, mock_post): 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): diff --git a/gophish/test/test_injector.py b/gophish/test/test_injector.py index 903f7c70..491aa308 100644 --- a/gophish/test/test_injector.py +++ b/gophish/test/test_injector.py @@ -85,6 +85,19 @@ def test_missing_required_field(self): 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() From 183249ad2e89dbf5424d5fbd390fd8ddb399356d Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Fri, 17 Jul 2026 13:32:47 +0200 Subject: [PATCH 7/7] fix(gophish): log failures and surface Gophish error bodies (#311) Improve operational diagnosability of the Gophish injector: - Log the injection failure at error level (with inject id) in the process_message exception path, per the common-implementation logging guidance, so container logs carry the failure context. - Enrich client HTTP error messages with the trimmed Gophish response body (which carries the actionable reason: missing template / page / group, permission or 404 details) instead of only the status line, and log it at error level via the previously unused client logger. - Cover the new behaviour: injector error logging, and campaign / stats HTTP errors including the response body. --- .../gophish_injector/client/gophish_client.py | 35 ++++++++++++++-- gophish/gophish_injector/openaev_gophish.py | 4 ++ gophish/test/test_gophish.py | 40 +++++++++++++++++++ gophish/test/test_injector.py | 8 ++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/gophish/gophish_injector/client/gophish_client.py b/gophish/gophish_injector/client/gophish_client.py index ef9eae8c..13295c2a 100644 --- a/gophish/gophish_injector/client/gophish_client.py +++ b/gophish/gophish_injector/client/gophish_client.py @@ -70,9 +70,9 @@ def create_campaign( response.raise_for_status() body = response.json() except requests.HTTPError as exc: - return CampaignResult(False, f"Gophish campaign creation failed: {exc}") + return self._error_result("Gophish campaign creation failed", exc) except requests.RequestException as exc: - return CampaignResult(False, f"Gophish request error: {exc}") + return self._error_result("Gophish request error", exc) return CampaignResult( success=True, @@ -92,9 +92,9 @@ def get_campaign_stats(self, campaign_id: int) -> CampaignResult: response.raise_for_status() body = response.json() except requests.HTTPError as exc: - return CampaignResult(False, f"Gophish stats fetch failed: {exc}") + return self._error_result("Gophish stats fetch failed", exc) except requests.RequestException as exc: - return CampaignResult(False, f"Gophish request error: {exc}") + return self._error_result("Gophish request error", exc) return CampaignResult( success=True, @@ -103,6 +103,33 @@ def get_campaign_stats(self, campaign_id: int) -> CampaignResult: 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 {} diff --git a/gophish/gophish_injector/openaev_gophish.py b/gophish/gophish_injector/openaev_gophish.py index 32a2c304..5e58b534 100644 --- a/gophish/gophish_injector/openaev_gophish.py +++ b/gophish/gophish_injector/openaev_gophish.py @@ -107,6 +107,10 @@ def process_message(self, data: Dict) -> None: 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={ diff --git a/gophish/test/test_gophish.py b/gophish/test/test_gophish.py index d511accc..b2f16b1a 100644 --- a/gophish/test/test_gophish.py +++ b/gophish/test/test_gophish.py @@ -1,6 +1,7 @@ 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, @@ -75,3 +76,42 @@ def test_get_stats(self, mock_get): 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 index 491aa308..6f90d86d 100644 --- a/gophish/test/test_injector.py +++ b/gophish/test/test_injector.py @@ -75,6 +75,14 @@ def test_failure(self): 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()