#!/usr/bin/env php
<?php
/* ============================================================
   alpha-fim — fingerprint a directory tree, as root.

   Called by the panel's File Integrity Monitor through sudo. The panel runs as
   www-data and therefore cannot read /root/.ssh, /etc/sudoers.d or a mode-0600
   config — which are precisely the files worth watching. A monitor that
   silently skips them reports "no changes" forever and is worse than none.

   ⚠️ THIS SCRIPT IS ROOT-OWNED AND SELF-CONTAINED ON PURPOSE. It must never
   include anything from /var/www/alpha — that tree is writable by www-data,
   and root sourcing www-data-writable code is a straight path from "web bug"
   to "root shell".

   ⚠️ IT READS AND HASHES. It never writes, deletes, chmods or executes
   anything in the tree it is pointed at. The only output is on stdout.

   Usage: alpha-fim scan   <absolute-root> [comma,separated,excludes]
          alpha-fim exists <absolute-root>
   Output: JSON {"count":N,"bytes":N,"capped":bool,"skipped":N,"files":{rel:{…}}}
           or   {"exists":bool}
   ============================================================ */

const MAX_FILES = 40000;
const MAX_HASH  = 16777216;
const MAX_DEPTH = 24;

/* The only trees this helper will ever look at. An unbounded path argument
   would make the sudoers rule equivalent to "read any file on the box". */
const ALLOW = [
  '/etc',
  '/root/.ssh',
  '/usr/local/sbin',
  '/usr/local/bin',
  '/var/www',
  '/var/spool/cron',
  '/srv',
  '/opt',
  '/boot',
];

function fail($msg, $code = 2) { fwrite(STDERR, $msg . "\n"); exit($code); }

$argv = $_SERVER['argv'];
$cmd  = $argv[1] ?? '';
if ($cmd !== 'scan' && $cmd !== 'exists') fail('usage: alpha-fim scan|exists <root> [excludes]');

$arg = (string) ($argv[2] ?? '');
if ($arg === '' || $arg[0] !== '/') fail('root must be an absolute path');
/* rtrim would turn "/" into "" — say what is actually being refused. */
if (trim($arg, '/') === '') fail('refusing to scan /');
$root = rtrim($arg, '/');
$real = realpath($root);

/* ⚠️ The allow-list is checked against the RESOLVED path, before anything is
   read. "/etc/../root" resolves to /root and is refused here — checking the
   argument as written is exactly the hole that lets a caller out of the jail. */
$check = function ($p) {
  $p = rtrim($p, '/');
  if ($p === '') fail('refusing to scan /');
  foreach (ALLOW as $a) if ($p === $a || strpos($p . '/', $a . '/') === 0) return $p;
  fail('that path is not in the allow-list');
};

if ($cmd === 'exists') {
  /* ⚠️ Answers only "is there a directory here", and only for an allow-listed
     path. www-data cannot stat /root/.ssh, and without this the panel would
     drop the single most important watch on the box from its own suggestions
     and never say why. It reveals one bit about nine fixed directories. */
  if ($real === false) { $check($root); echo json_encode(['exists' => false]); exit(0); }
  $check($real);
  echo json_encode(['exists' => is_dir($real)]);
  exit(0);
}

if ($real === false || !is_dir($real)) fail('no such directory');
$real = $check($real);

$pats = array_values(array_filter(array_map('trim', explode(',', (string) ($argv[3] ?? '')))));

function excluded($rel, array $pats) {
  $base = basename($rel);
  foreach ($pats as $p) {
    if ($p === '') continue;
    if (strpos($p, '*') === false) {
      if ($base === $p) return true;
      if (strpos('/' . $rel . '/', '/' . $p . '/') !== false) return true;
    } elseif (fnmatch($p, $base) || fnmatch($p, $rel)) return true;
  }
  return false;
}

$files = []; $bytes = 0; $capped = false; $skipped = 0;
$stack = [[$real, 0]];

while ($stack) {
  [$dir, $depth] = array_pop($stack);
  if ($depth > MAX_DEPTH) continue;
  $dh = @opendir($dir);
  if (!$dh) { $skipped++; continue; }
  while (($e = readdir($dh)) !== false) {
    if ($e === '.' || $e === '..') continue;
    $full = $dir . '/' . $e;
    $rel  = ltrim(substr($full, strlen($real)), '/');
    if (excluded($rel, $pats)) continue;

    /* lstat, never stat: a symlink planted in a watched tree must be recorded
       as a symlink, not silently followed out of the jail. */
    $st = @lstat($full);
    if (!$st) { $skipped++; continue; }

    if (is_link($full)) {
      $files[$rel] = ['h' => 'L:' . substr(hash('sha256', (string) @readlink($full)), 0, 16),
                      's' => 0, 'm' => (int) $st['mtime'],
                      'p' => sprintf('%04o', $st['mode'] & 07777),
                      'u' => (int) $st['uid'], 'g' => (int) $st['gid'], 't' => 'link'];
      continue;
    }
    if (($st['mode'] & 0170000) === 0040000) { $stack[] = [$full, $depth + 1]; continue; }
    if (($st['mode'] & 0170000) !== 0100000) continue;

    if (count($files) >= MAX_FILES) { $capped = true; break; }

    $size = (int) $st['size'];
    $bytes += $size;
    if ($size <= MAX_HASH) {
      $h = @hash_file('sha256', $full);
      $h = $h === false ? 'unreadable' : substr($h, 0, 32);
    } else {
      $h = 'big:' . $size . ':' . $st['mtime'];
    }
    $files[$rel] = ['h' => $h, 's' => $size, 'm' => (int) $st['mtime'],
                    'p' => sprintf('%04o', $st['mode'] & 07777),
                    'u' => (int) $st['uid'], 'g' => (int) $st['gid'], 't' => 'file'];
  }
  closedir($dh);
  if ($capped) break;
}

/* ⚠️ The hashes go out, the CONTENTS never do. This helper deliberately has no
   "cat" mode: a sudo rule that prints any file under /etc as root would hand
   over /etc/shadow to anything that can reach the panel's action endpoint. */
echo json_encode(['count' => count($files), 'bytes' => $bytes, 'capped' => $capped,
                  'skipped' => $skipped, 'files' => $files], JSON_UNESCAPED_SLASHES);
