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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ CLAUDE.md
**/CLAUDE.md
**/.env
!**/.env.example
__pycache__/
*.py[cod]
# operator-provided TLS certs (GITEA_TLS_MODE=provided) — never commit
03_container_layer/docker/admin/gitea-registry/certs/*
!03_container_layer/docker/admin/gitea-registry/certs/.gitkeep
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ── MISP build / version ──────────────────────────────────────────────────────
MISP_VERSION=v2.5.37
MISP_VERSION=v2.5.44

# ── Network ───────────────────────────────────────────────────────────────────
MISP_HTTP_PORT=80
Expand Down
22 changes: 21 additions & 1 deletion 03_container_layer/docker/admin/misp-standalone/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
# ──────────────────────────────────────────────────────────────────────────────
FROM ubuntu:24.04 AS builder

ARG MISP_VERSION=v2.5.39
ARG MISP_VERSION=v2.5.44
ARG SEWF_CLUSTER_SHA256=55afe65a817c167a46dbd26850515aefb2bf72b9318f651d6adb172e498a44b4
ARG SEWF_GALAXY_SHA256=f1fe9a26eae4148542e0d89126cdf4231c803b2c3fd255137b42e2c5f969807a
ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y --no-install-recommends \
Expand All @@ -27,6 +29,16 @@ RUN git clone --depth 1 --branch "${MISP_VERSION}" --progress \
# --progress keeps git reporting per-submodule transfer stats during the wait.
RUN git -C misp submodule update --init --recursive --depth 1 --jobs 8 --progress

# Keep the MISP-bundled Synthetic Exercise World byte-identical to the
# independently vendored Range42 snapshot in 05_world_layer. UUID/count smoke
# checks cannot detect metadata or entity substitutions that preserve counts.
RUN printf '%s %s\n' "${SEWF_CLUSTER_SHA256}" \
/build/misp/app/files/misp-galaxy/clusters/exercise-world.json \
| sha256sum -c - \
&& printf '%s %s\n' "${SEWF_GALAXY_SHA256}" \
/build/misp/app/files/misp-galaxy/galaxies/exercise-world.json \
| sha256sum -c -

# PHP vendor dependencies — skip platform checks that require runtime extensions
WORKDIR /build/misp/app
RUN composer install \
Expand Down Expand Up @@ -80,6 +92,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=builder --chown=www-data:www-data /build/misp /var/www/MISP
COPY --from=builder /opt/misp-venv /opt/misp-venv

# Keep an immutable copy outside app/files. The named misp-files volume masks
# app/files at runtime and may contain data copied from an older image, so the
# entrypoint refreshes bundled galaxy definitions from this image-owned source.
COPY --from=builder --chown=www-data:www-data \
/build/misp/app/files/misp-galaxy /opt/misp-galaxy

# Permissions on the MISP tree are set immediately after the COPY so this
# slow layer stays cached even when config or provisioning files change below.
# chmod -R on the full submodule tree is unavoidable; no second chown pass.
Expand Down Expand Up @@ -118,11 +136,13 @@ COPY config/supervisord.conf /etc/supervisor/conf.d/misp.conf

COPY provisioning/entrypoint.sh /entrypoint.sh
COPY provisioning/configure-misp.sh /provisioning/configure-misp.sh
COPY provisioning/refresh-galaxy-files.sh /provisioning/refresh-galaxy-files.sh
COPY provisioning/wait-for-tcp.sh /provisioning/wait-for-tcp.sh

RUN chmod +x \
/entrypoint.sh \
/provisioning/configure-misp.sh \
/provisioning/refresh-galaxy-files.sh \
/provisioning/wait-for-tcp.sh

EXPOSE 80 443
Expand Down
22 changes: 17 additions & 5 deletions 03_container_layer/docker/admin/misp-standalone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Passwords are set via the corresponding `_PASSWORD` variables in `.env`.

| Variable | Description | Default |
|----------|-------------|---------|
| `MISP_VERSION` | MISP git tag to build from | `v2.5.37` |
| `MISP_VERSION` | MISP git tag to build from | `v2.5.44` |
| `MISP_PORT` | Host port for the MISP web UI | `8080` |
| `MISP_BASEURL` | URL advertised in events/feeds/e-mails | `http://localhost:8080` |
| `MISP_ORG` | Default organisation name | `Default Organisation` |
Expand All @@ -81,6 +81,11 @@ Passwords are set via the corresponding `_PASSWORD` variables in `.env`.
| `MISP_DB_*` / `DB_*` | MariaDB connection settings | see `.env.example` |
| `REDIS_HOST` / `REDIS_PORT` | Redis connection | `redis` / `6379` |

The Docker build verifies the SHA-256 values of the bundled `exercise-world`
galaxy and cluster against the pinned snapshots under
`05_world_layer/sewf/nacre/`. A MISP version bump that changes either file
fails the build until the world package is deliberately reviewed and updated.

---

## Build and push
Expand All @@ -89,13 +94,13 @@ Passwords are set via the corresponding `_PASSWORD` variables in `.env`.
# Build and tag for a registry
docker build \
--target runtime \
--build-arg MISP_VERSION=v2.5.37 \
-t registry.example.com/range42/misp-standalone:v2.5.37 \
--build-arg MISP_VERSION=v2.5.44 \
-t registry.example.com/range42/misp-standalone:v2.5.44 \
-t registry.example.com/range42/misp-standalone:latest \
.

# Push
docker push registry.example.com/range42/misp-standalone:v2.5.37
docker push registry.example.com/range42/misp-standalone:v2.5.44
docker push registry.example.com/range42/misp-standalone:latest
```

Expand All @@ -106,7 +111,7 @@ docker push registry.example.com/range42/misp-standalone:latest
| Volume | Contents |
|--------|----------|
| `db-data` | MariaDB data files |
| `misp-files` | MISP uploaded files |
| `misp-files` | MISP file storage and bundled content definitions |
| `misp-attachments` | Event attachments |
| `misp-logs` | MISP application logs |
| `keys` | Bootstrap auth-key and final `api-keys.txt` |
Expand All @@ -126,6 +131,13 @@ The bootstrap sentinel `/var/www/MISP/.bootstrapped` prevents re-provisioning on

If the container is **recreated** (e.g. after `docker compose up --build`), the sentinel is gone but the DB volume persists — the bootstrap detects the existing schema, skips the seed, and proceeds safely.

On every MISP container start, the image's bundled `misp-galaxy` snapshot is
copied into `misp-files` before bootstrap or normal startup. This refreshes
definitions when an existing named volume survives an image upgrade. The
provisioner also reconciles the two managed sample-event UUIDs through the MISP
edit API, so fixture changes such as new exercise-world tags reach existing lab
databases.

---

## Troubleshooting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ services:
context: .
target: runtime
args:
MISP_VERSION: ${MISP_VERSION:-v2.5.37}
image: misp-standalone:${MISP_VERSION:-v2.5.37}
MISP_VERSION: ${MISP_VERSION:-v2.5.44}
image: misp-standalone:${MISP_VERSION:-v2.5.44}
restart: unless-stopped
env_file:
- path: .env
Expand Down Expand Up @@ -94,7 +94,7 @@ services:
context: .
target: runtime
args:
MISP_VERSION: ${MISP_VERSION:-v2.5.37}
MISP_VERSION: ${MISP_VERSION:-v2.5.44}
entrypoint: ["/provisioning/provision.sh"]
env_file:
- path: .env
Expand All @@ -106,6 +106,7 @@ services:
- ./provisioning/provision-users.sh:/provisioning/provision-users.sh:ro
- ./provisioning/provision-warninglists.sh:/provisioning/provision-warninglists.sh:ro
- ./provisioning/provision-content.sh:/provisioning/provision-content.sh:ro
- ./provisioning/verify-exercise-world.py:/provisioning/verify-exercise-world.py:ro
- ./provisioning/provision-sample-events.sh:/provisioning/provision-sample-events.sh:ro
- ./provisioning/sample-events:/provisioning/sample-events:ro
depends_on:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ SENTINEL="/keys/.bootstrapped"

log() { echo "[entrypoint] $*"; }

# Refresh the image-bundled galaxy definitions before MISP imports them. This
# also upgrades an existing misp-files volume that was initialized by an older
# image and would otherwise mask the newly bundled files.
/provisioning/refresh-galaxy-files.sh

# ── Wait for MariaDB and Redis ────────────────────────────────────────────────

log "Waiting for database (${DB_HOST:-db}:${DB_PORT:-3306}) …"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
#!/usr/bin/env bash
# provision-sample-events.sh — imports sample MISP training events.
# Idempotent: checks event UUID before creating; skips if already present.
# Idempotent: creates missing fixtures and reconciles existing fixture UUIDs.
#
# Add more fixtures by dropping .json files into provisioning/sample-events/.
# Each file must contain a top-level "Event" object with a "uuid" field.
set -euo pipefail

MISP_URL="https://misp"
ADMIN_KEY_FILE="/keys/admin-authkey"
EVENTS_DIR="/provisioning/sample-events"
MISP_URL="${MISP_URL:-https://misp}"
ADMIN_KEY_FILE="${MISP_ADMIN_KEY_FILE:-/keys/admin-authkey}"
EVENTS_DIR="${MISP_SAMPLE_EVENTS_DIR:-/provisioning/sample-events}"

log() { echo "[provision-sample-events] $*" >&2; }

Expand Down Expand Up @@ -63,19 +63,22 @@ print(ev.get('info', sys.argv[1]))
fi

if misp_get "/events/view/${event_uuid}" >/dev/null 2>&1; then
log " Already exists (${event_uuid}): ${event_info}"
continue
endpoint="/events/edit/${event_uuid}"
action="Updated"
else
endpoint="/events/add"
action="Created"
fi

resp=$(misp_post_file "${fixture}" "/events/add")
resp=$(misp_post_file "${fixture}" "${endpoint}")
created_uuid=$(echo "${resp}" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('Event', {}).get('uuid', ''))
" 2>/dev/null || true)

if [ -n "${created_uuid}" ]; then
log " Created (${created_uuid}): ${event_info}"
if [ "${created_uuid}" = "${event_uuid}" ]; then
log " ${action} (${created_uuid}): ${event_info}"
else
log " WARNING: unexpected response for ${fixture}: ${resp:0:300}"
fi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ set -euo pipefail
/provisioning/provision-users.sh
/provisioning/provision-warninglists.sh
/provisioning/provision-content.sh
python3 /provisioning/verify-exercise-world.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh persisted galaxy files before verification

When upgrading an existing Compose deployment, the misp-files named volume mounted over /var/www/MISP/app/files in docker-compose.yml:71-73 retains the previous v2.5.37 contents and masks the v2.5.44 files checked during the image build. Consequently, provision-content.sh:64-65 imports the old bundled galaxies and this newly mandatory verifier exits before sample-event provisioning because Nacre is absent. Copy or synchronize the new bundled galaxy data into the persistent volume before updating and verifying it.

Useful? React with 👍 / 👎.

/provisioning/provision-sample-events.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Refresh image-bundled MISP galaxy definitions in the persistent app/files
# volume. Docker initializes a new named volume from the image once, but does
# not update an existing volume when a newer image is deployed.
set -euo pipefail

SEED_DIR="${MISP_GALAXY_SEED_DIR:-/opt/misp-galaxy}"
TARGET_DIR="${MISP_GALAXY_TARGET_DIR:-/var/www/MISP/app/files/misp-galaxy}"
OWNER="${MISP_GALAXY_OWNER-www-data:www-data}"

log() { echo "[refresh-galaxy-files] $*"; }

if [ ! -d "${SEED_DIR}/clusters" ] || [ ! -d "${SEED_DIR}/galaxies" ]; then
log "ERROR: bundled galaxy seed is incomplete at ${SEED_DIR}"
exit 1
fi

log "Refreshing bundled galaxy definitions in ${TARGET_DIR} …"
mkdir -p "${TARGET_DIR}"
cp -a "${SEED_DIR}/." "${TARGET_DIR}/"

if [ -n "${OWNER}" ]; then
chown -R "${OWNER}" "${TARGET_DIR}"
fi

log "Bundled galaxy definitions are current."
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
"Tag": [
{"name": "tlp:amber"},
{"name": "kill-chain:Delivery"},
{"name": "kill-chain:Exploitation"}
{"name": "kill-chain:Exploitation"},
{"name": "misp-galaxy:exercise-world=\"Asterin Union\""},
{"name": "misp-galaxy:exercise-world=\"NovaCore Systems\""},
{"name": "misp-galaxy:exercise-world=\"TA-700 Obsidian Jackal\""}
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update existing sample events instead of skipping them

For deployments where these sample-event UUIDs were imported before this upgrade, provision-sample-events.sh:65-67 detects each existing event and skips it, so none of the newly added Nacre tags are applied. The seeding change therefore only works on fresh databases; the provisioner needs to patch or replace existing fixtures when their contents change.

Useful? React with 👍 / 👎.

],
"Attribute": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
{"name": "tlp:amber"},
{"name": "kill-chain:Installation"},
{"name": "kill-chain:Command and Control"},
{"name": "kill-chain:Actions on Objectives"}
{"name": "kill-chain:Actions on Objectives"},
{"name": "misp-galaxy:exercise-world=\"Velkar Republic\""},
{"name": "misp-galaxy:exercise-world=\"HelixCore Group\""},
{"name": "misp-galaxy:exercise-world=\"TA-701 Silver Mantis\""}
],
"Attribute": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Fail provisioning unless the pinned Synthetic Exercise World is loaded."""

from __future__ import annotations

import json
import os
import ssl
import sys
from pathlib import Path
from typing import Any
from urllib.request import Request, urlopen


EXPECTED_GALAXY_UUID = "3c3de5f0-5982-4c7f-88cf-8abf43b8d6c1"
EXPECTED_COLLECTION_UUID = "7d6d7f2f-b3d4-4bc5-9f27-43e12f7f4658"
EXPECTED_ENTITY_COUNT = 60


class ExerciseWorldVerificationError(ValueError):
pass


def _list_items(payload: Any) -> list[Any]:
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
for key in ("response", "Galaxies", "galaxies"):
if isinstance(payload.get(key), list):
return payload[key]
if isinstance(payload.get("Galaxy"), list):
return payload["Galaxy"]
return [payload]


def find_exercise_world_galaxy(payload: Any) -> str:
for item in _list_items(payload):
galaxy = item.get("Galaxy", item) if isinstance(item, dict) else {}
if galaxy.get("type") != "exercise-world":
continue
if galaxy.get("uuid") != EXPECTED_GALAXY_UUID:
raise ExerciseWorldVerificationError(
f"exercise-world galaxy UUID mismatch: {galaxy.get('uuid')}"
)
galaxy_id = galaxy.get("id")
if galaxy_id is None:
raise ExerciseWorldVerificationError("exercise-world galaxy is missing its database id")
return str(galaxy_id)
raise ExerciseWorldVerificationError("exercise-world galaxy is not loaded")


def verify_cluster_payload(payload: Any) -> None:
root = payload.get("Galaxy", payload) if isinstance(payload, dict) else {}
clusters = payload.get("GalaxyCluster") if isinstance(payload, dict) else None
if not isinstance(clusters, list) and isinstance(root, dict):
clusters = root.get("GalaxyCluster")
if not isinstance(clusters, list):
raise ExerciseWorldVerificationError("exercise-world response has no GalaxyCluster list")

normalized = [
item.get("GalaxyCluster", item) if isinstance(item, dict) else {}
for item in clusters
]
if len(normalized) != EXPECTED_ENTITY_COUNT:
raise ExerciseWorldVerificationError(
f"exercise-world entity count mismatch: expected {EXPECTED_ENTITY_COUNT}, got {len(normalized)}"
)

entity_uuids = {item.get("uuid") for item in normalized}
if None in entity_uuids or len(entity_uuids) != EXPECTED_ENTITY_COUNT:
raise ExerciseWorldVerificationError("exercise-world entity UUIDs are missing or duplicated")
collection_uuids = {item.get("collection_uuid") for item in normalized}
if collection_uuids != {EXPECTED_COLLECTION_UUID}:
raise ExerciseWorldVerificationError(
f"exercise-world collection UUID mismatch: {sorted(str(value) for value in collection_uuids)}"
)


def misp_get(base_url: str, auth_key: str, path: str) -> Any:
request = Request(
f"{base_url.rstrip('/')}{path}",
headers={"Authorization": auth_key, "Accept": "application/json"},
)
context = ssl._create_unverified_context()
with urlopen(request, context=context, timeout=30) as response:
return json.load(response)


def main() -> int:
base_url = os.environ.get("MISP_URL", "https://misp")
key_file = Path(os.environ.get("MISP_ADMIN_KEY_FILE", "/keys/admin-authkey"))
try:
auth_key = key_file.read_text(encoding="utf-8").strip()
if not auth_key:
raise ExerciseWorldVerificationError(f"empty admin auth key: {key_file}")
galaxy_id = find_exercise_world_galaxy(misp_get(base_url, auth_key, "/galaxies/index.json"))
verify_cluster_payload(misp_get(base_url, auth_key, f"/galaxies/view/{galaxy_id}.json"))
except (OSError, json.JSONDecodeError, ExerciseWorldVerificationError) as exc:
print(f"[verify-exercise-world] ERROR: {exc}", file=sys.stderr)
return 1

print(
f"[verify-exercise-world] verified galaxy {EXPECTED_GALAXY_UUID} "
f"with {EXPECTED_ENTITY_COUNT} entities",
file=sys.stderr,
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 2 additions & 0 deletions 05_world_layer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
Loading