All files / src/routes Security.svelte

0% Statements 0/171
0% Branches 0/1
0% Functions 0/1
0% Lines 0/171

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       
<script>
  import { get } from "../lib/api.js";
  import { onMount } from "svelte";
 
  let logs = $state([]);
  let stats = $state(null);
  let loading = $state(false);
  let filter = $state("all");
  let ipFilter = $state("");
  let startDate = $state("");
  let endDate = $state("");
  let filterError = $state("");
 
  onMount(loadAll);
 
  async function loadAll() {
    loading = true;
    await Promise.all([loadStats(), loadLogs()]);
    loading = false;
  }
 
  async function loadStats() {
    try {
      stats = await get("/security/stats");
    } catch (e) {
      console.error("Failed to load security stats:", e);
    }
  }
 
  function buildLogsQuery() {
    const params = new URLSearchParams({ limit: "300" });
    if (filter !== "all") params.set("type", filter);
    if (ipFilter.trim()) params.set("ip", ipFilter.trim());
    if (startDate) params.set("start_date", startDate);
    if (endDate) params.set("end_date", endDate);
    return params.toString();
  }
 
  function validateFilters() {
    if (startDate && endDate && startDate > endDate) {
      filterError = "From date must be on or before To date";
      return false;
    }
    filterError = "";
    return true;
  }
 
  async function loadLogs() {
    if (!validateFilters()) {
      logs = [];
      return;
    }
 
    try {
      const res = await get(`/security/logs?${buildLogsQuery()}`);
      logs = res.logs;
    } catch (e) {
      console.error("Failed to load security logs:", e);
    }
  }
 
  async function applyTypeFilter(nextFilter) {
    filter = nextFilter;
    await loadLogs();
  }
 
  async function applyIpFilter(ip) {
    ipFilter = ip;
    await loadLogs();
  }
 
  async function clearFilters() {
    filter = "all";
    ipFilter = "";
    startDate = "";
    endDate = "";
    filterError = "";
    await loadLogs();
  }
 
  let maxTimelineCount = $derived(
    stats ? Math.max(1, ...stats.timeline.map(t => t.count)) : 1
  );
 
  let hasActiveFilters = $derived(
    filter !== "all" || !!ipFilter.trim() || !!startDate || !!endDate
  );
 
  function isBlockedScannerEvent(entry) {
    if (entry.type !== "unauthorized") return false;
 
    const message = (entry.message || "").toLowerCase();
    return [
      "/.git/",
      "phpinfo",
      "debug.log",
      "laravel.log",
      "stripe",
      "swagger.json",
      "wp-config",
      "credentials",
      "secrets",
      "config",
      "@vite",
      "manifest.json",
    ].some((needle) => message.includes(needle));
  }
 
  const logTypeFilters = ["all", "auth_failure", "suspicious_request", "unauthorized"];
  const logTypeButtonLabels = {
    all: "All",
    auth_failure: "Auth",
    suspicious_request: "Requests",
    unauthorized: "Blocked",
  };
 
  const typeLabels = { auth_failure: "Auth", suspicious_request: "Request", unauthorized: "Blocked", error: "Error" };
  const typeColors = {
    auth_failure: "text-red-400",
    suspicious_request: "text-amber-400",
    unauthorized: "text-orange-400",
    error: "text-surface-500"
  };
</script>
 
