Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b30e875be
|
||
|
|
802793f64d
|
||
|
|
3a50eb6500
|
||
|
|
eed2a6d826
|
||
|
|
983f078228
|
||
|
|
583fc55d0b
|
||
|
|
26661ff970
|
||
|
|
c3d861cb51
|
||
|
|
7cc41c352d
|
||
|
|
ac341827ff | ||
|
|
dd70946a53
|
||
|
|
40a36fa9c6
|
||
|
|
51194f9778
|
||
|
|
dc4d7d5f18 | ||
|
|
8d9d6e4146
|
||
|
|
431f69dc24 | ||
|
|
53e908177c | ||
|
|
3018d484d5
|
||
|
|
5a532ec968
|
||
|
|
9dc39831a0
|
||
|
|
e3e352dbc6 | ||
|
|
7f056088a1 | ||
|
|
3c408903fb | ||
|
|
5efa770fe7
|
||
|
|
f8546ed60f
|
||
|
|
59fe04d6bd
|
||
|
|
62e5ce8e43
|
||
|
|
7cc24f6271
|
||
|
|
b2d415a1a7
|
||
|
|
833985a65f | ||
|
|
5875b7f7cf | ||
|
|
76d71bb285 | ||
|
|
c25761f7fb | ||
|
|
ea2e544a76 | ||
|
|
ac3b422377 | ||
|
|
0d8e4d8ccb | ||
|
|
04f22c031d
|
||
|
|
3b0da4c5a2 | ||
|
|
048f22ba2c | ||
|
|
0e6ecf1f16 | ||
|
|
e27b0c57db | ||
|
|
564222d082 | ||
|
|
d0db901e65 | ||
|
|
6d785b4252 | ||
|
|
8f78abb808 | ||
|
|
c6550981a4 | ||
|
|
cccd9eedd0 | ||
|
|
4056d29124 | ||
|
|
205bafb94b | ||
|
|
6b3c1e2bd6 | ||
|
|
d18b433d01 | ||
|
|
01e86c78d0 | ||
|
|
0b40e4e799 | ||
|
|
7b1217a2ef |
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
scripts/supervisord.pid
|
||||
scripts/supervisord.log
|
||||
scripts/metrics/__pycache__
|
||||
scripts/CVE_scanner/__pycache__
|
||||
scripts/plugins.json
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -1,140 +0,0 @@
|
||||
# WordPress Docker Setup
|
||||
|
||||
This repository provides a Dockerized environment for running WordPress locally using Docker Compose. It's designed for quick development and testing without complex installations.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Apache Server**: Single Apache container with `mod_userdir` enabled
|
||||
- **User Data**: Shared volume containing student org home directories
|
||||
- **WordPress**: Separate WordPress + MariaDB containers for testing
|
||||
- **Structure**: Each org has `/home/<orgname>/public_html/` served at `http://localhost:8080/~<orgname>`
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. **Clone the repository:**
|
||||
|
||||
```bash
|
||||
git clone https://git.1337.berkeley.edu/Committee/Secure-OCF-Web-Hosting.git
|
||||
cd Secure-OCF-Web-Hosting/WordPress-Docker
|
||||
```
|
||||
|
||||
2. **Install Docker Desktop:**
|
||||
- [macOS](https://docs.docker.com/desktop/setup/install/mac-install/)
|
||||
- [Windows](https://docs.docker.com/desktop/setup/install/windows-install/)
|
||||
|
||||
3. **Build and start the containers:**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. **Access WordPress:**
|
||||
|
||||
Open your browser and navigate to:
|
||||
|
||||
```
|
||||
http://localhost:8081
|
||||
```
|
||||
|
||||
Follow the on-screen instructions to complete the WordPress setup.
|
||||
|
||||
### Access sites:
|
||||
- **Student org sites**:
|
||||
- http://localhost:8080/~studentorg1 (Default, no plugins)
|
||||
- http://localhost:8080/~studentorg2 (Legit plugins)
|
||||
- http://localhost:8080/~studentorg3 (BAD BAD plugins)
|
||||
- **WordPress**: http://localhost:8081
|
||||
|
||||
### Add new student org:
|
||||
```bash
|
||||
docker exec -it ocf_apache_server bash
|
||||
mkdir -p /home/neworg/public_html
|
||||
echo "<h1>New Org</h1>" > /home/neworg/public_html/index.html
|
||||
chown -R www-data:www-data /home/neworg
|
||||
```
|
||||
|
||||
### View Apache logs:
|
||||
```bash
|
||||
docker logs ocf_apache_server
|
||||
```
|
||||
|
||||
## Virtual machine (isolated testing)
|
||||
|
||||
If you plan to experiment with or reverse-engineer malicious plugins, do this work only in an isolated virtual machine (VM). We provide a link to a VMware download here:
|
||||
|
||||
https://drive.google.com/drive/folders/1lGABtrC7S9C2OpGTAlL6uG4DqqE-1zuR
|
||||
|
||||
Guidance:
|
||||
- Use VMware and create a VM running a disposable OS such as Ubuntu or Kali Linux.
|
||||
- Perform any development, testing, or analysis of intentionally malicious plugins only inside that VM. Do not deploy or test them on your host machine or any production network.
|
||||
- Keep the VM network isolated from your production network when possible and snapshot the VM before risky experiments so you can revert changes.
|
||||
|
||||
Only analyze or test malware and malicious plugins in controlled, authorized environments. Do not use these techniques to attack or harm others.
|
||||
|
||||
## Volume management
|
||||
|
||||
> [!WARNING] Contact JQ or another officer if you are editing the default setup and don't understand the following documentation.
|
||||
|
||||
There are 3 important mount points. `userdata`, `wp-content`, and `backup.sql` are all files / directories on the host machine that are then mounted on the respective containers.
|
||||
|
||||
Currently, backup.sql is mounted to the database container, `userdata` doesn't do anything, and `wp-content` is mounted to the `wordpress` container. They are set up for minimum reproducibility: i.e. there is an admin user with the default page. Credentials are in the `docker-compose.yml` file. If you'd like to make changes to the default, it is imperative that you make a backup of the sql file. You should then copy that `backupX.sql` file to the one in the repo so that the database will update.
|
||||
|
||||
```bash
|
||||
docker compose exec -it db /bin/bash
|
||||
mariadb-dump -u wp_user -p wp_db > backupX.sql # Change X to the number of the userdata volume you're editing.
|
||||
# Then enter the password for wp_user, which should be in the docker compose.
|
||||
exit
|
||||
docker compose cp db:/backupX.sql ./backupX.sql
|
||||
```
|
||||
|
||||
As a one-liner:
|
||||
```bash
|
||||
docker compose exec -it db "mariadb-dump -u wp_user -p wp_db > backupX.sql" # Make sure you change X!
|
||||
```
|
||||
|
||||
|
||||
The rest should auto-update. If you don't want to make a change to the `wp-content` directory or database, you can them to your `.gitignore`.
|
||||
|
||||
## ⚙️ Managing Containers
|
||||
|
||||
* **Start containers:**
|
||||
|
||||
```bash
|
||||
docker-compose start
|
||||
```
|
||||
|
||||
* **Stop containers:**
|
||||
|
||||
```bash
|
||||
docker-compose stop
|
||||
```
|
||||
|
||||
* **Stop and remove containers:**
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
Add the `-v` flag to also remove volumes:
|
||||
|
||||
```bash
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 🔐 Accessing phpMyAdmin
|
||||
|
||||
If phpMyAdmin is included in your setup:
|
||||
|
||||
* Open your browser and navigate to:
|
||||
|
||||
```
|
||||
http://localhost:8081
|
||||
```
|
||||
@@ -1,28 +0,0 @@
|
||||
# Dockerfile (apache-static)
|
||||
FROM httpd:2.4
|
||||
|
||||
LABEL maintainer="random@gmail.com"
|
||||
|
||||
# Enable required Apache modules for user directories and PHP
|
||||
RUN sed -i \
|
||||
-e 's/#LoadModule userdir_module/LoadModule userdir_module/' \
|
||||
-e 's/#LoadModule vhost_alias_module/LoadModule vhost_alias_module/' \
|
||||
-e 's/#LoadModule rewrite_module/LoadModule rewrite_module/' \
|
||||
-e 's/#Include conf\/extra\/httpd-userdir.conf/Include conf\/extra\/httpd-userdir.conf/' \
|
||||
/usr/local/apache2/conf/httpd.conf
|
||||
|
||||
# Add custom configuration for user directories
|
||||
COPY httpd-userdir.conf /usr/local/apache2/conf/extra/httpd-userdir.conf
|
||||
|
||||
# Create example student org directories with basic content
|
||||
RUN mkdir -p /home/studentorg1/public_html && \
|
||||
echo "<h1>Welcome to Student Org 1's OCF Page</h1><p>This is a test page.</p>" > /home/studentorg1/public_html/index.html && \
|
||||
mkdir -p /home/studentorg2/public_html && \
|
||||
echo "<h1>Welcome to Student Org 2's OCF Page</h1><p>This is a test page.</p>" > /home/studentorg2/public_html/index.html && \
|
||||
mkdir -p /home/studentorg3/public_html && \
|
||||
echo "<h1>Welcome to Student Org 3's OCF Page</h1><p>This is a test page.</p>" > /home/studentorg3/public_html/index.html
|
||||
|
||||
# Set proper ownership
|
||||
RUN chown -R www-data:www-data /home
|
||||
|
||||
EXPOSE 80
|
||||
@@ -1,24 +0,0 @@
|
||||
# Settings for user home directories
|
||||
|
||||
<VirtualHost *:80>
|
||||
UseCanonicalName Off
|
||||
|
||||
# Map subdomains like user.ocf.local to /home/user/public_html
|
||||
VirtualDocumentRoot /home/%1/public_html
|
||||
|
||||
ServerAlias *.ocf.local
|
||||
|
||||
# Mimics OCF structure where user directories are in /home
|
||||
|
||||
<Directory /home/*/public_html>
|
||||
AllowOverride All
|
||||
Options MultiViews Indexes SymLinksIfOwnerMatch IncludesNoExec
|
||||
Require all granted
|
||||
|
||||
# Allow .htaccess usage for student customization
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
</IfModule>
|
||||
</Directory>
|
||||
|
||||
</VirtualHost>
|
||||
@@ -1,20 +0,0 @@
|
||||
# Settings for user home directories
|
||||
#
|
||||
# Required module: mod_userdir
|
||||
|
||||
UserDir /home/*/public_html
|
||||
|
||||
#
|
||||
# Control access to UserDir directories.
|
||||
# Mimics OCF structure where user directories are in /home
|
||||
#
|
||||
<Directory "/home/*/public_html">
|
||||
AllowOverride All
|
||||
Options MultiViews Indexes SymLinksIfOwnerMatch IncludesNoExec
|
||||
Require all granted
|
||||
|
||||
# Allow .htaccess usage for student customization
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
</IfModule>
|
||||
</Directory>
|
||||
@@ -1,28 +0,0 @@
|
||||
/*M!999999\- enable the sandbox mode */
|
||||
-- MariaDB dump 10.19-11.8.3-MariaDB, for debian-linux-gnu (aarch64)
|
||||
--
|
||||
-- Host: localhost Database: wp_db
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 11.8.3-MariaDB-ubu2404
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*M!100616 SET @OLD_NOTE_VERBOSITY=@@NOTE_VERBOSITY, NOTE_VERBOSITY=0 */;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */;
|
||||
|
||||
-- Dump completed on 2025-10-22 5:21:45
|
||||
@@ -1,63 +0,0 @@
|
||||
services:
|
||||
apache:
|
||||
build:
|
||||
context: ./apache
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- userdata1:/home/user1/public_html
|
||||
- userdata2:/home/user2/public_html
|
||||
- userdata3:/home/user3/public_html
|
||||
container_name: apache-static
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: mariadb:11
|
||||
container_name: wp_db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpass
|
||||
MYSQL_DATABASE: wp_db
|
||||
MYSQL_USER: wp_user
|
||||
MYSQL_PASSWORD: wp_pass
|
||||
volumes:
|
||||
- ./backup2.sql:/docker-entrypoint-initdb.d/backup.sql # CHANGEME to edit different WordPress sites
|
||||
restart: unless-stopped
|
||||
|
||||
wordpress:
|
||||
image: wordpress:latest
|
||||
container_name: wordpress_dev_container
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
WORDPRESS_DB_HOST: db:3306
|
||||
WORDPRESS_DB_USER: wp_user
|
||||
WORDPRESS_DB_PASSWORD: wp_pass
|
||||
WORDPRESS_DB_NAME: wp_db
|
||||
ports:
|
||||
- "8081:80" # WordPress site -> http://localhost:8081
|
||||
volumes:
|
||||
- userdata2:/var/www/html/wp-content # CHANGEME to edit different WordPress sites
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# Default user:
|
||||
# username: admin
|
||||
# password: GH^*m)qprg7VOZJ(JJ
|
||||
userdata1:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
device: ${PWD}/userdata1
|
||||
o: bind
|
||||
userdata2:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
device: ${PWD}/userdata2
|
||||
o: bind
|
||||
userdata3:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
device: ${PWD}/userdata3
|
||||
o: bind
|
||||
@@ -1,19 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Local Apache Static Landing</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background:#0f172a; color:#e6eef8; text-align:center; padding-top:8vh; }
|
||||
h1 { color:#10b981; }
|
||||
p { color:#cbd5e1; }
|
||||
a { color:#93c5fd; text-decoration:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Apache Static Host</h1>
|
||||
<p>This is the static landing page container.</p>
|
||||
<p><a href="http://localhost:8081/wp-admin" target="_blank">Open WordPress setup (wp-admin)</a></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
# Secure OCF Web Hosting - Setup Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Install Docker Loki Plugin
|
||||
|
||||
Choose the appropriate command for your system architecture:
|
||||
|
||||
**For AMD64 hosts:**
|
||||
```bash
|
||||
docker plugin install grafana/loki-docker-driver:3.3.2-amd64 --alias loki --grant-all-permissions
|
||||
```
|
||||
|
||||
**For ARM64 hosts:**
|
||||
```bash
|
||||
docker plugin install grafana/loki-docker-driver:3.3.2-arm64 --alias loki --grant-all-permissions
|
||||
```
|
||||
|
||||
### 2. Configure User Data Volume
|
||||
|
||||
Edit `docker-compose.yml` and update the `USERDATA` volume to point to the volume you want to scan.
|
||||
|
||||
## Running Instructions
|
||||
|
||||
### Start the Stack
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Stop the Stack
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## Accessing Services
|
||||
|
||||
- **Grafana**: http://localhost:3000
|
||||
- Username: `admin`
|
||||
- Password: `ocfsecure`
|
||||
@@ -0,0 +1,80 @@
|
||||
services:
|
||||
tools:
|
||||
image: debian:latest
|
||||
tty: true
|
||||
depends_on:
|
||||
- loki
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- USERDATA:/scan # Put any volumes you want scanned here
|
||||
- ../scripts:/scripts
|
||||
- ../scripts/crontab:/etc/cron.d/crontab
|
||||
command: sh -c "
|
||||
apt-get -qq update && apt-get -qq install -y python3 python3-venv python3-pip supervisor cron &&
|
||||
chmod 0644 /etc/cron.d/crontab &&
|
||||
cd /scripts &&
|
||||
python3 -m venv .venv &&
|
||||
. .venv/bin/activate &&
|
||||
pip3 install -q -r requirements.txt &&
|
||||
chmod +x /scripts/entrypoint.sh &&
|
||||
/scripts/entrypoint.sh &&
|
||||
cron -f
|
||||
"
|
||||
|
||||
working_dir: /scripts
|
||||
logging:
|
||||
driver: loki
|
||||
options:
|
||||
loki-url: "http://localhost:3100/loki/api/v1/push"
|
||||
mode: "non-blocking"
|
||||
|
||||
# Grafana Loki Stack
|
||||
# Install docker loki plugin:
|
||||
# Change -amd64 to -arm64 in the image tag for ARM64 hosts.
|
||||
# docker plugin install grafana/loki-docker-driver:3.3.2-arm64 --alias loki --grant-all-permissions
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: loki
|
||||
ports:
|
||||
- "3100:3100"
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
volumes:
|
||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
||||
- loki-data:/loki
|
||||
restart: unless-stopped
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:10.4.0
|
||||
container_name: grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=ocfsecure
|
||||
depends_on:
|
||||
- loki
|
||||
restart: unless-stopped
|
||||
|
||||
# alloy:
|
||||
# image: grafana/alloy:latest
|
||||
# container_name: alloy
|
||||
# volumes:
|
||||
# - ./alloy-config.yml:/etc/alloy/config.alloy
|
||||
# - ./logs:/var/log/scanner
|
||||
# ports:
|
||||
# - "12345:12345" # optional web UI/debug
|
||||
# command: run /etc/alloy/config.alloy
|
||||
# environment:
|
||||
# - ALLOY_ENABLE_WEB=true
|
||||
# depends_on:
|
||||
# - loki
|
||||
# restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
USERDATA: # Put the volume with your user data here
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ../test_vol # Change this to the path of your user data
|
||||
loki-data: # persist loki data
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"directory": "/scan/person1/public_html",
|
||||
"wordpress": true,
|
||||
"name_of_detector": "Blacklisted Plugins",
|
||||
"timestamp": "2024-06-20T12:34:56Z",
|
||||
"plugins": {
|
||||
"vulnerable_plugin": {
|
||||
"version": "1.2.3",
|
||||
"status": "vulnerable",
|
||||
"reason": "Custom between each detector (Good luck aggregating this )"
|
||||
},
|
||||
"safe_plugin": {
|
||||
"status": "safe",
|
||||
"reason": "Custom between each detector (Good luck aggregating this )"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
|
||||
common:
|
||||
path_prefix: /tmp/loki
|
||||
instance_addr: 127.0.0.1
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /tmp/loki/chunks
|
||||
rules_directory: /tmp/loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
|
||||
query_range:
|
||||
results_cache:
|
||||
cache:
|
||||
embedded_cache:
|
||||
enabled: true
|
||||
max_size_mb: 100
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2020-05-15
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
ruler:
|
||||
alertmanager_url: http://localhost:9093
|
||||
|
||||
analytics:
|
||||
reporting_enabled: false
|
||||
|
||||
limits_config:
|
||||
retention_period: 30d
|
||||
|
||||
compactor:
|
||||
working_directory: /tmp/loki/retention
|
||||
delete_request_store: filesystem
|
||||
retention_enabled: true
|
||||
retention_delete_delay: 2h
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CVE Scanner for WordPress plugins using Wordfence vulnerability database."""
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def load_json_local(path: str) -> dict:
|
||||
"""Load JSON data from a local file.
|
||||
|
||||
Args:
|
||||
path: Filesystem path to the JSON file.
|
||||
|
||||
Returns:
|
||||
Parsed JSON data as a dictionary or list.
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_json_from_url(url: str) -> dict:
|
||||
"""Load JSON data from a URL.
|
||||
|
||||
Args:
|
||||
url: URL to fetch JSON from.
|
||||
|
||||
Returns:
|
||||
Parsed JSON data as a dictionary or list.
|
||||
"""
|
||||
import requests
|
||||
r = requests.get(url, timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments.
|
||||
|
||||
Returns:
|
||||
Parsed arguments namespace.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scan WordPress plugins for CVE vulnerabilities using Wordfence data."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
"-i",
|
||||
help="Path to local vulnerabilities JSON file (default: wordfence_vulnerabilities.json)",
|
||||
default="wordfence_vulnerabilities.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
"-u",
|
||||
help="URL to download vulnerabilities JSON if local file not found",
|
||||
default=""
|
||||
)
|
||||
parser.add_argument(
|
||||
"--plugins",
|
||||
"-p",
|
||||
nargs="+",
|
||||
help="List of plugin slugs to check (space-separated)",
|
||||
default=[]
|
||||
)
|
||||
parser.add_argument(
|
||||
"--plugins-file",
|
||||
"-f",
|
||||
help="Path to file containing plugin slugs (one per line)",
|
||||
default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out-csv",
|
||||
"-c",
|
||||
help="Output CSV file path (if not specified, CSV output is skipped)",
|
||||
default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out-json",
|
||||
"-j",
|
||||
help="Output JSON file path (if not specified, prints to stdout)",
|
||||
default=None
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_plugin_list(plugins: list[str], plugins_file: str | None) -> set[str]:
|
||||
"""Load and combine plugin slugs from CLI args and optional file.
|
||||
|
||||
Args:
|
||||
plugins: List of plugin slugs from command line.
|
||||
plugins_file: Optional path to file with plugin slugs (one per line).
|
||||
|
||||
Returns:
|
||||
Set of plugin slugs to check.
|
||||
"""
|
||||
plugin_set = set(plugins)
|
||||
|
||||
if plugins_file and os.path.exists(plugins_file):
|
||||
with open(plugins_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
slug = line.strip()
|
||||
if slug and not slug.startswith("#"):
|
||||
plugin_set.add(slug)
|
||||
|
||||
return plugin_set
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the CVE scanner."""
|
||||
args = parse_args()
|
||||
|
||||
# Load plugin list
|
||||
bad_plugins = load_plugin_list(args.plugins, args.plugins_file)
|
||||
|
||||
if not bad_plugins:
|
||||
print("Warning: No plugins specified to check. Use --plugins or --plugins-file.")
|
||||
print("Proceeding to load vulnerability data anyway...")
|
||||
|
||||
# Load vulnerability data
|
||||
data = None
|
||||
# Prioritize URL if specified
|
||||
if args.url:
|
||||
print(f"Downloading from URL: {args.url}")
|
||||
data = load_json_from_url(args.url)
|
||||
elif os.path.exists(args.input):
|
||||
print(f"Loading local file: {args.input}")
|
||||
data = load_json_local(args.input)
|
||||
else:
|
||||
raise SystemExit(f"Error: Local file '{args.input}' not found and --url not provided.")
|
||||
|
||||
# Extract vulnerability entries
|
||||
if isinstance(data, dict) and "vulnerabilities" in data:
|
||||
entries = data["vulnerabilities"]
|
||||
elif isinstance(data, list):
|
||||
entries = data
|
||||
else:
|
||||
entries = []
|
||||
if isinstance(data, dict):
|
||||
for v in data.values():
|
||||
if isinstance(v, list):
|
||||
entries = v
|
||||
break
|
||||
|
||||
# Find matches
|
||||
matches = []
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.get("type") != "plugin":
|
||||
continue
|
||||
slug = entry.get("slug", "").strip()
|
||||
if not slug:
|
||||
continue
|
||||
if slug in bad_plugins:
|
||||
matches.append({
|
||||
"slug": slug,
|
||||
"title": entry.get("title"),
|
||||
"cve": entry.get("cve"),
|
||||
"severity": entry.get("severity"),
|
||||
"cvss_score": entry.get("cvss_score"),
|
||||
"cve_link": entry.get("cve_link"),
|
||||
"raw": entry
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Display results
|
||||
if not matches:
|
||||
print("No matches found for the specified plugins.")
|
||||
else:
|
||||
print(f"\nFound {len(matches)} vulnerability matches:")
|
||||
for m in matches:
|
||||
print(f"- {m['slug']} | CVE: {m.get('cve')} | sev: {m.get('severity')} | CVSS: {m.get('cvss_score')}")
|
||||
print(f" link: {m.get('cve_link')}")
|
||||
|
||||
# Write CSV output (only if specified)
|
||||
if args.out_csv:
|
||||
with open(args.out_csv, "w", newline='', encoding="utf-8") as csvf:
|
||||
writer = csv.DictWriter(csvf, fieldnames=["slug", "title", "cve", "severity", "cvss_score", "cve_link"])
|
||||
writer.writeheader()
|
||||
for m in matches:
|
||||
writer.writerow({
|
||||
"slug": m["slug"],
|
||||
"title": m.get("title"),
|
||||
"cve": m.get("cve"),
|
||||
"severity": m.get("severity"),
|
||||
"cvss_score": m.get("cvss_score"),
|
||||
"cve_link": m.get("cve_link")
|
||||
})
|
||||
print(f"\nSaved CSV results to {args.out_csv}")
|
||||
|
||||
# Write JSON output (to file or stdout)
|
||||
if args.out_json:
|
||||
with open(args.out_json, "w", encoding="utf-8") as jf:
|
||||
json.dump(matches, jf, ensure_ascii=False, indent=2)
|
||||
print(f"Saved JSON results to {args.out_json}")
|
||||
else:
|
||||
# Print to stdout if no JSON file specified
|
||||
print("\nJSON output:")
|
||||
print(json.dumps(matches, ensure_ascii=False, indent=2))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# path to your vulnerability JSON file
|
||||
json_path = "wordfence_vulnerabilities.json"
|
||||
|
||||
def load_cvss_scores(path):
|
||||
"""Load JSON data and extract all CVSS scores into a list"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
scores = []
|
||||
for entry in data:
|
||||
# make sure cvss_score exists and is numeric
|
||||
if "cvss_score" in entry and isinstance(entry["cvss_score"], (int, float)):
|
||||
scores.append(entry["cvss_score"])
|
||||
return scores
|
||||
|
||||
|
||||
def plot_hist(scores):
|
||||
"""Plot a histogram of CVSS scores"""
|
||||
plt.figure(figsize=(8, 5))
|
||||
plt.hist(scores, bins=10, edgecolor="black", color="skyblue")
|
||||
plt.title("Distribution of CVSS Scores in Vulnerability Data")
|
||||
plt.xlabel("CVSS Score")
|
||||
plt.ylabel("Number of Vulnerabilities")
|
||||
plt.grid(alpha=0.4)
|
||||
plt.savefig("cvss_distribution.png")
|
||||
plt.show()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
scores = load_cvss_scores(json_path)
|
||||
print(f"Loaded {len(scores)} CVSS scores.")
|
||||
print("Sample:", scores[:10])
|
||||
|
||||
if scores:
|
||||
plot_hist(scores)
|
||||
else:
|
||||
print("⚠️ No CVSS scores found in the JSON file.")
|
||||
@@ -0,0 +1,2 @@
|
||||
get_CVE.py **MUST** be ran before better_cve_scanner can be ran since it runs using a list that is fetched daily.
|
||||
CVE_scanner.py runs on a static list called wordfence_vulnerabilities.json that is stored in the same directory, which is an old list of major vulnerabilities.
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
import requests
|
||||
import os
|
||||
|
||||
URL = "https://www.wordfence.com/api/intelligence/v2/vulnerabilities/scanner"
|
||||
OUT_FILE = "wordfence_CVE.json"
|
||||
|
||||
def main():
|
||||
print("API request.....")
|
||||
r = requests.get(URL, timeout=60)
|
||||
r.raise_for_status()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
out_path = os.path.join(script_dir, OUT_FILE)
|
||||
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(r.text)
|
||||
|
||||
print("File Saved!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
* * * * * root echo "Tools container is online." > /proc/1/fd/1 2>/proc/1/fd/2
|
||||
00 00 * * * root /scripts/entrypoint.sh > /proc/1/fd/1 2>/proc/1/fd/2
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
python3 /scripts/utils/plugins.py
|
||||
python3 /scripts/metrics/analyze_plugins.py -i /scripts/plugins.json
|
||||
@@ -0,0 +1,18 @@
|
||||
# WordPress Metrics
|
||||
|
||||
## scrape_wordpress_plugins.py
|
||||
- Uses requests to scrape the whole plugins list using https://api.wordpress.org/plugins/
|
||||
- Scrape in pages of 100 plugins, takes about ~30 mins to complete
|
||||
- Stores as output.json
|
||||
|
||||
## analyze_data.py
|
||||
- Calculate basic statistics (percentiles, mean, median, sd ...)
|
||||
- Visualize them with matplot in a logrithrimic scale
|
||||
|
||||
## query_plugin.py
|
||||
- Change slug to equal the target plugins (should be able to directly plug the outputs from detect_plugins.sh)
|
||||
- TODO: Implement warning system
|
||||
|
||||
## security_rating.py
|
||||
- Implements some sort of basic rating system factoring in number of active installations, last-update date, and ratings
|
||||
- sample use case: python3 security_rating.py {plugin name}
|
||||
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
def print_info(name, data):
|
||||
# Convert the list to a NumPy array
|
||||
data_np = np.array(data)
|
||||
|
||||
# Calculate basic statistics
|
||||
mean = np.mean(data_np)
|
||||
median = np.median(data_np)
|
||||
std_dev = np.std(data_np)
|
||||
variance = np.var(data_np)
|
||||
min_val = np.min(data_np)
|
||||
max_val = np.max(data_np)
|
||||
sum_val = np.sum(data_np)
|
||||
|
||||
# Calculate percentiles
|
||||
percentile_25 = np.percentile(data_np, 25)
|
||||
percentile_50 = np.percentile(data_np, 50) # This is the same as the median
|
||||
percentile_75 = np.percentile(data_np, 75)
|
||||
percentile_90 = np.percentile(data_np, 90)
|
||||
|
||||
# Print the results
|
||||
print(f"\n\nDescriptive Statistics for {name}:")
|
||||
print(f"Mean: {mean}")
|
||||
print(f"Median: {median}")
|
||||
print(f"Standard Deviation: {std_dev}")
|
||||
print(f"Variance: {variance}")
|
||||
print(f"Minimum: {min_val}")
|
||||
print(f"Maximum: {max_val}")
|
||||
print(f"Sum: {sum_val}")
|
||||
print("\nPercentiles:")
|
||||
print(f"25th Percentile: {percentile_25}")
|
||||
print(f"50th Percentile (Median): {percentile_50}")
|
||||
print(f"75th Percentile: {percentile_75}")
|
||||
print(f"90th Percentile: {percentile_90}")
|
||||
|
||||
|
||||
with open('output.json', 'r') as f:
|
||||
json_data = json.load(f)
|
||||
|
||||
# print(len(data))
|
||||
downloaded = [plugin["downloaded"] for plugin in json_data]
|
||||
rating = [plugin["rating"] for plugin in json_data]
|
||||
active_installs = [plugin["active_installs"] for plugin in json_data]
|
||||
num_ratings = [plugin["num_ratings"] for plugin in json_data]
|
||||
|
||||
print_info("downloaded", downloaded)
|
||||
print_info("rating", rating)
|
||||
print_info("active_installs", active_installs)
|
||||
print_info("num_ratings", num_ratings)
|
||||
|
||||
# plt.hist(downloaded, bins=500, log=True)
|
||||
# plt.show()
|
||||
fig, axs = plt.subplots(nrows=2, ncols=2, figsize=(8, 6))
|
||||
axs[0,0].hist(downloaded, bins=100, log=True)
|
||||
axs[0,0].set_title("Distribution of downloaded")
|
||||
|
||||
axs[0,1].hist(rating, bins=20, log=True)
|
||||
axs[0,1].set_title("Distribution of rating")
|
||||
|
||||
axs[1,0].hist(active_installs, bins=10, log=True)
|
||||
axs[1,0].set_title("Distribution of active_installs")
|
||||
|
||||
axs[1,1].hist(num_ratings, bins=100, log=True)
|
||||
axs[1,1].set_title("Distribution of num_ratings")
|
||||
|
||||
# Add labels and title
|
||||
# plt.xlabel("Value")
|
||||
# plt.ylabel("Frequency (log)")
|
||||
|
||||
# Display the plot
|
||||
plt.show()
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze WordPress plugins for security vulnerabilities using security ratings."""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Import security_rating functions
|
||||
from security_rating import get_plugin_info, calculate_security_score
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments.
|
||||
|
||||
Returns:
|
||||
Parsed arguments namespace.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze WordPress plugins for security issues using security ratings."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
"-i",
|
||||
required=True,
|
||||
help="Path to input JSON file (from output_recursive_plugins)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
help="Path to output JSON file (if not specified, prints to stdout)",
|
||||
default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detector-name",
|
||||
"-d",
|
||||
help="Name of detector to use in output (default: Security Rating Analyzer)",
|
||||
default="Security Rating Analyzer"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def analyze_plugin(slug: str) -> dict:
|
||||
"""Analyze a single plugin and return its status.
|
||||
|
||||
Args:
|
||||
slug: Plugin slug to analyze.
|
||||
|
||||
Returns:
|
||||
Dict with status and reason fields.
|
||||
"""
|
||||
plugin_info = get_plugin_info(slug)
|
||||
|
||||
if not plugin_info:
|
||||
return {
|
||||
"status": "unknown",
|
||||
"reason": f"Could not fetch plugin information from WordPress.org API"
|
||||
}
|
||||
|
||||
score = calculate_security_score(plugin_info)
|
||||
|
||||
# Determine status based on score
|
||||
# Safe: score >= 65 (Excellent or Good)
|
||||
# Vulnerable: score < 65 (Moderate or Poor)
|
||||
if score >= 85:
|
||||
status = "safe"
|
||||
reason = f"Excellent security rating ({score}/100) — Actively maintained and trusted"
|
||||
elif score >= 65:
|
||||
status = "safe"
|
||||
reason = f"Good security rating ({score}/100) — Generally safe but monitor updates"
|
||||
elif score >= 45:
|
||||
status = "vulnerable"
|
||||
reason = f"Moderate security rating ({score}/100) — May be outdated or less popular"
|
||||
else:
|
||||
status = "vulnerable"
|
||||
reason = f"Poor security rating ({score}/100) — High risk of security issues"
|
||||
|
||||
result = {
|
||||
"status": status,
|
||||
"reason": reason,
|
||||
"score": score
|
||||
}
|
||||
|
||||
# Add version if available
|
||||
version = plugin_info.get("version")
|
||||
if version:
|
||||
result["version"] = version
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def analyze_directory(directory_name: str, plugins_dict: dict, detector_name: str) -> dict:
|
||||
"""Analyze all plugins in a directory.
|
||||
|
||||
Args:
|
||||
directory_name: Name of the directory being analyzed.
|
||||
plugins_dict: Dict mapping plugin slug to absolute path.
|
||||
detector_name: Name of the detector.
|
||||
|
||||
Returns:
|
||||
Analysis result in the specified format.
|
||||
"""
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
analyzed_plugins = {}
|
||||
|
||||
print(f"\nAnalyzing plugins for directory: {directory_name}")
|
||||
print(f"Found {len(plugins_dict)} plugins to analyze")
|
||||
|
||||
for slug, path in plugins_dict.items():
|
||||
print(f" Analyzing {slug}...", end=" ", flush=True)
|
||||
result = analyze_plugin(slug)
|
||||
analyzed_plugins[slug] = result
|
||||
print(f"{result['status']}")
|
||||
|
||||
return {
|
||||
"directory": directory_name,
|
||||
"wordpress": True,
|
||||
"name_of_detector": detector_name,
|
||||
"timestamp": timestamp,
|
||||
"plugins": analyzed_plugins
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the plugin analyzer."""
|
||||
args = parse_args()
|
||||
|
||||
# Load input JSON
|
||||
try:
|
||||
with open(args.input, "r", encoding="utf-8") as f:
|
||||
input_data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Input file '{args.input}' not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in '{args.input}': {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Analyze each directory
|
||||
results = []
|
||||
|
||||
for directory_name, plugins_dict in input_data.items():
|
||||
if not plugins_dict:
|
||||
print(f"Skipping {directory_name} (no plugins found)")
|
||||
continue
|
||||
|
||||
analysis = analyze_directory(directory_name, plugins_dict, args.detector_name)
|
||||
results.append(analysis)
|
||||
|
||||
# Output results
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
print(f"\nWrote analysis results to {args.output}")
|
||||
else:
|
||||
print("\nAnalysis results:")
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
import requests
|
||||
|
||||
slug = "akismet"
|
||||
response = requests.get("https://api.wordpress.org/plugins/info/1.2/?action=query_plugins"
|
||||
f"&slug={slug}")
|
||||
response_json = response.json()
|
||||
response_json = response_json['plugins'][0]
|
||||
# print(response_json)
|
||||
print(response_json['downloaded'])
|
||||
print(response_json['rating'])
|
||||
print(response_json['active_installs'])
|
||||
print(response_json['num_ratings'])
|
||||
|
||||
# TODO: Implement warning system
|
||||
@@ -0,0 +1,37 @@
|
||||
from time import sleep
|
||||
|
||||
import requests
|
||||
import json
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
starting_page = 1
|
||||
request_per_page = 100
|
||||
response = requests.get(f"https://api.wordpress.org/plugins/info/1.2/?action=query_plugins"
|
||||
f"&request[page]={starting_page}"
|
||||
f"&request[per_page]={request_per_page}")
|
||||
response_json = response.json()
|
||||
# print(response_json)
|
||||
|
||||
page_no = response_json['info']['pages']
|
||||
plugins_info_list = []
|
||||
plugins_info_str = ""
|
||||
for i in tqdm(range(page_no)):
|
||||
response = requests.get(f"https://api.wordpress.org/plugins/info/1.2/?action=query_plugins"
|
||||
f"&request[page]={i+1}"
|
||||
f"&request[per_page]={request_per_page}")
|
||||
response_json = response.json()
|
||||
plugins_info_list.append(response_json['plugins'])
|
||||
# plugins_info_str += str(response_json['plugins'])
|
||||
sleep(0.1)
|
||||
|
||||
flattened_list = [item for sublist in plugins_info_list for item in sublist]
|
||||
|
||||
plugins_info_json = json.dumps(flattened_list, indent=4)
|
||||
print(plugins_info_json)
|
||||
|
||||
filename = "output.json"
|
||||
|
||||
# Open the file in write mode ('w') and use json.dump() to write the data
|
||||
with open(filename, 'w') as f:
|
||||
f.write(plugins_info_json)
|
||||
@@ -0,0 +1,101 @@
|
||||
import requests
|
||||
import datetime
|
||||
import sys
|
||||
|
||||
from dateutil import parser
|
||||
import datetime
|
||||
|
||||
def get_plugin_info(slug):
|
||||
"""Fetch plugin info from the WordPress API."""
|
||||
url = f"https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request[slug]={slug}"
|
||||
response = requests.get(url)
|
||||
if response.status_code != 200:
|
||||
print(f"Error: Unable to fetch data for plugin '{slug}'.")
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
def calculate_security_score(plugin):
|
||||
"""Calculate a security score (0-100) for a WordPress plugin."""
|
||||
score = 0
|
||||
|
||||
# last updated
|
||||
last_updated_str = plugin.get("last_updated")
|
||||
if last_updated_str:
|
||||
try:
|
||||
last_updated = parser.parse(last_updated_str)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
if last_updated.tzinfo is None:
|
||||
last_updated = last_updated.replace(tzinfo=datetime.timezone.utc)
|
||||
days_since_update = (now - last_updated).days
|
||||
|
||||
if days_since_update <= 90:
|
||||
score += 40
|
||||
elif days_since_update <= 180:
|
||||
score += 30
|
||||
elif days_since_update <= 365:
|
||||
score += 20
|
||||
else:
|
||||
score += 10
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not parse date '{last_updated_str}': {e}")
|
||||
score += 0
|
||||
else:
|
||||
score += 0
|
||||
|
||||
# user rating
|
||||
rating = plugin.get("rating")
|
||||
if isinstance(rating, (int, float)):
|
||||
score += (rating / 100) * 30
|
||||
else:
|
||||
score += 0
|
||||
|
||||
# active installs
|
||||
installs = plugin.get("active_installs")
|
||||
if isinstance(installs, int):
|
||||
if installs >= 1_000_000:
|
||||
score += 20
|
||||
elif installs >= 100_000:
|
||||
score += 15
|
||||
elif installs >= 10_000:
|
||||
score += 10
|
||||
elif installs > 0:
|
||||
score += 5
|
||||
else:
|
||||
score += 0
|
||||
|
||||
return round(score, 1)
|
||||
|
||||
|
||||
def rate_plugin(slug):
|
||||
plugin = get_plugin_info(slug)
|
||||
if not plugin:
|
||||
return
|
||||
|
||||
score = calculate_security_score(plugin)
|
||||
name = plugin.get("name", slug)
|
||||
last_updated = plugin.get("last_updated", "Unknown")
|
||||
rating = plugin.get("rating", "Unknown")
|
||||
installs = plugin.get("active_installs", "Unknown")
|
||||
|
||||
print(f"\n🔍 Security Rating for '{name}'")
|
||||
print("=" * (25 + len(name)))
|
||||
print(f"Last Updated: {last_updated}")
|
||||
print(f"User Rating: {rating}/100")
|
||||
print(f"Active Installs: {installs}")
|
||||
print(f"→ Security Score: {score}/100")
|
||||
|
||||
if score >= 85:
|
||||
print("Excellent — Actively maintained and trusted")
|
||||
elif score >= 65:
|
||||
print("Good — Generally safe but monitor updates")
|
||||
elif score >= 45:
|
||||
print("Moderate — May be outdated or less popular")
|
||||
else:
|
||||
print("Poor — High risk of security issues")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python plugin_security_rating.py <plugin-slug>")
|
||||
sys.exit(1)
|
||||
slug = sys.argv[1]
|
||||
rate_plugin(slug)
|
||||
@@ -0,0 +1,4 @@
|
||||
requests
|
||||
numpy
|
||||
matplotlib
|
||||
python-dateutil
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/*
|
||||
@@ -0,0 +1,360 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def _get_plugin_dirs(directory: str) -> tuple[str, str]:
|
||||
"""Return (WP_PLUGIN_DIR, WPMU_PLUGIN_DIR) for a WordPress install.
|
||||
|
||||
If `wp-config.php` exists, this will try to resolve custom constants such
|
||||
as WP_PLUGIN_DIR and WPMU_PLUGIN_DIR. Otherwise it returns the default
|
||||
`wp-content/plugins` and `wp-content/mu-plugins` paths under `directory`.
|
||||
"""
|
||||
config_path = os.path.join(directory, "wp-config.php")
|
||||
|
||||
# Default standard WordPress locations
|
||||
plugin_path = os.path.join(directory, "wp-content", "plugins")
|
||||
mu_plugin_path = os.path.join(directory, "wp-content", "mu-plugins")
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
print(f"Notice: {config_path} not found. Returning default paths.")
|
||||
return plugin_path, mu_plugin_path
|
||||
|
||||
# READ mode ('r') is critical. 'w' would wipe the file.
|
||||
with open(config_path, "r", encoding="utf-8", errors="ignore") as config:
|
||||
content = config.read()
|
||||
|
||||
def resolve_php_path(php_value_str: str, root_dir: str) -> str:
|
||||
"""Resolve simple PHP path expressions used in wp-config.php.
|
||||
|
||||
Args:
|
||||
php_value_str: Right-hand value extracted from the define() call.
|
||||
root_dir: Filesystem path to the WordPress root (dirname of wp-config.php).
|
||||
|
||||
Returns:
|
||||
Normalized filesystem path produced from the PHP expression.
|
||||
"""
|
||||
# 1. Handle dirname(__FILE__)
|
||||
# In wp-config, __FILE__ is the config file itself, so dirname is the root_dir
|
||||
# We replace it with the actual root_dir enclosed in quotes to make it a string literal for parsing
|
||||
simulated_val = php_value_str.replace("dirname(__FILE__)", f"'{root_dir}'")
|
||||
|
||||
# 2. Remove PHP comments if present (//)
|
||||
simulated_val = simulated_val.split("//")[0]
|
||||
|
||||
# 3. Split by concatenation operator '.'
|
||||
parts = simulated_val.split(".")
|
||||
|
||||
clean_parts = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
# Strip quotes (' or ") from string literals
|
||||
if (part.startswith("'") and part.endswith("'")) or (
|
||||
part.startswith('"') and part.endswith('"')
|
||||
):
|
||||
clean_parts.append(part[1:-1])
|
||||
else:
|
||||
# If we encounter a variable we didn't substitute, likely can't resolve statically
|
||||
pass
|
||||
|
||||
# 4. Join to get full path
|
||||
full_path = "".join(clean_parts)
|
||||
return os.path.normpath(full_path)
|
||||
|
||||
# Regex looks for: define( 'CONSTANT_NAME', value );
|
||||
# It is case-insensitive and allows for various spacing/quoting styles
|
||||
plugin_regex = re.compile(
|
||||
r"define\s*\(\s*['\"]WP_PLUGIN_DIR['\"]\s*,\s*(.*?)\s*\)\s*;", re.IGNORECASE
|
||||
)
|
||||
mu_regex = re.compile(
|
||||
r"define\s*\(\s*['\"]WPMU_PLUGIN_DIR['\"]\s*,\s*(.*?)\s*\)\s*;", re.IGNORECASE
|
||||
)
|
||||
|
||||
plugin_match = plugin_regex.search(content)
|
||||
if plugin_match:
|
||||
# Extract the value part (e.g., dirname(__FILE__) . '/plugins')
|
||||
raw_value = plugin_match.group(1)
|
||||
plugin_path = resolve_php_path(raw_value, directory)
|
||||
|
||||
mu_match = mu_regex.search(content)
|
||||
if mu_match:
|
||||
raw_value = mu_match.group(1)
|
||||
mu_plugin_path = resolve_php_path(raw_value, directory)
|
||||
|
||||
return plugin_path, mu_plugin_path
|
||||
|
||||
|
||||
def _get_plugins_from_dirs(plugin_dirs: list[str]) -> dict[str, str]:
|
||||
"""Scan plugin directories and return mapping of slug -> main PHP path.
|
||||
|
||||
Args:
|
||||
plugin_dirs: List of plugin directories to scan.
|
||||
|
||||
Returns:
|
||||
Mapping from plugin slug to absolute path of the plugin's main PHP file.
|
||||
|
||||
Notes:
|
||||
Only files containing a WordPress plugin header (e.g. "Plugin Name:")
|
||||
are considered; this mirrors how WordPress discovers admin-visible plugins.
|
||||
"""
|
||||
plugins: dict[str, str] = {}
|
||||
|
||||
# Regex to detect the Plugin Name header used by WordPress plugin main files
|
||||
header_re = re.compile(r"Plugin\s+Name\s*:\s*(.+)", re.IGNORECASE)
|
||||
|
||||
def has_plugin_header(file_path: str) -> bool:
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
# Read a limited amount of the file; headers are near the top
|
||||
sample = fh.read(8192)
|
||||
except OSError:
|
||||
return False
|
||||
return bool(header_re.search(sample))
|
||||
|
||||
for p_dir in plugin_dirs:
|
||||
if not p_dir:
|
||||
continue
|
||||
if not os.path.isdir(p_dir):
|
||||
# skip non-existent plugin dirs silently
|
||||
continue
|
||||
|
||||
try:
|
||||
entries = sorted(os.listdir(p_dir))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
# Skip hidden files/dirs
|
||||
if entry.startswith("."):
|
||||
continue
|
||||
|
||||
entry_path = os.path.join(p_dir, entry)
|
||||
|
||||
# Single-file plugin (foo.php) directly in plugins dir
|
||||
if os.path.isfile(entry_path) and entry.lower().endswith(".php"):
|
||||
if has_plugin_header(entry_path):
|
||||
slug = os.path.splitext(entry)[0]
|
||||
# Keep first discovered instance (do not override)
|
||||
plugins.setdefault(slug, entry_path)
|
||||
continue
|
||||
|
||||
# Directory-based plugin
|
||||
if os.path.isdir(entry_path):
|
||||
# Candidate files to check (prefer directory-named php file)
|
||||
candidates: list[str] = []
|
||||
dir_named = os.path.join(entry_path, f"{entry}.php")
|
||||
if os.path.isfile(dir_named):
|
||||
candidates.append(dir_named)
|
||||
|
||||
# Add any top-level php files in the plugin directory
|
||||
try:
|
||||
for f in os.listdir(entry_path):
|
||||
if f.lower().endswith(".php"):
|
||||
candidates.append(os.path.join(entry_path, f))
|
||||
except OSError:
|
||||
# unreadable directory; skip
|
||||
continue
|
||||
|
||||
found = None
|
||||
for c in candidates:
|
||||
if has_plugin_header(c):
|
||||
found = c
|
||||
break
|
||||
|
||||
if found:
|
||||
slug = entry
|
||||
plugins.setdefault(slug, found)
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
def get_plugins(directory: str) -> dict[str, str]:
|
||||
"""Return admin-visible plugins for the WordPress install at ``directory``.
|
||||
|
||||
Args:
|
||||
directory: Path to the WordPress installation root (where wp-config.php lives).
|
||||
|
||||
Returns:
|
||||
Mapping of plugin slug to the absolute path of the plugin main file.
|
||||
"""
|
||||
p_dir, _mu_dir = _get_plugin_dirs(directory)
|
||||
return _get_plugins_from_dirs([p_dir])
|
||||
|
||||
|
||||
def _get_obfuscated_plugins(plugin_dirs: list[str]) -> dict[str, str]:
|
||||
"""Return plugins that may lack a plugin header (e.g. mu-plugins).
|
||||
|
||||
Args:
|
||||
plugin_dirs: List of directories to scan for obfuscated/mu plugins.
|
||||
|
||||
Returns:
|
||||
Mapping of slug to absolute filesystem path. Includes top-level .php
|
||||
files and one representative .php file from subdirectories.
|
||||
"""
|
||||
obf_plugins: dict[str, str] = {}
|
||||
|
||||
for p_dir in plugin_dirs:
|
||||
if not p_dir:
|
||||
continue
|
||||
if not os.path.isdir(p_dir):
|
||||
continue
|
||||
|
||||
try:
|
||||
entries = sorted(os.listdir(p_dir))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
if entry.startswith("."):
|
||||
continue
|
||||
|
||||
entry_path = os.path.join(p_dir, entry)
|
||||
|
||||
# Top-level php files are executed as mu-plugins
|
||||
if os.path.isfile(entry_path) and entry.lower().endswith(".php"):
|
||||
slug = os.path.splitext(entry)[0]
|
||||
obf_plugins.setdefault(slug, entry_path)
|
||||
continue
|
||||
|
||||
# Directory: pick a representative php file if present
|
||||
if os.path.isdir(entry_path):
|
||||
candidates: list[str] = []
|
||||
dir_named = os.path.join(entry_path, f"{entry}.php")
|
||||
if os.path.isfile(dir_named):
|
||||
candidates.append(dir_named)
|
||||
|
||||
try:
|
||||
for f in os.listdir(entry_path):
|
||||
if f.lower().endswith(".php"):
|
||||
candidates.append(os.path.join(entry_path, f))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if candidates:
|
||||
chosen = candidates[0]
|
||||
slug = entry
|
||||
obf_plugins.setdefault(slug, chosen)
|
||||
|
||||
return obf_plugins
|
||||
|
||||
|
||||
def get_mu_plugins(directory: str) -> dict[str, str]:
|
||||
"""Return mu-plugins (files executed without standard headers).
|
||||
|
||||
Args:
|
||||
directory: Path to the WordPress installation root.
|
||||
|
||||
Returns:
|
||||
Mapping of slug to absolute path for mu-plugins (may lack headers).
|
||||
"""
|
||||
_p_dir, mu_dir = _get_plugin_dirs(directory)
|
||||
return _get_obfuscated_plugins([mu_dir])
|
||||
|
||||
def output_recursive_plugins(scan_directory: str, outfile: str):
|
||||
"""Writes mu-plugins and normal admin-visible plugins to outfile
|
||||
|
||||
Outputs a format like:
|
||||
```json
|
||||
{ 'user1' : {
|
||||
'plugin1' : '/abs/path/to/plugin1',
|
||||
'plugin2' : '/abs/path/to/plugin2',
|
||||
},
|
||||
'user2' : {
|
||||
'plugin1' : ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Args:
|
||||
scan_directory: Path to directory containing WordPress installation subdirectories.
|
||||
outfile: Path to output JSON file.
|
||||
|
||||
Returns:
|
||||
Writes a mapping of directory in scan_directory to dict of plugin slug to absolute path for all plugins (may lack headers).
|
||||
"""
|
||||
import json
|
||||
|
||||
result = {}
|
||||
|
||||
if not os.path.isdir(scan_directory):
|
||||
print(f"Error: {scan_directory} is not a directory")
|
||||
return
|
||||
|
||||
try:
|
||||
subdirs = sorted(os.listdir(scan_directory))
|
||||
except OSError as e:
|
||||
print(f"Error reading {scan_directory}: {e}")
|
||||
return
|
||||
|
||||
for subdir in subdirs:
|
||||
# Skip hidden directories
|
||||
if subdir.startswith("."):
|
||||
continue
|
||||
|
||||
subdir_path = os.path.join(scan_directory, subdir)
|
||||
|
||||
# Only process directories
|
||||
if not os.path.isdir(subdir_path):
|
||||
continue
|
||||
|
||||
# Get plugin directories for this WordPress install
|
||||
p_dir, mu_dir = _get_plugin_dirs(subdir_path)
|
||||
|
||||
# Collect both normal and mu-plugins
|
||||
all_plugins = {}
|
||||
|
||||
# Get normal plugins (with headers)
|
||||
normal_plugins = _get_plugins_from_dirs([p_dir])
|
||||
all_plugins.update(normal_plugins)
|
||||
|
||||
# Get mu-plugins (may lack headers)
|
||||
mu_plugins = _get_obfuscated_plugins([mu_dir])
|
||||
all_plugins.update(mu_plugins)
|
||||
|
||||
# Only add to result if we found any plugins
|
||||
if all_plugins:
|
||||
result[subdir] = all_plugins
|
||||
|
||||
# Write to output file
|
||||
with open(outfile, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"Wrote plugin data for {len(result)} directories to {outfile}")
|
||||
|
||||
|
||||
def _test():
|
||||
# Change this to test against a real directory on your machine
|
||||
target_dir = "/var/www/html"
|
||||
|
||||
# For demonstration, we'll create a fake dummy file in the current folder
|
||||
# to show how the parser works without needing a real WP installation.
|
||||
dummy_config = """
|
||||
<?php
|
||||
// Standard default style
|
||||
define( 'DB_NAME', 'database_name_here' );
|
||||
|
||||
// Custom plugin directory style
|
||||
define( 'WP_PLUGIN_DIR', dirname(__FILE__) . '/custom/location/plugins' );
|
||||
|
||||
// Hardcoded absolute path style
|
||||
define( 'WPMU_PLUGIN_DIR', '/usr/local/share/wordpress/mu-plugins' );
|
||||
?>
|
||||
"""
|
||||
|
||||
with open("wp-config.php", "w") as f:
|
||||
f.write(dummy_config)
|
||||
|
||||
print(f"Testing with dummy config in: {os.getcwd()}")
|
||||
p_dir, mu_dir = _get_plugin_dirs(os.getcwd())
|
||||
|
||||
print(f"Detected Plugin Dir: {p_dir}")
|
||||
print(f"Detected MU Plugin Dir: {mu_dir}")
|
||||
|
||||
# Clean up dummy file
|
||||
os.remove("wp-config.php")
|
||||
|
||||
|
||||
# --- Example Usage ---
|
||||
if __name__ == "__main__":
|
||||
SCANDIR = "/scan"
|
||||
OUTFILE = "/scripts/plugins.json"
|
||||
output_recursive_plugins(SCANDIR, OUTFILE)
|
||||
@@ -0,0 +1,107 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from deliverable.utils.plugins import _get_plugins_from_dirs, _get_obfuscated_plugins
|
||||
|
||||
|
||||
def write_file(path: Path, content: str):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
class TestGetPluginsFromDirs(unittest.TestCase):
|
||||
def test_single_file_plugin_detected(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = Path(td) / "plugins"
|
||||
p.mkdir()
|
||||
single = p / "hello.php"
|
||||
write_file(
|
||||
single,
|
||||
"""<?php
|
||||
/*
|
||||
Plugin Name: Hello Plugin
|
||||
*/
|
||||
// plugin body
|
||||
""",
|
||||
)
|
||||
|
||||
res = _get_plugins_from_dirs([str(p)])
|
||||
self.assertIn("hello", res)
|
||||
self.assertEqual(
|
||||
os.path.normpath(res["hello"]), os.path.normpath(str(single))
|
||||
)
|
||||
|
||||
def test_directory_plugin_with_dir_named_file(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = Path(td) / "plugins"
|
||||
plugin_dir = p / "akismet"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
main = plugin_dir / "akismet.php"
|
||||
write_file(
|
||||
main,
|
||||
"""<?php
|
||||
/*
|
||||
Plugin Name: Akismet Mock
|
||||
*/
|
||||
""",
|
||||
)
|
||||
|
||||
res = _get_plugins_from_dirs([str(p)])
|
||||
self.assertIn("akismet", res)
|
||||
self.assertEqual(
|
||||
os.path.normpath(res["akismet"]), os.path.normpath(str(main))
|
||||
)
|
||||
|
||||
def test_directory_plugin_with_other_php_file(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = Path(td) / "plugins"
|
||||
plugin_dir = p / "sample"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
main = plugin_dir / "main.php"
|
||||
write_file(
|
||||
main,
|
||||
"""<?php
|
||||
/*
|
||||
Plugin Name: Sample Plugin
|
||||
*/
|
||||
""",
|
||||
)
|
||||
|
||||
res = _get_plugins_from_dirs([str(p)])
|
||||
# slug is the directory name
|
||||
self.assertIn("sample", res)
|
||||
self.assertEqual(
|
||||
os.path.normpath(res["sample"]), os.path.normpath(str(main))
|
||||
)
|
||||
|
||||
def test_files_without_header_ignored(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = Path(td) / "plugins"
|
||||
p.mkdir()
|
||||
noheader = p / "noheader.php"
|
||||
write_file(noheader, "<?php\n// no plugin header here\n")
|
||||
|
||||
res = _get_plugins_from_dirs([str(p)])
|
||||
self.assertNotIn("noheader", res)
|
||||
|
||||
def test_mu_plugins_include_no_header(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = Path(td) / "mu-plugins"
|
||||
p.mkdir()
|
||||
noheader = p / "bootstrap.php"
|
||||
write_file(
|
||||
noheader, "<?php\n// initialization file without plugin header\n"
|
||||
)
|
||||
|
||||
res = _get_obfuscated_plugins([str(p)])
|
||||
# mu-plugin should be included even without a header
|
||||
self.assertIn("bootstrap", res)
|
||||
self.assertEqual(
|
||||
os.path.normpath(res["bootstrap"]), os.path.normpath(str(noheader))
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 199 B After Width: | Height: | Size: 199 B |
|
Before Width: | Height: | Size: 904 B After Width: | Height: | Size: 904 B |