5
.github/workflows/docker-build-push.yml
vendored
5
.github/workflows/docker-build-push.yml
vendored
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- beta
|
|
||||||
- dev
|
- dev
|
||||||
- github-actions-ci
|
- github-actions-ci
|
||||||
paths:
|
paths:
|
||||||
@@ -15,10 +14,6 @@ on:
|
|||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- 'entrypoint.sh'
|
- 'entrypoint.sh'
|
||||||
- '.github/workflows/docker-build-push.yml'
|
- '.github/workflows/docker-build-push.yml'
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
7
.github/workflows/helm-package-push.yml
vendored
7
.github/workflows/helm-package-push.yml
vendored
@@ -4,18 +4,11 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- beta
|
|
||||||
- dev
|
- dev
|
||||||
- github-actions-ci
|
- github-actions-ci
|
||||||
paths:
|
paths:
|
||||||
- 'helm/**'
|
- 'helm/**'
|
||||||
- '.github/workflows/helm-package-push.yml'
|
- '.github/workflows/helm-package-push.yml'
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
release:
|
|
||||||
types:
|
|
||||||
- published
|
|
||||||
- created
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -43,6 +43,7 @@
|
|||||||
- [Docker Run](#docker-run)
|
- [Docker Run](#docker-run)
|
||||||
- [Docker Compose](#docker-compose)
|
- [Docker Compose](#docker-compose)
|
||||||
- [Kubernetes](#kubernetes)
|
- [Kubernetes](#kubernetes)
|
||||||
|
- [Local (Python)](#local-python)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [config.yaml](#configuration-via-configyaml)
|
- [config.yaml](#configuration-via-configyaml)
|
||||||
- [Environment Variables](#configuration-via-enviromental-variables)
|
- [Environment Variables](#configuration-via-enviromental-variables)
|
||||||
@@ -63,6 +64,8 @@ Tip: crawl the `robots.txt` paths for additional fun
|
|||||||
|
|
||||||
It creates realistic fake web applications filled with low‑hanging fruit such as admin panels, configuration files, and exposed fake credentials to attract and identify suspicious activity.
|
It creates realistic fake web applications filled with low‑hanging fruit such as admin panels, configuration files, and exposed fake credentials to attract and identify suspicious activity.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
By wasting attacker resources, Krawl helps clearly distinguish malicious behavior from legitimate crawlers.
|
By wasting attacker resources, Krawl helps clearly distinguish malicious behavior from legitimate crawlers.
|
||||||
|
|
||||||
It features:
|
It features:
|
||||||
@@ -77,8 +80,9 @@ It features:
|
|||||||
- **Customizable Wordlists**: Easy JSON-based configuration
|
- **Customizable Wordlists**: Easy JSON-based configuration
|
||||||
- **Random Error Injection**: Mimic real server behavior
|
- **Random Error Injection**: Mimic real server behavior
|
||||||
|
|
||||||

|
You can easily expose Krawl alongside your other services to shield them from web crawlers and malicious users using a reverse proxy. For more details, see the [Reverse Proxy documentation](docs/reverse-proxy.md).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Krawl Dashboard
|
## Krawl Dashboard
|
||||||
|
|
||||||
@@ -160,6 +164,17 @@ docker-compose down
|
|||||||
### Kubernetes
|
### Kubernetes
|
||||||
**Krawl is also available natively on Kubernetes**. Installation can be done either [via manifest](kubernetes/README.md) or [using the helm chart](helm/README.md).
|
**Krawl is also available natively on Kubernetes**. Installation can be done either [via manifest](kubernetes/README.md) or [using the helm chart](helm/README.md).
|
||||||
|
|
||||||
|
### Python + Uvicorn
|
||||||
|
|
||||||
|
Run Krawl directly with Python (suggested version 13) and uvicorn for local development or testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app:app --host 0.0.0.0 --port 5000 --app-dir src
|
||||||
|
```
|
||||||
|
|
||||||
|
Access the server at `http://localhost:5000`
|
||||||
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
Krawl uses a **configuration hierarchy** in which **environment variables take precedence over the configuration file**. This approach is recommended for Docker deployments and quick out-of-the-box customization.
|
Krawl uses a **configuration hierarchy** in which **environment variables take precedence over the configuration file**. This approach is recommended for Docker deployments and quick out-of-the-box customization.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: krawl-chart
|
name: krawl-chart
|
||||||
description: A Helm chart for Krawl honeypot server
|
description: A Helm chart for Krawl honeypot server
|
||||||
type: application
|
type: application
|
||||||
version: 1.1.0
|
version: 1.1.3
|
||||||
appVersion: 1.1.0
|
appVersion: 1.1.3
|
||||||
keywords:
|
keywords:
|
||||||
- honeypot
|
- honeypot
|
||||||
- security
|
- security
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ A Helm chart for deploying the Krawl honeypot application on Kubernetes.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \
|
helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \
|
||||||
--version 1.1.0 \
|
--version 1.1.3 \
|
||||||
--namespace krawl-system \
|
--namespace krawl-system \
|
||||||
--create-namespace \
|
--create-namespace \
|
||||||
-f values.yaml # optional
|
-f values.yaml # optional
|
||||||
@@ -169,7 +169,7 @@ kubectl get secret krawl-server -n krawl-system \
|
|||||||
You can override individual values with `--set` without a values file:
|
You can override individual values with `--set` without a values file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
helm install krawl oci://ghcr.io/blessedrebus/krawl-chart --version 1.1.0 \
|
helm install krawl oci://ghcr.io/blessedrebus/krawl-chart --version 1.1.3 \
|
||||||
--set ingress.hosts[0].host=honeypot.example.com \
|
--set ingress.hosts[0].host=honeypot.example.com \
|
||||||
--set config.canary.token_url=https://canarytokens.com/your-token
|
--set config.canary.token_url=https://canarytokens.com/your-token
|
||||||
```
|
```
|
||||||
@@ -177,7 +177,7 @@ helm install krawl oci://ghcr.io/blessedrebus/krawl-chart --version 1.1.0 \
|
|||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
helm upgrade krawl oci://ghcr.io/blessedrebus/krawl-chart --version 1.1.0 -f values.yaml
|
helm upgrade krawl oci://ghcr.io/blessedrebus/krawl-chart --version 1.1.3 -f values.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Uninstalling
|
## Uninstalling
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ replicaCount: 1
|
|||||||
image:
|
image:
|
||||||
repository: ghcr.io/blessedrebus/krawl
|
repository: ghcr.io/blessedrebus/krawl
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "1.1.0"
|
tag: "1.1.3"
|
||||||
|
|
||||||
imagePullSecrets: []
|
imagePullSecrets: []
|
||||||
nameOverride: "krawl"
|
nameOverride: "krawl"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 343 KiB |
120
img/use-case.drawio
Normal file
120
img/use-case.drawio
Normal file
File diff suppressed because one or more lines are too long
BIN
img/use-case.png
Normal file
BIN
img/use-case.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 367 KiB |
@@ -815,24 +815,25 @@ class DatabaseManager:
|
|||||||
def flag_stale_ips_for_reevaluation(self) -> int:
|
def flag_stale_ips_for_reevaluation(self) -> int:
|
||||||
"""
|
"""
|
||||||
Flag IPs for reevaluation where:
|
Flag IPs for reevaluation where:
|
||||||
- last_seen is between 5 and 30 days ago
|
- last_seen is newer than the configured retention period
|
||||||
- last_analysis is more than 5 days ago
|
- last_analysis is more than 5 days ago
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of IPs flagged for reevaluation
|
Number of IPs flagged for reevaluation
|
||||||
"""
|
"""
|
||||||
|
from config import get_config
|
||||||
|
|
||||||
session = self.session
|
session = self.session
|
||||||
try:
|
try:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
last_seen_lower = now - timedelta(days=30)
|
retention_days = get_config().database_retention_days
|
||||||
last_seen_upper = now - timedelta(days=5)
|
last_seen_cutoff = now - timedelta(days=retention_days)
|
||||||
last_analysis_cutoff = now - timedelta(days=5)
|
last_analysis_cutoff = now - timedelta(days=5)
|
||||||
|
|
||||||
count = (
|
count = (
|
||||||
session.query(IpStats)
|
session.query(IpStats)
|
||||||
.filter(
|
.filter(
|
||||||
IpStats.last_seen >= last_seen_lower,
|
IpStats.last_seen >= last_seen_cutoff,
|
||||||
IpStats.last_seen <= last_seen_upper,
|
|
||||||
IpStats.last_analysis <= last_analysis_cutoff,
|
IpStats.last_analysis <= last_analysis_cutoff,
|
||||||
IpStats.need_reevaluation == False,
|
IpStats.need_reevaluation == False,
|
||||||
IpStats.manual_category == False,
|
IpStats.manual_category == False,
|
||||||
@@ -882,6 +883,7 @@ class DatabaseManager:
|
|||||||
ip_filter: Optional[str] = None,
|
ip_filter: Optional[str] = None,
|
||||||
suspicious_only: bool = False,
|
suspicious_only: bool = False,
|
||||||
since_minutes: Optional[int] = None,
|
since_minutes: Optional[int] = None,
|
||||||
|
sort_order: str = "desc",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Retrieve access logs with pagination and optional filtering.
|
Retrieve access logs with pagination and optional filtering.
|
||||||
@@ -892,6 +894,7 @@ class DatabaseManager:
|
|||||||
ip_filter: Filter by IP address
|
ip_filter: Filter by IP address
|
||||||
suspicious_only: Only return suspicious requests
|
suspicious_only: Only return suspicious requests
|
||||||
since_minutes: Only return logs from the last N minutes
|
since_minutes: Only return logs from the last N minutes
|
||||||
|
sort_order: Sort direction for timestamp ('asc' or 'desc')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of access log dictionaries
|
List of access log dictionaries
|
||||||
@@ -899,7 +902,12 @@ class DatabaseManager:
|
|||||||
session = self.session
|
session = self.session
|
||||||
try:
|
try:
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
query = session.query(AccessLog).order_by(AccessLog.timestamp.desc())
|
order = (
|
||||||
|
AccessLog.timestamp.asc()
|
||||||
|
if sort_order == "asc"
|
||||||
|
else AccessLog.timestamp.desc()
|
||||||
|
)
|
||||||
|
query = session.query(AccessLog).order_by(order)
|
||||||
|
|
||||||
if ip_filter:
|
if ip_filter:
|
||||||
query = query.filter(AccessLog.ip == sanitize_ip(ip_filter))
|
query = query.filter(AccessLog.ip == sanitize_ip(ip_filter))
|
||||||
@@ -1503,6 +1511,7 @@ class DatabaseManager:
|
|||||||
"path": log.path,
|
"path": log.path,
|
||||||
"user_agent": log.user_agent,
|
"user_agent": log.user_agent,
|
||||||
"timestamp": log.timestamp.isoformat(),
|
"timestamp": log.timestamp.isoformat(),
|
||||||
|
"log_id": log.id,
|
||||||
}
|
}
|
||||||
for log in logs
|
for log in logs
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -180,7 +180,10 @@ async def htmx_access_logs_by_ip(
|
|||||||
):
|
):
|
||||||
db = get_db()
|
db = get_db()
|
||||||
result = db.get_access_logs_paginated(
|
result = db.get_access_logs_paginated(
|
||||||
page=max(1, page), page_size=25, ip_filter=ip_filter
|
page=max(1, page),
|
||||||
|
page_size=25,
|
||||||
|
ip_filter=ip_filter,
|
||||||
|
sort_order=sort_order if sort_order in ("asc", "desc") else "desc",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalize pagination key (DB returns total_attackers, template expects total)
|
# Normalize pagination key (DB returns total_attackers, template expects total)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ Periodically deletes old records based on configured retention_days.
|
|||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from database import get_database
|
from database import get_database
|
||||||
from logger import get_app_logger
|
from logger import get_app_logger
|
||||||
|
|
||||||
@@ -26,12 +28,18 @@ app_logger = get_app_logger()
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""
|
"""
|
||||||
Delete access logs, credential attempts, and attack detections
|
Delete old records based on the configured retention period.
|
||||||
older than the configured retention period.
|
Keeps suspicious access logs, their attack detections, linked IPs,
|
||||||
|
category history, and all credential attempts.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from config import get_config
|
from config import get_config
|
||||||
from models import AccessLog, CredentialAttempt, AttackDetection
|
from models import (
|
||||||
|
AccessLog,
|
||||||
|
AttackDetection,
|
||||||
|
IpStats,
|
||||||
|
CategoryHistory,
|
||||||
|
)
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
retention_days = config.database_retention_days
|
retention_days = config.database_retention_days
|
||||||
@@ -41,35 +49,71 @@ def main():
|
|||||||
|
|
||||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
cutoff = datetime.now() - timedelta(days=retention_days)
|
||||||
|
|
||||||
# Delete attack detections linked to old access logs first (FK constraint)
|
# Delete attack detections linked to old NON-suspicious access logs (FK constraint)
|
||||||
old_log_ids = session.query(AccessLog.id).filter(AccessLog.timestamp < cutoff)
|
old_nonsuspicious_log_ids = session.query(AccessLog.id).filter(
|
||||||
|
AccessLog.timestamp < cutoff,
|
||||||
|
AccessLog.is_suspicious == False,
|
||||||
|
AccessLog.is_honeypot_trigger == False,
|
||||||
|
)
|
||||||
detections_deleted = (
|
detections_deleted = (
|
||||||
session.query(AttackDetection)
|
session.query(AttackDetection)
|
||||||
.filter(AttackDetection.access_log_id.in_(old_log_ids))
|
.filter(AttackDetection.access_log_id.in_(old_nonsuspicious_log_ids))
|
||||||
.delete(synchronize_session=False)
|
.delete(synchronize_session=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete old access logs
|
# Delete old non-suspicious access logs (keep suspicious ones)
|
||||||
logs_deleted = (
|
logs_deleted = (
|
||||||
session.query(AccessLog)
|
session.query(AccessLog)
|
||||||
.filter(AccessLog.timestamp < cutoff)
|
.filter(
|
||||||
|
AccessLog.timestamp < cutoff,
|
||||||
|
AccessLog.is_suspicious == False,
|
||||||
|
AccessLog.is_honeypot_trigger == False,
|
||||||
|
)
|
||||||
.delete(synchronize_session=False)
|
.delete(synchronize_session=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete old credential attempts
|
# IPs to preserve: those with any suspicious access logs
|
||||||
creds_deleted = (
|
preserved_ips = (
|
||||||
session.query(CredentialAttempt)
|
session.query(AccessLog.ip)
|
||||||
.filter(CredentialAttempt.timestamp < cutoff)
|
.filter(
|
||||||
|
or_(
|
||||||
|
AccessLog.is_suspicious == True,
|
||||||
|
AccessLog.is_honeypot_trigger == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete stale IPs, but keep those linked to suspicious logs
|
||||||
|
ips_deleted = (
|
||||||
|
session.query(IpStats)
|
||||||
|
.filter(
|
||||||
|
IpStats.last_seen < cutoff,
|
||||||
|
~IpStats.ip.in_(preserved_ips),
|
||||||
|
)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete old category history, but keep records for preserved IPs
|
||||||
|
history_deleted = (
|
||||||
|
session.query(CategoryHistory)
|
||||||
|
.filter(
|
||||||
|
CategoryHistory.timestamp < cutoff,
|
||||||
|
~CategoryHistory.ip.in_(preserved_ips),
|
||||||
|
)
|
||||||
.delete(synchronize_session=False)
|
.delete(synchronize_session=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
if logs_deleted or creds_deleted or detections_deleted:
|
total = logs_deleted + detections_deleted + ips_deleted + history_deleted
|
||||||
|
if total:
|
||||||
app_logger.info(
|
app_logger.info(
|
||||||
f"DB retention: Deleted {logs_deleted} access logs, "
|
f"DB retention: Deleted {logs_deleted} access logs, "
|
||||||
f"{detections_deleted} attack detections, "
|
f"{detections_deleted} attack detections, "
|
||||||
f"{creds_deleted} credential attempts older than {retention_days} days"
|
f"{ips_deleted} stale IPs, "
|
||||||
|
f"{history_deleted} category history records "
|
||||||
|
f"older than {retention_days} days"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -45,7 +45,10 @@
|
|||||||
<td>{{ log.timestamp | format_ts }}</td>
|
<td>{{ log.timestamp | format_ts }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if log.id %}
|
{% if log.id %}
|
||||||
<button class="view-btn" @click="viewRawRequest({{ log.id }})">View Request</button>
|
<button class="view-btn" @click="viewRawRequest({{ log.id }})" title="View Request">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M9.4 16.6 4.8 12l4.6-4.6L8 6l-6 6 6 6zm5.2 0L19.2 12l-4.6-4.6L16 6l6 6-6 6z"/></svg>
|
||||||
|
<span class="view-btn-tooltip">View Request</span>
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -62,7 +62,10 @@
|
|||||||
<td>{{ attack.timestamp | format_ts }}</td>
|
<td>{{ attack.timestamp | format_ts }}</td>
|
||||||
<td style="display: flex; gap: 6px; flex-wrap: wrap;">
|
<td style="display: flex; gap: 6px; flex-wrap: wrap;">
|
||||||
{% if attack.log_id %}
|
{% if attack.log_id %}
|
||||||
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button>
|
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})" title="View Request">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M9.4 16.6 4.8 12l4.6-4.6L8 6l-6 6 6 6zm5.2 0L19.2 12l-4.6-4.6L16 6l6 6-6 6z"/></svg>
|
||||||
|
<span class="view-btn-tooltip">View Request</span>
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="inspect-btn" @click="openIpInsight('{{ attack.ip | e }}')" title="Inspect IP">
|
<button class="inspect-btn" @click="openIpInsight('{{ attack.ip | e }}')" title="Inspect IP">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||||
|
|||||||
@@ -14,7 +14,14 @@
|
|||||||
<pre class="raw-request-content" x-text="rawModal.content"></pre>
|
<pre class="raw-request-content" x-text="rawModal.content"></pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="raw-request-modal-footer">
|
<div class="raw-request-modal-footer">
|
||||||
<button class="raw-request-download-btn" @click="downloadRawRequest()">Download as .txt</button>
|
<button class="raw-request-icon-btn" @click="copyRawRequest($event)" title="Copy to clipboard">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>
|
||||||
|
<span class="raw-request-icon-tooltip">Copy to clipboard</span>
|
||||||
|
</button>
|
||||||
|
<button class="raw-request-icon-btn" @click="downloadRawRequest()" title="Download as .txt">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/><path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06Z"/></svg>
|
||||||
|
<span class="raw-request-icon-tooltip">Download as .txt</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,7 +118,10 @@
|
|||||||
<td>{{ attack.timestamp | format_ts }}</td>
|
<td>{{ attack.timestamp | format_ts }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if attack.log_id %}
|
{% if attack.log_id %}
|
||||||
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button>
|
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})" title="View Request">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M9.4 16.6 4.8 12l4.6-4.6L8 6l-6 6 6 6zm5.2 0L19.2 12l-4.6-4.6L16 6l6 6-6 6z"/></svg>
|
||||||
|
<span class="view-btn-tooltip">View Request</span>
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<th>Path</th>
|
<th>Path</th>
|
||||||
<th>User-Agent</th>
|
<th>User-Agent</th>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
<th style="width: 40px;"></th>
|
<th style="width: 80px;"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -24,7 +24,13 @@
|
|||||||
<td>{{ activity.path | e }}</td>
|
<td>{{ activity.path | e }}</td>
|
||||||
<td style="word-break: break-all;">{{ (activity.user_agent | default(''))[:80] | e }}</td>
|
<td style="word-break: break-all;">{{ (activity.user_agent | default(''))[:80] | e }}</td>
|
||||||
<td>{{ activity.timestamp | format_ts(time_only=True) }}</td>
|
<td>{{ activity.timestamp | format_ts(time_only=True) }}</td>
|
||||||
<td>
|
<td style="display: flex; gap: 6px; flex-wrap: wrap;">
|
||||||
|
{% if activity.log_id %}
|
||||||
|
<button class="view-btn" @click="viewRawRequest({{ activity.log_id }})" title="View Request">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M9.4 16.6 4.8 12l4.6-4.6L8 6l-6 6 6 6zm5.2 0L19.2 12l-4.6-4.6L16 6l6 6-6 6z"/></svg>
|
||||||
|
<span class="view-btn-tooltip">View Request</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<button class="inspect-btn" @click="openIpInsight('{{ activity.ip | e }}')" title="Inspect IP">
|
<button class="inspect-btn" @click="openIpInsight('{{ activity.ip | e }}')" title="Inspect IP">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1108,20 +1108,47 @@ tbody {
|
|||||||
border-top: 1px solid #30363d;
|
border-top: 1px solid #30363d;
|
||||||
border-radius: 0 0 6px 6px;
|
border-radius: 0 0 6px 6px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.raw-request-download-btn {
|
.raw-request-icon-btn {
|
||||||
padding: 8px 16px;
|
position: relative;
|
||||||
background: #238636;
|
display: inline-flex;
|
||||||
color: #ffffff;
|
align-items: center;
|
||||||
border: none;
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: #21262d;
|
||||||
|
color: #8b949e;
|
||||||
|
border: 1px solid #30363d;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-weight: 500;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.raw-request-download-btn:hover {
|
.raw-request-icon-btn:hover {
|
||||||
background: #2ea043;
|
background: #30363d;
|
||||||
|
color: #58a6ff;
|
||||||
|
border-color: #58a6ff;
|
||||||
|
}
|
||||||
|
.raw-request-icon-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #1c2128;
|
||||||
|
color: #e6edf3;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.raw-request-icon-btn:hover .raw-request-icon-tooltip {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Attack Types Cell Styling */
|
/* Attack Types Cell Styling */
|
||||||
@@ -1576,19 +1603,45 @@ tbody {
|
|||||||
|
|
||||||
/* Dynamically injected button styles (previously in JS) */
|
/* Dynamically injected button styles (previously in JS) */
|
||||||
.view-btn {
|
.view-btn {
|
||||||
padding: 4px 10px;
|
position: relative;
|
||||||
background: #21262d;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
background: none;
|
||||||
|
color: #8b949e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.view-btn:hover {
|
||||||
color: #58a6ff;
|
color: #58a6ff;
|
||||||
|
background: rgba(88, 166, 255, 0.1);
|
||||||
|
}
|
||||||
|
.view-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
.view-btn-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #1c2128;
|
||||||
|
color: #e6edf3;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #30363d;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
.view-btn:hover {
|
.view-btn:hover .view-btn-tooltip {
|
||||||
background: #30363d;
|
opacity: 1;
|
||||||
border-color: #58a6ff;
|
|
||||||
}
|
}
|
||||||
.inspect-btn {
|
.inspect-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1603,8 +1656,8 @@ tbody {
|
|||||||
transition: color 0.2s, background 0.2s;
|
transition: color 0.2s, background 0.2s;
|
||||||
}
|
}
|
||||||
.inspect-btn svg {
|
.inspect-btn svg {
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
.inspect-btn:hover {
|
.inspect-btn:hover {
|
||||||
|
|||||||
@@ -111,6 +111,20 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.rawModal.logId = null;
|
this.rawModal.logId = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async copyRawRequest(event) {
|
||||||
|
if (!this.rawModal.content) return;
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
const originalHTML = btn.innerHTML;
|
||||||
|
const checkIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="#3fb950"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>';
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.rawModal.content);
|
||||||
|
btn.innerHTML = checkIcon;
|
||||||
|
} catch {
|
||||||
|
btn.style.color = '#f85149';
|
||||||
|
}
|
||||||
|
setTimeout(() => { btn.innerHTML = originalHTML; btn.style.color = ''; }, 1500);
|
||||||
|
},
|
||||||
|
|
||||||
downloadRawRequest() {
|
downloadRawRequest() {
|
||||||
if (!this.rawModal.content) return;
|
if (!this.rawModal.content) return;
|
||||||
const blob = new Blob([this.rawModal.content], { type: 'text/plain' });
|
const blob = new Blob([this.rawModal.content], { type: 'text/plain' });
|
||||||
|
|||||||
Reference in New Issue
Block a user