<div class="space-y-6">
  <div class="flex items-center justify-between">
    <h2 class="text-lg font-bold text-surface-800 dark:text-surface-100">Security</h2>
    <button onclick={loadAll} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
      {loading ? "Loading..." : "Refresh"}
    </button>
  </div>
 
  <!-- Stats cards -->
  {#if stats}
    <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
        <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Failed Logins (24h)</p>
        <p class="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">{stats.failed_logins_24h}</p>
      </div>
      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
        <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Suspicious Requests</p>
        <p class="text-2xl font-bold text-amber-600 dark:text-amber-400 mt-1">{stats.suspicious_requests}</p>
      </div>
      <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
        <p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Unique IPs</p>
        <p class="text-2xl font-bold text-surface-700 dark:text-surface-200 mt-1">{stats.unique_ips}</p>
      </div>
    </div>
  {/if}
 
  <!-- Timeline chart -->
  {#if stats?.timeline}
    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Activity (Last 24h)</h3>
      <div class="flex items-end gap-1 h-24">
        {#each [...stats.timeline].reverse() as bar}
          <div class="flex-1 flex flex-col items-center justify-end h-full">
            <div
              class="w-full rounded-t transition-all {bar.count > 0 ? 'bg-primary-500 dark:bg-primary-400' : 'bg-surface-100 dark:bg-surface-700'}"
              style="height: {bar.count > 0 ? Math.max(8, (bar.count / maxTimelineCount) * 100) : 4}%"
              title="{bar.hours_ago}h ago: {bar.count} events"
            ></div>
          </div>
        {/each}
      </div>
      <div class="flex justify-between mt-1">
        <span class="text-[10px] text-surface-400">24h ago</span>
        <span class="text-[10px] text-surface-400">now</span>
      </div>
    </div>
  {/if}
 
  <!-- Top IPs -->
  {#if stats?.top_ips?.length}
    <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
      <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top IPs</h3>
      <div class="space-y-2">
        {#each stats.top_ips as entry}
          <div class="flex items-center gap-3 text-sm">
            <button onclick={() => applyIpFilter(entry.ip)} class="text-xs font-mono text-surface-600 hover:text-primary-600 dark:text-surface-300 dark:hover:text-primary-300 w-36 shrink-0 text-left transition-colors">{entry.ip}</button>
            <div class="flex-1 bg-surface-100 dark:bg-surface-700 rounded-full h-2 overflow-hidden">
              <div class="bg-red-500 dark:bg-red-400 h-full rounded-full" style="width: {(entry.count / stats.top_ips[0].count) * 100}%"></div>
            </div>
            <span class="text-xs text-surface-500 w-8 text-right">{entry.count}</span>
          </div>
        {/each}
      </div>
    </div>
  {/if}
 
  <!-- Log table -->
  <div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
    <div class="flex items-start justify-between gap-3 mb-3">
      <div>
        <h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Event Log</h3>
        <p class="text-xs text-surface-400 mt-1">Filter recent events by type, IP, or date range.</p>
      </div>
      {#if hasActiveFilters}
        <button onclick={clearFilters} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
          Clear filters
        </button>
      {/if}
    </div>
 
    <div class="flex flex-wrap gap-1.5 mb-3">
      {#each logTypeFilters as f}
        <button onclick={() => applyTypeFilter(f)} class="px-2.5 py-1 text-xs rounded-md transition-colors {filter === f ? 'bg-surface-800 text-white dark:bg-surface-200 dark:text-surface-900' : 'bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
          {logTypeButtonLabels[f]}
        </button>
      {/each}
    </div>
 
    <div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
      <label class="block">
        <span class="block text-xs font-medium text-surface-500 mb-1">Filter by IP</span>
        <input
          type="text"
          bind:value={ipFilter}
          placeholder="e.g. 1.2.3.4"
          onkeydown={async (e) => { if (e.key === 'Enter') await loadLogs(); }}
          class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
        />
      </label>
      <label class="block">
        <span class="block text-xs font-medium text-surface-500 mb-1">From</span>
        <input
          type="date"
          bind:value={startDate}
          onchange={loadLogs}
          class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
        />
      </label>
      <label class="block">
        <span class="block text-xs font-medium text-surface-500 mb-1">To</span>
        <input
          type="date"
          bind:value={endDate}
          onchange={loadLogs}
          class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
        />
      </label>
    </div>
 
    <div class="flex items-center gap-2 mb-3">
      <button onclick={loadLogs} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors dark:bg-primary-900/30 dark:text-primary-300">
        Apply filters
      </button>
      {#if ipFilter.trim()}
        <button onclick={() => applyIpFilter(ipFilter.trim())} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
          Use typed IP
        </button>
      {/if}
    </div>
 
    {#if filterError}
      <p class="text-sm text-rose-500 mb-3">{filterError}</p>
    {/if}
 
    {#if logs.length > 0}
      <div class="max-h-96 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
        {#each logs as e}
          <div class="text-xs font-mono flex gap-2 leading-5">
            <span class="{typeColors[e.type] || 'text-surface-500'} w-12 shrink-0">{typeLabels[e.type] || e.type}</span>
            <span class="text-surface-500 shrink-0">{e.timestamp}</span>
            {#if e.ip}
              <button onclick={() => applyIpFilter(e.ip)} class="text-surface-400 hover:text-primary-300 shrink-0 w-28 text-left transition-colors">{e.ip}</button>
            {/if}
            {#if e.username}
              <span class="text-red-400 shrink-0">{e.username}</span>
            {/if}
            {#if isBlockedScannerEvent(e)}
              <span class="shrink-0 rounded-md bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-medium text-orange-300 border border-orange-500/30">scanner</span>
            {/if}
            <span class="text-surface-300 truncate">{e.message}</span>
          </div>
        {/each}
      </div>
    {:else if !loading}
      <p class="text-sm text-surface-400">{hasActiveFilters ? "No security events match the current filters" : "No security events found"}</p>
    {/if}
  </div>
</div>