Finish remaining code
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM docker.io/library/python:3.14-slim
|
||||
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:5000", "app.server:app"]
|
||||
86
README.md
86
README.md
@@ -1,12 +1,82 @@
|
||||
# jss-devsecops-challenge
|
||||
|
||||
This is a practical test for a DevSecOps Engineer candidate at Bank J. Safra Sarasin. Instructions received (on 09.02.2025) from the recruiter are attached to this repo.
|
||||
DevSecOps assessment - device registration and statistics API built with Python (Flask) and MariaDB.
|
||||
|
||||
### On 09.02.2026
|
||||
I created repo on https://git.harshanu.space. I'll work on code changes tomorrow as I will be free in the afternoon (Fashing Tuesday, half day work)
|
||||
## What it does
|
||||
|
||||
### On 10.02.2026
|
||||
- Defined the database schema for the API endpoints in `init.sql`.
|
||||
- Pulled the `docker.io/library/mariadb:12` Docker image.
|
||||
- Tested the schema by spinning up a MariaDB container and verifying the table creation.
|
||||
- Python will be used to write API endpoints
|
||||
A single API server that tracks device type registrations per user. It exposes three endpoints:
|
||||
|
||||
- `POST /log/auth` - logs a user login event by storing the device type
|
||||
- `GET /log/auth/statistics?deviceType=iOS` - returns count of registrations for a given device type
|
||||
- `POST /device/register` - directly registers a device type for a user
|
||||
|
||||
Accepted device types: iOS, Android, Watch, TV, Tablet, Desktop, IoT.
|
||||
In contrast to API endpoints asked in pdf file, I used smaller case here.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
app/server.py - Flask application with all endpoints
|
||||
tests/test_app.py - unit tests (mocked DB)
|
||||
init.sql - MariaDB schema initialization
|
||||
Dockerfile - container image for the API
|
||||
docker-compose.yml - full stack (MariaDB + API)
|
||||
requirements.txt - Python dependencies
|
||||
```
|
||||
|
||||
## Docker Hub
|
||||
|
||||
The API image is published to Docker Hub:
|
||||
|
||||
```
|
||||
docker.io/harshavmb/jss-devsecops-api:latest
|
||||
```
|
||||
|
||||
To pull it directly:
|
||||
|
||||
```bash
|
||||
docker pull harshavmb/jss-devsecops-api:latest
|
||||
```
|
||||
|
||||
MariaDB is used from the official registry (`docker.io/library/mariadb:12`) and does not need a custom image. The schema is applied at startup via the init SQL script mounted in docker-compose.
|
||||
|
||||
## Running locally with Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
This starts MariaDB, runs the init SQL script, and launches the API on port 5000.
|
||||
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Security controls
|
||||
|
||||
- Input validation on userKey (regex whitelist) and deviceType (enum whitelist)
|
||||
- Parameterized SQL queries to prevent injection
|
||||
- Rate limiting (30 req/min per endpoint, 60 req/min global)
|
||||
- Non-root container user
|
||||
- No secrets hardcoded (configurable via DATABASE_URL env var)
|
||||
- No docker/podman container is run as root user (that depends on how docker/podman are configured on host)
|
||||
- Authentication of endpoints is left out for brevity
|
||||
|
||||
## Configuration
|
||||
|
||||
The API reads `DATABASE_URL` from the environment. Format:
|
||||
|
||||
```
|
||||
mysql://user:password@host:port/database
|
||||
```
|
||||
|
||||
Default: `mysql://root:my-secret-pw@localhost:3306/devsecops_db`
|
||||
|
||||
19
TIMELINE.md
Normal file
19
TIMELINE.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# jss-devsecops-challenge
|
||||
|
||||
This is a practical test for a DevSecOps Engineer candidate at Bank J. Safra Sarasin. Instructions received (on 09.02.2025) from the recruiter are attached to this repo.
|
||||
|
||||
### On 09.02.2026
|
||||
I created repo on https://git.harshanu.space. I'll work on code changes tomorrow as I will be free in the afternoon (Fashing Tuesday, half day work)
|
||||
|
||||
### On 10.02.2026
|
||||
- Defined the database schema for the API endpoints in `init.sql`.
|
||||
- Pulled the `docker.io/library/mariadb:12` Docker image.
|
||||
- Tested the schema by spinning up a MariaDB container and verifying the table creation.
|
||||
- Python will be used to write API endpoints
|
||||
|
||||
## On 13.02.2026
|
||||
- Finished the remaining part
|
||||
- app folder contains the python code
|
||||
- tests folder contains the unit tests
|
||||
- Dockerfile builds the python code in app/ directory & uses `gunicorn` HTTP web server to serve API endpoints
|
||||
- docker-compose.yml
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
121
app/server.py
Normal file
121
app/server.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
import mysql.connector
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
limiter = Limiter(get_remote_address, app=app, default_limits=["60 per minute"])
|
||||
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL", "mysql://root:my-secret-pw@localhost:3306/devsecops_db"
|
||||
)
|
||||
|
||||
## I added more devices as I felt they are relevant to the ones asked in pdf
|
||||
VALID_DEVICE_TYPES = {"iOS", "Android", "Watch", "TV", "Tablet", "Desktop", "IoT"}
|
||||
USER_KEY_PATTERN = re.compile(r"^[a-zA-Z0-9_\-\.@]{1,255}$")
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
parsed = urlparse(DATABASE_URL)
|
||||
return mysql.connector.connect(
|
||||
host=parsed.hostname,
|
||||
port=parsed.port or 3306,
|
||||
user=parsed.username,
|
||||
password=parsed.password,
|
||||
database=parsed.path.lstrip("/"),
|
||||
)
|
||||
|
||||
|
||||
def validate_device_type(device_type):
|
||||
return device_type in VALID_DEVICE_TYPES
|
||||
|
||||
|
||||
def validate_user_key(user_key):
|
||||
return bool(user_key) and USER_KEY_PATTERN.match(user_key)
|
||||
|
||||
|
||||
@app.route("/log/auth", methods=["POST"])
|
||||
@limiter.limit("30 per minute")
|
||||
def log_auth():
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"statusCode": 400, "message": "bad_request"}), 400
|
||||
|
||||
user_key = data.get("userKey", "")
|
||||
device_type = data.get("deviceType", "")
|
||||
|
||||
if not validate_user_key(user_key) or not validate_device_type(device_type):
|
||||
return jsonify({"statusCode": 400, "message": "bad_request"}), 400
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO device_registrations (user_key, device_type) VALUES (%s, %s)",
|
||||
(user_key, device_type),
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({"statusCode": 200, "message": "success"}), 200
|
||||
except Exception:
|
||||
return jsonify({"statusCode": 400, "message": "bad_request"}), 400
|
||||
|
||||
|
||||
@app.route("/log/auth/statistics", methods=["GET"])
|
||||
@limiter.limit("30 per minute")
|
||||
def log_auth_statistics():
|
||||
device_type = request.args.get("deviceType", "")
|
||||
|
||||
if not validate_device_type(device_type):
|
||||
return jsonify({"deviceType": device_type, "count": -1}), 400
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM device_registrations WHERE device_type = %s",
|
||||
(device_type,),
|
||||
)
|
||||
count = cursor.fetchone()[0]
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({"deviceType": device_type, "count": count}), 200
|
||||
except Exception:
|
||||
return jsonify({"deviceType": device_type, "count": -1}), 500
|
||||
|
||||
|
||||
@app.route("/device/register", methods=["POST"])
|
||||
@limiter.limit("30 per minute")
|
||||
def register_device():
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"statusCode": 400}), 400
|
||||
|
||||
user_key = data.get("userKey", "")
|
||||
device_type = data.get("deviceType", "")
|
||||
|
||||
if not validate_user_key(user_key) or not validate_device_type(device_type):
|
||||
return jsonify({"statusCode": 400}), 400
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO device_registrations (user_key, device_type) VALUES (%s, %s)",
|
||||
(user_key, device_type),
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return jsonify({"statusCode": 200}), 200
|
||||
except Exception:
|
||||
return jsonify({"statusCode": 400}), 400
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000)
|
||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
mariadb:
|
||||
image: docker.io/library/mariadb:12
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-my-secret-pw}
|
||||
MARIADB_DATABASE: devsecops_db
|
||||
volumes:
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
- db_data:/var/lib/mysql
|
||||
ports:
|
||||
- "3306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
image: docker.io/harshavmb/jss-devsecops-api:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
DATABASE_URL: mysql://root:${DB_ROOT_PASSWORD:-my-secret-pw}@mariadb:3306/devsecops_db
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
flask==3.1.*
|
||||
flask-limiter==3.9.*
|
||||
mysql-connector-python==9.2.*
|
||||
requests==2.32.*
|
||||
gunicorn==23.0.*
|
||||
pytest==8.3.*
|
||||
164
tests/test_app.py
Normal file
164
tests/test_app.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
from app.server import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app.config["TESTING"] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestLogAuth:
|
||||
def test_missing_body(self, client):
|
||||
resp = client.post("/log/auth", content_type="application/json")
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["message"] == "bad_request"
|
||||
|
||||
def test_invalid_device_type(self, client):
|
||||
resp = client.post(
|
||||
"/log/auth",
|
||||
data=json.dumps({"userKey": "user1", "deviceType": "Nokia"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_invalid_user_key(self, client):
|
||||
resp = client.post(
|
||||
"/log/auth",
|
||||
data=json.dumps({"userKey": "", "deviceType": "iOS"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_user_key_injection(self, client):
|
||||
resp = client.post(
|
||||
"/log/auth",
|
||||
data=json.dumps({"userKey": "'; DROP TABLE--", "deviceType": "iOS"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@patch("app.server.get_db_connection")
|
||||
def test_success(self, mock_conn, client):
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.return_value.cursor.return_value = mock_cursor
|
||||
|
||||
resp = client.post(
|
||||
"/log/auth",
|
||||
data=json.dumps({"userKey": "user1", "deviceType": "iOS"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["message"] == "success"
|
||||
|
||||
@patch("app.server.get_db_connection")
|
||||
def test_db_failure(self, mock_conn, client):
|
||||
mock_conn.side_effect = Exception("db error")
|
||||
|
||||
resp = client.post(
|
||||
"/log/auth",
|
||||
data=json.dumps({"userKey": "user1", "deviceType": "Android"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestLogAuthStatistics:
|
||||
def test_invalid_device_type(self, client):
|
||||
resp = client.get("/log/auth/statistics?deviceType=Nokia")
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["count"] == -1
|
||||
|
||||
def test_missing_device_type(self, client):
|
||||
resp = client.get("/log/auth/statistics")
|
||||
assert resp.status_code == 400
|
||||
|
||||
@patch("app.server.get_db_connection")
|
||||
def test_success(self, mock_conn, client):
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchone.return_value = (5,)
|
||||
mock_conn.return_value.cursor.return_value = mock_cursor
|
||||
|
||||
resp = client.get("/log/auth/statistics?deviceType=iOS")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["deviceType"] == "iOS"
|
||||
assert data["count"] == 5
|
||||
|
||||
@patch("app.server.get_db_connection")
|
||||
def test_db_failure(self, mock_conn, client):
|
||||
mock_conn.side_effect = Exception("connection refused")
|
||||
|
||||
resp = client.get("/log/auth/statistics?deviceType=Android")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["count"] == -1
|
||||
|
||||
|
||||
class TestDeviceRegister:
|
||||
def test_missing_body(self, client):
|
||||
resp = client.post("/device/register", content_type="application/json")
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["statusCode"] == 400
|
||||
|
||||
def test_invalid_device_type(self, client):
|
||||
resp = client.post(
|
||||
"/device/register",
|
||||
data=json.dumps({"userKey": "user1", "deviceType": "Fridge"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_empty_user_key(self, client):
|
||||
resp = client.post(
|
||||
"/device/register",
|
||||
data=json.dumps({"userKey": "", "deviceType": "iOS"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_special_chars_rejected(self, client):
|
||||
resp = client.post(
|
||||
"/device/register",
|
||||
data=json.dumps({"userKey": "<script>alert(1)</script>", "deviceType": "iOS"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@patch("app.server.get_db_connection")
|
||||
def test_success(self, mock_conn, client):
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.return_value.cursor.return_value = mock_cursor
|
||||
|
||||
resp = client.post(
|
||||
"/device/register",
|
||||
data=json.dumps({"userKey": "user1", "deviceType": "Android"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["statusCode"] == 200
|
||||
|
||||
@patch("app.server.get_db_connection")
|
||||
def test_db_failure(self, mock_conn, client):
|
||||
mock_conn.side_effect = Exception("db down")
|
||||
|
||||
resp = client.post(
|
||||
"/device/register",
|
||||
data=json.dumps({"userKey": "user1", "deviceType": "Watch"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@patch("app.server.get_db_connection")
|
||||
def test_all_valid_device_types(self, mock_conn, client):
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.return_value.cursor.return_value = mock_cursor
|
||||
for dt in ["iOS", "Android", "Watch", "TV", "Tablet", "Desktop", "IoT"]:
|
||||
resp = client.post(
|
||||
"/device/register",
|
||||
data=json.dumps({"userKey": "user1", "deviceType": dt}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
Reference in New Issue
Block a user