Fix 8 code-review findings: security + reliability

1. agent.py: shell allow-check reads cfg, not server payload (RCE fix)
2. webhook.php: move WEBHOOK_SECRET to gitignored config.php; rotate secret
3. agent.py: replace recursive main() with while loop (RecursionError fix)
4. jarvis-deploy.sh: push force-revert to GitHub on syntax fail (loop fix)
5. agent.py: self_update() verifies SHA-256 before exec (integrity fix)
6. agent.php: remove JARVIS_IP from browser-action bypass (auth fix)
7. jarvis-watchdog.sh: escape SQL vars in alert() to prevent injection
8. jarvis-deploy.sh: atomic mv instead of cat+truncate (TOCTOU fix)

Also: distribute jarvis-agent.py.sha256 alongside agent for integrity checks
This commit is contained in:
2026-05-25 14:27:27 +00:00
parent 45fef11785
commit ecbc2e09a5
7 changed files with 99 additions and 27 deletions
+32 -9
View File
@@ -331,9 +331,9 @@ def execute_command(cmd: dict) -> dict:
return {"success": True, "updated": updated}
elif cmd_type == "shell":
# Only allow if explicitly enabled in config
if not cmd_data.get("allowed", False):
return {"success": False, "error": "Shell commands not enabled"}
# Guard reads LOCAL config, not the server-supplied payload
if not cfg.get("allow_shell_commands", False):
return {"success": False, "error": "Shell commands not enabled in agent config"}
cmd_str = cmd_data.get("command", "")
r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30)
return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]}
@@ -359,15 +359,13 @@ def main():
poll_interval = int(cfg.get("poll_interval", 30))
heartbeat_every = int(cfg.get("heartbeat_every", 10))
# Register if no API key yet
# Register if no API key yet — loop (not recurse) to avoid stack overflow
api_key = state.get("api_key", "")
if not api_key:
while not api_key:
api_key = register(cfg, state)
if not api_key:
print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True)
time.sleep(60)
main()
return
headers = {"X-Agent-Key": api_key}
last_metrics = 0
@@ -424,7 +422,9 @@ def main():
# ── Self-update ────────────────────────────────────────────────────────────────
def self_update(cfg: dict) -> bool:
"""Check JARVIS server for a newer version of this script. If different, replace and restart."""
"""Check JARVIS server for a newer version of this script.
Verifies SHA-256 hash from <update_url>.sha256 before replacing."""
import hashlib
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else ""
update_url = cfg.get("update_url", default_update_url)
@@ -432,14 +432,37 @@ def self_update(cfg: dict) -> bool:
return False
script_path = os.path.abspath(__file__)
try:
# Download expected hash first
hash_url = update_url + ".sha256"
req_hash = urllib.request.Request(hash_url)
req_hash.add_header("User-Agent", "JARVIS-Agent/1.0")
if _host_header:
req_hash.add_header("Host", _host_header)
try:
with urllib.request.urlopen(req_hash, timeout=10) as resp:
expected_hash = resp.read().decode().strip().split()[0]
except Exception:
expected_hash = None
# Download new script
req = urllib.request.Request(update_url)
req.add_header("User-Agent", "JARVIS-Agent/1.0")
if _host_header:
req.add_header("Host", _host_header)
with urllib.request.urlopen(req, timeout=30) as resp:
new_content = resp.read()
# Verify hash if available — abort if mismatch
if expected_hash:
actual_hash = hashlib.sha256(new_content).hexdigest()
if actual_hash != expected_hash:
print(f"[JARVIS] Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting", flush=True)
return False
with open(script_path, "rb") as f:
current = f.read()
if new_content != current:
print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True)
print(f"[JARVIS] Update verified — replacing {script_path} and restarting...", flush=True)
with open(script_path, "wb") as f:
f.write(new_content)
os.execv(sys.executable, [sys.executable] + sys.argv)
+1 -1
View File
@@ -53,7 +53,7 @@ if ($agentAction !== 'register') {
if (in_array($agentAction, $browserActions)) {
$token = $_SESSION['jarvis_token'] ?? '';
$localIP = $_SERVER['REMOTE_ADDR'] ?? '';
if (empty($token) && !in_array($localIP, ['127.0.0.1', '::1', JARVIS_IP])) {
if (empty($token) && !in_array($localIP, ['127.0.0.1', '::1'])) {
agent_error(401, 'Unauthorized');
}
$agent = null;
+15 -5
View File
@@ -13,9 +13,12 @@ log() { echo "[$(TS)] $1" >> "$LOG"; }
[ ! -f "$QUEUE" ] && exit 0
[ ! -s "$QUEUE" ] && exit 0
# Snapshot and clear queue atomically
SNAPSHOT=$(cat "$QUEUE")
> "$QUEUE"
# Atomically take ownership of the queue via rename — prevents TOCTOU loss of
# entries written between a cat and truncate
PROCESSING="${QUEUE}.processing"
mv "$QUEUE" "$PROCESSING" 2>/dev/null || exit 0
SNAPSHOT=$(cat "$PROCESSING")
rm -f "$PROCESSING"
while IFS= read -r path; do
[ -z "$path" ] && continue
@@ -51,13 +54,20 @@ while IFS= read -r path; do
done <<< "$CHANGED"
if [ "$SYNTAX_OK" = false ]; then
log "SYNTAX ERROR in $BAD_FILE — reverting to $BEFORE"
log "SYNTAX ERROR in $BAD_FILE — reverting locally and pushing revert to GitHub"
git reset --hard "$BEFORE" >> "$LOG" 2>&1
# Push the revert so GitHub matches the live server — prevents infinite re-deploy loop
git push --force origin HEAD:main >> "$LOG" 2>&1
PUSH_EXIT=$?
if [ $PUSH_EXIT -ne 0 ]; then
log "WARNING: Force-push of revert failed (exit $PUSH_EXIT) — bad commit still on GitHub"
fi
# Insert alert into JARVIS DB
BAD_ESCAPED=$(printf '%s' "$BAD_FILE" | sed "s/'/\\\\\\'/g")
mysql -u jarvis_user -pJ4rv1s_Pr0t0c0l_2026! jarvis_db -se \
"INSERT INTO alerts (alert_type,title,message,severity)
VALUES ('deploy_fail','Deploy reverted: syntax error',
'PHP syntax error in $BAD_FILE. Commit $AFTER was reverted automatically.','critical');" 2>/dev/null
'PHP syntax error in $BAD_ESCAPED. Commit $AFTER was reverted and force-pushed to GitHub.','critical');" 2>/dev/null
log "Reverted. Bad commit: $AFTER"
continue
fi
+10 -2
View File
@@ -10,15 +10,23 @@ TS() { date '+%Y-%m-%d %H:%M:%S'; }
log() { echo "[$(TS)] $1" >> "$LOG"; }
# Escape single quotes for MySQL string interpolation in bash
sql_esc() { printf '%s' "$1" | sed "s/'/\\\\''/g"; }
alert() {
local type="$1" title="$2" msg="$3" sev="${4:-warning}"
local e_type e_title e_msg e_sev
e_type=$(sql_esc "$type"); e_title=$(sql_esc "$title")
e_msg=$(sql_esc "$msg"); e_sev=$(sql_esc "$sev")
$MYSQL "INSERT IGNORE INTO alerts (alert_type,title,message,severity,source_key,auto_resolve)
VALUES ('$type','$title','$msg','$sev','watchdog:$type',1);" 2>/dev/null
VALUES ('$e_type','$e_title','$e_msg','$e_sev','watchdog:$e_type',1);" 2>/dev/null
}
resolve() {
local e_key
e_key=$(sql_esc "$1")
$MYSQL "UPDATE alerts SET resolved=1,resolved_at=NOW()
WHERE source_key='watchdog:$1' AND resolved=0;" 2>/dev/null
WHERE source_key='watchdog:$e_key' AND resolved=0;" 2>/dev/null
}
# ── Service health ─────────────────────────────────────────────────────────────
+32 -9
View File
@@ -331,9 +331,9 @@ def execute_command(cmd: dict) -> dict:
return {"success": True, "updated": updated}
elif cmd_type == "shell":
# Only allow if explicitly enabled in config
if not cmd_data.get("allowed", False):
return {"success": False, "error": "Shell commands not enabled"}
# Guard reads LOCAL config, not the server-supplied payload
if not cfg.get("allow_shell_commands", False):
return {"success": False, "error": "Shell commands not enabled in agent config"}
cmd_str = cmd_data.get("command", "")
r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30)
return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]}
@@ -359,15 +359,13 @@ def main():
poll_interval = int(cfg.get("poll_interval", 30))
heartbeat_every = int(cfg.get("heartbeat_every", 10))
# Register if no API key yet
# Register if no API key yet — loop (not recurse) to avoid stack overflow
api_key = state.get("api_key", "")
if not api_key:
while not api_key:
api_key = register(cfg, state)
if not api_key:
print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True)
time.sleep(60)
main()
return
headers = {"X-Agent-Key": api_key}
last_metrics = 0
@@ -424,7 +422,9 @@ def main():
# ── Self-update ────────────────────────────────────────────────────────────────
def self_update(cfg: dict) -> bool:
"""Check JARVIS server for a newer version of this script. If different, replace and restart."""
"""Check JARVIS server for a newer version of this script.
Verifies SHA-256 hash from <update_url>.sha256 before replacing."""
import hashlib
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else ""
update_url = cfg.get("update_url", default_update_url)
@@ -432,14 +432,37 @@ def self_update(cfg: dict) -> bool:
return False
script_path = os.path.abspath(__file__)
try:
# Download expected hash first
hash_url = update_url + ".sha256"
req_hash = urllib.request.Request(hash_url)
req_hash.add_header("User-Agent", "JARVIS-Agent/1.0")
if _host_header:
req_hash.add_header("Host", _host_header)
try:
with urllib.request.urlopen(req_hash, timeout=10) as resp:
expected_hash = resp.read().decode().strip().split()[0]
except Exception:
expected_hash = None
# Download new script
req = urllib.request.Request(update_url)
req.add_header("User-Agent", "JARVIS-Agent/1.0")
if _host_header:
req.add_header("Host", _host_header)
with urllib.request.urlopen(req, timeout=30) as resp:
new_content = resp.read()
# Verify hash if available — abort if mismatch
if expected_hash:
actual_hash = hashlib.sha256(new_content).hexdigest()
if actual_hash != expected_hash:
print(f"[JARVIS] Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting", flush=True)
return False
with open(script_path, "rb") as f:
current = f.read()
if new_content != current:
print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True)
print(f"[JARVIS] Update verified — replacing {script_path} and restarting...", flush=True)
with open(script_path, "wb") as f:
f.write(new_content)
os.execv(sys.executable, [sys.executable] + sys.argv)
+1
View File
@@ -0,0 +1 @@
6c93ea50f3a91472444a10838b89d4222b8378cd153efee4ed9f75d7d5fb25b2
+8 -1
View File
@@ -3,9 +3,16 @@
* GitHub Auto-Deploy Webhook
* Verifies GitHub HMAC signature, then queues the repo for git pull.
* A root cron job (/usr/local/bin/jarvis-deploy.sh) processes the queue every minute.
*
* WEBHOOK_SECRET is loaded from api/config.php (gitignored) never hardcoded here.
*/
define('WEBHOOK_SECRET', '8a8c50c83d37527bdef876f1736b654235724a1a475cb8e5');
require_once __DIR__ . '/../../api/config.php';
if (!defined('WEBHOOK_SECRET')) {
http_response_code(500);
echo json_encode(['error' => 'Webhook not configured']);
exit;
}
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt');
define('DEPLOY_LOG', '/home/jarvis.orbishosting.com/logs/deploy.log');