Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
580 changes: 290 additions & 290 deletions .circleci/config.yml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions gophish/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
config.yml
**/__pycache__
20 changes: 20 additions & 0 deletions gophish/.env.sample
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions gophish/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
30 changes: 30 additions & 0 deletions gophish/README.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions gophish/config.yml.sample
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions gophish/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions gophish/gophish_injector/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# OpenAEV Gophish Phishing Injector
__version__ = "1.0.0"
Empty file.
142 changes: 142 additions & 0 deletions gophish/gophish_injector/client/gophish_client.py
Original file line number Diff line number Diff line change
@@ -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},
Comment thread
SamuelHassine marked this conversation as resolved.
"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)),
}
Empty file.
29 changes: 29 additions & 0 deletions gophish/gophish_injector/configuration/config_loader.py
Original file line number Diff line number Diff line change
@@ -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,
)
21 changes: 21 additions & 0 deletions gophish/gophish_injector/configuration/gophish_config.py
Original file line number Diff line number Diff line change
@@ -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."
),
)
Comment thread
SamuelHassine marked this conversation as resolved.
16 changes: 16 additions & 0 deletions gophish/gophish_injector/configuration/injector_config_override.py
Original file line number Diff line number Diff line change
@@ -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",
)
Loading
Loading