Step 3: Configure PHP-FPM to use the proxy
Add this to the bottom of your PHP-FPM pool configuration (typically /etc/php/8.5/fpm/pool.d/www.conf):
; --- SQUID PROXY INTEGRATION ---
; Prevent the master process from clearing environment variables
clear_env = no
; Route all outbound HTTP/HTTPS through the local Squid proxy
env[http_proxy] = http://127.0.0.1:3128
env[https_proxy] = http://127.0.0.1:3128
env[HTTP_PROXY] = http://127.0.0.1:3128
env[HTTPS_PROXY] = http://127.0.0.1:3128
; --- ACCESS LOGGING FOR SQUID CORRELATION ---
; Logs every PHP request with its script path and host — the Python
; audit script cross-references these against Squid's connection logs.
access.log = /var/log/php8.5-fpm.access.log
access.format = "[%t] %m %{REQUEST_METHOD}e %f %{HTTP_HOST}e"
The access log format:
%t— Timestamp. Produces[13/May/2026:10:30:45 +0000]. This is what the Python audit script parses to match events between the Squid log and the PHP log within a 2-second window.%m— Request duration in milliseconds.234means the PHP request took 234ms. Useful for spotting slow requests, but secondary to the audit script's correlation work.%{REQUEST_METHOD}e— The HTTP method. Almost alwaysGETorPOST. Included for completeness; the audit script doesn't use it directly.%f— The full script path on disk. Produces/var/www/mysite.com/public_html/wp-cron.php. This is the critical field — it tells the audit script exactly which PHP file handled the request, which gets cross-referenced against Squid's connection log to answer "which script made this outbound call?"%{HTTP_HOST}e— The host header from the request. Producesmysite.com. Tells the audit script which site the request belongs to. Without this, you'd know a script on the server phoned home but not which virtual host it was running under.
Without the script path and host, you'd know that an outbound connection happened — but not which site or which plugin initiated it. With both, the audit script can answer "who called home and from where."
While you're in the pool configuration, lock down the functions that could bypass the proxy or execute arbitrary commands. Add this after the access-logging block:
; --- RESTRICTED FUNCTIONS ---
; Block command execution, process control, and raw socket functions.
; These have no legitimate use in a PHP web application and are common
; targets for malicious code that tries to bypass HTTP-level controls.
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname
A few of these deserve explanation:
exec,passthru,shell_exec,system,proc_open,popen— Direct command execution. If a malicious script can call these, it doesn't need to phone home over HTTP — it can run a reverse shell, spawn a crypto miner, or dump your database locally.curl_multi_exec— The multi-curl interface. Some malware uses this as an alternative to the standard cURL functions, assuming onlycurl_execgets blocked. Single-request cURL still works through the Squid proxy; multi-curl — which has no practical use in a web request/response cycle — is disabled.pcntl_*(process control) — Forking, signaling, and waiting on child processes. A compromised script that can fork can spawn background processes that outlive the PHP request and bypass the proxy entirely.posix_kill,posix_setuid,posix_setgid— Privilege escalation and process manipulation. There is no scenario where a PHP web application should be sending signals to other processes or changing its UID.
Step 4: Pass the host header from Nginx to PHP-FPM
PHP-FPM's %{HTTP_HOST}e reads the HTTP_HOST environment variable, which Nginx must set. In your Nginx fastcgi_params or inside the location ~ \.php$ block, add:
fastcgi_param HTTP_HOST $host;
Most Nginx installations already include this in fastcgi_params — verify yours does. If HTTP_HOST is missing, the PHP access log will show a blank host field and the audit script won't be able to identify which site made the request.
Step 5: The audit script
Save as /usr/local/sbin/squid_list.py:
#!/usr/bin/env python3
import re
import sys
from datetime import datetime
SQUID_LOG = "/var/log/squid/access.log"
PHP_LOG = "/var/log/php8.5-fpm.access.log"
# Domains your infrastructure legitimately connects to.
# These are filtered out so the output only shows connections
# worth investigating — unexpected domains, C2 callbacks, exfiltration.
TRUSTED_DOMAINS = [
"api.wordpress.org", "wordpress.org",
"githubusercontent.com", "perfops.one",
]
def parse_squid():
"""Extract timestamp and destination domain from Squid CONNECT logs."""
squid_events = []
squid_regex = re.compile(
r"^(\d+\.\d+)\s+\d+\s+127\.0\.0\.1\s+\S+\s+\d+\s+CONNECT\s+([^:]+)"
)
try:
with open(SQUID_LOG, "r") as f:
for line in f:
match = squid_regex.match(line)
if match:
unix_ts = float(match.group(1))
dt = datetime.fromtimestamp(unix_ts).replace(microsecond=0)
squid_events.append({"time": dt, "domain": match.group(2)})
except FileNotFoundError:
pass
return squid_events
def parse_php():
"""Extract timestamp, script path, and site host from PHP-FPM access log."""
php_events = []
# Log format: [%t] %m %{REQUEST_METHOD}e %f %{HTTP_HOST}e
php_regex = re.compile(
r"^\[([^\]]+)\]\s+\S+\s+\S+\s+(\S+)\s+(\S+)"
)
try:
with open(PHP_LOG, "r") as f:
for line in f:
match = php_regex.match(line)
if match:
time_str = match.group(1).split(" ")[0]
dt = datetime.strptime(time_str, "%d/%b/%Y:%H:%M:%S")
script_path = match.group(2)
vhost = match.group(3)
php_events.append({
"time": dt,
"script": script_path,
"vhost": vhost,
})
except FileNotFoundError:
pass
return php_events
def extract_readable_path(full_path):
"""Strip the document root to show a clean site/script pair."""
match = re.search(r"/www/([^/]+)/public_html/(.+\.php)$", full_path)
if match:
return match.group(1), match.group(2)
return None, full_path[-40:]
def cross_reference(filter_vhost=None):
"""Correlate Squid outbound connections with the PHP script that made them."""
squid = parse_squid()
php = parse_php()
if not squid or not php:
return "No log data available to correlate."
lines = []
for p_ev in php:
for s_ev in squid:
# Skip trusted infrastructure domains — these are expected
if any(trusted in s_ev["domain"] for trusted in TRUSTED_DOMAINS):
continue
# Match events within 2 seconds of each other
if abs((p_ev["time"] - s_ev["time"]).total_seconds()) <= 2:
vhost = p_ev["vhost"] if p_ev["vhost"] else "UNKNOWN"
_, script = extract_readable_path(p_ev["script"])
if filter_vhost and filter_vhost not in vhost:
continue
time_str = p_ev["time"].strftime("%m-%d %H:%M:%S")
lines.append(
f"{time_str} | {vhost} -> {script} | {s_ev['domain']}"
)
return "\n".join(lines[-20:]) if lines else "No suspicious outbound connections detected."
if __name__ == "__main__":
search_arg = sys.argv[1] if len(sys.argv) > 1 else None
print(cross_reference(search_arg))
sudo chmod +x /usr/local/sbin/squid_list.py
Run it to see the last 20 unexpected outbound connections:
sudo /usr/local/sbin/squid_list.py
Filter by a specific site:
sudo /usr/local/sbin/squid_list.py mysite.com
Sample output:
05-13 10:30:45 | mysite.com -> wp-content/plugins/analytics-plus/collector.php | tracking.example.com
05-13 10:31:02 | mysite.com -> wp-admin/admin-ajax.php | cdn.malicious.net
05-13 10:31:15 | anothersite.com -> wp-cron.php | api.phone-home.io
Each line tells you when it happened, which site, which script initiated the connection, and where it went. A domain that doesn't show up in your trusted list is worth investigating. Add your own infrastructure domains to TRUSTED_DOMAINS — the list grows as you identify legitimate services (theme vendors, security plugins, webmaster tools) so the output stays focused on what's actually unexpected.
What this won't catch
This setup monitors PHP's outbound connections that go through HTTP/HTTPS — cURL, file_get_contents(), stream wrappers, SOAP. It won't catch:
- Raw socket connections that bypass HTTP entirely (
fsockopen(),socket_create()) - DNS lookups (PHP resolves hostnames before Squid sees the connection)
- Connections from non-PHP processes running on the same server
Those are separate problems with separate solutions. What this does catch is the 95% of outbound PHP traffic that uses the standard HTTP layer — and it catches it with enough detail to know exactly which file on which site made the call. For the inbound side — blocking attacks before they reach Nginx — the fail2ban + nftables guide covers the firewall layer. Together they close the loop: inbound attacks get blocked, outbound compromise attempts get logged.
This guide is maintained as part of a modular, SSL-first framework. Each configuration is audited for production stability and modern security standards.
Compatibility: Tested against current stable releases. While optimized for the stack above, core logic remains relevant for Nginx 1.26+ and PHP 8.2+ environments.
2026-06-07: Removed the cron scheduling section (Step 6) — neither the inbound blacklist update nor the certbot renewal job are Squid-specific, and both are covered in their respective guides. The article now ends at the audit script; the fail2ban cross-link in the closing paragraph connects readers to the inbound security layer.
2026-06-06: Removed the domain blocklist from the Squid configuration. The blocklist had never been enforced (missing ACL directives in squid.conf). When properly configured during the Dallas-to-Chicago migration, it consumed ~400 MB of RAM and pegged the CPU for 60–90 seconds on reload — an expensive insurance policy against a threat that doesn't match the server's actual risk profile. The Squid proxy remains active as a lightweight audit layer (~24 MB). Bumped Nginx to 1.31.1.