What is UEDF Sentinel?
UEDF (United Eswatini Defence Force) Sentinel v5.0 is a military command-and-control platform for real-time drone fleet management. Built as a production PHP 8.3 monolith: 27,900 lines, 16 MVC controllers, MySQL 8 database.
Architecture: PHP backend serving a RESTful JSON API plus server-rendered MVC views, a Node.js WebSocket shim for real-time telemetry, and NATS JetStream as the message bus. Every write operation feeds a blockchain-chained tamper-evident audit log (SHA-256 Merkle tree, each event hash-linked to the previous one). TOTP 2FA is enforced on all administrative accounts.
Fleet Command
Real-time drone position tracking via WebSocket telemetry. Mission planning, waypoint assignment, fleet status dashboard. Live telemetry from MAVLink → NATS → WebSocket → browser.
Auth & Access Control
TOTP 2FA (Google Authenticator compatible) on all admin/commander accounts. RBAC with 4 roles enforced at controller level on every API route. JWT tokens (15-min TTL) + refresh token rotation.
Audit Trail
Every write operation (mission create/update, drone assignment, user action) appended to a blockchain-chained audit log. SHA-256 hash of each event includes the previous event's hash — tampering any entry invalidates all subsequent entries.
Integration Layer
NATS JetStream for pub/sub between PHP backend and Node.js telemetry relay. MAVLink 2 telemetry from drones → NATS → WebSocket broker → browser dashboard. PHPUnit integration tests for all API routes.
Role-Based Access Control matrix
| Role | Fleet Control | Mission Planning | Audit Log | User Mgmt | System Config |
|---|---|---|---|---|---|
| System Admin | ✓ | ✓ | ✓ | ✓ | ✓ |
| Commander | ✓ | ✓ | ✓ | — | — |
| Operator | ✓ | — | — | — | — |
| Analyst | — | — | ✓ | — | — |
Audit Scope & Methodology
A structural code review of all PHP files outside vendor/. No automated scanner — every file read manually. The audit covered the full authentication pipeline, session management, CORS configuration, injection surfaces (SQL, command, path traversal), secrets hygiene, error disclosure, and brute-force protection.
Authentication & Sessions
Login handler, session fixation vectors, 2FA flow, brute-force lockout logic, JWT token handling across 16 controllers
Injection Surfaces
SQL queries in all API routes, command execution paths, file handler path traversal — all inputs traced from entry point to sink
Secrets Hygiene
All configuration files and HTML source scanned for hardcoded credentials, API keys, and database passwords in literals
Network Controls
CORS policy across all 16 API endpoints, security header coverage, error disclosure mode, TLS enforcement
What Was Found
| ID | Severity | Finding | File(s) | Status |
|---|---|---|---|---|
| F-01 | Critical | Hardcoded credentials pre-filled into production login form (commander / commander123) |
modules/login.php |
✓ Resolved |
| F-02 | Critical | API key and DB password as source literals — credential rotation requires a code push | api/config.php |
✓ Resolved |
| F-03 | Critical | CORS wildcard (Access-Control-Allow-Origin: *) on all Bearer token endpoints — any origin can read authenticated API responses |
api/auth.php, api/*.php |
✓ Resolved |
| F-04 | High | Session ID not rotated on login — classic session fixation attack vector | src/Auth.php |
✓ Resolved |
| F-05 | High | Off-by-one error in brute-force lockout — 6th login attempt slipped past a 5-attempt gate | api/auth.php |
✓ Resolved |
| F-06 | High | Zero security headers on any API endpoint — missing CSP, HSTS, X-Frame-Options, X-Content-Type-Options | api/*.php (all) |
✓ Resolved |
| F-07 | High | display_errors: 1 in production config — stack traces and file paths exposed to users |
config/app.php |
✓ Resolved |
| F-08 | High | Raw SQL string concatenation in mission and chat API routes — SQL injection surface | api/missions.php, api/chat.php |
✓ Resolved |
| F-09 | High | DB password falls back silently to empty string when env var is absent on localhost | src/Database.php |
✓ Resolved |
Before / After
F-01 — Hardcoded credentials removed
// Login form pre-filled with production credentials <input type="text" name="username" value="commander" /> <input type="password" name="password" value="commander123" />
<input type="text" name="username" placeholder="Username" /> <input type="password" name="password" placeholder="Password" /> // Credentials injected only via environment variables
F-02 — Hardcoded secrets replaced with environment variables
define('SENTINEL_API_KEY', 'uedf-sentinel-mobile-2026'); define('DB_PASS', 'sentinel_prod_pass'); // Rotation requires a code push and deployment — key lives in git history
define('SENTINEL_API_KEY', $_ENV['SENTINEL_API_KEY'] ?? throw new \RuntimeException('SENTINEL_API_KEY not set')); define('DB_PASS', $_ENV['DB_PASSWORD'] ?? throw new \RuntimeException('DB_PASSWORD not set')); // Rotation: update env var in deployment config, restart process. Zero code change.
F-03 — CORS wildcard replaced with allowlist
header('Access-Control-Allow-Origin: *');
// Allowlist from environment variable $allowed = array_filter(explode(',', $_ENV['SENTINEL_CORS_ORIGINS'] ?? '')); $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; if (in_array($origin, $allowed, true)) { header('Access-Control-Allow-Origin: ' . $origin); header('Vary: Origin'); }
F-04 — Session fixation patched
session_start(); // ... validate username + password ... // Session ID never rotated — attacker who fixed the ID before login now owns the session $_SESSION['user_id'] = $user->id; $_SESSION['role'] = $user->role;
session_start(); // ... validate username + password ... session_regenerate_id(true); // destroys old session data, issues new SID $_SESSION['user_id'] = $user->id; $_SESSION['role'] = $user->role;
F-05 — Brute-force off-by-one corrected
if ($attempts > MAX_LOGIN_ATTEMPTS) { lockout(); } // MAX=5 → 6th attempt NOT blocked (> 5 is false when attempts=5)
if ($attempts >= MAX_LOGIN_ATTEMPTS) { lockout(); } // MAX=5 → 6th attempt blocked (>= 5 is true when attempts=5)
F-06 — Security headers added to all API responses
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
// No X-Content-Type-Options, X-Frame-Options, CSP, HSTS, or Referrer-Policy
// ... CORS allowlist (see F-03) ... // Defence-in-depth headers on every API response header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); header('Content-Security-Policy: default-src \'self\''); header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); header('Referrer-Policy: no-referrer'); header('X-Permitted-Cross-Domain-Policies: none');
F-07 — Error disclosure disabled in production
// Feature Flags 'websocket' => true, 'debug' => true, 'maintenance' => false, // PHP renders exceptions, stack traces, and file paths directly to the HTTP response
// Feature Flags 'websocket' => true, 'debug' => false, // Never expose errors to users in production; use error_log instead 'maintenance' => false,
F-08 — Raw SQL replaced with prepared statements
// SQL built by string concatenation — classic injection surface $id = $_GET['id']; $result = $db->query("SELECT * FROM missions WHERE id=" . $id); // Payload: ?id=1 OR 1=1 — returns all missions regardless of RBAC
$stmt = $db->prepare("SELECT * FROM missions WHERE id = ? AND user_id = ?"); $stmt->execute([$_GET['id'], $_SESSION['user_id']]); // Parameter binding prevents injection. user_id check enforces RBAC at query level. $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
F-09 — Silent DB fallback removed
function logActivity($user_id, $action, $details = '') { try { $pdo = new PDO('mysql:host=localhost;dbname=uedf_sentinel', 'root', ''); // Hardcoded localhost + empty password — silently used if env vars are absent
function logActivity($user_id, $action, $details = '') { try { $dbHost = $_ENV['DB_HOST'] ?? throw new \RuntimeException('DB_HOST not set'); $dbName = $_ENV['DB_NAME'] ?? throw new \RuntimeException('DB_NAME not set'); $dbUser = $_ENV['DB_USER'] ?? throw new \RuntimeException('DB_USER not set'); $dbPass = $_ENV['DB_PASS'] ?? throw new \RuntimeException('DB_PASS not set'); $pdo = new PDO("mysql:host={$dbHost};dbname={$dbName}", $dbUser, $dbPass); // Missing env var throws immediately — no silent misconfiguration possible
How It Was Delivered
Manual audit — every PHP file read
No automated scanner. All 16 controllers, API routes, auth handlers, config files, and session management code reviewed line-by-line against OWASP Top 10.
Written report with reproducible steps
Each finding documented with severity, affected file, root cause, and a concrete before/after code patch. No vague recommendations.
22 files patched, 317 lines changed, 0 regressions
All 9 findings resolved. PHPUnit integration tests written to lock each fix. Existing test suite remained green throughout.
CI security gate installed
GitHub Actions workflow added — security regressions caught at PR time, before they reach main.
Preventing Regressions
A GitHub Actions workflow was added as part of the engagement. Every pull request to main must pass three automated checks before merge is allowed.
Check 1 — No hardcoded secrets
# .github/workflows/security.yml - name: No hardcoded credentials run: | ! grep -rn \ "commander123\|uedf-sentinel-mobile\|sentinel_prod_pass" \ --include="*.php" .
Check 2 — Auth enforced on all internal pages
- name: Auth check present on all controllers run: | for f in src/Controllers/*.php; do grep -q "Auth::requireLogin\|session_start" "$f" || \ { echo "MISSING auth: $f"; exit 1; } done
Check 3 — No raw SQL concatenation
- name: No string-concatenated SQL run: | ! grep -rn 'query(.*\$_GET\|query(.*\$_POST\|query(.*\$_REQUEST' \ --include="*.php" .
All three gates run on every push. A finding-class regression triggers a build failure and blocks the PR.
Is your codebase carrying hidden risk?
If your PHP or Python application hasn't had a structural review, I offer a free 15-minute initial assessment — honest findings, no obligation. Security audits start at R3,700.