Add mysql-scanner: MySQL malware detection tool
This commit is contained in:
11
mysql-scanner/.env.example
Normal file
11
mysql-scanner/.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# MySQL Scanner - Environment Variables Example
|
||||||
|
# Copy this to .env and fill in your database credentials
|
||||||
|
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=wp_user
|
||||||
|
DB_PASSWORD=your_password_here
|
||||||
|
DB_NAME=wordpress
|
||||||
|
|
||||||
|
# Optional: Limit patterns (comma-separated)
|
||||||
|
# PATTERNS=eval,base64,script
|
||||||
97
mysql-scanner/README.md
Normal file
97
mysql-scanner/README.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# mysql-scanner
|
||||||
|
|
||||||
|
MySQL database malware scanner for WordPress and generic schemas.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Detect hacked content and malware in MySQL databases by scanning text columns for:
|
||||||
|
- Base64 encoded payloads
|
||||||
|
- PHP dangerous functions (eval, exec, system, etc.)
|
||||||
|
- HTML/JS injection (script tags, iframes, event handlers)
|
||||||
|
- Obfuscation patterns
|
||||||
|
- Suspicious comment signatures
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Schema-agnostic**: Analyzes any database structure automatically
|
||||||
|
- **WordPress-aware**: Prioritizes common WordPress tables (wp_posts, wp_options, etc.)
|
||||||
|
- **Pattern library**: Extensible detection patterns
|
||||||
|
- **Contextual reports**: Shows table, column, row ID, and offending snippets
|
||||||
|
- **Risk levels**: High/Medium/Low classification
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/go/mysql-scanner
|
||||||
|
go build -o mysql-scanner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scan WordPress database
|
||||||
|
./mysql-scanner --host localhost --user wp_user --password secret --db wordpress
|
||||||
|
|
||||||
|
# Scan with custom port and pattern filters
|
||||||
|
./mysql-scanner --host localhost --port 3307 --user user --db mydb --patterns eval,base64,script
|
||||||
|
|
||||||
|
# Output JSON for automated processing
|
||||||
|
./mysql-scanner --host localhost --user user --db mydb --json --output scan-results.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=username
|
||||||
|
DB_PASSWORD=secret
|
||||||
|
DB_NAME=database
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detection Patterns
|
||||||
|
|
||||||
|
### PHP Malware
|
||||||
|
- `eval()`, `assert()`, `create_function()`
|
||||||
|
- `exec()`, `system()`, `passthru()`, `shell_exec()`
|
||||||
|
- `base64_decode()`, `gzinflate()`, `str_rot13()`
|
||||||
|
- `preg_replace()` with `/e` modifier (deprecated but dangerous)
|
||||||
|
|
||||||
|
### Web Shell Patterns
|
||||||
|
- `$_GET`, `$_POST`, `$_REQUEST` with eval
|
||||||
|
- `error_reporting(0)` followed by obfuscation
|
||||||
|
- Variable function calls (`$func()`, `$a($b)`)
|
||||||
|
|
||||||
|
### HTML/JS Injection
|
||||||
|
- `<script>...</script>` tags
|
||||||
|
- `<iframe>` tags (especially with hidden attributes)
|
||||||
|
- Event handlers: `onerror=`, `onload=`, `onclick=`
|
||||||
|
- `javascript:` protocol in attributes
|
||||||
|
|
||||||
|
### Base64 Payloads
|
||||||
|
- Long base64 strings (>200 chars)
|
||||||
|
- Base64 followed by decode/eval patterns
|
||||||
|
- Double-encoded base64
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
1. **Connection**: MySQL driver with configurable auth
|
||||||
|
2. **Schema Discovery**: Queries `information_schema` for tables and text columns
|
||||||
|
3. **Row Scanning**: Iterates through all rows, extracts text content
|
||||||
|
4. **Pattern Matching**: Runs detection patterns against each text field
|
||||||
|
5. **Reporting**: Aggregates findings with context and risk levels
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- This tool requires database credentials with SELECT access
|
||||||
|
- Recommended for incident response and periodic security audits
|
||||||
|
- Can be integrated into CI/CD for automated scanning
|
||||||
|
- **Never expose scan reports publicly** - they may contain sensitive data
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Heuristic scoring for combined risk factors
|
||||||
|
- YARA rule support for advanced signatures
|
||||||
|
- Automatic quarantine/sanitization suggestions
|
||||||
|
- Real-time monitoring mode with change detection
|
||||||
|
- Integration with WordPress security plugins
|
||||||
212
mysql-scanner/USAGE.md
Normal file
212
mysql-scanner/USAGE.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# MySQL Scanner - Usage Examples
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic WordPress Scan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/go/mysql-scanner
|
||||||
|
|
||||||
|
# Using command-line flags
|
||||||
|
./mysql-scanner --host localhost --user wp_user --password secret --db wordpress
|
||||||
|
|
||||||
|
# Using environment variables
|
||||||
|
DB_HOST=localhost DB_USER=wp_user DB_PASSWORD=secret DB_NAME=wordpress ./mysql-scanner
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by Pattern Type
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Only scan for eval() and base64_decode()
|
||||||
|
./mysql-scanner --host localhost --user user --db mydb --patterns eval,base64
|
||||||
|
|
||||||
|
# Only scan for script tags and iframes
|
||||||
|
./mysql-scanner --host localhost --user user --db mydb --patterns script_tag,iframe_tag
|
||||||
|
|
||||||
|
# List all available patterns (check README.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Output
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Output JSON to stdout
|
||||||
|
./mysql-scanner --host localhost --user user --db mydb --json
|
||||||
|
|
||||||
|
# Save JSON to file
|
||||||
|
./mysql-scanner --host localhost --user user --db mydb --json --output scan-results.json
|
||||||
|
|
||||||
|
# Pretty-print JSON with jq
|
||||||
|
./mysql-scanner --host localhost --user user --db mydb --json | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Understanding the Output
|
||||||
|
|
||||||
|
### Human-Readable Output
|
||||||
|
|
||||||
|
```
|
||||||
|
Connected to wp_user@localhost:3306/wordpress
|
||||||
|
Found 12 tables
|
||||||
|
Scanning table: wp_posts
|
||||||
|
Scanning 4 text columns: [post_content post_excerpt post_title post_password]
|
||||||
|
Scanning table: wp_options
|
||||||
|
Scanning 3 text columns: [option_value option_name autoload]
|
||||||
|
|
||||||
|
=== SCAN RESULTS ===
|
||||||
|
Database: wordpress
|
||||||
|
Tables scanned: 12
|
||||||
|
Findings: 3 total (High: 1, Medium: 2, Low: 0)
|
||||||
|
|
||||||
|
🔴 HIGH RISK (1)
|
||||||
|
|
||||||
|
[high] wp_posts.post_content (ID: 42)
|
||||||
|
Pattern: eval_function
|
||||||
|
Match: eval(
|
||||||
|
Snippet: Some text with <?php eval(base64_decode('SGVsbG8=')); ?> malicious code
|
||||||
|
|
||||||
|
🟡 MEDIUM RISK (2)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Output
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"database": "wordpress",
|
||||||
|
"tables_scanned": 12,
|
||||||
|
"rows_scanned": 3,
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"table": "wp_posts",
|
||||||
|
"column": "post_content",
|
||||||
|
"row_id": "42",
|
||||||
|
"risk_level": "high",
|
||||||
|
"pattern": "eval_function",
|
||||||
|
"match": "eval(",
|
||||||
|
"snippet": "Some text with <?php eval(base64_decode('SGVsbG8=')); ?>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"high_risk_count": 1,
|
||||||
|
"medium_risk_count": 2,
|
||||||
|
"low_risk_count": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
### Incident Response
|
||||||
|
|
||||||
|
When you suspect a WordPress compromise:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full scan of the database
|
||||||
|
./mysql-scanner --host localhost --user wp_user --password *** --db wordpress --json > incident-scan.json
|
||||||
|
|
||||||
|
# Check only high-risk patterns first
|
||||||
|
./mysql-scanner --host localhost --user wp_user --password *** --db wordpress --patterns eval,assert,base64_decode,exec,system
|
||||||
|
```
|
||||||
|
|
||||||
|
### Periodic Security Audits
|
||||||
|
|
||||||
|
Set up a cron job for automated scanning:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# /usr/local/bin/wordpress-malware-scan.sh
|
||||||
|
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
OUTPUT_DIR="/var/log/malware-scans"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
for DB in wordpress wordpress_blog wordpress_site2; do
|
||||||
|
echo "Scanning $DB..."
|
||||||
|
./mysql-scanner --host localhost --user scanner_user --password *** \
|
||||||
|
--db "$DB" --json --output "$OUTPUT_DIR/$DB-$DATE.json"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Email if high-risk findings
|
||||||
|
HIGH_COUNT=$(jq '.high_risk_count' "$OUTPUT_DIR"/*-$DATE.json | awk '{s+=$1} END {print s}')
|
||||||
|
if [ "$HIGH_COUNT" -gt 0 ]; then
|
||||||
|
echo "ALERT: $HIGH_COUNT high-risk malware findings detected" | \
|
||||||
|
mail -s "WordPress Malware Scan Results" admin@example.com
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Testing
|
||||||
|
|
||||||
|
Scan development databases before production deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scan staging database
|
||||||
|
./mysql-scanner --host staging-db.example.com --user dev_user --password *** \
|
||||||
|
--db wordpress_staging --patterns script_tag,iframe_tag,javascript_protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
## WordPress-Specific Tables
|
||||||
|
|
||||||
|
The scanner automatically handles these common WordPress tables:
|
||||||
|
|
||||||
|
- `wp_posts` - post_content, post_excerpt, post_title
|
||||||
|
- `wp_options` - option_value (theme options, plugins settings)
|
||||||
|
- `wp_comments` - comment_content
|
||||||
|
- `wp_commentmeta` - meta_value
|
||||||
|
- `wp_postmeta` - meta_value
|
||||||
|
- `wp_usermeta` - meta_value
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Connection Failed
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: failed to connect to database: dial tcp 127.0.0.1:3306: connect: connection refused
|
||||||
|
```
|
||||||
|
|
||||||
|
- Check that MySQL is running: `systemctl status mysql`
|
||||||
|
- Verify host and port: `--host localhost --port 3306`
|
||||||
|
- Check firewall rules
|
||||||
|
|
||||||
|
### Access Denied
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Access denied for user 'wp_user'@'localhost'
|
||||||
|
```
|
||||||
|
|
||||||
|
- Verify username and password
|
||||||
|
- Ensure user has SELECT permissions on the database
|
||||||
|
- Check host restrictions (localhost vs %)
|
||||||
|
|
||||||
|
### No Text Columns Found
|
||||||
|
|
||||||
|
Some tables may only have numeric columns and will be skipped. This is normal.
|
||||||
|
|
||||||
|
### Too Many False Positives
|
||||||
|
|
||||||
|
Some patterns like `eval(` may trigger on legitimate content. Use pattern filtering:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exclude false-positive patterns
|
||||||
|
./mysql-scanner --db mydb --patterns base64_decode,script_tag,iframe_tag
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Never commit scan results to version control** - they may contain sensitive data
|
||||||
|
2. **Use read-only database accounts** - Create a dedicated scanner user with SELECT only
|
||||||
|
3. **Secure scan output files** - Set appropriate file permissions (chmod 600)
|
||||||
|
4. **Rotate scanner credentials** - Change passwords regularly
|
||||||
|
5. **Monitor scan logs** - Track who is running scans and when
|
||||||
|
|
||||||
|
## Creating a Read-Only Scanner User
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Create a scanner user with minimal permissions
|
||||||
|
CREATE USER 'scanner'@'localhost' IDENTIFIED BY 'strong_password_here';
|
||||||
|
|
||||||
|
-- Grant SELECT on all tables
|
||||||
|
GRANT SELECT ON wordpress.* TO 'scanner'@'localhost';
|
||||||
|
|
||||||
|
-- Flush privileges
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
-- Verify
|
||||||
|
SHOW GRANTS FOR 'scanner'@'localhost';
|
||||||
|
```
|
||||||
5
mysql-scanner/go.mod
Normal file
5
mysql-scanner/go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module github.com/wltbagent/mysql-scanner
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require github.com/go-sql-driver/mysql v1.7.1
|
||||||
2
mysql-scanner/go.sum
Normal file
2
mysql-scanner/go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
579
mysql-scanner/main.go
Normal file
579
mysql-scanner/main.go
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds scanner configuration
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Database string
|
||||||
|
Patterns []string
|
||||||
|
JSON bool
|
||||||
|
Output string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finding represents a detected issue
|
||||||
|
type Finding struct {
|
||||||
|
Table string `json:"table"`
|
||||||
|
Column string `json:"column"`
|
||||||
|
RowID string `json:"row_id"`
|
||||||
|
RiskLevel string `json:"risk_level"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
Match string `json:"match"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
LineNumbers []int `json:"line_numbers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanResult holds complete scan results
|
||||||
|
type ScanResult struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Tables int `json:"tables_scanned"`
|
||||||
|
Rows int `json:"rows_scanned"`
|
||||||
|
Findings []Finding `json:"findings"`
|
||||||
|
HighRisk int `json:"high_risk_count"`
|
||||||
|
MedRisk int `json:"medium_risk_count"`
|
||||||
|
LowRisk int `json:"low_risk_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern defines a detection pattern
|
||||||
|
type Pattern struct {
|
||||||
|
Name string
|
||||||
|
Risk string
|
||||||
|
Regex *regexp.Regexp
|
||||||
|
Sensitive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var patterns []Pattern
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Initialize detection patterns
|
||||||
|
patterns = []Pattern{
|
||||||
|
// PHP dangerous functions - HIGH RISK
|
||||||
|
{
|
||||||
|
Name: "eval_function",
|
||||||
|
Risk: "high",
|
||||||
|
Regex: regexp.MustCompile(`\beval\s*\(`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "assert_function",
|
||||||
|
Risk: "high",
|
||||||
|
Regex: regexp.MustCompile(`\bassert\s*\(`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create_function",
|
||||||
|
Risk: "high",
|
||||||
|
Regex: regexp.MustCompile(`\bcreate_function\s*\(`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "exec_function",
|
||||||
|
Risk: "high",
|
||||||
|
Regex: regexp.MustCompile(`\b(?:exec|system|passthru|shell_exec)\s*\(`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Base64 with decode - HIGH RISK
|
||||||
|
{
|
||||||
|
Name: "base64_decode",
|
||||||
|
Risk: "high",
|
||||||
|
Regex: regexp.MustCompile(`\bbase64_decode\s*\(`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "gzinflate",
|
||||||
|
Risk: "high",
|
||||||
|
Regex: regexp.MustCompile(`\bgzinflate\s*\(`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "str_rot13",
|
||||||
|
Risk: "medium",
|
||||||
|
Regex: regexp.MustCompile(`\bstr_rot13\s*\(`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Long base64 strings - MEDIUM RISK
|
||||||
|
{
|
||||||
|
Name: "long_base64",
|
||||||
|
Risk: "medium",
|
||||||
|
Regex: regexp.MustCompile(`[A-Za-z0-9+/]{200,}={0,2}`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// HTML/JS injection - MEDIUM RISK
|
||||||
|
{
|
||||||
|
Name: "script_tag",
|
||||||
|
Risk: "medium",
|
||||||
|
Regex: regexp.MustCompile(`(?i)<script[^>]*>.*?</script>`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "iframe_tag",
|
||||||
|
Risk: "medium",
|
||||||
|
Regex: regexp.MustCompile(`(?i)<iframe[^>]*(?:hidden|width=0|height=0)[^>]*>`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "javascript_protocol",
|
||||||
|
Risk: "medium",
|
||||||
|
Regex: regexp.MustCompile(`(?i)javascript:\s*[^\s"'>]+`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "event_handler",
|
||||||
|
Risk: "medium",
|
||||||
|
Regex: regexp.MustCompile(`(?i)\b(?:onerror|onload|onclick|onmouseover)\s*=`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Web shell patterns - HIGH RISK
|
||||||
|
{
|
||||||
|
Name: "superglobal_eval",
|
||||||
|
Risk: "high",
|
||||||
|
Regex: regexp.MustCompile(`(?:\$_(?:GET|POST|REQUEST|COOKIE))\[.*\]\s*\(\s*\$`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "error_reporting_suppress",
|
||||||
|
Risk: "medium",
|
||||||
|
Regex: regexp.MustCompile(`error_reporting\s*\(\s*0\s*\)`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Variable function calls - MEDIUM RISK
|
||||||
|
{
|
||||||
|
Name: "variable_function",
|
||||||
|
Risk: "medium",
|
||||||
|
Regex: regexp.MustCompile(`\$[a-zA-Z_]\w*\s*\(`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common malware comment signatures - LOW RISK
|
||||||
|
{
|
||||||
|
Name: "malware_comment",
|
||||||
|
Risk: "low",
|
||||||
|
Regex: regexp.MustCompile(`(?i)/\*.*(?:shell|backdoor|webshell|hack|c99|r57).*\*/`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Obfuscation indicators - MEDIUM RISK
|
||||||
|
{
|
||||||
|
Name: "hex_encode",
|
||||||
|
Risk: "medium",
|
||||||
|
Regex: regexp.MustCompile(`\\x[0-9a-fA-F]{2}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
config := parseFlags()
|
||||||
|
|
||||||
|
if config.User == "" || config.Database == "" {
|
||||||
|
log.Fatal("Error: --user and --db are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
db, err := connectDB(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
fmt.Printf("Connected to %s@%s:%d/%s\n", config.User, config.Host, config.Port, config.Database)
|
||||||
|
|
||||||
|
// Scan database
|
||||||
|
result, err := scanDatabase(db, config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Scan failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output results
|
||||||
|
if config.JSON {
|
||||||
|
outputJSON(result, config.Output)
|
||||||
|
} else {
|
||||||
|
outputHuman(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags() Config {
|
||||||
|
host := flag.String("host", getEnv("DB_HOST", "localhost"), "Database host")
|
||||||
|
port := flag.Int("port", getEnvInt("DB_PORT", 3306), "Database port")
|
||||||
|
user := flag.String("user", getEnv("DB_USER", ""), "Database user")
|
||||||
|
password := flag.String("password", getEnv("DB_PASSWORD", ""), "Database password")
|
||||||
|
db := flag.String("db", getEnv("DB_NAME", ""), "Database name")
|
||||||
|
patterns := flag.String("patterns", "", "Comma-separated patterns to scan (default: all)")
|
||||||
|
jsonOut := flag.Bool("json", false, "Output JSON instead of human-readable")
|
||||||
|
output := flag.String("output", "", "Output file path (default: stdout)")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
var patternList []string
|
||||||
|
if *patterns != "" {
|
||||||
|
patternList = strings.Split(*patterns, ",")
|
||||||
|
for i := range patternList {
|
||||||
|
patternList[i] = strings.TrimSpace(patternList[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config{
|
||||||
|
Host: *host,
|
||||||
|
Port: *port,
|
||||||
|
User: *user,
|
||||||
|
Password: *password,
|
||||||
|
Database: *db,
|
||||||
|
Patterns: patternList,
|
||||||
|
JSON: *jsonOut,
|
||||||
|
Output: *output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectDB(config Config) (*sql.DB, error) {
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true",
|
||||||
|
config.User,
|
||||||
|
config.Password,
|
||||||
|
config.Host,
|
||||||
|
config.Port,
|
||||||
|
config.Database,
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDatabase(db *sql.DB, config Config) (*ScanResult, error) {
|
||||||
|
result := &ScanResult{
|
||||||
|
Database: config.Database,
|
||||||
|
Findings: []Finding{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all tables
|
||||||
|
tables, err := getTables(db, config.Database)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get tables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d tables\n", len(tables))
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
fmt.Printf("Scanning table: %s\n", table)
|
||||||
|
|
||||||
|
columns, err := getTextColumns(db, config.Database, table)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: failed to get columns for %s: %v", table, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(columns) == 0 {
|
||||||
|
fmt.Printf(" No text columns, skipping\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Scanning %d text columns: %v\n", len(columns), columns)
|
||||||
|
|
||||||
|
rows, err := scanTable(db, table, columns, config.Patterns)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: failed to scan table %s: %v", table, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Findings = append(result.Findings, rows...)
|
||||||
|
result.Tables++
|
||||||
|
result.Rows += len(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count risk levels
|
||||||
|
for _, f := range result.Findings {
|
||||||
|
switch f.RiskLevel {
|
||||||
|
case "high":
|
||||||
|
result.HighRisk++
|
||||||
|
case "medium":
|
||||||
|
result.MedRisk++
|
||||||
|
case "low":
|
||||||
|
result.LowRisk++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTables(db *sql.DB, dbName string) ([]string, error) {
|
||||||
|
query := `SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = ? AND table_type = 'BASE TABLE'`
|
||||||
|
|
||||||
|
rows, err := db.Query(query, dbName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tables []string
|
||||||
|
for rows.Next() {
|
||||||
|
var table string
|
||||||
|
if err := rows.Scan(&table); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tables = append(tables, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTextColumns(db *sql.DB, dbName, table string) ([]string, error) {
|
||||||
|
query := `SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = ? AND table_name = ?
|
||||||
|
AND data_type IN ('text', 'longtext', 'mediumtext', 'varchar', 'char')
|
||||||
|
ORDER BY ordinal_position`
|
||||||
|
|
||||||
|
rows, err := db.Query(query, dbName, table)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var columns []string
|
||||||
|
for rows.Next() {
|
||||||
|
var col string
|
||||||
|
if err := rows.Scan(&col); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
columns = append(columns, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTable(db *sql.DB, table string, columns, activePatterns []string) ([]Finding, error) {
|
||||||
|
var findings []Finding
|
||||||
|
|
||||||
|
// Build SELECT query with ID column if exists
|
||||||
|
query, idColumn := buildSelectQuery(table, columns)
|
||||||
|
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// Get column names for scanning
|
||||||
|
colNames, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
// Scan row into values
|
||||||
|
values := make([]interface{}, len(colNames))
|
||||||
|
valuePtrs := make([]interface{}, len(colNames))
|
||||||
|
for i := range values {
|
||||||
|
valuePtrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Scan(valuePtrs...); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get row identifier
|
||||||
|
rowID := ""
|
||||||
|
if idColumn != "" {
|
||||||
|
for i, name := range colNames {
|
||||||
|
if name == idColumn && values[i] != nil {
|
||||||
|
rowID = fmt.Sprintf("%v", values[i])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan each text column
|
||||||
|
for i, name := range colNames {
|
||||||
|
if values[i] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip ID column
|
||||||
|
if name == idColumn {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content := fmt.Sprintf("%v", values[i])
|
||||||
|
if len(content) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run patterns
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
// Filter patterns if specified
|
||||||
|
if len(activePatterns) > 0 && !contains(activePatterns, pattern.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if pattern.Regex.MatchString(content) {
|
||||||
|
matches := pattern.Regex.FindAllString(content, 10)
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
finding := Finding{
|
||||||
|
Table: table,
|
||||||
|
Column: name,
|
||||||
|
RowID: rowID,
|
||||||
|
RiskLevel: pattern.Risk,
|
||||||
|
Pattern: pattern.Name,
|
||||||
|
Match: match,
|
||||||
|
Snippet: truncate(content, 200),
|
||||||
|
}
|
||||||
|
|
||||||
|
findings = append(findings, finding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSelectQuery(table string, columns []string) (string, string) {
|
||||||
|
// Try to find an ID column for row identification
|
||||||
|
idColumns := []string{"id", "ID", "post_id", "comment_id", "user_id", "option_id", "term_id"}
|
||||||
|
var idColumn string
|
||||||
|
|
||||||
|
// For now, we'll select all columns and figure out ID during scan
|
||||||
|
selectCols := "*"
|
||||||
|
if len(columns) > 0 {
|
||||||
|
// Include ID column if we can guess it
|
||||||
|
for _, idCol := range idColumns {
|
||||||
|
if contains(columns, idCol) {
|
||||||
|
idColumn = idCol
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("SELECT %s FROM %s", selectCols, table)
|
||||||
|
return query, idColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputHuman(result *ScanResult) {
|
||||||
|
fmt.Printf("\n=== SCAN RESULTS ===\n")
|
||||||
|
fmt.Printf("Database: %s\n", result.Database)
|
||||||
|
fmt.Printf("Tables scanned: %d\n", result.Tables)
|
||||||
|
fmt.Printf("Findings: %d total (High: %d, Medium: %d, Low: %d)\n\n",
|
||||||
|
len(result.Findings), result.HighRisk, result.MedRisk, result.LowRisk)
|
||||||
|
|
||||||
|
if len(result.Findings) == 0 {
|
||||||
|
fmt.Println("✓ No suspicious patterns detected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by risk level
|
||||||
|
high := []Finding{}
|
||||||
|
medium := []Finding{}
|
||||||
|
low := []Finding{}
|
||||||
|
|
||||||
|
for _, f := range result.Findings {
|
||||||
|
switch f.RiskLevel {
|
||||||
|
case "high":
|
||||||
|
high = append(high, f)
|
||||||
|
case "medium":
|
||||||
|
medium = append(medium, f)
|
||||||
|
case "low":
|
||||||
|
low = append(low, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print high risk first
|
||||||
|
if len(high) > 0 {
|
||||||
|
fmt.Printf("🔴 HIGH RISK (%d)\n", len(high))
|
||||||
|
printFindings(high, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(medium) > 0 {
|
||||||
|
fmt.Printf("\n🟡 MEDIUM RISK (%d)\n", len(medium))
|
||||||
|
printFindings(medium, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(low) > 0 {
|
||||||
|
fmt.Printf("\n🟢 LOW RISK (%d)\n", len(low))
|
||||||
|
printFindings(low, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printFindings(findings []Finding, maxDisplay int) {
|
||||||
|
display := min(maxDisplay, len(findings))
|
||||||
|
for i := 0; i < display; i++ {
|
||||||
|
f := findings[i]
|
||||||
|
fmt.Printf("\n [%s] %s.%s", f.RiskLevel, f.Table, f.Column)
|
||||||
|
if f.RowID != "" {
|
||||||
|
fmt.Printf(" (ID: %s)", f.RowID)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n Pattern: %s\n", f.Pattern)
|
||||||
|
fmt.Printf(" Match: %s\n", truncate(f.Match, 100))
|
||||||
|
fmt.Printf(" Snippet: %s\n", f.Snippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(findings) > maxDisplay {
|
||||||
|
fmt.Printf("\n ... and %d more\n", len(findings)-maxDisplay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputJSON(result *ScanResult, outputPath string) {
|
||||||
|
data, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to marshal JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputPath != "" {
|
||||||
|
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
||||||
|
log.Fatalf("Failed to write output file: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Results written to %s\n", outputPath)
|
||||||
|
} else {
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, fallback int) int {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
var i int
|
||||||
|
fmt.Sscanf(value, "%d", &i)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(slice []string, item string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if strings.EqualFold(s, item) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
BIN
mysql-scanner/mysql-scanner
Executable file
BIN
mysql-scanner/mysql-scanner
Executable file
Binary file not shown.
Reference in New Issue
Block a user