mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
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:
+32
-9
@@ -331,9 +331,9 @@ def execute_command(cmd: dict) -> dict:
|
|||||||
return {"success": True, "updated": updated}
|
return {"success": True, "updated": updated}
|
||||||
|
|
||||||
elif cmd_type == "shell":
|
elif cmd_type == "shell":
|
||||||
# Only allow if explicitly enabled in config
|
# Guard reads LOCAL config, not the server-supplied payload
|
||||||
if not cmd_data.get("allowed", False):
|
if not cfg.get("allow_shell_commands", False):
|
||||||
return {"success": False, "error": "Shell commands not enabled"}
|
return {"success": False, "error": "Shell commands not enabled in agent config"}
|
||||||
cmd_str = cmd_data.get("command", "")
|
cmd_str = cmd_data.get("command", "")
|
||||||
r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30)
|
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]}
|
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))
|
poll_interval = int(cfg.get("poll_interval", 30))
|
||||||
heartbeat_every = int(cfg.get("heartbeat_every", 10))
|
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", "")
|
api_key = state.get("api_key", "")
|
||||||
if not api_key:
|
while not api_key:
|
||||||
api_key = register(cfg, state)
|
api_key = register(cfg, state)
|
||||||
if not api_key:
|
if not api_key:
|
||||||
print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True)
|
print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True)
|
||||||
time.sleep(60)
|
time.sleep(60)
|
||||||
main()
|
|
||||||
return
|
|
||||||
|
|
||||||
headers = {"X-Agent-Key": api_key}
|
headers = {"X-Agent-Key": api_key}
|
||||||
last_metrics = 0
|
last_metrics = 0
|
||||||
@@ -424,7 +422,9 @@ def main():
|
|||||||
# ── Self-update ────────────────────────────────────────────────────────────────
|
# ── Self-update ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def self_update(cfg: dict) -> bool:
|
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("/")
|
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
|
||||||
default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else ""
|
default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else ""
|
||||||
update_url = cfg.get("update_url", default_update_url)
|
update_url = cfg.get("update_url", default_update_url)
|
||||||
@@ -432,14 +432,37 @@ def self_update(cfg: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
script_path = os.path.abspath(__file__)
|
script_path = os.path.abspath(__file__)
|
||||||
try:
|
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 = urllib.request.Request(update_url)
|
||||||
req.add_header("User-Agent", "JARVIS-Agent/1.0")
|
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:
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
new_content = resp.read()
|
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:
|
with open(script_path, "rb") as f:
|
||||||
current = f.read()
|
current = f.read()
|
||||||
if new_content != current:
|
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:
|
with open(script_path, "wb") as f:
|
||||||
f.write(new_content)
|
f.write(new_content)
|
||||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ if ($agentAction !== 'register') {
|
|||||||
if (in_array($agentAction, $browserActions)) {
|
if (in_array($agentAction, $browserActions)) {
|
||||||
$token = $_SESSION['jarvis_token'] ?? '';
|
$token = $_SESSION['jarvis_token'] ?? '';
|
||||||
$localIP = $_SERVER['REMOTE_ADDR'] ?? '';
|
$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_error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
$agent = null;
|
$agent = null;
|
||||||
|
|||||||
+15
-5
@@ -13,9 +13,12 @@ log() { echo "[$(TS)] $1" >> "$LOG"; }
|
|||||||
[ ! -f "$QUEUE" ] && exit 0
|
[ ! -f "$QUEUE" ] && exit 0
|
||||||
[ ! -s "$QUEUE" ] && exit 0
|
[ ! -s "$QUEUE" ] && exit 0
|
||||||
|
|
||||||
# Snapshot and clear queue atomically
|
# Atomically take ownership of the queue via rename — prevents TOCTOU loss of
|
||||||
SNAPSHOT=$(cat "$QUEUE")
|
# entries written between a cat and truncate
|
||||||
> "$QUEUE"
|
PROCESSING="${QUEUE}.processing"
|
||||||
|
mv "$QUEUE" "$PROCESSING" 2>/dev/null || exit 0
|
||||||
|
SNAPSHOT=$(cat "$PROCESSING")
|
||||||
|
rm -f "$PROCESSING"
|
||||||
|
|
||||||
while IFS= read -r path; do
|
while IFS= read -r path; do
|
||||||
[ -z "$path" ] && continue
|
[ -z "$path" ] && continue
|
||||||
@@ -51,13 +54,20 @@ while IFS= read -r path; do
|
|||||||
done <<< "$CHANGED"
|
done <<< "$CHANGED"
|
||||||
|
|
||||||
if [ "$SYNTAX_OK" = false ]; then
|
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
|
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
|
# Insert alert into JARVIS DB
|
||||||
|
BAD_ESCAPED=$(printf '%s' "$BAD_FILE" | sed "s/'/\\\\\\'/g")
|
||||||
mysql -u jarvis_user -pJ4rv1s_Pr0t0c0l_2026! jarvis_db -se \
|
mysql -u jarvis_user -pJ4rv1s_Pr0t0c0l_2026! jarvis_db -se \
|
||||||
"INSERT INTO alerts (alert_type,title,message,severity)
|
"INSERT INTO alerts (alert_type,title,message,severity)
|
||||||
VALUES ('deploy_fail','Deploy reverted: syntax error',
|
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"
|
log "Reverted. Bad commit: $AFTER"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -10,15 +10,23 @@ TS() { date '+%Y-%m-%d %H:%M:%S'; }
|
|||||||
|
|
||||||
log() { echo "[$(TS)] $1" >> "$LOG"; }
|
log() { echo "[$(TS)] $1" >> "$LOG"; }
|
||||||
|
|
||||||
|
# Escape single quotes for MySQL string interpolation in bash
|
||||||
|
sql_esc() { printf '%s' "$1" | sed "s/'/\\\\''/g"; }
|
||||||
|
|
||||||
alert() {
|
alert() {
|
||||||
local type="$1" title="$2" msg="$3" sev="${4:-warning}"
|
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)
|
$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() {
|
resolve() {
|
||||||
|
local e_key
|
||||||
|
e_key=$(sql_esc "$1")
|
||||||
$MYSQL "UPDATE alerts SET resolved=1,resolved_at=NOW()
|
$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 ─────────────────────────────────────────────────────────────
|
# ── Service health ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -331,9 +331,9 @@ def execute_command(cmd: dict) -> dict:
|
|||||||
return {"success": True, "updated": updated}
|
return {"success": True, "updated": updated}
|
||||||
|
|
||||||
elif cmd_type == "shell":
|
elif cmd_type == "shell":
|
||||||
# Only allow if explicitly enabled in config
|
# Guard reads LOCAL config, not the server-supplied payload
|
||||||
if not cmd_data.get("allowed", False):
|
if not cfg.get("allow_shell_commands", False):
|
||||||
return {"success": False, "error": "Shell commands not enabled"}
|
return {"success": False, "error": "Shell commands not enabled in agent config"}
|
||||||
cmd_str = cmd_data.get("command", "")
|
cmd_str = cmd_data.get("command", "")
|
||||||
r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30)
|
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]}
|
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))
|
poll_interval = int(cfg.get("poll_interval", 30))
|
||||||
heartbeat_every = int(cfg.get("heartbeat_every", 10))
|
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", "")
|
api_key = state.get("api_key", "")
|
||||||
if not api_key:
|
while not api_key:
|
||||||
api_key = register(cfg, state)
|
api_key = register(cfg, state)
|
||||||
if not api_key:
|
if not api_key:
|
||||||
print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True)
|
print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True)
|
||||||
time.sleep(60)
|
time.sleep(60)
|
||||||
main()
|
|
||||||
return
|
|
||||||
|
|
||||||
headers = {"X-Agent-Key": api_key}
|
headers = {"X-Agent-Key": api_key}
|
||||||
last_metrics = 0
|
last_metrics = 0
|
||||||
@@ -424,7 +422,9 @@ def main():
|
|||||||
# ── Self-update ────────────────────────────────────────────────────────────────
|
# ── Self-update ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def self_update(cfg: dict) -> bool:
|
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("/")
|
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
|
||||||
default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else ""
|
default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else ""
|
||||||
update_url = cfg.get("update_url", default_update_url)
|
update_url = cfg.get("update_url", default_update_url)
|
||||||
@@ -432,14 +432,37 @@ def self_update(cfg: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
script_path = os.path.abspath(__file__)
|
script_path = os.path.abspath(__file__)
|
||||||
try:
|
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 = urllib.request.Request(update_url)
|
||||||
req.add_header("User-Agent", "JARVIS-Agent/1.0")
|
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:
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
new_content = resp.read()
|
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:
|
with open(script_path, "rb") as f:
|
||||||
current = f.read()
|
current = f.read()
|
||||||
if new_content != current:
|
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:
|
with open(script_path, "wb") as f:
|
||||||
f.write(new_content)
|
f.write(new_content)
|
||||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
6c93ea50f3a91472444a10838b89d4222b8378cd153efee4ed9f75d7d5fb25b2
|
||||||
@@ -3,9 +3,16 @@
|
|||||||
* GitHub Auto-Deploy Webhook
|
* GitHub Auto-Deploy Webhook
|
||||||
* Verifies GitHub HMAC signature, then queues the repo for git pull.
|
* 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.
|
* 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_QUEUE', '/tmp/jarvis-deploy-queue.txt');
|
||||||
define('DEPLOY_LOG', '/home/jarvis.orbishosting.com/logs/deploy.log');
|
define('DEPLOY_LOG', '/home/jarvis.orbishosting.com/logs/deploy.log');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user