diff --git a/.github/workflows/android-lint.yml b/.github/workflows/android-lint.yml new file mode 100644 index 000000000..3b6125ac2 --- /dev/null +++ b/.github/workflows/android-lint.yml @@ -0,0 +1,45 @@ +name: Android Lint + +on: + push: + branches: [master, dev] + paths: + - 'src/MobileShepherd/MobileShepherd/**' + pull_request: + branches: [master, dev] + paths: + - 'src/MobileShepherd/MobileShepherd/**' + +jobs: + android-lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/MobileShepherd/MobileShepherd + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Android Lint + run: ./gradlew lintDebug + + - name: Upload Lint Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-lint-report + path: src/MobileShepherd/MobileShepherd/app/build/reports/lint-results-debug.html + retention-days: 30 diff --git a/.github/workflows/build-and-publish-dev.yml b/.github/workflows/build-and-publish-dev.yml new file mode 100644 index 000000000..af914c359 --- /dev/null +++ b/.github/workflows/build-and-publish-dev.yml @@ -0,0 +1,66 @@ +name: build-and-publish-dev +on: + workflow_dispatch: + # Disabled for fork - requires Google Cloud credentials + # push: + # branches: + # - "dev" + +permissions: + contents: read + id-token: write + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - id: "auth" + name: "Authenticate to Google Cloud" + uses: "google-github-actions/auth@v2" + with: + token_format: "access_token" + workload_identity_provider: ${{secrets.WORKLOAD_IDENTITY_PROVIDER_DEV}} + service_account: ${{secrets.SERVICE_ACCOUNT_ID_DEV}} + + - name: Login to GCR + uses: docker/login-action@v2 + with: + registry: us-docker.pkg.dev + username: oauth2accesstoken + password: ${{ steps.auth.outputs.access_token }} + + - name: Set environment variables + uses: c-py/action-dotenv-to-setenv@v2 + with: + env-file: .env + + - name: Set up JDK 1.8 + uses: actions/setup-java@v4 + with: + java-version: 8 + distribution: 'temurin' + - name: Build Maven with Docker Profile + run: mvn clean install -Pdocker -DskipTests -B + - name: Build and push + uses: docker/bake-action@master + with: + push: true + env: + IMAGE_TOMCAT: security-shepherd:latest + IMAGE_MARIADB: security-shepherd_mariadb:latest + IMAGE_MONGO: security-shepherd_mongo:latest + CONTAINER_TOMCAT: secshep-tomcat + CONTAINER_MARIADB: secshep-mariadb + CONTAINER_MONGO: secshep-mongo + MONGO_BIND_ADDRESS: "0.0.0.0" + # - name: Setup tmate session + # uses: mxschmitt/action-tmate@v3 + # with: + # limit-access-to-actor: true + # if: ${{ failure() }} diff --git a/mobile/.gitignore b/mobile/.gitignore index df1ce3d5e..79e0fdefb 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -1,29 +1,21 @@ -# built application files -*.apk -*.ap_ - -# files for the dex VM -*.dex - -# Java class files -*.class - -# generated files -bin/ -gen/ - -# Local configuration file (sdk path, etc) -local.properties - -# Eclipse project files -.classpath -.project - -# Proguard folder generated by Eclipse -proguard/ - -# Intellij project files *.iml -*.ipr -*.iws -.idea/ \ No newline at end of file +.gradle +/local.properties +# Ignore all IDE project files +.idea/ +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +CHALLENGE_SOLUTIONS.txt +missing_challenges.txt +MOBILE_SHEPHERD_WALKTHROUGH.md +# Dev planning docs (local only) +MATERIAL3_IMPLEMENTATION_SUMMARY.md +MOBILE_UI_MODERNIZATION_PLAN.md +# Environment / secrets +.env +.env.* diff --git a/mobile/app/.gitignore b/mobile/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/mobile/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/mobile/app/build.gradle b/mobile/app/build.gradle new file mode 100644 index 000000000..86ae11b3b --- /dev/null +++ b/mobile/app/build.gradle @@ -0,0 +1,75 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'org.owasp.mobileshepherd' + compileSdk 34 + + defaultConfig { + applicationId "org.owasp.mobileshepherd" + minSdk 21 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + minifyEnabled false + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + testOptions { + unitTests { + returnDefaultValues = true + } + } + sourceSets { + test { + java { + // These test files reference unimplemented modules — exclude from compilation + // until the corresponding fragments/models are implemented. + exclude 'org/owasp/mobileshepherd/InsecureData3Test.java' + exclude 'org/owasp/mobileshepherd/ReverseEngineering2Test.java' + exclude 'org/owasp/mobileshepherd/ReverseEngineeringChallenge3Test.java' + exclude 'org/owasp/mobileshepherd/SecurityMisconfigChallenge3Test.java' + exclude 'org/owasp/mobileshepherd/SupplyChainChallengeTest.java' + } + } + } + buildFeatures { + viewBinding true + } +} + +dependencies { + + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.preference:preference:1.2.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' + implementation 'androidx.navigation:navigation-fragment:2.7.7' + implementation 'androidx.navigation:navigation-ui:2.7.7' + implementation 'androidx.exifinterface:exifinterface:1.3.7' + implementation 'androidx.dynamicanimation:dynamicanimation:1.0.0' + + // Unit testing + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.10' + + // Instrumented testing + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' +} \ No newline at end of file diff --git a/mobile/app/proguard-rules.pro b/mobile/app/proguard-rules.pro new file mode 100644 index 000000000..41f0d234e --- /dev/null +++ b/mobile/app/proguard-rules.pro @@ -0,0 +1,101 @@ +# 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 + +# =================================== +# REVERSE ENGINEERING LESSONS/CHALLENGES +# Keep these unobfuscated for educational purposes +# =================================== + +# Reverse Engineering Lesson - meant to be reverse engineered +-keep class org.owasp.mobileshepherd.ui.lessons.** { *; } + +# Reverse Engineering Challenges 1-3 - meant to be reverse engineered +-keep class org.owasp.mobileshepherd.ui.challenges.** { *; } + +# =================================== +# OBFUSCATE EVERYTHING ELSE +# These should be protected from reverse engineering +# =================================== + +# Keep names for ViewBinding and ViewModels to avoid runtime issues +-keep class org.owasp.mobileshepherd.databinding.** { *; } +-keep class * extends androidx.lifecycle.ViewModel { *; } + +# Keep MainActivity and navigation infrastructure +-keep class org.owasp.mobileshepherd.MainActivity { *; } +-keep class org.owasp.mobileshepherd.LandingActivity { *; } +-keep class org.owasp.mobileshepherd.Preferences { *; } + +# Keep Fragment classes but obfuscate their internals +-keepnames class * extends androidx.fragment.app.Fragment + +# Keep these challenge/lesson packages OBFUSCATED (not in RE category) +# Insecure Data Storage - should be obfuscated +-keepnames class org.owasp.mobileshepherd.ui.lessons.insecuredata.** +-keepnames class org.owasp.mobileshepherd.ui.challenges.insecuredata1.** +-keepnames class org.owasp.mobileshepherd.ui.challenges.insecuredata3.** + +# Poor Authentication - should be obfuscated +-keepnames class org.owasp.mobileshepherd.ui.lessons.poorauth.** +-keepnames class org.owasp.mobileshepherd.ui.challenges.poorauth.** + +# Supply Chain Security - should be obfuscated +-keepnames class org.owasp.mobileshepherd.ui.lessons.supplychain.** +-keepnames class org.owasp.mobileshepherd.ui.challenges.supplychain.** + +# Insecure Communication - should be obfuscated +-keepnames class org.owasp.mobileshepherd.ui.lessons.insecurecomm.** +-keepnames class org.owasp.mobileshepherd.ui.challenges.insecurecomm.** + +# Cryptography - should be obfuscated +-keepnames class org.owasp.mobileshepherd.ui.lessons.crypto.** +-keepnames class org.owasp.mobileshepherd.ui.challenges.crypto.** + +# Security Misconfiguration - should be obfuscated +-keepnames class org.owasp.mobileshepherd.ui.lessons.securitymisconfig.** +-keepnames class org.owasp.mobileshepherd.ui.challenges.securitymisconfig.** + +# Input Validation - should be obfuscated +-keepnames class org.owasp.mobileshepherd.ui.lessons.inputvalidation.** +-keepnames class org.owasp.mobileshepherd.ui.challenges.inputvalidation.** + +# Insecure Authorization - should be obfuscated +-keepnames class org.owasp.mobileshepherd.ui.lessons.insecureauthorization.** + +# Home fragment +-keepnames class org.owasp.mobileshepherd.ui.home.** + +# Aggressive obfuscation settings +-optimizationpasses 5 +-overloadaggressively +-repackageclasses '' +-allowaccessmodification + +# Obfuscate string constants (except in RE packages) +-adaptclassstrings + +# Remove logging for non-RE packages +-assumenosideeffects class android.util.Log { + public static *** d(...); + public static *** v(...); + public static *** i(...); +} + +# Keep source file and line numbers for debugging +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# AndroidX and Material Components +-keep class androidx.** { *; } +-keep interface androidx.** { *; } +-keep class com.google.android.material.** { *; } + +# Navigation component +-keep class androidx.navigation.** { *; } + +# Prevent stripping of enum classes +-keepclassmembers enum * { *; } \ No newline at end of file diff --git a/mobile/app/src/androidTest/java/com/owasp/reverser/ExampleInstrumentedTest.java b/mobile/app/src/androidTest/java/com/owasp/reverser/ExampleInstrumentedTest.java new file mode 100644 index 000000000..15d121754 --- /dev/null +++ b/mobile/app/src/androidTest/java/com/owasp/reverser/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package org.owasp.mobileshepherd; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("org.owasp.mobileshepherd", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/mobile/app/src/main/AndroidManifest.xml b/mobile/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0a7792c9f --- /dev/null +++ b/mobile/app/src/main/AndroidManifest.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/LandingActivity.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/LandingActivity.java new file mode 100644 index 000000000..afddda826 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/LandingActivity.java @@ -0,0 +1,73 @@ +package org.owasp.mobileshepherd; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.OvershootInterpolator; +import android.widget.Button; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +public class LandingActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_landing); + + Button enterAppButton = findViewById(R.id.enter_app_button); + enterAppButton.setOnClickListener(v -> { + Intent intent = new Intent(LandingActivity.this, LoginActivity.class); + startActivity(intent); + finish(); + }); + + runEntranceAnimations(); + } + + private void runEntranceAnimations() { + TextView title = findViewById(R.id.landing_title); + TextView subtitle = findViewById(R.id.landing_subtitle); + View warningBox = findViewById(R.id.landing_warning_box); + TextView description = findViewById(R.id.landing_description); + View enterAppButton = findViewById(R.id.enter_app_button); + TextView license = findViewById(R.id.landing_license); + + for (View v : new View[]{title, subtitle, warningBox, description, enterAppButton, license}) { + v.setAlpha(0f); + v.setTranslationY(dpToPx(40)); + } + + animateSlideUp(title, 500, 150); + animateSlideUp(subtitle, 450, 300); + animateSlideUp(warningBox, 400, 440); + animateSlideUp(description, 400, 560); + + // Button with extra overshoot for emphasis + enterAppButton.animate() + .alpha(1f) + .translationY(0f) + .setDuration(500) + .setStartDelay(660) + .setInterpolator(new OvershootInterpolator(1.5f)) + .start(); + + animateSlideUp(license, 300, 760); + } + + private void animateSlideUp(View v, long duration, long delay) { + v.animate() + .alpha(1f) + .translationY(0f) + .setDuration(duration) + .setStartDelay(delay) + .setInterpolator(new DecelerateInterpolator()) + .start(); + } + + private float dpToPx(int dp) { + return dp * getResources().getDisplayMetrics().density; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/LoginActivity.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/LoginActivity.java new file mode 100644 index 000000000..1a27dc562 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/LoginActivity.java @@ -0,0 +1,152 @@ +package org.owasp.mobileshepherd; + +import android.content.Intent; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.View; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; +import org.owasp.mobileshepherd.utils.AuthManager; + +/** + * Full-screen login/register screen matching the Security Shepherd web app style. + * + *

Launched from {@link LandingActivity} (after the warning splash) and from + * {@link MainActivity} when the user taps "Sign In to Server" on the Home screen. + * On successful authentication the user proceeds to {@link MainActivity}. + * The "Continue offline" button skips authentication entirely. + */ +public class LoginActivity extends AppCompatActivity { + + /** Extra key: when {@code true}, the user arrived from inside the app (HomeFragment). */ + public static final String EXTRA_FROM_APP = "from_app"; + + private boolean isRegisterMode = false; + + private TextInputEditText serverField; + private TextInputEditText usernameField; + private TextInputEditText passwordField; + private TextInputEditText emailField; + private TextInputLayout emailLayout; + private TextView statusText; + private MaterialButton tabSignIn; + private MaterialButton tabRegister; + private MaterialButton submitButton; + private TextView step3Text; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // If already authenticated, skip straight to the main app + if (AuthManager.isAuthenticated(this)) { + goToMain(); + return; + } + + setContentView(R.layout.activity_login); + + serverField = findViewById(R.id.login_server_url); + usernameField = findViewById(R.id.login_username); + passwordField = findViewById(R.id.login_password); + emailField = findViewById(R.id.login_email); + emailLayout = findViewById(R.id.login_email_layout); + statusText = findViewById(R.id.login_status_text); + tabSignIn = findViewById(R.id.tab_sign_in); + tabRegister = findViewById(R.id.tab_register); + submitButton = findViewById(R.id.login_submit_button); + step3Text = findViewById(R.id.login_step3_text); + + // Pre-fill saved credentials so returning users don't have to re-enter everything + String savedServer = AuthManager.getServerUrl(this); + if (!savedServer.isEmpty()) serverField.setText(savedServer); + String savedUser = AuthManager.getUsername(this); + if (!savedUser.isEmpty()) usernameField.setText(savedUser); + + tabSignIn.setOnClickListener(v -> setMode(false)); + tabRegister.setOnClickListener(v -> setMode(true)); + submitButton.setOnClickListener(v -> onSubmit()); + + boolean fromApp = getIntent().getBooleanExtra(EXTRA_FROM_APP, false); + MaterialButton offlineButton = findViewById(R.id.login_offline_button); + if (fromApp) { + offlineButton.setText(R.string.cancel); + } + offlineButton.setOnClickListener(v -> { + // Offline mode: go back to the main app (or finish if launched from within it) + goToMain(); + }); + + setMode(false); + } + + private void setMode(boolean registerMode) { + isRegisterMode = registerMode; + if (registerMode) { + emailLayout.setVisibility(View.VISIBLE); + submitButton.setText(R.string.auth_dialog_title_register); + step3Text.setText(R.string.login_step3_register); + } else { + emailLayout.setVisibility(View.GONE); + submitButton.setText(R.string.auth_button_sign_in); + step3Text.setText(R.string.login_step3_signin); + } + statusText.setVisibility(View.GONE); + } + + private void onSubmit() { + String serverUrl = serverField.getText() != null ? serverField.getText().toString().trim() : ""; + String username = usernameField.getText() != null ? usernameField.getText().toString().trim() : ""; + String password = passwordField.getText() != null ? passwordField.getText().toString() : ""; + String email = emailField.getText() != null ? emailField.getText().toString().trim() : ""; + + if (TextUtils.isEmpty(serverUrl) || TextUtils.isEmpty(username) || TextUtils.isEmpty(password)) { + showStatus(getString(R.string.login_error_fill_fields), true); + return; + } + + submitButton.setEnabled(false); + tabSignIn.setEnabled(false); + tabRegister.setEnabled(false); + showStatus(getString(R.string.login_connecting), false); + + AuthManager.AuthCallback callback = (success, message) -> { + if (success) { + goToMain(); + } else { + submitButton.setEnabled(true); + tabSignIn.setEnabled(true); + tabRegister.setEnabled(true); + showStatus(message, true); + } + }; + + if (isRegisterMode) { + AuthManager.register(this, serverUrl, username, password, email, callback); + } else { + AuthManager.login(this, serverUrl, username, password, callback); + } + } + + private void showStatus(String message, boolean isError) { + statusText.setText(message); + statusText.setTextColor(isError + ? getResources().getColor(android.R.color.holo_red_dark, getTheme()) + : getResources().getColor(android.R.color.darker_gray, getTheme())); + statusText.setVisibility(View.VISIBLE); + } + + private void goToMain() { + boolean fromApp = getIntent().getBooleanExtra(EXTRA_FROM_APP, false); + if (!fromApp) { + Intent intent = new Intent(this, MainActivity.class); + startActivity(intent); + } + finish(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/MainActivity.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/MainActivity.java new file mode 100644 index 000000000..4d9b9dfb1 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/MainActivity.java @@ -0,0 +1,501 @@ +package org.owasp.mobileshepherd; + +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.MenuItem; +import android.view.Menu; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import com.google.android.material.navigation.NavigationView; + +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.navigation.NavController; +import androidx.navigation.Navigation; +import androidx.navigation.ui.AppBarConfiguration; +import androidx.navigation.ui.NavigationUI; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.snackbar.Snackbar; +import org.owasp.mobileshepherd.databinding.ActivityMainBinding; +import org.owasp.mobileshepherd.utils.AuthManager; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MainActivity extends AppCompatActivity { + + private AppBarConfiguration mAppBarConfiguration; + private ActivityMainBinding binding; + private ProgressTracker progressTracker; + private NavigationAdapter navigationAdapter; + private static final Map NAV_TO_MODULE_MAP = new HashMap<>(); + + static { + // Lessons + NAV_TO_MODULE_MAP.put(R.id.nav_lesson, FlagValidator.Module.RE_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_insecure_data_lesson, FlagValidator.Module.IDS_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_poor_auth_lesson, FlagValidator.Module.POOR_AUTH_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_insecure_authorization_lesson, FlagValidator.Module.INSECURE_AUTH_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_supply_chain_lesson, FlagValidator.Module.SUPPLY_CHAIN_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_insecure_comm_lesson, FlagValidator.Module.INSECURE_COMM_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_insufficient_crypto_lesson, FlagValidator.Module.INSUFFICIENT_CRYPTO_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_security_misconfig_lesson, FlagValidator.Module.SECURITY_MISCONFIG_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_input_validation_lesson, FlagValidator.Module.INPUT_VALIDATION_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_privacy_lesson, FlagValidator.Module.PRIVACY_LESSON); + NAV_TO_MODULE_MAP.put(R.id.nav_client_side_injection_lesson, FlagValidator.Module.CLIENT_SIDE_INJECTION_LESSON); + + // Challenges + NAV_TO_MODULE_MAP.put(R.id.nav_challenge1, FlagValidator.Module.RE_CHALLENGE_1); + NAV_TO_MODULE_MAP.put(R.id.nav_insecure_data1, FlagValidator.Module.IDS_CHALLENGE_1); + NAV_TO_MODULE_MAP.put(R.id.nav_poor_auth_challenge, FlagValidator.Module.POOR_AUTH_CHALLENGE); + NAV_TO_MODULE_MAP.put(R.id.nav_insecure_comm_challenge, FlagValidator.Module.INSECURE_COMM_CHALLENGE); + NAV_TO_MODULE_MAP.put(R.id.nav_insufficient_crypto_challenge, FlagValidator.Module.INSUFFICIENT_CRYPTO_CHALLENGE); + NAV_TO_MODULE_MAP.put(R.id.nav_security_misconfig_challenge2, FlagValidator.Module.SECURITY_MISCONFIG_CHALLENGE_2); + NAV_TO_MODULE_MAP.put(R.id.nav_client_side_injection_challenge1, FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_1); + NAV_TO_MODULE_MAP.put(R.id.nav_client_side_injection_challenge2, FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_2); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + // Apply saved theme before calling super.onCreate + applyTheme(); + + super.onCreate(savedInstanceState); + + binding = ActivityMainBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + setSupportActionBar(binding.appBarMain.toolbar); + + // Add scale animation to FAB on click + binding.appBarMain.fab.setScaleX(1f); + binding.appBarMain.fab.setScaleY(1f); + + binding.appBarMain.fab.setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View view) { + // Animate FAB scale on click + view.animate() + .scaleX(0.9f) + .scaleY(0.9f) + .setDuration(100) + .withEndAction(() -> { + view.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(100) + .start(); + }) + .start(); + + //create alert dialogue for floating "help" action button. + AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this); + + //set the message + String message = "Mobile Shepherd - Security Training Platform\n\n" + + "This application is designed to teach mobile application security through " + + "hands-on lessons and challenges based on the OWASP Mobile Top 10.\n\n" + + "Part of the OWASP Security Shepherd project."; + + builder.setMessage(message); + builder.setTitle("About Mobile Shepherd"); + builder.setCancelable(true); + + builder.setPositiveButton("OWASP Mobile Top 10", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, + android.net.Uri.parse("https://owasp.org/www-project-mobile-top-10/")); + startActivity(browserIntent); + } + }); + + builder.setNeutralButton("Security Shepherd", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, + android.net.Uri.parse("https://owasp.org/www-project-security-shepherd/")); + startActivity(browserIntent); + } + }); + + builder.setNegativeButton("Close", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + }); + + AlertDialog alertDialog = builder.create(); + alertDialog.show(); + } + }); + + DrawerLayout drawer = binding.drawerLayout; + + // Wire up nav header auth UI (header is included directly in nav_drawer_layout) + TextView authStatus = findViewById(R.id.nav_header_auth_status); + Button authButton = findViewById(R.id.nav_header_auth_button); + updateNavHeader(authStatus, authButton); + authButton.setOnClickListener(v -> { + if (AuthManager.isAuthenticated(this)) { + AuthManager.logout(this); + updateNavHeader(authStatus, authButton); + refreshNavigation(); + Toast.makeText(this, "Signed out", Toast.LENGTH_SHORT).show(); + } else { + showAuthDialog(authStatus, authButton); + } + }); + // Refresh header when drawer opens + drawer.addDrawerListener(new DrawerLayout.SimpleDrawerListener() { + @Override + public void onDrawerOpened(View drawerView) { + updateNavHeader(authStatus, authButton); + } + }); + + // Initialize ProgressTracker + progressTracker = new ProgressTracker(this); + + // Set up completion change listener to refresh navigation + ProgressTracker.setGlobalCompletionListener(() -> { + runOnUiThread(() -> refreshNavigation()); + }); + + // Setup RecyclerView for navigation + RecyclerView navRecyclerView = findViewById(R.id.nav_recycler_view); + navRecyclerView.setLayoutManager(new LinearLayoutManager(this)); + + List navigationItems = createNavigationItems(); + navigationAdapter = new NavigationAdapter(navigationItems, item -> { + // Handle navigation item click + NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main); + navController.navigate(item.getNavigationId()); + drawer.closeDrawers(); + }, progressTracker, NAV_TO_MODULE_MAP); + navRecyclerView.setAdapter(navigationAdapter); + + // Passing each menu ID as a set of Ids because each + // menu should be considered as top level destinations. + mAppBarConfiguration = new AppBarConfiguration.Builder( + R.id.nav_home, R.id.nav_lesson, R.id.nav_insecure_data_lesson, R.id.nav_poor_auth_lesson, R.id.nav_insecure_authorization_lesson, R.id.nav_supply_chain_lesson, R.id.nav_insecure_comm_lesson, R.id.nav_insufficient_crypto_lesson, R.id.nav_security_misconfig_lesson, + R.id.nav_challenge1, + R.id.nav_insecure_data1, + R.id.nav_poor_auth_challenge, R.id.nav_insecure_comm_challenge, R.id.nav_insufficient_crypto_challenge, + R.id.nav_security_misconfig_challenge2, + R.id.nav_input_validation_lesson, + R.id.nav_privacy_lesson, + R.id.nav_client_side_injection_lesson, R.id.nav_client_side_injection_challenge1, R.id.nav_client_side_injection_challenge2, + R.id.nav_adb_reference, R.id.nav_scoreboard + ).setOpenableLayout(drawer) + .build(); + NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main); + NavigationUI.setupActionBarWithNavController(this, navController, mAppBarConfiguration); + + // Hide FAB on the Home screen; show it on all other destinations + navController.addOnDestinationChangedListener((controller, destination, arguments) -> { + if (destination.getId() == R.id.nav_home) { + binding.appBarMain.fab.hide(); + } else { + binding.appBarMain.fab.show(); + } + }); + } + + private List createNavigationItems() { + List items = new ArrayList<>(); + + // Home + items.add(new NavigationItem(1, "Home", R.drawable.ic_menu_home, R.id.nav_home)); + + // Lessons group + NavigationItem lessonsGroup = new NavigationItem(2, "Lessons", R.drawable.ic_menu_camera); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(21, "Reverse Engineering", 0, R.id.nav_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(22, "Insecure Data Storage", 0, R.id.nav_insecure_data_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(23, "Poor Authentication", 0, R.id.nav_poor_auth_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(24, "Insecure Authorization", 0, R.id.nav_insecure_authorization_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(25, "Supply Chain Security", 0, R.id.nav_supply_chain_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(26, "Insecure Communication", 0, R.id.nav_insecure_comm_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(27, "Insufficient Cryptography", 0, R.id.nav_insufficient_crypto_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(28, "Security Misconfiguration", 0, R.id.nav_security_misconfig_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(29, "Input Validation", 0, R.id.nav_input_validation_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(30, "Privacy Controls", 0, R.id.nav_privacy_lesson)); + addChildIfNotCompleted(lessonsGroup, new NavigationItem(31, "Client-Side Injection", 0, R.id.nav_client_side_injection_lesson)); + if (lessonsGroup.getChildren().size() > 0) items.add(lessonsGroup); + + // Challenges group + NavigationItem challengesGroup = new NavigationItem(3, "Challenges", R.drawable.ic_menu_code); + + // Reverse Engineering sub-group + NavigationItem reverseEngGroup = new NavigationItem(40, "Reverse Engineering", 0); + addChildIfNotCompleted(reverseEngGroup, new NavigationItem(41, "Challenge 1", 0, R.id.nav_challenge1)); + if (reverseEngGroup.getChildren().size() > 0) challengesGroup.addChild(reverseEngGroup); + + // Insecure Data Storage sub-group + NavigationItem insecureDataGroup = new NavigationItem(44, "Insecure Data Storage", 0); + addChildIfNotCompleted(insecureDataGroup, new NavigationItem(45, "Challenge 1", 0, R.id.nav_insecure_data1)); + if (insecureDataGroup.getChildren().size() > 0) challengesGroup.addChild(insecureDataGroup); + + // Individual challenges + addChildIfNotCompleted(challengesGroup, new NavigationItem(47, "Poor Authentication", 0, R.id.nav_poor_auth_challenge)); + addChildIfNotCompleted(challengesGroup, new NavigationItem(49, "Insecure Communication", 0, R.id.nav_insecure_comm_challenge)); + addChildIfNotCompleted(challengesGroup, new NavigationItem(50, "Insufficient Cryptography", 0, R.id.nav_insufficient_crypto_challenge)); + + // Security Misconfiguration sub-group + NavigationItem securityMisconfigGroup = new NavigationItem(51, "Security Misconfiguration", 0); + addChildIfNotCompleted(securityMisconfigGroup, new NavigationItem(52, "Challenge 1", 0, R.id.nav_security_misconfig_challenge2)); + if (securityMisconfigGroup.getChildren().size() > 0) challengesGroup.addChild(securityMisconfigGroup); + + // Client-Side Injection sub-group + NavigationItem clientSideGroup = new NavigationItem(55, "Client-Side Injection", 0); + addChildIfNotCompleted(clientSideGroup, new NavigationItem(56, "Challenge 1", 0, R.id.nav_client_side_injection_challenge1)); + addChildIfNotCompleted(clientSideGroup, new NavigationItem(57, "Challenge 2", 0, R.id.nav_client_side_injection_challenge2)); + if (clientSideGroup.getChildren().size() > 0) challengesGroup.addChild(clientSideGroup); + + if (challengesGroup.getChildren().size() > 0) items.add(challengesGroup); + + // ADB Reference + items.add(new NavigationItem(4, "ADB Reference", R.drawable.ic_menu_code, R.id.nav_adb_reference)); + + // Scoreboard — only shown when signed in to a server + if (AuthManager.isAuthenticated(this)) { + items.add(new NavigationItem(6, "Scoreboard", R.drawable.ic_menu_home, R.id.nav_scoreboard)); + } + + // Completed group - show all completed lessons and challenges + NavigationItem completedGroup = new NavigationItem(5, "Completed", R.drawable.ic_menu_camera); + boolean hasCompleted = false; + + for (Map.Entry entry : NAV_TO_MODULE_MAP.entrySet()) { + if (progressTracker.isCompleted(entry.getValue())) { + hasCompleted = true; + String title = getTitleForNavId(entry.getKey()); + completedGroup.addChild(new NavigationItem(1000 + entry.getKey(), title, 0, entry.getKey())); + } + } + + if (hasCompleted) { + items.add(completedGroup); + } + + return items; + } + + private String getTitleForNavId(int navId) { + // Map navigation IDs to readable titles + if (navId == R.id.nav_lesson) return "Reverse Engineering"; + if (navId == R.id.nav_insecure_data_lesson) return "Insecure Data Storage"; + if (navId == R.id.nav_poor_auth_lesson) return "Poor Authentication"; + if (navId == R.id.nav_insecure_authorization_lesson) return "Insecure Authorization"; + if (navId == R.id.nav_supply_chain_lesson) return "Supply Chain Security"; + if (navId == R.id.nav_insecure_comm_lesson) return "Insecure Communication"; + if (navId == R.id.nav_insufficient_crypto_lesson) return "Insufficient Cryptography"; + if (navId == R.id.nav_security_misconfig_lesson) return "Security Misconfiguration"; + if (navId == R.id.nav_input_validation_lesson) return "Input Validation"; + if (navId == R.id.nav_privacy_lesson) return "Privacy Controls"; + if (navId == R.id.nav_client_side_injection_lesson) return "Client-Side Injection"; + if (navId == R.id.nav_challenge1) return "RE Challenge 1"; + if (navId == R.id.nav_insecure_data1) return "IDS Challenge 1"; + if (navId == R.id.nav_poor_auth_challenge) return "Poor Auth Challenge"; + if (navId == R.id.nav_insecure_comm_challenge) return "Insecure Comm Challenge"; + if (navId == R.id.nav_insufficient_crypto_challenge) return "Crypto Challenge"; + if (navId == R.id.nav_security_misconfig_challenge2) return "Security Misconfig Challenge"; + if (navId == R.id.nav_client_side_injection_challenge1) return "Client Injection Challenge 1"; + if (navId == R.id.nav_client_side_injection_challenge2) return "Client Injection Challenge 2"; + return "Module"; + } + + private void addChildIfNotCompleted(NavigationItem parent, NavigationItem child) { + // Only add the child if it's not completed + if (child.getNavigationId() != 0) { + FlagValidator.Module module = NAV_TO_MODULE_MAP.get(child.getNavigationId()); + if (module != null && progressTracker.isCompleted(module)) { + return; // Skip completed items + } + } + parent.addChild(child); + } + + public void refreshNavigation() { + List navigationItems = createNavigationItems(); + navigationAdapter.updateItems(navigationItems); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.main, menu); + return true; + } + + public boolean onOptionsItemSelected(MenuItem item) { + switch(item.getItemId()) { + case R.id.action_adb_reference: { + NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main); + navController.navigate(R.id.nav_adb_reference); + } + return true; + case R.id.action_settings: { + Intent goToSettings = new Intent(this, Preferences.class); + startActivity(goToSettings); + Toast.makeText(this, "Settings Selected", Toast.LENGTH_SHORT).show(); + } + return true; + case R.id.action_exit: + finish(); + return true; + + } + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onSupportNavigateUp() { + NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main); + return NavigationUI.navigateUp(navController, mAppBarConfiguration) + || super.onSupportNavigateUp(); + } + + private void updateNavHeader(TextView statusView, Button button) { + if (AuthManager.isAuthenticated(this)) { + String username = AuthManager.getUsername(this); + statusView.setText(getString(R.string.auth_status_online, username)); + button.setText(R.string.auth_button_sign_out); + } else { + statusView.setText(R.string.auth_status_offline); + button.setText(R.string.auth_button_sign_in); + } + } + + /** Called by HomeFragment to open the sign-in dialog from the home screen card. */ + public void openAuthDialog() { + TextView authStatus = findViewById(R.id.nav_header_auth_status); + Button authButton = findViewById(R.id.nav_header_auth_button); + showAuthDialog(authStatus, authButton); + } + + private void showAuthDialog(TextView statusView, Button button) { + final boolean[] isRegisterMode = {false}; + + View dialogView = getLayoutInflater().inflate(R.layout.dialog_auth, null); + EditText serverField = dialogView.findViewById(R.id.auth_server_url); + EditText usernameField = dialogView.findViewById(R.id.auth_username); + EditText passwordField = dialogView.findViewById(R.id.auth_password); + EditText emailField = dialogView.findViewById(R.id.auth_email); + View emailLabel = dialogView.findViewById(R.id.auth_email_label); + TextView statusText = dialogView.findViewById(R.id.auth_status_text); + + // Pre-fill saved server URL + String savedServer = AuthManager.getServerUrl(this); + if (!savedServer.isEmpty()) serverField.setText(savedServer); + String savedUser = AuthManager.getUsername(this); + if (!savedUser.isEmpty()) usernameField.setText(savedUser); + + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(R.string.auth_dialog_title_login) + .setView(dialogView) + .setPositiveButton(R.string.auth_button_sign_in, null) // overridden below + .setNeutralButton(R.string.auth_switch_to_register, null) + .setNegativeButton(android.R.string.cancel, null) + .create(); + + dialog.setOnShowListener(d -> { + Button positive = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + Button neutral = dialog.getButton(AlertDialog.BUTTON_NEUTRAL); + + positive.setOnClickListener(v -> { + String serverUrl = serverField.getText().toString().trim(); + String username = usernameField.getText().toString().trim(); + String password = passwordField.getText().toString(); + String email = emailField.getText().toString().trim(); + + if (TextUtils.isEmpty(serverUrl) || TextUtils.isEmpty(username) + || TextUtils.isEmpty(password)) { + statusText.setText("Please fill in all required fields"); + statusText.setVisibility(View.VISIBLE); + return; + } + + positive.setEnabled(false); + neutral.setEnabled(false); + statusText.setText("Connecting..."); + statusText.setVisibility(View.VISIBLE); + + AuthManager.AuthCallback callback = (success, message) -> { + if (success) { + updateNavHeader(statusView, button); + refreshNavigation(); + dialog.dismiss(); + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + } else { + positive.setEnabled(true); + neutral.setEnabled(true); + statusText.setText(message); + } + }; + + if (isRegisterMode[0]) { + AuthManager.register(this, serverUrl, username, password, email, callback); + } else { + AuthManager.login(this, serverUrl, username, password, callback); + } + }); + + neutral.setOnClickListener(v -> { + isRegisterMode[0] = !isRegisterMode[0]; + if (isRegisterMode[0]) { + dialog.setTitle(getString(R.string.auth_dialog_title_register)); + positive.setText(R.string.auth_dialog_title_register); + neutral.setText(R.string.auth_switch_to_login); + emailField.setVisibility(View.VISIBLE); + emailLabel.setVisibility(View.VISIBLE); + } else { + dialog.setTitle(getString(R.string.auth_dialog_title_login)); + positive.setText(R.string.auth_button_sign_in); + neutral.setText(R.string.auth_switch_to_register); + emailField.setVisibility(View.GONE); + emailLabel.setVisibility(View.GONE); + } + statusText.setVisibility(View.GONE); + }); + }); + + dialog.show(); + } + + private void applyTheme() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + String themeValue = preferences.getString("theme_preference", "system"); + + switch (themeValue) { + case "light": + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + break; + case "dark": + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + break; + case "system": + default: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + break; + } + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/NavigationAdapter.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/NavigationAdapter.java new file mode 100644 index 000000000..f8135820a --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/NavigationAdapter.java @@ -0,0 +1,173 @@ +package org.owasp.mobileshepherd; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class NavigationAdapter extends RecyclerView.Adapter { + + private List items; + private List displayedItems; + private OnItemClickListener listener; + private ProgressTracker progressTracker; + private Map navToModuleMap; + + public interface OnItemClickListener { + void onItemClick(NavigationItem item); + } + + public NavigationAdapter(List items, OnItemClickListener listener, + ProgressTracker progressTracker, Map navToModuleMap) { + this.items = items; + this.displayedItems = new ArrayList<>(); + this.listener = listener; + this.progressTracker = progressTracker; + this.navToModuleMap = navToModuleMap; + updateDisplayedItems(); + } + + private void updateDisplayedItems() { + displayedItems.clear(); + for (NavigationItem item : items) { + addItemWithChildren(item, 0); + } + } + + public void updateItems(List newItems) { + this.items = newItems; + updateDisplayedItems(); + notifyDataSetChanged(); + } + + private void addItemWithChildren(NavigationItem item, int depth) { + displayedItems.add(item); + if (item.isExpandable() && item.isExpanded()) { + for (NavigationItem child : item.getChildren()) { + addItemWithChildren(child, depth + 1); + } + } + } + + private int getItemDepth(NavigationItem item) { + return getItemDepth(item, items, 0); + } + + private int getItemDepth(NavigationItem target, List itemList, int currentDepth) { + for (NavigationItem item : itemList) { + if (item == target) { + return currentDepth; + } + if (item.isExpandable()) { + int childDepth = getItemDepth(target, item.getChildren(), currentDepth + 1); + if (childDepth != -1) { + return childDepth; + } + } + } + return -1; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.nav_item_layout, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + NavigationItem item = displayedItems.get(position); + holder.bind(item); + } + + @Override + public int getItemCount() { + return displayedItems.size(); + } + + public class ViewHolder extends RecyclerView.ViewHolder { + private ImageView icon; + private TextView title; + private ImageView expandIcon; + private TextView completedIndicator; + private View itemView; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + this.itemView = itemView; + icon = itemView.findViewById(R.id.nav_item_icon); + title = itemView.findViewById(R.id.nav_item_title); + expandIcon = itemView.findViewById(R.id.nav_item_expand_icon); + completedIndicator = itemView.findViewById(R.id.nav_item_completed_indicator); + } + + public void bind(NavigationItem item) { + title.setText(item.getTitle()); + + // Check if this item is completed + boolean isCompleted = false; + if (item.getNavigationId() != 0 && navToModuleMap != null) { + FlagValidator.Module module = navToModuleMap.get(item.getNavigationId()); + if (module != null && progressTracker != null) { + isCompleted = progressTracker.isCompleted(module); + } + } + + // Show/hide completed indicator + if (isCompleted && !item.isExpandable()) { + completedIndicator.setVisibility(View.VISIBLE); + } else { + completedIndicator.setVisibility(View.GONE); + } + + int depth = getItemDepth(item); + int leftPadding = 16 + (depth * 32); // 16dp base, 32dp per level + + itemView.setPaddingRelative( + leftPadding, + itemView.getPaddingTop(), + itemView.getPaddingEnd(), + itemView.getPaddingBottom() + ); + + // Show icon only for top-level items (depth 0) + if (depth == 0 && item.getIconResId() != 0) { + icon.setImageResource(item.getIconResId()); + icon.setVisibility(View.VISIBLE); + } else { + icon.setVisibility(View.GONE); + } + + // Show expand icon for expandable items at any level + if (item.isExpandable()) { + expandIcon.setVisibility(View.VISIBLE); + expandIcon.setRotation(item.isExpanded() ? 180 : 0); + } else { + expandIcon.setVisibility(View.GONE); + } + + itemView.setOnClickListener(v -> { + if (item.isExpandable()) { + item.setExpanded(!item.isExpanded()); + updateDisplayedItems(); + notifyDataSetChanged(); + } else { + listener.onItemClick(item); + } + }); + } + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/NavigationItem.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/NavigationItem.java new file mode 100644 index 000000000..aa15cf8f1 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/NavigationItem.java @@ -0,0 +1,66 @@ +package org.owasp.mobileshepherd; + +import java.util.ArrayList; +import java.util.List; + +public class NavigationItem { + private int id; + private String title; + private int iconResId; + private boolean isExpandable; + private boolean isExpanded; + private List children; + private int navigationId; // For navigation component + + public NavigationItem(int id, String title, int iconResId, int navigationId) { + this.id = id; + this.title = title; + this.iconResId = iconResId; + this.navigationId = navigationId; + this.isExpandable = false; + this.isExpanded = false; + this.children = new ArrayList<>(); + } + + public NavigationItem(int id, String title, int iconResId) { + this(id, title, iconResId, -1); + this.isExpandable = true; + } + + public void addChild(NavigationItem child) { + this.children.add(child); + this.isExpandable = true; + } + + public int getId() { + return id; + } + + public String getTitle() { + return title; + } + + public int getIconResId() { + return iconResId; + } + + public boolean isExpandable() { + return isExpandable; + } + + public boolean isExpanded() { + return isExpanded; + } + + public void setExpanded(boolean expanded) { + isExpanded = expanded; + } + + public List getChildren() { + return children; + } + + public int getNavigationId() { + return navigationId; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/Preferences.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/Preferences.java new file mode 100644 index 000000000..cbcfcc2a0 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/Preferences.java @@ -0,0 +1,131 @@ +package org.owasp.mobileshepherd; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.preference.EditTextPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +/** + * This file is part of the Security Shepherd Project. + * + *

The Security Shepherd project is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version.
+ * + *

The Security Shepherd project is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details.
+ * + *

You should have received a copy of the GNU General Public License along with the Security + * Shepherd project. If not, see . + * + * @author Sean Duggan + */ +public class Preferences extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Enable back button in action bar + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + } + + getSupportFragmentManager() + .beginTransaction() + .replace(android.R.id.content, new PrefsFragment()) + .commit(); + } + + @Override + public boolean onSupportNavigateUp() { + finish(); + return true; + } + + public static class PrefsFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + // Load the preferences from an XML resource + setPreferencesFromResource(R.xml.preferences, rootKey); + + // Set up theme preference listener + ListPreference themePreference = findPreference("theme_preference"); + if (themePreference != null) { + themePreference.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + String themeValue = (String) newValue; + applyTheme(themeValue); + return true; + } + }); + } + + // Set up submit issue preference listener + Preference submitIssuePreference = findPreference("submit_issue_preference"); + if (submitIssuePreference != null) { + submitIssuePreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + String issuesUrl = "https://github.com/OWASP/SecurityShepherd/issues"; + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(issuesUrl)); + startActivity(browserIntent); + return true; + } + }); + } + + // Set up reset progress preference listener + Preference resetProgressPreference = findPreference("reset_progress_preference"); + if (resetProgressPreference != null) { + resetProgressPreference.setOnPreferenceClickListener(preference -> { + new AlertDialog.Builder(requireContext()) + .setTitle("Reset All Progress") + .setMessage("This will mark all lessons and challenges as incomplete. This cannot be undone.\n\nAre you sure?") + .setPositiveButton("Reset", (dialog, which) -> { + ProgressTracker tracker = new ProgressTracker(requireContext()); + tracker.resetProgress(); + ProgressTracker.CompletionChangeListener listener = + ProgressTracker.getGlobalCompletionListener(); + if (listener != null) { + listener.onCompletionChanged(); + } + android.widget.Toast.makeText( + requireContext(), + "All progress has been reset", + android.widget.Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton("Cancel", null) + .show(); + return true; + }); + } + } + + private void applyTheme(String themeValue) { + switch (themeValue) { + case "light": + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + break; + case "dark": + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + break; + case "system": + default: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + break; + } + } + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/adb/AdbReferenceFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/adb/AdbReferenceFragment.java new file mode 100644 index 000000000..2f554c85e --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/adb/AdbReferenceFragment.java @@ -0,0 +1,74 @@ +package org.owasp.mobileshepherd.ui.adb; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import org.owasp.mobileshepherd.databinding.FragmentAdbReferenceBinding; + +public class AdbReferenceFragment extends Fragment { + + private FragmentAdbReferenceBinding binding; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + + binding = FragmentAdbReferenceBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + // Setup copy buttons for each command category + setupCopyButton(binding.copyLogcat, "adb logcat"); + setupCopyButton(binding.copyLogcatFilter, "adb logcat -s TAG_NAME"); + setupCopyButton(binding.copyLogcatGrep, "adb logcat | grep \"search_term\""); + setupCopyButton(binding.copyLogcatClear, "adb logcat -c"); + + setupCopyButton(binding.copyListDevices, "adb devices"); + setupCopyButton(binding.copyShell, "adb shell"); + setupCopyButton(binding.copyInstall, "adb install app.apk"); + setupCopyButton(binding.copyUninstall, "adb uninstall com.package.name"); + + setupCopyButton(binding.copyPullFile, "adb pull /data/data/org.owasp.mobileshepherd/databases/Users.db"); + setupCopyButton(binding.copyPullSharedPrefs, "adb pull /data/data/org.owasp.mobileshepherd/shared_prefs/"); + setupCopyButton(binding.copyListFiles, "adb shell ls /data/data/org.owasp.mobileshepherd/"); + setupCopyButton(binding.copyListDatabases, "adb shell ls /data/data/org.owasp.mobileshepherd/databases/"); + + setupCopyButton(binding.copySqlite, "adb shell sqlite3 /data/data/org.owasp.mobileshepherd/databases/Users.db"); + setupCopyButton(binding.copySqliteQuery, "sqlite> SELECT * FROM users;"); + setupCopyButton(binding.copyCat, "adb shell cat /data/data/org.owasp.mobileshepherd/shared_prefs/UserCredentials.xml"); + + setupCopyButton(binding.copyGetPackages, "adb shell pm list packages | grep reverser"); + setupCopyButton(binding.copyPackagePath, "adb shell pm path org.owasp.mobileshepherd"); + setupCopyButton(binding.copyPullApk, "adb pull /data/app/org.owasp.mobileshepherd-*/base.apk"); + setupCopyButton(binding.copyClearData, "adb shell pm clear org.owasp.mobileshepherd"); + + setupCopyButton(binding.copyRunAsVault, "adb shell run-as org.owasp.mobileshepherd cat shared_prefs/recovery_vault.xml"); + setupCopyButton(binding.copyPullVault, "adb pull /data/data/org.owasp.mobileshepherd/shared_prefs/recovery_vault.xml"); + setupCopyButton(binding.copyJadx, "jadx-gui app-debug.apk"); + setupCopyButton(binding.copyXorDecode, "python3 -c \"import base64; d=base64.b64decode('PASTE_BASE64_HERE'); print(''.join(chr(b^0x42) for b in d))\""); + + return root; + } + + private void setupCopyButton(View button, String command) { + button.setOnClickListener(v -> { + ClipboardManager clipboard = (ClipboardManager) requireContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("ADB Command", command); + clipboard.setPrimaryClip(clip); + Toast.makeText(getContext(), "Copied to clipboard!", Toast.LENGTH_SHORT).show(); + }); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge1Fragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge1Fragment.java new file mode 100644 index 000000000..64624ccd1 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge1Fragment.java @@ -0,0 +1,258 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; + +/** + * Centralized flag validation system using SHA-256 hashes. + * Provides secure client-side validation and supports progress tracking. + */ +public class FlagValidator { + + private static final String TAG = "FlagValidator"; + + // Module type constants + public static final String TYPE_LESSON = "lesson"; + public static final String TYPE_CHALLENGE = "challenge"; + + // Module identifiers + public enum Module { + // Reverse Engineering + RE_LESSON("re_lesson", TYPE_LESSON), + RE_CHALLENGE_1("re_challenge_1", TYPE_CHALLENGE), + + // Insecure Data Storage + IDS_LESSON("ids_lesson", TYPE_LESSON), + IDS_CHALLENGE_1("ids_challenge_1", TYPE_CHALLENGE), + + // Poor Authentication + POOR_AUTH_LESSON("poor_auth_lesson", TYPE_LESSON), + POOR_AUTH_CHALLENGE("poor_auth_challenge", TYPE_CHALLENGE), + + // Insecure Authorization + INSECURE_AUTH_LESSON("insecure_auth_lesson", TYPE_LESSON), + + // Supply Chain + SUPPLY_CHAIN_LESSON("supply_chain_lesson", TYPE_LESSON), + + // Insecure Communication + INSECURE_COMM_LESSON("insecure_comm_lesson", TYPE_LESSON), + INSECURE_COMM_CHALLENGE("insecure_comm_challenge", TYPE_CHALLENGE), + + // Insufficient Cryptography + INSUFFICIENT_CRYPTO_LESSON("insufficient_crypto_lesson", TYPE_LESSON), + INSUFFICIENT_CRYPTO_CHALLENGE("insufficient_crypto_challenge", TYPE_CHALLENGE), + + // Security Misconfiguration + SECURITY_MISCONFIG_LESSON("security_misconfig_lesson", TYPE_LESSON), + SECURITY_MISCONFIG_CHALLENGE_2("security_misconfig_challenge_2", TYPE_CHALLENGE), + + // Input Validation + INPUT_VALIDATION_LESSON("input_validation_lesson", TYPE_LESSON), + + // Privacy Controls + PRIVACY_LESSON("privacy_lesson", TYPE_LESSON), + + // Client-Side Injection + CLIENT_SIDE_INJECTION_LESSON("client_side_injection_lesson", TYPE_LESSON), + CLIENT_SIDE_INJECTION_CHALLENGE_1("client_side_injection_challenge_1", TYPE_CHALLENGE), + CLIENT_SIDE_INJECTION_CHALLENGE_2("client_side_injection_challenge_2", TYPE_CHALLENGE); + + private final String id; + private final String type; + + Module(String id, String type) { + this.id = id; + this.type = type; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + } + + // RE_LESSON and RE_CHALLENGE_1 retain local hashes — the SHA-256 in the APK is the + // target of the reverse-engineering challenge itself. All other modules require a live + // server session; no offline fallback is provided. + private static final Map FLAG_HASHES = new HashMap() {{ + put(Module.RE_LESSON, "a0c066b9cd89c084709330a943fb6b333d45c932ed613e428bc52078b5722e57"); + put(Module.RE_CHALLENGE_1, "f04a272a2d82a0168f44559f9957dc9e028ecb13195c12fce17ac08d4af91deb"); + }}; + + /** + * Validates a flag submission using SHA-256 hash comparison. + * + * @param module The module being validated + * @param submittedFlag The flag submitted by the user + * @return true if the flag is correct, false otherwise + */ + public static boolean validateFlag(Module module, String submittedFlag) { + if (submittedFlag == null || submittedFlag.trim().isEmpty()) { + return false; + } + + String expectedHash = FLAG_HASHES.get(module); + if (expectedHash == null) { + Log.e(TAG, "No hash found for module: " + module.getId()); + return false; + } + + String submittedHash = sha256(submittedFlag.trim()); + boolean isValid = expectedHash.equalsIgnoreCase(submittedHash); + + Log.d(TAG, isValid ? "[OK] Correct flag for " + module.getId() + : "[FAIL] Incorrect flag for " + module.getId()); + return isValid; + } + + /** + * Computes SHA-256 hash of the input string. + * + * @param input The string to hash + * @return Hexadecimal representation of the hash + */ + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + // Convert bytes to hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "SHA-256 algorithm not available", e); + return ""; + } + } + + // ------------------------------------------------------------------------- + // Server-side validation + // ------------------------------------------------------------------------- + + /** + * Callback interface for asynchronous flag validation results. + * + *

{@link #onResult(boolean)} is always invoked on the main (UI) thread. + */ + public interface ValidationCallback { + void onResult(boolean correct); + } + + /** + * Validates a flag against the configured Shepherd server when a session is active. + * Falls back to local SHA-256 comparison when no session is available, + * so the app remains usable without a running server instance. + * + *

This method is non-blocking. The result is delivered on the main thread via + * {@code callback}. + * + * @param context Application context used to read shared preferences. + * @param module The module being validated. + * @param flag The flag string submitted by the student. + * @param callback Receives {@code true} when the flag is correct, {@code false} otherwise. + */ + public static void validateFlag( + Context context, + Module module, + String flag, + ValidationCallback callback) { + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String serverUrl = prefs.getString("server_preference", "").trim(); + String sessionCookie = AuthManager.getMobileSessionCookie(context); + + if (serverUrl.isEmpty() || sessionCookie.isEmpty()) { + // No active server session — only RE modules have offline hashes. + Log.d(TAG, "No server session — offline validation for " + module.getId()); + boolean result = validateFlag(module, flag); + new Handler(Looper.getMainLooper()).post(() -> callback.onResult(result)); + return; + } + + final String endpointUrl = serverUrl.replaceAll("/+$", "") + "/mobileFlagSubmit"; + final String moduleId = module.getId(); + final String trimmedFlag = flag.trim(); + final Handler mainHandler = new Handler(Looper.getMainLooper()); + + new Thread(() -> { + boolean correct = false; + HttpURLConnection conn = null; + try { + String body = + "moduleId=" + URLEncoder.encode(moduleId, "UTF-8") + + "&flag=" + URLEncoder.encode(trimmedFlag, "UTF-8"); + + conn = (HttpURLConnection) new URL(endpointUrl).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + conn.setRequestProperty("Cookie", sessionCookie); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + JSONObject json = new JSONObject(sb.toString()); + correct = json.optBoolean("correct", false); + Log.d(TAG, "Server validation for " + moduleId + ": " + correct); + } else { + Log.w(TAG, "Server returned HTTP " + status + " for " + moduleId); + correct = validateFlag(module, trimmedFlag); + } + } catch (Exception e) { + Log.e(TAG, "Server validation failed for " + moduleId + ": " + e.getMessage()); + correct = validateFlag(module, trimmedFlag); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + + final boolean result = correct; + mainHandler.post(() -> callback.onResult(result)); + }).start(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge1Model.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge1Model.java new file mode 100644 index 000000000..57c15d58e --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge1Model.java @@ -0,0 +1,15 @@ +package org.owasp.mobileshepherd.ui.challenges.clientsideinjection; + +import androidx.lifecycle.ViewModel; + +import org.owasp.mobileshepherd.utils.FlagValidator; + +public class ClientSideInjectionChallenge1Model extends ViewModel { + + public boolean validateFlag(String flag) { + return FlagValidator.validateFlag( + FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_1, + flag + ); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge2Fragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge2Fragment.java new file mode 100644 index 000000000..077d807ac --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge2Fragment.java @@ -0,0 +1,271 @@ +package org.owasp.mobileshepherd.ui.challenges.clientsideinjection; + +import android.content.ContentValues; +import android.content.Intent; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentClientSideInjectionChallenge2Binding; +import org.owasp.mobileshepherd.ui.challenges.clientsideinjection.helpers.Challenge2DatabaseHelper; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +public class ClientSideInjectionChallenge2Fragment extends Fragment { + + private FragmentClientSideInjectionChallenge2Binding binding; + private ClientSideInjectionChallenge2Model viewModel; + private Challenge2DatabaseHelper dbHelper; + private static final String TAG = "CSI_Challenge2"; + private ProgressTracker progressTracker; + private boolean fabExpanded = false; + private String currentFlag = ""; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + binding = FragmentClientSideInjectionChallenge2Binding.inflate(inflater, container, false); + View root = binding.getRoot(); + + viewModel = new ViewModelProvider(this).get(ClientSideInjectionChallenge2Model.class); + progressTracker = new ProgressTracker(requireContext()); + + // Initialize database + dbHelper = new Challenge2DatabaseHelper(requireContext()); + FlagProvider.getFlag( + requireContext(), + FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_2, + flag -> { + if (!isAdded()) return; + currentFlag = flag; + initializeDatabase(flag); + }); + + // Setup expandable FAB with command reference and OWASP link + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_2)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showVulnerabilityInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + // Set initial FAB appearance based on completion status + + // Setup search button + binding.searchProductButton.setOnClickListener(v -> searchProducts()); + + // Setup submit flag button + binding.submitFlagButton.setOnClickListener(v -> submitFlag()); + + return root; + } + + private void initializeDatabase(String flagValue) { + SQLiteDatabase db = dbHelper.getWritableDatabase(); + + // Clear existing data + db.execSQL("DELETE FROM products"); + db.execSQL("DELETE FROM secrets"); + + // Insert sample products + insertProduct(db, "Laptop", "Electronics", 999.99, 15); + insertProduct(db, "Mouse", "Electronics", 29.99, 50); + insertProduct(db, "Keyboard", "Electronics", 79.99, 30); + insertProduct(db, "Monitor", "Electronics", 299.99, 20); + insertProduct(db, "Desk", "Furniture", 199.99, 10); + + // Insert secret table — admin_token value is the user-specific HMAC flag + insertSecret(db, "admin_token", flagValue); + insertSecret(db, "api_key", "sk_test_1234567890"); + + db.close(); + } + + private void insertProduct(SQLiteDatabase db, String name, String category, double price, int stock) { + ContentValues values = new ContentValues(); + values.put("name", name); + values.put("category", category); + values.put("price", price); + values.put("stock", stock); + db.insert("products", null, values); + } + + private void insertSecret(SQLiteDatabase db, String key, String value) { + ContentValues values = new ContentValues(); + values.put("secret_key", key); + values.put("secret_value", value); + db.insert("secrets", null, values); + } + + private void searchProducts() { + String searchTerm = binding.productSearchInput.getText().toString(); + + if (searchTerm.isEmpty()) { + Toast.makeText(getContext(), "Please enter a search term", Toast.LENGTH_SHORT).show(); + return; + } + + // VULNERABLE: User input concatenated into SQL query - allows UNION-based injection + String query = "SELECT name, category, price, stock FROM products WHERE name LIKE '%" + searchTerm + "%' OR category LIKE '%" + searchTerm + "%'"; + + Log.d(TAG, "Executing query: " + query); + + SQLiteDatabase db = dbHelper.getReadableDatabase(); + Cursor cursor = null; + + try { + cursor = db.rawQuery(query, null); + + StringBuilder results = new StringBuilder(); + int count = 0; + int columnCount = cursor.getColumnCount(); + + while (cursor.moveToNext()) { + count++; + + // Handle results dynamically (supports UNION injection) + for (int i = 0; i < columnCount; i++) { + String columnName = cursor.getColumnName(i); + String value = cursor.getString(i); + + results.append(columnName).append(": ").append(value).append("\n"); + + // Check if flag was extracted + if (value != null && !currentFlag.isEmpty() && value.equals(currentFlag)) { + results.append("\n SECRET DISCOVERED!\n"); + results.append("You've extracted data from the hidden 'secrets' table!\n"); + results.append("Flag: ").append(value).append("\n\n"); + results.append("Submit this flag to complete the challenge!\n"); + } + } + results.append("---\n"); + } + + if (count == 0) { + binding.resultText.setText("No products found matching: " + searchTerm); + } else { + binding.resultText.setText(results.toString()); + } + + } catch (Exception e) { + binding.resultText.setText("Error: " + e.getMessage() + + "\n\nHint: The secrets table has columns: secret_key, secret_value" + + "\nTry using UNION SELECT to query multiple tables!"); + Log.e(TAG, "SQL Error", e); + } finally { + if (cursor != null) { + cursor.close(); + } + db.close(); + } + } + + private void submitFlag() { + String submittedFlag = binding.flagInput.getText().toString(); + + if (submittedFlag.isEmpty()) { + Toast.makeText(getContext(), "Please enter the flag", Toast.LENGTH_SHORT).show(); + return; + } + + FlagValidator.validateFlag( + requireContext(), + FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_2, + submittedFlag, + correct -> { + if (!isAdded()) return; + if (correct) { + progressTracker.markCompleted(FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_2); + int completionCount = progressTracker.getCompletionCount(FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_2); + String completionText = completionCount > 1 ? " (Completed " + completionCount + " times)" : ""; + + new AlertDialog.Builder(requireContext()) + .setTitle(" Success!") + .setMessage("Congratulations! You've successfully used UNION-based SQL injection to extract data from the hidden 'secrets' table.\n\nFlag: " + submittedFlag + completionText) + .setPositiveButton("OK", null) + .show(); + binding.flagInput.setText(""); + } else { + Toast.makeText(getContext(), "Incorrect flag. Keep trying!", Toast.LENGTH_LONG).show(); + } + }); + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showVulnerabilityInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.client_side_injection_challenge2_intro); + // vulnerabilitiesText.setText(R.string.client_side_injection_challenge2_vulnerabilities); + + hintsSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Client-Side Injection Challenge 2\nOWASP M7: Client Code Quality") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + if (dbHelper != null) { + dbHelper.close(); + } + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge2Model.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge2Model.java new file mode 100644 index 000000000..238171bdd --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/ClientSideInjectionChallenge2Model.java @@ -0,0 +1,15 @@ +package org.owasp.mobileshepherd.ui.challenges.clientsideinjection; + +import androidx.lifecycle.ViewModel; + +import org.owasp.mobileshepherd.utils.FlagValidator; + +public class ClientSideInjectionChallenge2Model extends ViewModel { + + public boolean validateFlag(String flag) { + return FlagValidator.validateFlag( + FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_2, + flag + ); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/helpers/Challenge1DatabaseHelper.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/helpers/Challenge1DatabaseHelper.java new file mode 100644 index 000000000..ab66b87ee --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/helpers/Challenge1DatabaseHelper.java @@ -0,0 +1,32 @@ +package org.owasp.mobileshepherd.ui.challenges.clientsideinjection.helpers; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +public class Challenge1DatabaseHelper extends SQLiteOpenHelper { + + private static final String DATABASE_NAME = "challenge1_injection.db"; + private static final int DATABASE_VERSION = 1; + + public Challenge1DatabaseHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + String createTable = "CREATE TABLE accounts (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "username TEXT NOT NULL, " + + "password TEXT NOT NULL, " + + "role TEXT, " + + "balance INTEGER DEFAULT 0)"; + db.execSQL(createTable); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL("DROP TABLE IF EXISTS accounts"); + onCreate(db); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/helpers/Challenge2DatabaseHelper.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/helpers/Challenge2DatabaseHelper.java new file mode 100644 index 000000000..c973c831e --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/clientsideinjection/helpers/Challenge2DatabaseHelper.java @@ -0,0 +1,41 @@ +package org.owasp.mobileshepherd.ui.challenges.clientsideinjection.helpers; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +public class Challenge2DatabaseHelper extends SQLiteOpenHelper { + + private static final String DATABASE_NAME = "challenge2_injection.db"; + private static final int DATABASE_VERSION = 1; + + public Challenge2DatabaseHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + // Create products table + String createProductsTable = "CREATE TABLE products (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "name TEXT NOT NULL, " + + "category TEXT, " + + "price REAL, " + + "stock INTEGER DEFAULT 0)"; + db.execSQL(createProductsTable); + + // Create hidden secrets table + String createSecretsTable = "CREATE TABLE secrets (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "secret_key TEXT NOT NULL, " + + "secret_value TEXT NOT NULL)"; + db.execSQL(createSecretsTable); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL("DROP TABLE IF EXISTS products"); + db.execSQL("DROP TABLE IF EXISTS secrets"); + onCreate(db); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/crypto/InsufficientCryptoChallengeFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/crypto/InsufficientCryptoChallengeFragment.java new file mode 100644 index 000000000..fea746573 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/crypto/InsufficientCryptoChallengeFragment.java @@ -0,0 +1,213 @@ +package org.owasp.mobileshepherd.ui.challenges.crypto; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Base64; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentInsufficientCryptoChallengeBinding; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +import java.nio.charset.StandardCharsets; + +public class InsufficientCryptoChallengeFragment extends Fragment { + + private FragmentInsufficientCryptoChallengeBinding binding; + private String currentFlag = ""; + private static final String TAG = "RecoveryVault"; + private ProgressTracker progressTracker; + private boolean fabExpanded = false; + + // Vulnerability: hardcoded single-byte XOR key used to "protect" stored data + private static final byte XOR_KEY = 0x42; + private static final String PREFS_NAME = "recovery_vault"; + private static final String PREFS_KEY = "vault_data"; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + + binding = FragmentInsufficientCryptoChallengeBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + progressTracker = new ProgressTracker(requireContext()); + + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.INSUFFICIENT_CRYPTO_CHALLENGE)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showHints(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + FlagProvider.getFlag(requireContext(), FlagValidator.Module.INSUFFICIENT_CRYPTO_CHALLENGE, + flagValue -> currentFlag = flagValue); + + binding.saveButton.setOnClickListener(v -> saveVault()); + binding.validateButton.setOnClickListener(v -> validateFlag()); + + return root; + } + + private void saveVault() { + String key1 = binding.key1Input.getText().toString().trim(); + String key2 = binding.key2Input.getText().toString().trim(); + + if (key1.isEmpty() || key2.isEmpty()) { + Toast.makeText(getContext(), "Enter both recovery keys before saving", Toast.LENGTH_SHORT).show(); + return; + } + + if (currentFlag.isEmpty()) { + Toast.makeText(getContext(), "Loading vault data, please wait...", Toast.LENGTH_SHORT).show(); + return; + } + + // Build JSON -- flag is hidden as "session_token" among legitimate-looking fields + String json = "{" + + "\"version\":1," + + "\"recovery_key_1\":\"" + key1 + "\"," + + "\"recovery_key_2\":\"" + key2 + "\"," + + "\"account_id\":\"usr_" + Integer.toHexString(key1.hashCode() & 0xFFFF) + "\"," + + "\"session_token\":\"" + currentFlag + "\"," + + "\"backup_timestamp\":" + System.currentTimeMillis() + + "}"; + + // XOR encode and Base64-wrap before storing + byte[] plainBytes = json.getBytes(StandardCharsets.UTF_8); + byte[] encoded = new byte[plainBytes.length]; + for (int i = 0; i < plainBytes.length; i++) { + encoded[i] = (byte) (plainBytes[i] ^ XOR_KEY); + } + String stored = Base64.encodeToString(encoded, Base64.NO_WRAP); + + SharedPreferences prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + prefs.edit().putString(PREFS_KEY, stored).apply(); + + Log.d(TAG, "Vault saved. Keys: " + key1 + ", " + key2); + + binding.savedConfirmation.setVisibility(View.VISIBLE); + binding.savedConfirmation.setText( + "\u2705 Recovery vault saved to SharedPreferences.\n\n" + + "File: shared_prefs/" + PREFS_NAME + ".xml\n" + + "Key: " + PREFS_KEY); + binding.key1Input.setEnabled(false); + binding.key2Input.setEnabled(false); + binding.saveButton.setEnabled(false); + } + + private void validateFlag() { + String enteredFlag = binding.flagInput.getText().toString().trim(); + + if (enteredFlag.isEmpty()) { + Toast.makeText(getContext(), "Please enter a flag", Toast.LENGTH_SHORT).show(); + return; + } + + FlagValidator.validateFlag(requireContext(), FlagValidator.Module.INSUFFICIENT_CRYPTO_CHALLENGE, + enteredFlag, isValid -> { + if (isValid) { + progressTracker.markCompleted(FlagValidator.Module.INSUFFICIENT_CRYPTO_CHALLENGE); + + binding.resultCard.setCardBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.success_bg)); + binding.resultText.setText("\u2713 Correct!\n\nYou recovered the flag from insecurely stored data."); + binding.resultText.setVisibility(View.VISIBLE); + binding.validateButton.setEnabled(false); + binding.flagInput.setEnabled(false); + + new AlertDialog.Builder(requireContext()) + .setTitle("\uD83C\uDF89 Challenge Complete!") + .setMessage("You exploited insecure local storage:\n\n" + + "\u2022 Data was XOR-encoded with a hardcoded key (0x42)\n" + + "\u2022 The key is visible in the decompiled source\n" + + "\u2022 SharedPreferences are readable via ADB on debug builds\n\n" + + "Real-world fix: Use Android Keystore + AES/GCM with a per-install key.") + .setPositiveButton("OK", null) + .show(); + } else { + binding.resultCard.setCardBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.error_bg)); + binding.resultText.setText("\u2717 Incorrect flag. Pull the vault data from SharedPreferences and decode it."); + binding.resultText.setVisibility(View.VISIBLE); + binding.flagInput.setText(""); + } + }); + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + private void showHints() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + + TextView introText = dialogView.findViewById(R.id.intro_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText("The app stores your recovery vault locally using a weak encoding scheme. " + + "Your goal is to recover the hidden session token from that stored data."); + + hintsSection.setVisibility(View.VISIBLE); + hintsText.setText( + "\u2022 Save the vault first, then inspect the device storage\n" + + "\u2022 ADB: adb shell run-as org.owasp.mobileshepherd cat shared_prefs/recovery_vault.xml\n" + + "\u2022 The stored value is Base64-encoded -- decode it first\n" + + "\u2022 Then look at the source code to find how the bytes were scrambled\n" + + "\u2022 Decompile with: jadx-gui app-debug.apk"); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Hints -- OWASP M6: Insufficient Cryptography") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + binding = null; + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/crypto/InsufficientCryptoChallengeModel.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/crypto/InsufficientCryptoChallengeModel.java new file mode 100644 index 000000000..909ff2546 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/crypto/InsufficientCryptoChallengeModel.java @@ -0,0 +1,12 @@ +package org.owasp.mobileshepherd.ui.challenges.crypto; + +import androidx.lifecycle.ViewModel; + +import org.owasp.mobileshepherd.utils.FlagValidator; + +public class InsufficientCryptoChallengeModel extends ViewModel { + + public boolean validateFlag(String flag) { + return FlagValidator.validateFlag(FlagValidator.Module.INSUFFICIENT_CRYPTO_CHALLENGE, flag); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/insecurecomm/InsecureCommChallengeFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/insecurecomm/InsecureCommChallengeFragment.java new file mode 100644 index 000000000..04dd04c73 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/insecurecomm/InsecureCommChallengeFragment.java @@ -0,0 +1,298 @@ +package org.owasp.mobileshepherd.ui.challenges.insecurecomm; + +import android.os.Bundle; +import android.util.Base64; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentInsecureCommChallengeBinding; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Random; + +public class InsecureCommChallengeFragment extends Fragment { + + private FragmentInsecureCommChallengeBinding binding; + private String currentFlag = ""; + private static final String TAG = "AppNetworkMonitor"; + private ProgressTracker progressTracker; + private boolean fabExpanded = false; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + + binding = FragmentInsecureCommChallengeBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + progressTracker = new ProgressTracker(requireContext()); + + // Fetch server flag so it is ready when simulateAppTraffic() is triggered + FlagProvider.getFlag(requireContext(), FlagValidator.Module.INSECURE_COMM_CHALLENGE, flagValue -> { + currentFlag = flagValue; + }); + + // Setup expandable FAB with command reference and OWASP link + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.INSECURE_COMM_CHALLENGE)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showVulnerabilityInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + // Set initial FAB appearance based on completion status + + binding.startAppButton.setOnClickListener(v -> simulateAppTraffic()); + binding.submitFlagButton.setOnClickListener(v -> submitFlag()); + + return root; + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showVulnerabilityInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.insecure_comm_intro); + // vulnerabilitiesText.setText(R.string.insecure_comm_vulnerabilities); + + hintsSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Insecure Communication Challenge\nOWASP M5: Insecure Communication") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + private void simulateAppTraffic() { + binding.startAppButton.setEnabled(false); + binding.trafficStatus.setText("Monitoring network traffic..."); + + new Thread(() -> { + try { + // Simulate multiple network requests with noise + Thread.sleep(500); + makeSecureAnalyticsRequest(); + + Thread.sleep(800); + makeInsecureApiRequest(); // This one contains the flag + + Thread.sleep(600); + makeSecureImageRequest(); + + Thread.sleep(700); + makeInsecureMetricsRequest(); + + Thread.sleep(500); + makeSecureAuthRequest(); + + requireActivity().runOnUiThread(() -> { + binding.trafficStatus.setText("[OK] Network monitoring complete!\n\n5 requests captured. Analyze logcat to find insecure traffic."); + binding.trafficStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.colorPrimary)); + binding.submitSection.setVisibility(View.VISIBLE); + Toast.makeText(getContext(), "Check logcat tag: AppNetworkMonitor", Toast.LENGTH_LONG).show(); + }); + + } catch (Exception e) { + Log.e(TAG, "Error: " + e.getMessage()); + } + }).start(); + } + + private void makeSecureAnalyticsRequest() { + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "[Request #1] Analytics Endpoint"); + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "POST https://analytics.secure-api.com/events"); + Log.d(TAG, "Protocol: HTTPS/TLS 1.3 [OK] ENCRYPTED"); + Log.d(TAG, "Headers: [ENCRYPTED]"); + Log.d(TAG, "Body: [ENCRYPTED]"); + Log.d(TAG, "Status: [OK] Secure connection established"); + Log.d(TAG, ""); + } + + private void makeInsecureApiRequest() { + String flag = currentFlag.isEmpty() ? "[connect to server to load flag]" : currentFlag; + + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "[Request #2] User Session Endpoint"); + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "GET http://api.legacy-backend.com/user/session"); + Log.d(TAG, "Protocol: HTTP/1.1 WARNING: PLAINTEXT"); + Log.d(TAG, ""); + Log.d(TAG, "Headers:"); + Log.d(TAG, " Host: api.legacy-backend.com"); + Log.d(TAG, " Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"); + Log.d(TAG, " X-Session-Token: " + flag); + Log.d(TAG, " User-Agent: MobileApp/2.1.4"); + Log.d(TAG, " Accept: application/json"); + Log.d(TAG, ""); + Log.d(TAG, "SECURITY WARNING: Sensitive token sent over HTTP!"); + Log.d(TAG, ""); + + try { + URL url = new URL("http://api.legacy-backend.com/user/session"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("X-Session-Token", flag); + conn.setRequestProperty("User-Agent", "MobileApp/2.1.4"); + conn.setConnectTimeout(2000); + conn.setReadTimeout(2000); + + try { + conn.connect(); + } catch (Exception e) { + Log.d(TAG, "Connection failed (expected)"); + } + } catch (Exception e) { + // Expected to fail + } + } + + private void makeSecureImageRequest() { + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "[Request #3] Image CDN"); + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "GET https://cdn.secure-images.com/avatar/user123.jpg"); + Log.d(TAG, "Protocol: HTTPS/TLS 1.3 [OK] ENCRYPTED"); + Log.d(TAG, "Headers: [ENCRYPTED]"); + Log.d(TAG, "Status: [OK] Secure connection established"); + Log.d(TAG, ""); + } + + private void makeInsecureMetricsRequest() { + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "[Request #4] Metrics Endpoint"); + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "POST http://metrics.old-service.com/collect"); + Log.d(TAG, "Protocol: HTTP/1.1 WARNING: PLAINTEXT"); + Log.d(TAG, ""); + Log.d(TAG, "Headers:"); + Log.d(TAG, " Content-Type: application/json"); + Log.d(TAG, "Body:"); + Log.d(TAG, " {\"event\":\"app_open\",\"user_id\":\"u_847263\"}"); + Log.d(TAG, ""); + Log.d(TAG, "WARNING: Non-sensitive data, but still unencrypted"); + Log.d(TAG, ""); + } + + private void makeSecureAuthRequest() { + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "[Request #5] Authentication Endpoint"); + Log.d(TAG, "───────────────────────────────────"); + Log.d(TAG, "POST https://auth.secure-api.com/token/refresh"); + Log.d(TAG, "Protocol: HTTPS/TLS 1.3 [OK] ENCRYPTED"); + Log.d(TAG, "Headers: [ENCRYPTED]"); + Log.d(TAG, "Body: [ENCRYPTED]"); + Log.d(TAG, "Status: [OK] Secure connection established"); + Log.d(TAG, ""); + Log.d(TAG, "═══════════════════════════════════"); + Log.d(TAG, "Network capture complete"); + Log.d(TAG, "═══════════════════════════════════"); + } + + private void submitFlag() { + String enteredFlag = binding.flagInput.getText().toString().trim(); + + if (enteredFlag.isEmpty()) { + Toast.makeText(getContext(), "Please enter a flag", Toast.LENGTH_SHORT).show(); + return; + } + + FlagValidator.validateFlag(requireContext(), FlagValidator.Module.INSECURE_COMM_CHALLENGE, + enteredFlag, isValid -> { + if (isValid) { + progressTracker.markCompleted(FlagValidator.Module.INSECURE_COMM_CHALLENGE); + int completionCount = progressTracker.getCompletionCount(FlagValidator.Module.INSECURE_COMM_CHALLENGE); + String completionText = completionCount > 1 ? " (Completed " + completionCount + " times)" : ""; + + binding.flagValidationCard.setCardBackgroundColor(ContextCompat.getColor(requireContext(), R.color.success_bg)); + binding.resultText.setText("\u2713 Correct Flag!\n\nYou successfully intercepted the insecure HTTP traffic and found the session token!"); + binding.resultText.setVisibility(View.VISIBLE); + + Toast.makeText(getContext(), "Challenge Complete!", Toast.LENGTH_LONG).show(); + + new AlertDialog.Builder(requireContext()) + .setTitle("\uD83C\uDF89 Success!") + .setMessage("Congratulations! You intercepted insecure network traffic.\n\nFlag: " + enteredFlag + completionText) + .setPositiveButton("OK", null) + .show(); + + binding.submitFlagButton.setEnabled(false); + binding.flagInput.setEnabled(false); + } else { + binding.flagValidationCard.setCardBackgroundColor(ContextCompat.getColor(requireContext(), R.color.error_bg)); + binding.resultText.setText("\u2717 Incorrect Flag\n\nHint: Look for HTTP (not HTTPS) requests in the logs"); + binding.resultText.setVisibility(View.VISIBLE); + + Toast.makeText(getContext(), "Incorrect flag. Keep analyzing!", Toast.LENGTH_SHORT).show(); + + binding.flagInput.setText(""); + } + }); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/insecuredata1/InsecureData1Fragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/insecuredata1/InsecureData1Fragment.java new file mode 100644 index 000000000..12b50ba45 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/insecuredata1/InsecureData1Fragment.java @@ -0,0 +1,184 @@ +package org.owasp.mobileshepherd.ui.challenges.insecuredata1; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentInsecureData1Binding; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +public class InsecureData1Fragment extends Fragment { + + private FragmentInsecureData1Binding binding; + private SQLiteDatabase passwordDB = null; + private String currentFlag = ""; + private ProgressTracker progressTracker; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + binding = FragmentInsecureData1Binding.inflate(inflater, container, false); + View root = binding.getRoot(); + + progressTracker = new ProgressTracker(requireContext()); + + // Setup FAB for vulnerability information + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.IDS_CHALLENGE_1)); + } + + // Create the vulnerable database, then seed it once the server flag is available + createDatabase(); + FlagProvider.getFlag(requireContext(), FlagValidator.Module.IDS_CHALLENGE_1, flagValue -> { + currentFlag = flagValue; + insertUsers(); + displayUsers(); + }); + + // Wire up flag validation + binding.validateButton.setOnClickListener(v -> { + String enteredFlag = binding.flagInput.getText().toString().trim(); + FlagValidator.validateFlag(requireContext(), FlagValidator.Module.IDS_CHALLENGE_1, + enteredFlag, isValid -> { + if (isValid) { + progressTracker.markCompleted(FlagValidator.Module.IDS_CHALLENGE_1); + int completionCount = progressTracker.getCompletionCount(FlagValidator.Module.IDS_CHALLENGE_1); + String completionText = completionCount > 1 ? " (Completed " + completionCount + " times)" : ""; + + Toast.makeText(getContext(), "Correct! Flag validated!", Toast.LENGTH_LONG).show(); + binding.resultCard.setCardBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.success_bg)); + + new AlertDialog.Builder(requireContext()) + .setTitle("\uD83C\uDF89 Success!") + .setMessage("Congratulations! You extracted the flag from the SQLite database.\n\nFlag: " + enteredFlag + completionText) + .setPositiveButton("OK", null) + .show(); + } else { + Toast.makeText(getContext(), "Incorrect flag. Keep looking!", Toast.LENGTH_SHORT).show(); + binding.resultCard.setCardBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.error_bg)); + binding.flagInput.setText(""); + } + }); + }); + + return root; + } + + private void showVulnerabilityInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + // View bestPracticesSection = dialogView.findViewById(R.id.best_practices_section); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.insecure_data_intro); + // vulnerabilitiesText.setText(R.string.insecure_data_vulns); + + hintsSection.setVisibility(View.GONE); + + // bestPracticesSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Insecure Data Storage - SQLite") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + private void createDatabase() { + try { + passwordDB = requireContext().openOrCreateDatabase( + "passwordDB", android.content.Context.MODE_PRIVATE, null); + passwordDB.execSQL( + "CREATE TABLE IF NOT EXISTS passwordDB " + + "(id integer primary key, name VARCHAR, password VARCHAR);"); + } catch (Exception e) { + Log.e("DB ERROR", "Error Creating Database", e); + } + } + + private void insertUsers() { + if (passwordDB == null) return; + try { + passwordDB.execSQL("DELETE FROM passwordDB;"); + passwordDB.execSQL( + "INSERT INTO passwordDB (name, password) VALUES ('Admin', ?);", + new Object[]{currentFlag}); + passwordDB.execSQL("INSERT INTO passwordDB (name, password) VALUES ('john_doe', 'password123');"); + passwordDB.execSQL("INSERT INTO passwordDB (name, password) VALUES ('alice_smith', 'welcome2024');"); + passwordDB.execSQL("INSERT INTO passwordDB (name, password) VALUES ('bob_johnson', 'qwerty456');"); + } catch (Exception e) { + Log.e("DB ERROR", "Error Inserting Users", e); + } + } + + private void displayUsers() { + if (passwordDB == null || binding == null) return; + try { + Cursor cursor = passwordDB.rawQuery("SELECT name FROM passwordDB", null); + LinearLayout container = binding.usersListContainer; + container.removeAllViews(); + while (cursor.moveToNext()) { + String username = cursor.getString(0); + + LinearLayout userRow = new LinearLayout(requireContext()); + userRow.setOrientation(LinearLayout.HORIZONTAL); + userRow.setPadding(0, 8, 0, 8); + + TextView userIcon = new TextView(requireContext()); + userIcon.setText("\u2022 "); + userIcon.setTextSize(16); + + TextView userName = new TextView(requireContext()); + userName.setText(username); + userName.setTextSize(16); + + TextView passwordHidden = new TextView(requireContext()); + passwordHidden.setText(" \u2022 Password: ******"); + passwordHidden.setTextSize(14); + passwordHidden.setTextColor( + ContextCompat.getColor(requireContext(), R.color.text_secondary)); + + userRow.addView(userIcon); + userRow.addView(userName); + userRow.addView(passwordHidden); + container.addView(userRow); + } + cursor.close(); + } catch (Exception e) { + Log.e("DB ERROR", "Error Displaying Users", e); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (passwordDB != null) { + passwordDB.close(); + } + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/insecuredata1/InsecureData1Model.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/insecuredata1/InsecureData1Model.java new file mode 100644 index 000000000..f499e3fa7 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/insecuredata1/InsecureData1Model.java @@ -0,0 +1,12 @@ +package org.owasp.mobileshepherd.ui.challenges.insecuredata1; + +import androidx.lifecycle.ViewModel; + +import org.owasp.mobileshepherd.utils.FlagValidator; + +public class InsecureData1Model extends ViewModel { + + public boolean validateFlag(String flag) { + return FlagValidator.validateFlag(FlagValidator.Module.IDS_CHALLENGE_1, flag); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/poorauth/PoorAuthChallengeFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/poorauth/PoorAuthChallengeFragment.java new file mode 100644 index 000000000..cea7a2a27 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/poorauth/PoorAuthChallengeFragment.java @@ -0,0 +1,299 @@ +package org.owasp.mobileshepherd.ui.challenges.poorauth; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentPoorAuthChallengeBinding; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.util.Date; +import java.util.Random; + +public class PoorAuthChallengeFragment extends Fragment { + + private FragmentPoorAuthChallengeBinding binding; + private static String tempPassword; + private boolean passwordReset = false; + private static final String TAG = "PoorAuthChallenge"; + private static final String USERNAME = "Jack"; + private ProgressTracker progressTracker; + private boolean fabExpanded = false; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + binding = FragmentPoorAuthChallengeBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + progressTracker = new ProgressTracker(requireContext()); + + // Setup expandable FAB with command reference and OWASP link + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.POOR_AUTH_CHALLENGE)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showVulnerabilityInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + // Set initial FAB appearance based on completion status + + // Write insecure logs revealing security question answers + writeInsecureLogs(); + + // Setup forgot password button + binding.forgotPasswordButton.setOnClickListener(v -> showPasswordResetDialog()); + + // Setup login button + binding.loginButton.setOnClickListener(v -> handleLogin()); + + return root; + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showVulnerabilityInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.poor_auth_intro); + // vulnerabilitiesText.setText(R.string.poor_auth_vulnerabilities); + + hintsSection.setVisibility(View.GONE); + + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Poor Authentication Challenge\nOWASP M3: Insecure Authentication/Authorization") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + private void writeInsecureLogs() { + // Log sensitive information that reveals security answers + Log.d(TAG, "My name is Jack Meade, I'm here to kick ass and drink gravy!"); + Log.d(TAG, "Today I had chicken again! I love Chicken! #deliciousChicken"); + Log.d(TAG, "The house is flooded... uh oh"); + Log.d(TAG, "Misplaced my phone again, found it in the microwave."); + Log.d(TAG, "My mother just married again! Goodbye Mrs. Meade hello Mrs Jenkins!"); + + // Write to world-readable files (intentionally insecure) + writeWorldReadableLog("My name is Jack Meade, I'm here to kick ass and drink gravy!"); + writeWorldReadableLog("Today I had chicken again! I love Chicken! #deliciousChicken #whyDoIDoThis"); + writeWorldReadableLog("My mother just married again! Goodbye Mrs. Meade hello Mrs Jenkins!"); + + Toast.makeText(getContext(), "Hint: Check logcat for interesting information!", Toast.LENGTH_LONG).show(); + } + + private void writeWorldReadableLog(String content) { + Date date = new Date(); + Random rand = new Random(5); + String filename = "PoorAuthLog" + rand.nextInt(100); + String EOL = System.getProperty("line.separator"); + BufferedWriter writer = null; + + try { + writer = new BufferedWriter( + new OutputStreamWriter( + requireContext().openFileOutput(filename, Context.MODE_PRIVATE) + ) + ); + writer.write(content + EOL); + writer.write(date.toString() + EOL); + } catch (Exception e) { + Log.e(TAG, "Error writing log", e); + } finally { + if (writer != null) { + try { + writer.close(); + } catch (IOException e) { + Log.e(TAG, "Error closing writer", e); + } + } + } + } + + private void showPasswordResetDialog() { + // Show password reset section + binding.loginSection.setVisibility(View.GONE); + binding.resetSection.setVisibility(View.VISIBLE); + + binding.resetButton.setOnClickListener(v -> handlePasswordReset()); + binding.cancelButton.setOnClickListener(v -> { + binding.loginSection.setVisibility(View.VISIBLE); + binding.resetSection.setVisibility(View.GONE); + binding.question1Input.setText(""); + binding.question2Input.setText(""); + }); + } + + private void handlePasswordReset() { + String answer1 = binding.question1Input.getText().toString().trim(); + String answer2 = binding.question2Input.getText().toString().trim(); + + if (answer1.isEmpty() || answer2.isEmpty()) { + Toast.makeText(getContext(), "Empty Fields Detected.", Toast.LENGTH_SHORT).show(); + return; + } + + // Check security answers (intentionally weak questions) + if (answer1.equalsIgnoreCase("Chicken") && answer2.equalsIgnoreCase("Meade")) { + // Generate weak temporary password + tempPassword = generateWeakTempPassword(6); + passwordReset = true; + + Log.d(TAG, "Password reset successful! Temp password: " + tempPassword); + + binding.tempPasswordText.setText("Your temporary password is: " + tempPassword); + binding.tempPasswordText.setVisibility(View.VISIBLE); + + Toast.makeText(getContext(), "Password Reset! Use the temporary password to login.", Toast.LENGTH_LONG).show(); + + // Return to login screen after delay + binding.getRoot().postDelayed(() -> { + binding.loginSection.setVisibility(View.VISIBLE); + binding.resetSection.setVisibility(View.GONE); + binding.question1Input.setText(""); + binding.question2Input.setText(""); + }, 3000); + } else { + Toast.makeText(getContext(), "Invalid answers. Check the logs for hints!", Toast.LENGTH_SHORT).show(); + Log.d(TAG, "Invalid password reset attempt. Answers: " + answer1 + ", " + answer2); + } + } + + private void handleLogin() { + String username = binding.usernameInput.getText().toString().trim(); + String password = binding.passwordInput.getText().toString().trim(); + + if (username.isEmpty() || password.isEmpty()) { + Toast.makeText(getContext(), "Empty Fields Detected.", Toast.LENGTH_SHORT).show(); + return; + } + + Log.d(TAG, "Login attempt - Username: " + username + ", Password: " + password); + Log.d(TAG, "Password reset status: " + passwordReset + ", Temp password: " + tempPassword); + + if (!passwordReset) { + Toast.makeText(getContext(), "Your account has been locked! Use password reset.", Toast.LENGTH_LONG).show(); + return; + } + + if (username.equals(USERNAME) && password.equals(tempPassword)) { + binding.loginSection.setVisibility(View.GONE); + binding.successSection.setVisibility(View.VISIBLE); + binding.flagText.setText("Fetching your flag..."); + + Toast.makeText(getContext(), "Logged in successfully!", Toast.LENGTH_LONG).show(); + + FlagProvider.getFlag(requireContext(), FlagValidator.Module.POOR_AUTH_CHALLENGE, flag -> { + binding.flagText.setText("Congratulations! Here's your flag:\n\n" + flag); + }); + + binding.validateFlagButton.setOnClickListener(v -> validateFlag()); + } else { + Toast.makeText(getContext(), "Invalid Credentials!", Toast.LENGTH_SHORT).show(); + Log.d(TAG, "Login failed. Expected: " + USERNAME + "/" + tempPassword); + binding.passwordInput.setText(""); + } + } + + private void validateFlag() { + String enteredFlag = binding.flagInput.getText().toString().trim(); + FlagValidator.validateFlag(requireContext(), FlagValidator.Module.POOR_AUTH_CHALLENGE, enteredFlag, isValid -> { + if (isValid) { + progressTracker.markCompleted(FlagValidator.Module.POOR_AUTH_CHALLENGE); + int completionCount = progressTracker.getCompletionCount(FlagValidator.Module.POOR_AUTH_CHALLENGE); + String completionText = completionCount > 1 ? " (Completed " + completionCount + " times)" : ""; + + Toast.makeText(getContext(), "Flag validated successfully! Challenge complete!", Toast.LENGTH_LONG).show(); + binding.flagValidationCard.setCardBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.success_bg) + ); + + new AlertDialog.Builder(requireContext()) + .setTitle("\uD83C\uDF89 Success!") + .setMessage("Congratulations! You exploited weak authentication.\n\nFlag: " + enteredFlag + completionText) + .setPositiveButton("OK", null) + .show(); + } else { + Toast.makeText(getContext(), "Incorrect flag!", Toast.LENGTH_SHORT).show(); + binding.flagValidationCard.setCardBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.error_bg) + ); + binding.flagInput.setText(""); + } + }); + } + + private String generateWeakTempPassword(int length) { + // Intentionally weak: only numeric, predictable + Random random = new Random(); + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(random.nextInt(10)); + } + return sb.toString(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/poorauth/PoorAuthChallengeModel.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/poorauth/PoorAuthChallengeModel.java new file mode 100644 index 000000000..f50fc2ee0 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/poorauth/PoorAuthChallengeModel.java @@ -0,0 +1,12 @@ +package org.owasp.mobileshepherd.ui.challenges.poorauth; + +import androidx.lifecycle.ViewModel; + +import org.owasp.mobileshepherd.utils.FlagValidator; + +public class PoorAuthChallengeModel extends ViewModel { + + public boolean validateFlag(String enteredFlag) { + return FlagValidator.validateFlag(FlagValidator.Module.POOR_AUTH_CHALLENGE, enteredFlag); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/reverseengineering/ReverseEngineering1Fragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/reverseengineering/ReverseEngineering1Fragment.java new file mode 100644 index 000000000..b134ea99c --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/reverseengineering/ReverseEngineering1Fragment.java @@ -0,0 +1,152 @@ +package org.owasp.mobileshepherd.ui.challenges.reverseengineering; + +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.core.content.ContextCompat; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentReverseEngineering1Binding; +import org.owasp.mobileshepherd.ui.challenges.reverseengineering.ReverseEngineering1Model; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +public class ReverseEngineering1Fragment extends Fragment { + + private FragmentReverseEngineering1Binding binding; + private ReverseEngineering1Model challengeModel; + private ProgressTracker progressTracker; + private boolean fabExpanded = false; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + challengeModel = new ViewModelProvider(this).get(ReverseEngineering1Model.class); + + binding = FragmentReverseEngineering1Binding.inflate(inflater, container, false); + View root = binding.getRoot(); + + progressTracker = new ProgressTracker(requireContext()); + + // Setup expandable FAB with command reference and OWASP link + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.RE_CHALLENGE_1)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showVulnerabilityInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + // Set initial FAB appearance based on completion status + + final EditText inputFlag = binding.inputFlag; + final Button btnValidate = binding.btnValidate; + final Button btnClear = binding.btnClear; + final TextView textResult = binding.textResult; + + btnValidate.setOnClickListener(v -> { + String userInput = inputFlag.getText().toString().trim(); + if (challengeModel.validateFlag(userInput)) { + progressTracker.markCompleted(FlagValidator.Module.RE_CHALLENGE_1); + int completionCount = progressTracker.getCompletionCount(FlagValidator.Module.RE_CHALLENGE_1); + String completionText = completionCount > 1 ? " (Completed " + completionCount + " times)" : ""; + + textResult.setText(R.string.reverse_engineering_1_success); + textResult.setTextColor(ContextCompat.getColor(requireContext(), R.color.success_green)); + textResult.setVisibility(View.VISIBLE); + + new AlertDialog.Builder(requireContext()) + .setTitle(" Success!") + .setMessage("Congratulations! You found the flag.\n\nFlag: " + userInput + completionText) + .setPositiveButton("OK", null) + .show(); + } else { + textResult.setText(R.string.reverse_engineering_1_failure); + textResult.setTextColor(ContextCompat.getColor(requireContext(), R.color.security_red)); + textResult.setVisibility(View.VISIBLE); + } + }); + + btnClear.setOnClickListener(v -> { + inputFlag.setText(""); + textResult.setVisibility(View.GONE); + }); + + return root; + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showVulnerabilityInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + // View bestPracticesSection = dialogView.findViewById(R.id.best_practices_section); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.lesson_intro); + // vulnerabilitiesText.setText(R.string.lesson_tools); + + hintsSection.setVisibility(View.GONE); + + // bestPracticesSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Reverse Engineering Challenge 1\nOWASP M9: Reverse Engineering") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + binding = null; + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/reverseengineering/ReverseEngineering1Model.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/reverseengineering/ReverseEngineering1Model.java new file mode 100644 index 000000000..951eccb3b --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/reverseengineering/ReverseEngineering1Model.java @@ -0,0 +1,29 @@ +package org.owasp.mobileshepherd.ui.challenges.reverseengineering; + +import android.util.Base64; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.owasp.mobileshepherd.utils.FlagValidator; + +public class ReverseEngineering1Model extends ViewModel { + + // Flag stored for static analysis discovery + private static final String ENCODED_SECRET = "U2hhZG93X0tleV9IYW5nc19CeV9UaHJlYWQ="; + + private final MutableLiveData mText; + + public ReverseEngineering1Model() { + mText = new MutableLiveData<>(); + mText.setValue("Challenge 1"); + } + + public LiveData getText() { + return mText; + } + + public boolean validateFlag(String input) { + return FlagValidator.validateFlag(FlagValidator.Module.RE_CHALLENGE_1, input); + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/securitymisconfig/SecurityMisconfigChallenge2Fragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/securitymisconfig/SecurityMisconfigChallenge2Fragment.java new file mode 100644 index 000000000..95a63f4ec --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/challenges/securitymisconfig/SecurityMisconfigChallenge2Fragment.java @@ -0,0 +1,183 @@ +package org.owasp.mobileshepherd.ui.challenges.securitymisconfig; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.core.content.ContextCompat; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.textfield.TextInputEditText; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +public class SecurityMisconfigChallenge2Fragment extends Fragment { + + private static final String TAG = "BackupChallenge"; + private static final String PREFS_NAME = "BackupChallengePrefs"; + + private String currentFlag = ""; + private TextInputEditText flagInput; + private TextView resultText; + private ProgressTracker progressTracker; + private boolean fabExpanded = false; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.fragment_security_misconfig_challenge2, container, false); + + progressTracker = new ProgressTracker(requireContext()); + + // Setup expandable FAB with command reference and OWASP link + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.SECURITY_MISCONFIG_CHALLENGE_2)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showVulnerabilityInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + flagInput = root.findViewById(R.id.flag_input); + resultText = root.findViewById(R.id.result_text); + + Button validateButton = root.findViewById(R.id.validate_button); + validateButton.setOnClickListener(v -> validateFlag()); + + // Fetch server flag, then store it in SharedPreferences (will be backed up) + FlagProvider.getFlag(requireContext(), FlagValidator.Module.SECURITY_MISCONFIG_CHALLENGE_2, flagValue -> { + currentFlag = flagValue; + storeSecretData(); + }); + + return root; + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showVulnerabilityInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.security_misconfig_intro); + // vulnerabilitiesText.setText(R.string.security_misconfig_vulnerabilities); + + hintsSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Security Misconfiguration - Backup\nOWASP M10: Extraneous Functionality") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + private void storeSecretData() { + SharedPreferences prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + + // Store some normal preferences + editor.putString("username", "admin"); + editor.putString("theme", "dark"); + editor.putString("language", "en"); + + // Store the flag - this will be in the backup! + editor.putString("secret_flag", currentFlag); + editor.putString("flag_hint", "Extract me with adb backup!"); + + editor.apply(); + + Log.d(TAG, "Secret data stored in SharedPreferences"); + Log.d(TAG, "Backup is enabled for this app - data can be extracted!"); + Log.d(TAG, "Hint: adb backup -f backup.ab -noapk org.owasp.mobileshepherd"); + } + + private void validateFlag() { + String userInput = flagInput.getText().toString().trim(); + + if (userInput.isEmpty()) { + resultText.setVisibility(View.VISIBLE); + resultText.setText("Please enter a flag"); + resultText.setTextColor(ContextCompat.getColor(requireContext(), R.color.card_warning_text)); + resultText.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.card_warning_bg)); + return; + } + + FlagValidator.validateFlag(requireContext(), FlagValidator.Module.SECURITY_MISCONFIG_CHALLENGE_2, + userInput, isValid -> { + resultText.setVisibility(View.VISIBLE); + if (isValid) { + progressTracker.markCompleted(FlagValidator.Module.SECURITY_MISCONFIG_CHALLENGE_2); + int completionCount = progressTracker.getCompletionCount(FlagValidator.Module.SECURITY_MISCONFIG_CHALLENGE_2); + String completionText = completionCount > 1 ? " (Completed " + completionCount + " times)" : ""; + + resultText.setText("\u2713 SUCCESS!\n\nYou successfully extracted the backup and found the flag in SharedPreferences!"); + resultText.setTextColor(ContextCompat.getColor(requireContext(), R.color.success_text)); + resultText.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.success_bg)); + + new AlertDialog.Builder(requireContext()) + .setTitle("\uD83C\uDF89 Success!") + .setMessage("Congratulations! You extracted data from the app backup.\n\nFlag: " + userInput + completionText) + .setPositiveButton("OK", null) + .show(); + } else { + resultText.setText("\u2717 INCORRECT\n\nThat's not the right flag. Try extracting the app backup using ADB."); + resultText.setTextColor(ContextCompat.getColor(requireContext(), R.color.error_text)); + resultText.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.error_bg)); + Log.d(TAG, "Incorrect flag attempt: " + userInput); + } + }); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/home/HomeFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/home/HomeFragment.java new file mode 100644 index 000000000..502bf75ae --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/home/HomeFragment.java @@ -0,0 +1,70 @@ +package org.owasp.mobileshepherd.ui.home; + +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import com.google.android.material.button.MaterialButton; +import org.owasp.mobileshepherd.LoginActivity; +import org.owasp.mobileshepherd.MainActivity; +import org.owasp.mobileshepherd.databinding.FragmentHomeBinding; +import org.owasp.mobileshepherd.utils.AuthManager; + +public class HomeFragment extends Fragment { + + private FragmentHomeBinding binding; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + + binding = FragmentHomeBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + updateAuthCard(); + + return root; + } + + @Override + public void onResume() { + super.onResume(); + // Refresh card state when returning to this screen (e.g. after sign-in/out) + updateAuthCard(); + } + + private void updateAuthCard() { + TextView statusText = binding.getRoot().findViewById(org.owasp.mobileshepherd.R.id.home_auth_status_text); + MaterialButton authButton = binding.getRoot().findViewById(org.owasp.mobileshepherd.R.id.home_auth_button); + if (statusText == null || authButton == null) return; + + if (AuthManager.isAuthenticated(requireContext())) { + String username = AuthManager.getUsername(requireContext()); + statusText.setText("Signed in as " + username + " — flags update from server"); + authButton.setText("Sign Out"); + authButton.setOnClickListener(v -> { + AuthManager.logout(requireContext()); + updateAuthCard(); + }); + } else { + statusText.setText("Offline mode — sign in for server-validated flags"); + authButton.setText("Sign In to Server"); + authButton.setOnClickListener(v -> { + Intent intent = new Intent(requireActivity(), LoginActivity.class); + intent.putExtra(LoginActivity.EXTRA_FROM_APP, true); + startActivity(intent); + }); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/InputValidationLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/InputValidationLessonFragment.java new file mode 100644 index 000000000..2c32cddbf --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/InputValidationLessonFragment.java @@ -0,0 +1,222 @@ +package org.owasp.mobileshepherd.ui.lessons; + +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; +import android.content.Intent; +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.core.content.ContextCompat; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentInputValidationLessonBinding; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +public class InputValidationLessonFragment extends Fragment { + + private FragmentInputValidationLessonBinding binding; + private static final String TAG = "DeepLinkLoader"; + private boolean fabExpanded = false; + private ProgressTracker progressTracker; + private String currentFlag = ""; + + private static final String ADMIN_URL = "https://admin.internal/dashboard"; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + binding = FragmentInputValidationLessonBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + progressTracker = new ProgressTracker(requireContext()); + FlagProvider.getFlag( + requireContext(), + FlagValidator.Module.INPUT_VALIDATION_LESSON, + flagValue -> currentFlag = flagValue); + + // Quick link buttons + binding.loadExampleButton.setOnClickListener(v -> + processDeepLink("https://example.com/welcome")); + + binding.loadTrustedButton.setOnClickListener(v -> + processDeepLink("https://trusted-site.com/home")); + + binding.loadOwaspButton.setOnClickListener(v -> + processDeepLink("https://owasp.org/about")); + + // Custom deep link + binding.openLinkButton.setOnClickListener(v -> { + String url = binding.urlInput.getText().toString().trim(); + if (url.isEmpty()) { + Toast.makeText(getContext(), "Please enter a URL", Toast.LENGTH_SHORT).show(); + return; + } + processDeepLink(url); + }); + + // Setup expandable FAB + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.INPUT_VALIDATION_LESSON)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showDetailedInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + return root; + } + + private void processDeepLink(String url) { + Log.d(TAG, "Processing deep link: myapp://open?url=" + url); + + // VULNERABLE: Weak URL validation + if (!isUrlAllowed(url)) { + showError("Security Error", "URL blocked: " + url + "\n\nOnly example.com and trusted-site.com domains are allowed."); + Log.w(TAG, "URL validation failed: " + url); + return; + } + + Log.i(TAG, "URL validation passed: " + url); + loadContent(url); + } + + /** + * VULNERABILITY: Uses contains() instead of proper domain validation + * Can be bypassed with: https://evil.com?ref=example.com + * Or: https://example.com.evil.com + * Or: https://evil.com#example.com + */ + private boolean isUrlAllowed(String url) { + // Weak validation - checks if trusted domain appears anywhere in URL + return url.contains("example.com") || url.contains("trusted-site.com") || url.contains("owasp.org"); + } + + private void loadContent(String url) { + String title; + String body; + int cardColor = getResources().getColor(R.color.card_bg); + + // Simulate loading different content based on URL + if (url.equals("https://example.com/welcome")) { + title = "Example.com - Welcome"; + body = "Welcome to Example.com!\n\nThis is safe, trusted content from an approved domain."; + + } else if (url.equals("https://trusted-site.com/home")) { + title = "Trusted Site - Home"; + body = "Trusted Site Homepage\n\nYou are viewing content from an approved source."; + + } else if (url.equals("https://owasp.org/about")) { + title = "OWASP.org - About"; + body = "About OWASP\n\nThe Open Web Application Security Project (OWASP) is a nonprofit foundation that works to improve the security of software.\n\nThis is approved security education content."; + + } else if (url.contains("admin.internal")) { + // Hidden admin content - only accessible via validation bypass! + String flag = currentFlag; + title = "Admin Dashboard"; + body = "ACCESS GRANTED\n\n" + + "You successfully bypassed the URL validation!\n\n" + + "The validation only checks if 'example.com' or 'trusted-site.com' appears " + + "anywhere in the URL string, instead of properly validating the domain.\n\n" + + "This allowed you to access restricted admin.internal content.\n\n" + + "FLAG: " + flag; + cardColor = ContextCompat.getColor(requireContext(), R.color.success_bg); + Log.i(TAG, "Admin content accessed via bypass!"); + + progressTracker.markCompleted(FlagValidator.Module.INPUT_VALIDATION_LESSON); + FlagValidator.validateFlag(requireContext(), FlagValidator.Module.INPUT_VALIDATION_LESSON, + currentFlag, correct -> Log.d(TAG, "Server submission: " + correct)); + + } else { + // Generic external content + title = "External Content Loaded"; + body = "URL: " + url + "\n\n" + + "This URL passed validation and content was loaded.\n\n" + + "Try to find the hidden admin panel at admin.internal domain..."; + } + + binding.contentCard.setCardBackgroundColor(cardColor); + binding.contentTitle.setText(title); + binding.contentBody.setText(body); + + Toast.makeText(getContext(), "Content loaded successfully", Toast.LENGTH_SHORT).show(); + } + + private void showError(String title, String message) { + binding.contentCard.setCardBackgroundColor(ContextCompat.getColor(requireContext(), R.color.error_bg)); + binding.contentTitle.setText(title); + binding.contentBody.setText(message); + + Toast.makeText(getContext(), "URL validation failed", Toast.LENGTH_SHORT).show(); + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showDetailedInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + TextView moduleBanner = dialogView.findViewById(R.id.module_path_banner); + if (moduleBanner != null) moduleBanner.setVisibility(View.VISIBLE); + if (moduleBanner != null) moduleBanner.setText("org.owasp.mobileshepherd.input_validation"); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + // View bestPracticesSection = dialogView.findViewById(R.id.best_practices_section); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.input_validation_lesson_intro); + // vulnerabilitiesText.setText(R.string.input_validation_lesson_vulnerabilities); + + hintsSection.setVisibility(View.GONE); + + // bestPracticesSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Input/Output Validation\nOWASP M4: Insufficient Input/Output Validation") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/LessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/LessonFragment.java new file mode 100644 index 000000000..64624ccd1 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/LessonFragment.java @@ -0,0 +1,258 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; + +/** + * Centralized flag validation system using SHA-256 hashes. + * Provides secure client-side validation and supports progress tracking. + */ +public class FlagValidator { + + private static final String TAG = "FlagValidator"; + + // Module type constants + public static final String TYPE_LESSON = "lesson"; + public static final String TYPE_CHALLENGE = "challenge"; + + // Module identifiers + public enum Module { + // Reverse Engineering + RE_LESSON("re_lesson", TYPE_LESSON), + RE_CHALLENGE_1("re_challenge_1", TYPE_CHALLENGE), + + // Insecure Data Storage + IDS_LESSON("ids_lesson", TYPE_LESSON), + IDS_CHALLENGE_1("ids_challenge_1", TYPE_CHALLENGE), + + // Poor Authentication + POOR_AUTH_LESSON("poor_auth_lesson", TYPE_LESSON), + POOR_AUTH_CHALLENGE("poor_auth_challenge", TYPE_CHALLENGE), + + // Insecure Authorization + INSECURE_AUTH_LESSON("insecure_auth_lesson", TYPE_LESSON), + + // Supply Chain + SUPPLY_CHAIN_LESSON("supply_chain_lesson", TYPE_LESSON), + + // Insecure Communication + INSECURE_COMM_LESSON("insecure_comm_lesson", TYPE_LESSON), + INSECURE_COMM_CHALLENGE("insecure_comm_challenge", TYPE_CHALLENGE), + + // Insufficient Cryptography + INSUFFICIENT_CRYPTO_LESSON("insufficient_crypto_lesson", TYPE_LESSON), + INSUFFICIENT_CRYPTO_CHALLENGE("insufficient_crypto_challenge", TYPE_CHALLENGE), + + // Security Misconfiguration + SECURITY_MISCONFIG_LESSON("security_misconfig_lesson", TYPE_LESSON), + SECURITY_MISCONFIG_CHALLENGE_2("security_misconfig_challenge_2", TYPE_CHALLENGE), + + // Input Validation + INPUT_VALIDATION_LESSON("input_validation_lesson", TYPE_LESSON), + + // Privacy Controls + PRIVACY_LESSON("privacy_lesson", TYPE_LESSON), + + // Client-Side Injection + CLIENT_SIDE_INJECTION_LESSON("client_side_injection_lesson", TYPE_LESSON), + CLIENT_SIDE_INJECTION_CHALLENGE_1("client_side_injection_challenge_1", TYPE_CHALLENGE), + CLIENT_SIDE_INJECTION_CHALLENGE_2("client_side_injection_challenge_2", TYPE_CHALLENGE); + + private final String id; + private final String type; + + Module(String id, String type) { + this.id = id; + this.type = type; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + } + + // RE_LESSON and RE_CHALLENGE_1 retain local hashes — the SHA-256 in the APK is the + // target of the reverse-engineering challenge itself. All other modules require a live + // server session; no offline fallback is provided. + private static final Map FLAG_HASHES = new HashMap() {{ + put(Module.RE_LESSON, "a0c066b9cd89c084709330a943fb6b333d45c932ed613e428bc52078b5722e57"); + put(Module.RE_CHALLENGE_1, "f04a272a2d82a0168f44559f9957dc9e028ecb13195c12fce17ac08d4af91deb"); + }}; + + /** + * Validates a flag submission using SHA-256 hash comparison. + * + * @param module The module being validated + * @param submittedFlag The flag submitted by the user + * @return true if the flag is correct, false otherwise + */ + public static boolean validateFlag(Module module, String submittedFlag) { + if (submittedFlag == null || submittedFlag.trim().isEmpty()) { + return false; + } + + String expectedHash = FLAG_HASHES.get(module); + if (expectedHash == null) { + Log.e(TAG, "No hash found for module: " + module.getId()); + return false; + } + + String submittedHash = sha256(submittedFlag.trim()); + boolean isValid = expectedHash.equalsIgnoreCase(submittedHash); + + Log.d(TAG, isValid ? "[OK] Correct flag for " + module.getId() + : "[FAIL] Incorrect flag for " + module.getId()); + return isValid; + } + + /** + * Computes SHA-256 hash of the input string. + * + * @param input The string to hash + * @return Hexadecimal representation of the hash + */ + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + // Convert bytes to hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "SHA-256 algorithm not available", e); + return ""; + } + } + + // ------------------------------------------------------------------------- + // Server-side validation + // ------------------------------------------------------------------------- + + /** + * Callback interface for asynchronous flag validation results. + * + *

{@link #onResult(boolean)} is always invoked on the main (UI) thread. + */ + public interface ValidationCallback { + void onResult(boolean correct); + } + + /** + * Validates a flag against the configured Shepherd server when a session is active. + * Falls back to local SHA-256 comparison when no session is available, + * so the app remains usable without a running server instance. + * + *

This method is non-blocking. The result is delivered on the main thread via + * {@code callback}. + * + * @param context Application context used to read shared preferences. + * @param module The module being validated. + * @param flag The flag string submitted by the student. + * @param callback Receives {@code true} when the flag is correct, {@code false} otherwise. + */ + public static void validateFlag( + Context context, + Module module, + String flag, + ValidationCallback callback) { + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String serverUrl = prefs.getString("server_preference", "").trim(); + String sessionCookie = AuthManager.getMobileSessionCookie(context); + + if (serverUrl.isEmpty() || sessionCookie.isEmpty()) { + // No active server session — only RE modules have offline hashes. + Log.d(TAG, "No server session — offline validation for " + module.getId()); + boolean result = validateFlag(module, flag); + new Handler(Looper.getMainLooper()).post(() -> callback.onResult(result)); + return; + } + + final String endpointUrl = serverUrl.replaceAll("/+$", "") + "/mobileFlagSubmit"; + final String moduleId = module.getId(); + final String trimmedFlag = flag.trim(); + final Handler mainHandler = new Handler(Looper.getMainLooper()); + + new Thread(() -> { + boolean correct = false; + HttpURLConnection conn = null; + try { + String body = + "moduleId=" + URLEncoder.encode(moduleId, "UTF-8") + + "&flag=" + URLEncoder.encode(trimmedFlag, "UTF-8"); + + conn = (HttpURLConnection) new URL(endpointUrl).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + conn.setRequestProperty("Cookie", sessionCookie); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + JSONObject json = new JSONObject(sb.toString()); + correct = json.optBoolean("correct", false); + Log.d(TAG, "Server validation for " + moduleId + ": " + correct); + } else { + Log.w(TAG, "Server returned HTTP " + status + " for " + moduleId); + correct = validateFlag(module, trimmedFlag); + } + } catch (Exception e) { + Log.e(TAG, "Server validation failed for " + moduleId + ": " + e.getMessage()); + correct = validateFlag(module, trimmedFlag); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + + final boolean result = correct; + mainHandler.post(() -> callback.onResult(result)); + }).start(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/LessonModel.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/LessonModel.java new file mode 100644 index 000000000..a1215a7cc --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/LessonModel.java @@ -0,0 +1,50 @@ +package org.owasp.mobileshepherd.ui.lessons; + +import android.os.Build; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +public class LessonModel extends ViewModel { + + private final MutableLiveData modelText; + private final MutableLiveData serialText; + private final MutableLiveData manufacturerText; + private final MutableLiveData brandText; + private final MutableLiveData sdkText; + + public LessonModel() { + serialText = new MutableLiveData<>(); + modelText = new MutableLiveData<>(); + manufacturerText = new MutableLiveData<>(); + brandText = new MutableLiveData<>(); + sdkText = new MutableLiveData<>(); + + // Set device info + serialText.setValue(Build.SERIAL); + modelText.setValue(Build.MODEL); + manufacturerText.setValue(Build.MANUFACTURER); + brandText.setValue(Build.BRAND); + sdkText.setValue(String.valueOf(Build.VERSION.SDK_INT)); + } + + public LiveData getSerialText() { + return serialText; + } + + public LiveData getModelText() { + return modelText; + } + + public LiveData getManufacturerText() { + return manufacturerText; + } + + public LiveData getBrandText() { + return brandText; + } + + public LiveData getSDKText() { + return sdkText; + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/clientsideinjection/ClientSideInjectionLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/clientsideinjection/ClientSideInjectionLessonFragment.java new file mode 100644 index 000000000..1b33197a5 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/clientsideinjection/ClientSideInjectionLessonFragment.java @@ -0,0 +1,274 @@ +package org.owasp.mobileshepherd.ui.lessons.clientsideinjection; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentClientSideInjectionLessonBinding; +import org.owasp.mobileshepherd.ui.lessons.clientsideinjection.helpers.DatabaseHelper; +import org.owasp.mobileshepherd.utils.AuthManager; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +public class ClientSideInjectionLessonFragment extends Fragment { + + private FragmentClientSideInjectionLessonBinding binding; + private DatabaseHelper dbHelper; + private static final String TAG = "ClientSideInjection"; + private boolean fabExpanded = false; + private ProgressTracker progressTracker; + + // The flag seeded into the SQLite DB. In offline mode this is the static + // plaintext value; in online mode FlagProvider replaces it with the + // server-generated user-specific HMAC after the view is created. + private String currentFlag = ""; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + binding = FragmentClientSideInjectionLessonBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + // Initialize database + dbHelper = new DatabaseHelper(requireContext()); + progressTracker = new ProgressTracker(requireContext()); + + // Seed with offline flag immediately so the lesson is usable right away, + // then asynchronously replace with the dynamic server flag if online. + FlagProvider.getFlag( + requireContext(), + FlagValidator.Module.CLIENT_SIDE_INJECTION_LESSON, + flag -> { + if (!isAdded()) return; + currentFlag = flag; + initializeDatabase(flag); + }); + + // Setup expandable FAB with command reference and OWASP link + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.CLIENT_SIDE_INJECTION_LESSON)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showDetailedInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + // Setup search button + binding.searchButton.setOnClickListener(v -> performSearch()); + + return root; + } + + private void initializeDatabase(String flagValue) { + SQLiteDatabase db = dbHelper.getWritableDatabase(); + + // Clear existing data + db.execSQL("DELETE FROM users"); + + // Insert sample data + insertUser(db, "admin", "admin@app.com", "Administrator", false); + insertUser(db, "alice", "alice@app.com", "Alice Smith", false); + insertUser(db, "bob", "bob@app.com", "Bob Jones", false); + insertUser(db, "charlie", "charlie@app.com", "Charlie Brown", false); + + // Insert hidden admin user with flag (obscure username) + insertUser(db, "sys_root", "root@system.internal", flagValue, true); + + db.close(); + } + + private void insertUser(SQLiteDatabase db, String username, String email, String fullName, boolean isAdmin) { + ContentValues values = new ContentValues(); + values.put("username", username); + values.put("email", email); + values.put("full_name", fullName); + values.put("is_admin", isAdmin ? 1 : 0); + db.insert("users", null, values); + } + + private void performSearch() { + String searchTerm = binding.searchInput.getText().toString(); + + if (searchTerm.isEmpty()) { + Toast.makeText(getContext(), "Please enter a search term", Toast.LENGTH_SHORT).show(); + return; + } + + // VULNERABLE: Concatenating user input directly into SQL query + String query = "SELECT username, email, full_name, is_admin FROM users WHERE username = '" + searchTerm + "'"; + + Log.d(TAG, "Executing query: " + query); + + SQLiteDatabase db = dbHelper.getReadableDatabase(); + Cursor cursor = null; + + try { + cursor = db.rawQuery(query, null); + + int count = 0; + LinearLayout container = binding.resultContainer; + container.removeAllViews(); + + while (cursor.moveToNext()) { + count++; + String username = cursor.getString(0); + String email = cursor.getString(1); + String fullName = cursor.getString(2); + int isAdmin = cursor.getInt(3); + + View row = LayoutInflater.from(requireContext()) + .inflate(R.layout.item_user_result, container, false); + ((TextView) row.findViewById(R.id.item_username)).setText(username); + ((TextView) row.findViewById(R.id.item_email)).setText(email); + ((TextView) row.findViewById(R.id.item_full_name)).setText( + fullName != null ? fullName : ""); + TextView adminView = row.findViewById(R.id.item_is_admin); + if (isAdmin == 1) { + adminView.setText("Yes"); + adminView.setTextColor(getResources().getColor( + android.R.color.holo_red_light, null)); + } else { + adminView.setText("No"); + adminView.setTextColor(getResources().getColor( + android.R.color.darker_gray, null)); + } + container.addView(row); + + // Check if this is the hidden flag row + if (fullName != null && !currentFlag.isEmpty() + && fullName.equals(currentFlag)) { + submitFlagToServer(fullName); + } + } + + if (count == 0) { + binding.resultEmptyText.setText("No users found matching: " + searchTerm); + binding.resultEmptyText.setVisibility(View.VISIBLE); + binding.resultContainer.setVisibility(View.GONE); + } else { + binding.resultEmptyText.setVisibility(View.GONE); + binding.resultContainer.setVisibility(View.VISIBLE); + } + + } catch (Exception e) { + binding.resultEmptyText.setText("Error: " + e.getMessage() + "\n\nTip: Check your SQL syntax!"); + binding.resultEmptyText.setVisibility(View.VISIBLE); + binding.resultContainer.setVisibility(View.GONE); + Log.e(TAG, "SQL Error", e); + } finally { + if (cursor != null) { + cursor.close(); + } + db.close(); + } + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showDetailedInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.client_side_injection_lesson_intro); + // vulnerabilitiesText.setText(R.string.client_side_injection_lesson_vulnerabilities); + + hintsSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Client-Side Injection Lesson\nOWASP M7: Client Code Quality") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + /** + * Submits the discovered flag to the Shepherd server for validation. + * Falls back to local SHA-256 comparison when no server is configured. + * Marks the lesson complete and updates the FAB appearance on success. + */ + private void submitFlagToServer(String flag) { + // In case the DB was seeded before FlagProvider returned, use the + // most recent flag value rather than the one passed by the search results. + String flagToSubmit = currentFlag.isEmpty() ? flag : currentFlag; + FlagValidator.validateFlag( + requireContext(), + FlagValidator.Module.CLIENT_SIDE_INJECTION_LESSON, + flagToSubmit, + correct -> { + if (!isAdded()) return; + if (correct) { + progressTracker.markCompleted(FlagValidator.Module.CLIENT_SIDE_INJECTION_LESSON); + new androidx.appcompat.app.AlertDialog.Builder(requireContext()) + .setTitle("Lesson Complete") + .setMessage("Correct flag validated! You have successfully demonstrated " + + "a client-side SQL injection attack.") + .setPositiveButton("OK", null) + .show(); + } else { + Toast.makeText(requireContext(), + "Flag incorrect — keep trying!", Toast.LENGTH_SHORT).show(); + } + }); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + if (dbHelper != null) { + dbHelper.close(); + } + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/clientsideinjection/helpers/DatabaseHelper.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/clientsideinjection/helpers/DatabaseHelper.java new file mode 100644 index 000000000..8ed215e87 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/clientsideinjection/helpers/DatabaseHelper.java @@ -0,0 +1,32 @@ +package org.owasp.mobileshepherd.ui.lessons.clientsideinjection.helpers; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +public class DatabaseHelper extends SQLiteOpenHelper { + + private static final String DATABASE_NAME = "client_injection.db"; + private static final int DATABASE_VERSION = 1; + + public DatabaseHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + String createTable = "CREATE TABLE users (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "username TEXT NOT NULL, " + + "email TEXT NOT NULL, " + + "full_name TEXT, " + + "is_admin INTEGER DEFAULT 0)"; + db.execSQL(createTable); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL("DROP TABLE IF EXISTS users"); + onCreate(db); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/crypto/InsufficientCryptoLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/crypto/InsufficientCryptoLessonFragment.java new file mode 100644 index 000000000..64624ccd1 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/crypto/InsufficientCryptoLessonFragment.java @@ -0,0 +1,258 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; + +/** + * Centralized flag validation system using SHA-256 hashes. + * Provides secure client-side validation and supports progress tracking. + */ +public class FlagValidator { + + private static final String TAG = "FlagValidator"; + + // Module type constants + public static final String TYPE_LESSON = "lesson"; + public static final String TYPE_CHALLENGE = "challenge"; + + // Module identifiers + public enum Module { + // Reverse Engineering + RE_LESSON("re_lesson", TYPE_LESSON), + RE_CHALLENGE_1("re_challenge_1", TYPE_CHALLENGE), + + // Insecure Data Storage + IDS_LESSON("ids_lesson", TYPE_LESSON), + IDS_CHALLENGE_1("ids_challenge_1", TYPE_CHALLENGE), + + // Poor Authentication + POOR_AUTH_LESSON("poor_auth_lesson", TYPE_LESSON), + POOR_AUTH_CHALLENGE("poor_auth_challenge", TYPE_CHALLENGE), + + // Insecure Authorization + INSECURE_AUTH_LESSON("insecure_auth_lesson", TYPE_LESSON), + + // Supply Chain + SUPPLY_CHAIN_LESSON("supply_chain_lesson", TYPE_LESSON), + + // Insecure Communication + INSECURE_COMM_LESSON("insecure_comm_lesson", TYPE_LESSON), + INSECURE_COMM_CHALLENGE("insecure_comm_challenge", TYPE_CHALLENGE), + + // Insufficient Cryptography + INSUFFICIENT_CRYPTO_LESSON("insufficient_crypto_lesson", TYPE_LESSON), + INSUFFICIENT_CRYPTO_CHALLENGE("insufficient_crypto_challenge", TYPE_CHALLENGE), + + // Security Misconfiguration + SECURITY_MISCONFIG_LESSON("security_misconfig_lesson", TYPE_LESSON), + SECURITY_MISCONFIG_CHALLENGE_2("security_misconfig_challenge_2", TYPE_CHALLENGE), + + // Input Validation + INPUT_VALIDATION_LESSON("input_validation_lesson", TYPE_LESSON), + + // Privacy Controls + PRIVACY_LESSON("privacy_lesson", TYPE_LESSON), + + // Client-Side Injection + CLIENT_SIDE_INJECTION_LESSON("client_side_injection_lesson", TYPE_LESSON), + CLIENT_SIDE_INJECTION_CHALLENGE_1("client_side_injection_challenge_1", TYPE_CHALLENGE), + CLIENT_SIDE_INJECTION_CHALLENGE_2("client_side_injection_challenge_2", TYPE_CHALLENGE); + + private final String id; + private final String type; + + Module(String id, String type) { + this.id = id; + this.type = type; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + } + + // RE_LESSON and RE_CHALLENGE_1 retain local hashes — the SHA-256 in the APK is the + // target of the reverse-engineering challenge itself. All other modules require a live + // server session; no offline fallback is provided. + private static final Map FLAG_HASHES = new HashMap() {{ + put(Module.RE_LESSON, "a0c066b9cd89c084709330a943fb6b333d45c932ed613e428bc52078b5722e57"); + put(Module.RE_CHALLENGE_1, "f04a272a2d82a0168f44559f9957dc9e028ecb13195c12fce17ac08d4af91deb"); + }}; + + /** + * Validates a flag submission using SHA-256 hash comparison. + * + * @param module The module being validated + * @param submittedFlag The flag submitted by the user + * @return true if the flag is correct, false otherwise + */ + public static boolean validateFlag(Module module, String submittedFlag) { + if (submittedFlag == null || submittedFlag.trim().isEmpty()) { + return false; + } + + String expectedHash = FLAG_HASHES.get(module); + if (expectedHash == null) { + Log.e(TAG, "No hash found for module: " + module.getId()); + return false; + } + + String submittedHash = sha256(submittedFlag.trim()); + boolean isValid = expectedHash.equalsIgnoreCase(submittedHash); + + Log.d(TAG, isValid ? "[OK] Correct flag for " + module.getId() + : "[FAIL] Incorrect flag for " + module.getId()); + return isValid; + } + + /** + * Computes SHA-256 hash of the input string. + * + * @param input The string to hash + * @return Hexadecimal representation of the hash + */ + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + // Convert bytes to hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "SHA-256 algorithm not available", e); + return ""; + } + } + + // ------------------------------------------------------------------------- + // Server-side validation + // ------------------------------------------------------------------------- + + /** + * Callback interface for asynchronous flag validation results. + * + *

{@link #onResult(boolean)} is always invoked on the main (UI) thread. + */ + public interface ValidationCallback { + void onResult(boolean correct); + } + + /** + * Validates a flag against the configured Shepherd server when a session is active. + * Falls back to local SHA-256 comparison when no session is available, + * so the app remains usable without a running server instance. + * + *

This method is non-blocking. The result is delivered on the main thread via + * {@code callback}. + * + * @param context Application context used to read shared preferences. + * @param module The module being validated. + * @param flag The flag string submitted by the student. + * @param callback Receives {@code true} when the flag is correct, {@code false} otherwise. + */ + public static void validateFlag( + Context context, + Module module, + String flag, + ValidationCallback callback) { + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String serverUrl = prefs.getString("server_preference", "").trim(); + String sessionCookie = AuthManager.getMobileSessionCookie(context); + + if (serverUrl.isEmpty() || sessionCookie.isEmpty()) { + // No active server session — only RE modules have offline hashes. + Log.d(TAG, "No server session — offline validation for " + module.getId()); + boolean result = validateFlag(module, flag); + new Handler(Looper.getMainLooper()).post(() -> callback.onResult(result)); + return; + } + + final String endpointUrl = serverUrl.replaceAll("/+$", "") + "/mobileFlagSubmit"; + final String moduleId = module.getId(); + final String trimmedFlag = flag.trim(); + final Handler mainHandler = new Handler(Looper.getMainLooper()); + + new Thread(() -> { + boolean correct = false; + HttpURLConnection conn = null; + try { + String body = + "moduleId=" + URLEncoder.encode(moduleId, "UTF-8") + + "&flag=" + URLEncoder.encode(trimmedFlag, "UTF-8"); + + conn = (HttpURLConnection) new URL(endpointUrl).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + conn.setRequestProperty("Cookie", sessionCookie); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + JSONObject json = new JSONObject(sb.toString()); + correct = json.optBoolean("correct", false); + Log.d(TAG, "Server validation for " + moduleId + ": " + correct); + } else { + Log.w(TAG, "Server returned HTTP " + status + " for " + moduleId); + correct = validateFlag(module, trimmedFlag); + } + } catch (Exception e) { + Log.e(TAG, "Server validation failed for " + moduleId + ": " + e.getMessage()); + correct = validateFlag(module, trimmedFlag); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + + final boolean result = correct; + mainHandler.post(() -> callback.onResult(result)); + }).start(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecureauthorization/InsecureAuthorizationLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecureauthorization/InsecureAuthorizationLessonFragment.java new file mode 100644 index 000000000..f6b8a7b24 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecureauthorization/InsecureAuthorizationLessonFragment.java @@ -0,0 +1,241 @@ +package org.owasp.mobileshepherd.ui.lessons.insecureauthorization; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentInsecureAuthorizationLessonBinding; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +public class InsecureAuthorizationLessonFragment extends Fragment { + + private FragmentInsecureAuthorizationLessonBinding binding; + private SharedPreferences prefs; + private static final String PREFS_NAME = "UserSession"; + private boolean fabExpanded = false; + private ProgressTracker progressTracker; + private String currentFlag = ""; + + // Demo credentials + private static final String DEMO_USERNAME = "testuser"; + private static final String DEMO_PASSWORD = "password123"; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + binding = FragmentInsecureAuthorizationLessonBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + prefs = requireContext().getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE); + progressTracker = new ProgressTracker(requireContext()); + FlagProvider.getFlag( + requireContext(), + FlagValidator.Module.INSECURE_AUTH_LESSON, + flagValue -> currentFlag = flagValue); + + // Setup expandable FAB with command reference and OWASP link + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.INSECURE_AUTH_LESSON)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showDetailedInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + // Check if already logged in + if (isLoggedIn()) { + showDashboard(); + } else { + showLoginForm(); + } + + // Log the vulnerability hint + Log.d("AuthVulnerability", "Authorization check uses client-side role from SharedPreferences"); + Log.d("AuthVulnerability", "Role key: 'user_role' - Values: 'user' or 'admin'"); + + return root; + } + + private void showLoginForm() { + binding.loginCard.setVisibility(View.VISIBLE); + binding.dashboardCard.setVisibility(View.GONE); + + Button loginButton = binding.loginButton; + EditText usernameInput = binding.usernameInput; + EditText passwordInput = binding.passwordInput; + + loginButton.setOnClickListener(v -> { + String username = usernameInput.getText().toString().trim(); + String password = passwordInput.getText().toString().trim(); + + if (username.equals(DEMO_USERNAME) && password.equals(DEMO_PASSWORD)) { + // Successful login - store session with basic user role + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean("is_logged_in", true); + editor.putString("username", username); + editor.putString("user_role", "user"); // Insecure: stored client-side! + editor.putLong("login_timestamp", System.currentTimeMillis()); + editor.apply(); + + Log.i("Authorization", "User logged in: " + username + " with role: user"); + Log.d("Authorization", "Session stored in SharedPreferences: " + + requireContext().getApplicationInfo().dataDir + "/shared_prefs/UserSession.xml"); + + Toast.makeText(getContext(), "Login successful!", Toast.LENGTH_SHORT).show(); + showDashboard(); + } else { + Toast.makeText(getContext(), "Invalid credentials. Try: testuser / password123", Toast.LENGTH_LONG).show(); + } + }); + } + + private void showDashboard() { + binding.loginCard.setVisibility(View.GONE); + binding.dashboardCard.setVisibility(View.VISIBLE); + + String username = prefs.getString("username", "User"); + String role = prefs.getString("user_role", "user"); + + binding.welcomeText.setText("Welcome, " + username + "!"); + binding.roleText.setText("Current Role: " + role); + + Log.d("Authorization", "Dashboard loaded for user: " + username + " (role: " + role + ")"); + + // Access Admin Panel button + binding.adminPanelButton.setOnClickListener(v -> { + accessAdminPanel(); + }); + + // Logout button + binding.logoutButton.setOnClickListener(v -> { + logout(); + }); + } + + private void accessAdminPanel() { + String role = prefs.getString("user_role", "user"); + + Log.d("Authorization", "Admin panel access attempt - Current role: " + role); + + // INSECURE: Authorization check relies on client-controlled value + if ("admin".equals(role)) { + // Admin access granted + String flag = currentFlag; + binding.adminContentCard.setVisibility(View.VISIBLE); + binding.flagText.setText("Admin Flag: " + flag); + binding.accessDeniedText.setVisibility(View.GONE); + + Toast.makeText(getContext(), "Admin access granted! Flag revealed!", Toast.LENGTH_LONG).show(); + Log.i("Authorization", "ADMIN ACCESS GRANTED - Flag revealed"); + + FlagValidator.validateFlag(requireContext(), FlagValidator.Module.INSECURE_AUTH_LESSON, + flag, correct -> { + if (!isAdded()) return; + if (correct) { + progressTracker.markCompleted(FlagValidator.Module.INSECURE_AUTH_LESSON); + } + }); + } else { + // Access denied + binding.adminContentCard.setVisibility(View.VISIBLE); + binding.flagText.setText(""); + binding.accessDeniedText.setVisibility(View.VISIBLE); + + Toast.makeText(getContext(), "Access Denied: Admin privileges required", Toast.LENGTH_SHORT).show(); + Log.w("Authorization", "Admin access DENIED for role: " + role); + } + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showDetailedInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + // View bestPracticesSection = dialogView.findViewById(R.id.best_practices_section); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.insecure_authorization_intro); + // vulnerabilitiesText.setText("• Client-side authorization stored in SharedPreferences\n• Privilege escalation by modifying local role data\n• No server-side validation of permissions"); + + hintsSection.setVisibility(View.GONE); + + // bestPracticesSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Insecure Authorization\nOWASP M3: Insecure Authentication/Authorization") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + private boolean isLoggedIn() { + return prefs.getBoolean("is_logged_in", false); + } + + private void logout() { + SharedPreferences.Editor editor = prefs.edit(); + editor.clear(); + editor.apply(); + + Toast.makeText(getContext(), "Logged out successfully", Toast.LENGTH_SHORT).show(); + showLoginForm(); + binding.adminContentCard.setVisibility(View.GONE); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecurecomm/InsecureCommLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecurecomm/InsecureCommLessonFragment.java new file mode 100644 index 000000000..64624ccd1 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecurecomm/InsecureCommLessonFragment.java @@ -0,0 +1,258 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; + +/** + * Centralized flag validation system using SHA-256 hashes. + * Provides secure client-side validation and supports progress tracking. + */ +public class FlagValidator { + + private static final String TAG = "FlagValidator"; + + // Module type constants + public static final String TYPE_LESSON = "lesson"; + public static final String TYPE_CHALLENGE = "challenge"; + + // Module identifiers + public enum Module { + // Reverse Engineering + RE_LESSON("re_lesson", TYPE_LESSON), + RE_CHALLENGE_1("re_challenge_1", TYPE_CHALLENGE), + + // Insecure Data Storage + IDS_LESSON("ids_lesson", TYPE_LESSON), + IDS_CHALLENGE_1("ids_challenge_1", TYPE_CHALLENGE), + + // Poor Authentication + POOR_AUTH_LESSON("poor_auth_lesson", TYPE_LESSON), + POOR_AUTH_CHALLENGE("poor_auth_challenge", TYPE_CHALLENGE), + + // Insecure Authorization + INSECURE_AUTH_LESSON("insecure_auth_lesson", TYPE_LESSON), + + // Supply Chain + SUPPLY_CHAIN_LESSON("supply_chain_lesson", TYPE_LESSON), + + // Insecure Communication + INSECURE_COMM_LESSON("insecure_comm_lesson", TYPE_LESSON), + INSECURE_COMM_CHALLENGE("insecure_comm_challenge", TYPE_CHALLENGE), + + // Insufficient Cryptography + INSUFFICIENT_CRYPTO_LESSON("insufficient_crypto_lesson", TYPE_LESSON), + INSUFFICIENT_CRYPTO_CHALLENGE("insufficient_crypto_challenge", TYPE_CHALLENGE), + + // Security Misconfiguration + SECURITY_MISCONFIG_LESSON("security_misconfig_lesson", TYPE_LESSON), + SECURITY_MISCONFIG_CHALLENGE_2("security_misconfig_challenge_2", TYPE_CHALLENGE), + + // Input Validation + INPUT_VALIDATION_LESSON("input_validation_lesson", TYPE_LESSON), + + // Privacy Controls + PRIVACY_LESSON("privacy_lesson", TYPE_LESSON), + + // Client-Side Injection + CLIENT_SIDE_INJECTION_LESSON("client_side_injection_lesson", TYPE_LESSON), + CLIENT_SIDE_INJECTION_CHALLENGE_1("client_side_injection_challenge_1", TYPE_CHALLENGE), + CLIENT_SIDE_INJECTION_CHALLENGE_2("client_side_injection_challenge_2", TYPE_CHALLENGE); + + private final String id; + private final String type; + + Module(String id, String type) { + this.id = id; + this.type = type; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + } + + // RE_LESSON and RE_CHALLENGE_1 retain local hashes — the SHA-256 in the APK is the + // target of the reverse-engineering challenge itself. All other modules require a live + // server session; no offline fallback is provided. + private static final Map FLAG_HASHES = new HashMap() {{ + put(Module.RE_LESSON, "a0c066b9cd89c084709330a943fb6b333d45c932ed613e428bc52078b5722e57"); + put(Module.RE_CHALLENGE_1, "f04a272a2d82a0168f44559f9957dc9e028ecb13195c12fce17ac08d4af91deb"); + }}; + + /** + * Validates a flag submission using SHA-256 hash comparison. + * + * @param module The module being validated + * @param submittedFlag The flag submitted by the user + * @return true if the flag is correct, false otherwise + */ + public static boolean validateFlag(Module module, String submittedFlag) { + if (submittedFlag == null || submittedFlag.trim().isEmpty()) { + return false; + } + + String expectedHash = FLAG_HASHES.get(module); + if (expectedHash == null) { + Log.e(TAG, "No hash found for module: " + module.getId()); + return false; + } + + String submittedHash = sha256(submittedFlag.trim()); + boolean isValid = expectedHash.equalsIgnoreCase(submittedHash); + + Log.d(TAG, isValid ? "[OK] Correct flag for " + module.getId() + : "[FAIL] Incorrect flag for " + module.getId()); + return isValid; + } + + /** + * Computes SHA-256 hash of the input string. + * + * @param input The string to hash + * @return Hexadecimal representation of the hash + */ + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + // Convert bytes to hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "SHA-256 algorithm not available", e); + return ""; + } + } + + // ------------------------------------------------------------------------- + // Server-side validation + // ------------------------------------------------------------------------- + + /** + * Callback interface for asynchronous flag validation results. + * + *

{@link #onResult(boolean)} is always invoked on the main (UI) thread. + */ + public interface ValidationCallback { + void onResult(boolean correct); + } + + /** + * Validates a flag against the configured Shepherd server when a session is active. + * Falls back to local SHA-256 comparison when no session is available, + * so the app remains usable without a running server instance. + * + *

This method is non-blocking. The result is delivered on the main thread via + * {@code callback}. + * + * @param context Application context used to read shared preferences. + * @param module The module being validated. + * @param flag The flag string submitted by the student. + * @param callback Receives {@code true} when the flag is correct, {@code false} otherwise. + */ + public static void validateFlag( + Context context, + Module module, + String flag, + ValidationCallback callback) { + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String serverUrl = prefs.getString("server_preference", "").trim(); + String sessionCookie = AuthManager.getMobileSessionCookie(context); + + if (serverUrl.isEmpty() || sessionCookie.isEmpty()) { + // No active server session — only RE modules have offline hashes. + Log.d(TAG, "No server session — offline validation for " + module.getId()); + boolean result = validateFlag(module, flag); + new Handler(Looper.getMainLooper()).post(() -> callback.onResult(result)); + return; + } + + final String endpointUrl = serverUrl.replaceAll("/+$", "") + "/mobileFlagSubmit"; + final String moduleId = module.getId(); + final String trimmedFlag = flag.trim(); + final Handler mainHandler = new Handler(Looper.getMainLooper()); + + new Thread(() -> { + boolean correct = false; + HttpURLConnection conn = null; + try { + String body = + "moduleId=" + URLEncoder.encode(moduleId, "UTF-8") + + "&flag=" + URLEncoder.encode(trimmedFlag, "UTF-8"); + + conn = (HttpURLConnection) new URL(endpointUrl).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + conn.setRequestProperty("Cookie", sessionCookie); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + JSONObject json = new JSONObject(sb.toString()); + correct = json.optBoolean("correct", false); + Log.d(TAG, "Server validation for " + moduleId + ": " + correct); + } else { + Log.w(TAG, "Server returned HTTP " + status + " for " + moduleId); + correct = validateFlag(module, trimmedFlag); + } + } catch (Exception e) { + Log.e(TAG, "Server validation failed for " + moduleId + ": " + e.getMessage()); + correct = validateFlag(module, trimmedFlag); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + + final boolean result = correct; + mainHandler.post(() -> callback.onResult(result)); + }).start(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecuredata/DataStorageDebugActivity.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecuredata/DataStorageDebugActivity.java new file mode 100644 index 000000000..3c6de0432 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecuredata/DataStorageDebugActivity.java @@ -0,0 +1,36 @@ +package org.owasp.mobileshepherd.ui.lessons.insecuredata; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.widget.TextView; +import androidx.appcompat.app.AppCompatActivity; +import org.owasp.mobileshepherd.R; + +public class DataStorageDebugActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Create simple layout + TextView textView = new TextView(this); + textView.setPadding(48, 48, 48, 48); + textView.setTextSize(14); + + // Read some stored data as demonstration + SharedPreferences prefs = getSharedPreferences("app_data", MODE_PRIVATE); + String userId = prefs.getString("user_id", "demo_user_12345"); + String apiToken = prefs.getString("api_token", "demo_token_abc123xyz789"); + + String message = " Data Storage Debug Panel\n\n" + + "[OK] This activity is SAFE (exported=\"false\")\n\n" + + "This internal debugging tool shows stored application data:\n\n" + + "User ID: " + userId + "\n" + + "API Token: " + apiToken + "\n\n" + + "Since this activity is NOT exported, only the app itself can access it.\n\n" + + "Learn more in the Insecure Data Storage lesson."; + + textView.setText(message); + setContentView(textView); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecuredata/InsecureDataLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecuredata/InsecureDataLessonFragment.java new file mode 100644 index 000000000..64624ccd1 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/insecuredata/InsecureDataLessonFragment.java @@ -0,0 +1,258 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; + +/** + * Centralized flag validation system using SHA-256 hashes. + * Provides secure client-side validation and supports progress tracking. + */ +public class FlagValidator { + + private static final String TAG = "FlagValidator"; + + // Module type constants + public static final String TYPE_LESSON = "lesson"; + public static final String TYPE_CHALLENGE = "challenge"; + + // Module identifiers + public enum Module { + // Reverse Engineering + RE_LESSON("re_lesson", TYPE_LESSON), + RE_CHALLENGE_1("re_challenge_1", TYPE_CHALLENGE), + + // Insecure Data Storage + IDS_LESSON("ids_lesson", TYPE_LESSON), + IDS_CHALLENGE_1("ids_challenge_1", TYPE_CHALLENGE), + + // Poor Authentication + POOR_AUTH_LESSON("poor_auth_lesson", TYPE_LESSON), + POOR_AUTH_CHALLENGE("poor_auth_challenge", TYPE_CHALLENGE), + + // Insecure Authorization + INSECURE_AUTH_LESSON("insecure_auth_lesson", TYPE_LESSON), + + // Supply Chain + SUPPLY_CHAIN_LESSON("supply_chain_lesson", TYPE_LESSON), + + // Insecure Communication + INSECURE_COMM_LESSON("insecure_comm_lesson", TYPE_LESSON), + INSECURE_COMM_CHALLENGE("insecure_comm_challenge", TYPE_CHALLENGE), + + // Insufficient Cryptography + INSUFFICIENT_CRYPTO_LESSON("insufficient_crypto_lesson", TYPE_LESSON), + INSUFFICIENT_CRYPTO_CHALLENGE("insufficient_crypto_challenge", TYPE_CHALLENGE), + + // Security Misconfiguration + SECURITY_MISCONFIG_LESSON("security_misconfig_lesson", TYPE_LESSON), + SECURITY_MISCONFIG_CHALLENGE_2("security_misconfig_challenge_2", TYPE_CHALLENGE), + + // Input Validation + INPUT_VALIDATION_LESSON("input_validation_lesson", TYPE_LESSON), + + // Privacy Controls + PRIVACY_LESSON("privacy_lesson", TYPE_LESSON), + + // Client-Side Injection + CLIENT_SIDE_INJECTION_LESSON("client_side_injection_lesson", TYPE_LESSON), + CLIENT_SIDE_INJECTION_CHALLENGE_1("client_side_injection_challenge_1", TYPE_CHALLENGE), + CLIENT_SIDE_INJECTION_CHALLENGE_2("client_side_injection_challenge_2", TYPE_CHALLENGE); + + private final String id; + private final String type; + + Module(String id, String type) { + this.id = id; + this.type = type; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + } + + // RE_LESSON and RE_CHALLENGE_1 retain local hashes — the SHA-256 in the APK is the + // target of the reverse-engineering challenge itself. All other modules require a live + // server session; no offline fallback is provided. + private static final Map FLAG_HASHES = new HashMap() {{ + put(Module.RE_LESSON, "a0c066b9cd89c084709330a943fb6b333d45c932ed613e428bc52078b5722e57"); + put(Module.RE_CHALLENGE_1, "f04a272a2d82a0168f44559f9957dc9e028ecb13195c12fce17ac08d4af91deb"); + }}; + + /** + * Validates a flag submission using SHA-256 hash comparison. + * + * @param module The module being validated + * @param submittedFlag The flag submitted by the user + * @return true if the flag is correct, false otherwise + */ + public static boolean validateFlag(Module module, String submittedFlag) { + if (submittedFlag == null || submittedFlag.trim().isEmpty()) { + return false; + } + + String expectedHash = FLAG_HASHES.get(module); + if (expectedHash == null) { + Log.e(TAG, "No hash found for module: " + module.getId()); + return false; + } + + String submittedHash = sha256(submittedFlag.trim()); + boolean isValid = expectedHash.equalsIgnoreCase(submittedHash); + + Log.d(TAG, isValid ? "[OK] Correct flag for " + module.getId() + : "[FAIL] Incorrect flag for " + module.getId()); + return isValid; + } + + /** + * Computes SHA-256 hash of the input string. + * + * @param input The string to hash + * @return Hexadecimal representation of the hash + */ + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + // Convert bytes to hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "SHA-256 algorithm not available", e); + return ""; + } + } + + // ------------------------------------------------------------------------- + // Server-side validation + // ------------------------------------------------------------------------- + + /** + * Callback interface for asynchronous flag validation results. + * + *

{@link #onResult(boolean)} is always invoked on the main (UI) thread. + */ + public interface ValidationCallback { + void onResult(boolean correct); + } + + /** + * Validates a flag against the configured Shepherd server when a session is active. + * Falls back to local SHA-256 comparison when no session is available, + * so the app remains usable without a running server instance. + * + *

This method is non-blocking. The result is delivered on the main thread via + * {@code callback}. + * + * @param context Application context used to read shared preferences. + * @param module The module being validated. + * @param flag The flag string submitted by the student. + * @param callback Receives {@code true} when the flag is correct, {@code false} otherwise. + */ + public static void validateFlag( + Context context, + Module module, + String flag, + ValidationCallback callback) { + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String serverUrl = prefs.getString("server_preference", "").trim(); + String sessionCookie = AuthManager.getMobileSessionCookie(context); + + if (serverUrl.isEmpty() || sessionCookie.isEmpty()) { + // No active server session — only RE modules have offline hashes. + Log.d(TAG, "No server session — offline validation for " + module.getId()); + boolean result = validateFlag(module, flag); + new Handler(Looper.getMainLooper()).post(() -> callback.onResult(result)); + return; + } + + final String endpointUrl = serverUrl.replaceAll("/+$", "") + "/mobileFlagSubmit"; + final String moduleId = module.getId(); + final String trimmedFlag = flag.trim(); + final Handler mainHandler = new Handler(Looper.getMainLooper()); + + new Thread(() -> { + boolean correct = false; + HttpURLConnection conn = null; + try { + String body = + "moduleId=" + URLEncoder.encode(moduleId, "UTF-8") + + "&flag=" + URLEncoder.encode(trimmedFlag, "UTF-8"); + + conn = (HttpURLConnection) new URL(endpointUrl).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + conn.setRequestProperty("Cookie", sessionCookie); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + JSONObject json = new JSONObject(sb.toString()); + correct = json.optBoolean("correct", false); + Log.d(TAG, "Server validation for " + moduleId + ": " + correct); + } else { + Log.w(TAG, "Server returned HTTP " + status + " for " + moduleId); + correct = validateFlag(module, trimmedFlag); + } + } catch (Exception e) { + Log.e(TAG, "Server validation failed for " + moduleId + ": " + e.getMessage()); + correct = validateFlag(module, trimmedFlag); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + + final boolean result = correct; + mainHandler.post(() -> callback.onResult(result)); + }).start(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/poorauth/PoorAuthLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/poorauth/PoorAuthLessonFragment.java new file mode 100644 index 000000000..ba6a8e144 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/poorauth/PoorAuthLessonFragment.java @@ -0,0 +1,239 @@ +package org.owasp.mobileshepherd.ui.lessons.poorauth; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.core.content.ContextCompat; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentPoorAuthLessonBinding; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +public class PoorAuthLessonFragment extends Fragment { + + private FragmentPoorAuthLessonBinding binding; + private static final String TAG = "PoorAuthLesson"; + // SHA-256 hash of "654321" + private static final String HARDCODED_PIN_HASH = "481f6cc0511143ccdd7e2d1b1b94faf0a700a8b49cd13922a70b5ae28acaa8c5"; + + // Obfuscated flag - "Taco_Snores_On_A_Couch" + // XOR encoded with key 0x42, then Base64 encoded, then split + private static final String[] F = { + "FiMhLR", "0RLC0w", "JzEdDS", "wdAx", + "0BLTch", "Kg", "==" + }; + private static final byte K = 0x42; // XOR key + + private int attemptCount = 0; + private boolean fabExpanded = false; + private ProgressTracker progressTracker; + private String currentFlag = ""; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + + binding = FragmentPoorAuthLessonBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + progressTracker = new ProgressTracker(requireContext()); + FlagProvider.getFlag( + requireContext(), + FlagValidator.Module.POOR_AUTH_LESSON, + flagValue -> currentFlag = flagValue); + + // Setup FAB expansion + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.POOR_AUTH_LESSON)); + } + + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showDetailedInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + + + // Set initial FAB appearance based on completion status + + // Log the hardcoded PIN hash (intentionally insecure for demonstration) + Log.d(TAG, "Initializing authentication system..."); + Log.d(TAG, "PIN hash configured: " + HARDCODED_PIN_HASH); + Log.d(TAG, "System ready. PIN verification enabled."); + + binding.verifyButton.setOnClickListener(v -> verifyPin()); + + return root; + } + + private void verifyPin() { + String enteredPin = binding.pinInput.getText().toString().trim(); + attemptCount++; + + if (enteredPin.isEmpty()) { + Toast.makeText(getContext(), "Please enter a PIN", Toast.LENGTH_SHORT).show(); + return; + } + + String enteredPinHash = hashPin(enteredPin); + + Log.d(TAG, "PIN verification attempt #" + attemptCount); + Log.d(TAG, "Entered PIN hash: " + enteredPinHash); + Log.d(TAG, "Expected PIN hash: " + HARDCODED_PIN_HASH); + + if (enteredPinHash != null && enteredPinHash.equals(HARDCODED_PIN_HASH)) { + // Successful authentication + String flag = currentFlag.isEmpty() ? d() : currentFlag; + + binding.demoCard.setCardBackgroundColor(ContextCompat.getColor(requireContext(), R.color.success_bg)); + binding.flagText.setText("\u2713 Authentication Successful!\n\nFlag: " + flag); + binding.flagText.setVisibility(View.VISIBLE); + + Toast.makeText(getContext(), "Access Granted! Flag revealed!", Toast.LENGTH_LONG).show(); + Log.d(TAG, "Authentication successful! Flag: " + flag); + + progressTracker.markCompleted(FlagValidator.Module.POOR_AUTH_LESSON); + FlagValidator.validateFlag(requireContext(), FlagValidator.Module.POOR_AUTH_LESSON, + flag, correct -> Log.d(TAG, "Server submission result: " + correct)); + + binding.verifyButton.setEnabled(false); + binding.pinInput.setEnabled(false); + } else { + // Failed authentication + binding.demoCard.setCardBackgroundColor(ContextCompat.getColor(requireContext(), R.color.error_bg)); + binding.flagText.setVisibility(View.GONE); + + Toast.makeText(getContext(), "Access Denied! Incorrect PIN", Toast.LENGTH_SHORT).show(); + Log.e(TAG, "Authentication failed. Invalid PIN provided."); + + binding.pinInput.setText(""); + } + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + // Decode obfuscated flag - resist static analysis + private String d() { + try { + StringBuilder sb = new StringBuilder(); + for (String p : F) { + sb.append(p); + } + + byte[] decoded = Base64.getDecoder().decode(sb.toString()); + + byte[] result = new byte[decoded.length]; + for (int i = 0; i < decoded.length; i++) { + result[i] = (byte) (decoded[i] ^ K); + } + + return new String(result, StandardCharsets.UTF_8); + } catch (Exception e) { + Log.e(TAG, "Flag decode error", e); + return "[DECODE_ERROR]"; + } + } + + private String hashPin(String pin) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(pin.getBytes(StandardCharsets.UTF_8)); + + // Convert byte array to hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "Error hashing PIN", e); + return null; + } + } + + private void showDetailedInfo() { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_lesson_info, null); + TextView moduleBanner = dialogView.findViewById(R.id.module_path_banner); + if (moduleBanner != null) moduleBanner.setVisibility(View.VISIBLE); + if (moduleBanner != null) moduleBanner.setText("com.owasp.poor_authentication"); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(getString(R.string.poor_auth_intro)); + // vulnerabilitiesText.setText(getString(R.string.poor_auth_vulnerabilities)); + + hintsSection.setVisibility(View.GONE); + + additionalSection.setVisibility(View.GONE); + + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setTitle("Poor Authentication\nOWASP M3: Insecure Authentication/Authorization"); + builder.setView(dialogView); + builder.setPositiveButton("Close", null); + builder.show(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + // Hide mini FABs when leaving fragment + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/privacycontrols/PrivacyControlsLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/privacycontrols/PrivacyControlsLessonFragment.java new file mode 100644 index 000000000..64624ccd1 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/privacycontrols/PrivacyControlsLessonFragment.java @@ -0,0 +1,258 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; + +/** + * Centralized flag validation system using SHA-256 hashes. + * Provides secure client-side validation and supports progress tracking. + */ +public class FlagValidator { + + private static final String TAG = "FlagValidator"; + + // Module type constants + public static final String TYPE_LESSON = "lesson"; + public static final String TYPE_CHALLENGE = "challenge"; + + // Module identifiers + public enum Module { + // Reverse Engineering + RE_LESSON("re_lesson", TYPE_LESSON), + RE_CHALLENGE_1("re_challenge_1", TYPE_CHALLENGE), + + // Insecure Data Storage + IDS_LESSON("ids_lesson", TYPE_LESSON), + IDS_CHALLENGE_1("ids_challenge_1", TYPE_CHALLENGE), + + // Poor Authentication + POOR_AUTH_LESSON("poor_auth_lesson", TYPE_LESSON), + POOR_AUTH_CHALLENGE("poor_auth_challenge", TYPE_CHALLENGE), + + // Insecure Authorization + INSECURE_AUTH_LESSON("insecure_auth_lesson", TYPE_LESSON), + + // Supply Chain + SUPPLY_CHAIN_LESSON("supply_chain_lesson", TYPE_LESSON), + + // Insecure Communication + INSECURE_COMM_LESSON("insecure_comm_lesson", TYPE_LESSON), + INSECURE_COMM_CHALLENGE("insecure_comm_challenge", TYPE_CHALLENGE), + + // Insufficient Cryptography + INSUFFICIENT_CRYPTO_LESSON("insufficient_crypto_lesson", TYPE_LESSON), + INSUFFICIENT_CRYPTO_CHALLENGE("insufficient_crypto_challenge", TYPE_CHALLENGE), + + // Security Misconfiguration + SECURITY_MISCONFIG_LESSON("security_misconfig_lesson", TYPE_LESSON), + SECURITY_MISCONFIG_CHALLENGE_2("security_misconfig_challenge_2", TYPE_CHALLENGE), + + // Input Validation + INPUT_VALIDATION_LESSON("input_validation_lesson", TYPE_LESSON), + + // Privacy Controls + PRIVACY_LESSON("privacy_lesson", TYPE_LESSON), + + // Client-Side Injection + CLIENT_SIDE_INJECTION_LESSON("client_side_injection_lesson", TYPE_LESSON), + CLIENT_SIDE_INJECTION_CHALLENGE_1("client_side_injection_challenge_1", TYPE_CHALLENGE), + CLIENT_SIDE_INJECTION_CHALLENGE_2("client_side_injection_challenge_2", TYPE_CHALLENGE); + + private final String id; + private final String type; + + Module(String id, String type) { + this.id = id; + this.type = type; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + } + + // RE_LESSON and RE_CHALLENGE_1 retain local hashes — the SHA-256 in the APK is the + // target of the reverse-engineering challenge itself. All other modules require a live + // server session; no offline fallback is provided. + private static final Map FLAG_HASHES = new HashMap() {{ + put(Module.RE_LESSON, "a0c066b9cd89c084709330a943fb6b333d45c932ed613e428bc52078b5722e57"); + put(Module.RE_CHALLENGE_1, "f04a272a2d82a0168f44559f9957dc9e028ecb13195c12fce17ac08d4af91deb"); + }}; + + /** + * Validates a flag submission using SHA-256 hash comparison. + * + * @param module The module being validated + * @param submittedFlag The flag submitted by the user + * @return true if the flag is correct, false otherwise + */ + public static boolean validateFlag(Module module, String submittedFlag) { + if (submittedFlag == null || submittedFlag.trim().isEmpty()) { + return false; + } + + String expectedHash = FLAG_HASHES.get(module); + if (expectedHash == null) { + Log.e(TAG, "No hash found for module: " + module.getId()); + return false; + } + + String submittedHash = sha256(submittedFlag.trim()); + boolean isValid = expectedHash.equalsIgnoreCase(submittedHash); + + Log.d(TAG, isValid ? "[OK] Correct flag for " + module.getId() + : "[FAIL] Incorrect flag for " + module.getId()); + return isValid; + } + + /** + * Computes SHA-256 hash of the input string. + * + * @param input The string to hash + * @return Hexadecimal representation of the hash + */ + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + // Convert bytes to hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "SHA-256 algorithm not available", e); + return ""; + } + } + + // ------------------------------------------------------------------------- + // Server-side validation + // ------------------------------------------------------------------------- + + /** + * Callback interface for asynchronous flag validation results. + * + *

{@link #onResult(boolean)} is always invoked on the main (UI) thread. + */ + public interface ValidationCallback { + void onResult(boolean correct); + } + + /** + * Validates a flag against the configured Shepherd server when a session is active. + * Falls back to local SHA-256 comparison when no session is available, + * so the app remains usable without a running server instance. + * + *

This method is non-blocking. The result is delivered on the main thread via + * {@code callback}. + * + * @param context Application context used to read shared preferences. + * @param module The module being validated. + * @param flag The flag string submitted by the student. + * @param callback Receives {@code true} when the flag is correct, {@code false} otherwise. + */ + public static void validateFlag( + Context context, + Module module, + String flag, + ValidationCallback callback) { + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String serverUrl = prefs.getString("server_preference", "").trim(); + String sessionCookie = AuthManager.getMobileSessionCookie(context); + + if (serverUrl.isEmpty() || sessionCookie.isEmpty()) { + // No active server session — only RE modules have offline hashes. + Log.d(TAG, "No server session — offline validation for " + module.getId()); + boolean result = validateFlag(module, flag); + new Handler(Looper.getMainLooper()).post(() -> callback.onResult(result)); + return; + } + + final String endpointUrl = serverUrl.replaceAll("/+$", "") + "/mobileFlagSubmit"; + final String moduleId = module.getId(); + final String trimmedFlag = flag.trim(); + final Handler mainHandler = new Handler(Looper.getMainLooper()); + + new Thread(() -> { + boolean correct = false; + HttpURLConnection conn = null; + try { + String body = + "moduleId=" + URLEncoder.encode(moduleId, "UTF-8") + + "&flag=" + URLEncoder.encode(trimmedFlag, "UTF-8"); + + conn = (HttpURLConnection) new URL(endpointUrl).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + conn.setRequestProperty("Cookie", sessionCookie); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + JSONObject json = new JSONObject(sb.toString()); + correct = json.optBoolean("correct", false); + Log.d(TAG, "Server validation for " + moduleId + ": " + correct); + } else { + Log.w(TAG, "Server returned HTTP " + status + " for " + moduleId); + correct = validateFlag(module, trimmedFlag); + } + } catch (Exception e) { + Log.e(TAG, "Server validation failed for " + moduleId + ": " + e.getMessage()); + correct = validateFlag(module, trimmedFlag); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + + final boolean result = correct; + mainHandler.post(() -> callback.onResult(result)); + }).start(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/securitymisconfig/MisconfigLessonSecretActivity.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/securitymisconfig/MisconfigLessonSecretActivity.java new file mode 100644 index 000000000..6f20cd977 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/securitymisconfig/MisconfigLessonSecretActivity.java @@ -0,0 +1,53 @@ +package org.owasp.mobileshepherd.ui.lessons.securitymisconfig; + +import android.os.Bundle; +import android.widget.TextView; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +public class MisconfigLessonSecretActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_misconfig_lesson_secret); + + // Mark lesson as complete when exploited component is accessed + ProgressTracker progressTracker = new ProgressTracker(this); + progressTracker.markCompleted(FlagValidator.Module.SECURITY_MISCONFIG_LESSON); + + TextView keyText = findViewById(R.id.secret_key_text); + TextView messageText = findViewById(R.id.secret_message_text); + + keyText.setText("Loading..."); + FlagProvider.getFlag(this, FlagValidator.Module.SECURITY_MISCONFIG_LESSON, + flagValue -> { + keyText.setText(flagValue); + FlagValidator.validateFlag(MisconfigLessonSecretActivity.this, + FlagValidator.Module.SECURITY_MISCONFIG_LESSON, + flagValue, + correct -> android.util.Log.d("MisconfigLesson", "Server submission: " + correct)); + }); + + String message = "[WARN] SECURITY VULNERABILITY EXPLOITED!\n\n" + + "This activity was marked as 'exported=\"true\"' in AndroidManifest.xml, " + + "allowing ANY app (or ADB command) to invoke it without permission checks.\n\n" + + "Attacker Command Used:\n" + + "adb shell am start -n org.owasp.mobileshepherd/.ui.lessons.securitymisconfig.MisconfigLessonSecretActivity\n\n" + + "In a real app, this could expose:\n" + + "• Administrative functions\n" + + "• Password reset screens\n" + + "• Data export utilities\n" + + "• Debug/testing features\n\n" + + "FIX: Set android:exported=\"false\" or add proper permission requirements!\n\n" + + "[OK] Lesson automatically marked as complete!"; + + messageText.setText(message); + + Toast.makeText(this, "[OK] Security Misconfiguration Lesson Complete!", Toast.LENGTH_LONG).show(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/securitymisconfig/SecurityMisconfigLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/securitymisconfig/SecurityMisconfigLessonFragment.java new file mode 100644 index 000000000..780a24e9e --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/securitymisconfig/SecurityMisconfigLessonFragment.java @@ -0,0 +1,353 @@ +package org.owasp.mobileshepherd.ui.lessons.securitymisconfig; + +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; + +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.core.content.ContextCompat; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +public class SecurityMisconfigLessonFragment extends Fragment { + + private static final String TAG = "SecurityMisconfig"; + private boolean fabExpanded = false; + private ProgressTracker progressTracker; + private String currentFlag = ""; + + private com.google.android.material.textfield.TextInputEditText flagInputView; + private TextView flagResultView; + + private TextView debugStatus; + private TextView backupStatus; + private TextView exportedStatus; + private TextView networkStatus; + private TextView permissionsStatus; + private TextView resultText; + private LinearLayout componentsContainer; + private View componentsCard; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.fragment_security_misconfig_lesson, container, false); + + progressTracker = new ProgressTracker(requireContext()); + + debugStatus = root.findViewById(R.id.debug_status); + backupStatus = root.findViewById(R.id.backup_status); + exportedStatus = root.findViewById(R.id.exported_status); + networkStatus = root.findViewById(R.id.network_status); + permissionsStatus = root.findViewById(R.id.permissions_status); + resultText = root.findViewById(R.id.result_text); + componentsContainer = root.findViewById(R.id.components_container); + componentsCard = root.findViewById(R.id.components_card); + + Button checkButton = root.findViewById(R.id.check_config_button); + checkButton.setOnClickListener(v -> checkConfiguration()); + + // Component scanning for exported component discovery + Button scanComponentsButton = root.findViewById(R.id.scan_components_button); + scanComponentsButton.setOnClickListener(v -> scanComponents()); + + // Flag submission + flagInputView = root.findViewById(R.id.flag_input_misconfig); + flagResultView = root.findViewById(R.id.flag_result_misconfig); + Button submitFlagButton = root.findViewById(R.id.submit_flag_misconfig); + if (submitFlagButton != null) { + submitFlagButton.setOnClickListener(v -> submitFlag()); + } + + // Fetch server flag (exposed via debug log during checkConfiguration) + FlagProvider.getFlag(requireContext(), FlagValidator.Module.SECURITY_MISCONFIG_LESSON, flagValue -> { + currentFlag = flagValue; + }); + + // Setup expandable FAB with command reference and OWASP link + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.SECURITY_MISCONFIG_LESSON)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showDetailedInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + return root; + } + + private void checkConfiguration() { + int issues = 0; + + // Check if app is debuggable + try { + ApplicationInfo appInfo = requireContext().getApplicationInfo(); + boolean isDebuggable = (appInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + + if (isDebuggable) { + debugStatus.setText(" Debug Mode: ENABLED (Insecure)"); + debugStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.error_text)); + issues++; + Log.w(TAG, "Security Issue: Debug mode is enabled in production"); + Log.w(TAG, "DEBUG_SECRET=" + currentFlag); + } else { + debugStatus.setText("[OK] Debug Mode: Disabled (Secure)"); + debugStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.success_text)); + } + } catch (Exception e) { + debugStatus.setText("Debug Mode: Error checking"); + } + + // Check backup flag + try { + ApplicationInfo appInfo = requireContext().getApplicationInfo(); + boolean allowBackup = (appInfo.flags & ApplicationInfo.FLAG_ALLOW_BACKUP) != 0; + + if (allowBackup) { + backupStatus.setText(" Backup Allowed: TRUE (Insecure)"); + backupStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.error_text)); + issues++; + Log.w(TAG, "Security Issue: Backup is allowed, app data can be extracted"); + } else { + backupStatus.setText("[OK] Backup Allowed: FALSE (Secure)"); + backupStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.success_text)); + } + } catch (Exception e) { + backupStatus.setText("Backup Allowed: Error checking"); + } + + // Check for exported components + try { + PackageManager pm = requireContext().getPackageManager(); + String packageName = requireContext().getPackageName(); + int exportedCount = 0; + + // This is a simplified check - in reality you'd enumerate activities, services, receivers + exportedStatus.setText(" Exported Components: Found (Potential Risk)"); + exportedStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.card_warning_text)); + Log.w(TAG, "Security Warning: Exported components detected - verify they require permissions"); + } catch (Exception e) { + exportedStatus.setText("Exported Components: Error checking"); + } + + // Check network security + networkStatus.setText(" Network Security: Cleartext Traffic Allowed"); + networkStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.error_text)); + issues++; + Log.w(TAG, "Security Issue: Cleartext traffic (HTTP) is allowed"); + + // Check permissions + permissionsStatus.setText("[OK] Permissions: Following Least Privilege"); + permissionsStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.success_text)); + + // Show summary + resultText.setVisibility(View.VISIBLE); + if (issues >= 3) { + resultText.setText(" CRITICAL: " + issues + " major security misconfigurations found!\n\nThis app is vulnerable to multiple attack vectors."); + resultText.setTextColor(ContextCompat.getColor(requireContext(), R.color.error_text)); + resultText.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.error_bg)); + } else if (issues > 0) { + resultText.setText(" WARNING: " + issues + " security issues detected.\n\nReview configuration settings."); + resultText.setTextColor(ContextCompat.getColor(requireContext(), R.color.card_warning_text)); + resultText.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.card_warning_bg)); + } else { + resultText.setText("[OK] SECURE: No major issues detected.\n\nConfiguration looks good!"); + resultText.setTextColor(ContextCompat.getColor(requireContext(), R.color.success_text)); + resultText.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.success_bg)); + } + + Log.i(TAG, "Configuration check complete. Issues found: " + issues); + } + + private void submitFlag() { + if (flagInputView == null || flagResultView == null) return; + String entered = flagInputView.getText() != null ? flagInputView.getText().toString().trim() : ""; + if (entered.isEmpty()) { + flagResultView.setVisibility(View.VISIBLE); + flagResultView.setText("Please enter a flag."); + flagResultView.setTextColor(ContextCompat.getColor(requireContext(), R.color.card_warning_text)); + return; + } + FlagValidator.validateFlag(requireContext(), FlagValidator.Module.SECURITY_MISCONFIG_LESSON, + entered, isValid -> { + flagResultView.setVisibility(View.VISIBLE); + if (isValid) { + progressTracker.markCompleted(FlagValidator.Module.SECURITY_MISCONFIG_LESSON); + int completionCount = progressTracker.getCompletionCount(FlagValidator.Module.SECURITY_MISCONFIG_LESSON); + String completionText = completionCount > 1 ? " (Completed " + completionCount + " times)" : ""; + flagResultView.setText("\u2713 Correct!"); + flagResultView.setTextColor(ContextCompat.getColor(requireContext(), R.color.success_text)); + new AlertDialog.Builder(requireContext()) + .setTitle("\uD83C\uDF89 Lesson Complete!") + .setMessage("You found the flag exposed in the debug logs.\n\nFlag: " + entered + completionText) + .setPositiveButton("OK", null) + .show(); + } else { + flagResultView.setText("\u2717 Incorrect. Run the config check and inspect logcat for the DEBUG_SECRET line."); + flagResultView.setTextColor(ContextCompat.getColor(requireContext(), R.color.error_text)); + flagInputView.setText(""); + } + }); + } + + private void scanComponents() { + componentsContainer.removeAllViews(); + + try { + PackageManager pm = requireContext().getPackageManager(); + String packageName = requireContext().getPackageName(); + PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES); + + ActivityInfo[] activities = packageInfo.activities; + int exportedCount = 0; + + if (activities != null) { + for (ActivityInfo activity : activities) { + boolean isExported = activity.exported; + if (isExported) { + exportedCount++; + } + + // Create view for each component + LinearLayout itemLayout = new LinearLayout(requireContext()); + itemLayout.setOrientation(LinearLayout.VERTICAL); + itemLayout.setPadding(12, 8, 12, 8); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.setMargins(0, 0, 0, 8); + itemLayout.setLayoutParams(params); + + // Status indicator and activity name + TextView nameView = new TextView(requireContext()); + String statusIcon = isExported ? "VULN" : "SAFE"; + String statusText = isExported ? "EXPORTED" : "Safe"; + String activityName = activity.name.replace("org.owasp.mobileshepherd.", ""); + + nameView.setText(statusIcon + " " + activityName); + nameView.setTextSize(12); + nameView.setTextColor(isExported ? + ContextCompat.getColor(requireContext(), R.color.error_text) : + ContextCompat.getColor(requireContext(), R.color.success_text)); + nameView.setTypeface(null, android.graphics.Typeface.BOLD); + itemLayout.addView(nameView); + + // Full path and label + TextView detailsView = new TextView(requireContext()); + String labelText = activity.loadLabel(pm).toString(); + detailsView.setText(" Label: " + labelText + "\n " + statusText); + detailsView.setTextSize(10); + detailsView.setTextColor(ContextCompat.getColor(requireContext(), R.color.text_secondary)); + detailsView.setTypeface(android.graphics.Typeface.MONOSPACE); + detailsView.setPadding(0, 4, 0, 0); + itemLayout.addView(detailsView); + + // Divider + View divider = new View(requireContext()); + LinearLayout.LayoutParams dividerParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 1 + ); + dividerParams.setMargins(0, 8, 0, 0); + divider.setLayoutParams(dividerParams); + divider.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.card_stroke)); + itemLayout.addView(divider); + + componentsContainer.addView(itemLayout); + } + + componentsCard.setVisibility(View.VISIBLE); + + android.widget.Toast.makeText(requireContext(), + "Found " + activities.length + " activities (" + exportedCount + " exported)", + android.widget.Toast.LENGTH_SHORT).show(); + + Log.i(TAG, "Component scan complete. Total: " + activities.length + ", Exported: " + exportedCount); + } + } catch (Exception e) { + android.widget.Toast.makeText(requireContext(), + "Error scanning components: " + e.getMessage(), + android.widget.Toast.LENGTH_SHORT).show(); + Log.e(TAG, "Error scanning components", e); + } + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showDetailedInfo() { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_lesson_info, null); + TextView moduleBanner = dialogView.findViewById(R.id.module_path_banner); + if (moduleBanner != null) moduleBanner.setVisibility(View.VISIBLE); + if (moduleBanner != null) moduleBanner.setText("com.owasp.security_misconfiguration"); + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + // View bestPracticesSection = dialogView.findViewById(R.id.best_practices_section); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(R.string.security_misconfig_intro); + // vulnerabilitiesText.setText(R.string.security_misconfig_vulnerabilities); + + // bestPracticesSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + new AlertDialog.Builder(requireContext()) + .setTitle("Security Misconfiguration\nOWASP M10: Extraneous Functionality") + .setView(dialogView) + .setPositiveButton("Close", null) + .show(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/supplychain/SupplyChainLessonFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/supplychain/SupplyChainLessonFragment.java new file mode 100644 index 000000000..ddafb21df --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/lessons/supplychain/SupplyChainLessonFragment.java @@ -0,0 +1,194 @@ +package org.owasp.mobileshepherd.ui.lessons.supplychain; + +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.core.content.ContextCompat; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentSupplyChainLessonBinding; +import org.owasp.mobileshepherd.utils.FlagProvider; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ModuleInfoHelper; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Intent; +import android.net.Uri; + +public class SupplyChainLessonFragment extends Fragment { + + private FragmentSupplyChainLessonBinding binding; + private static final String TAG = "SupplyChainLesson"; + + // Simulated vulnerability in androidx.exifinterface:exifinterface:1.3.7 + // CVE-2024-XXXX: Debug mode exposes sensitive EXIF data processing keys + private static final String VULNERABLE_LIB = "androidx.exifinterface:exifinterface:1.3.7"; + private static final String EXIF_DEBUG_KEY = "exif_debug_processor_key_1337"; + + private boolean fabExpanded = false; + private ProgressTracker progressTracker; + private String currentFlag = ""; + + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + + binding = FragmentSupplyChainLessonBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + progressTracker = new ProgressTracker(requireContext()); + FlagProvider.getFlag( + requireContext(), + FlagValidator.Module.SUPPLY_CHAIN_LESSON, + flagValue -> currentFlag = flagValue); + + // Setup expandable FAB with command reference and OWASP link + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + + if (fab != null) { + fab.setOnClickListener(v -> ModuleInfoHelper.showDialog(requireContext(), FlagValidator.Module.SUPPLY_CHAIN_LESSON)); + } + if (fabCommandRef != null) { + fabCommandRef.setOnClickListener(v -> { + showDetailedInfo(); + collapseFab(fab, fabCommandRef, fabOwaspLink); + }); + } + + + // Initialize vulnerable Analytics SDK - logs hardcoded API key + initializeAnalyticsSDK(); + + // Setup flag submission + binding.submitButton.setOnClickListener(v -> submitFlag()); + + return root; + } + + /** + * Simulates vulnerability in androidx.exifinterface:exifinterface:1.3.7 + * This version has a debug mode that exposes internal processing keys + */ + private void initializeAnalyticsSDK() { + Log.d(TAG, "================================================="); + Log.d(TAG, "ExifInterface Library Initialization"); + Log.d(TAG, "================================================="); + Log.d(TAG, "Library: " + VULNERABLE_LIB); + Log.d(TAG, "Debug Mode: ENABLED"); + Log.d(TAG, "Initializing EXIF data processor..."); + Log.d(TAG, "Loading debug configuration..."); + Log.d(TAG, "Debug processor key: " + EXIF_DEBUG_KEY); + Log.d(TAG, "EXIF parser ready"); + Log.d(TAG, "Warning: Debug mode should be disabled in production"); + Log.d(TAG, "================================================="); + } + + private void submitFlag() { + String enteredFlag = binding.flagInput.getText().toString().trim(); + + if (enteredFlag.isEmpty()) { + Toast.makeText(getContext(), "Please enter a flag", Toast.LENGTH_SHORT).show(); + return; + } + + binding.submitButton.setEnabled(false); + + FlagValidator.validateFlag( + requireContext(), + FlagValidator.Module.SUPPLY_CHAIN_LESSON, + enteredFlag, + correct -> { + if (correct) { + binding.submissionCard.setCardBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.success_bg)); + binding.resultText.setText( + "SUCCESS!\n\nFlag: " + enteredFlag + "\n\nYou successfully identified" + + " the vulnerable dependency (" + VULNERABLE_LIB + ") which had" + + " debug mode enabled, exposing internal processing keys.\n\n" + + "OWASP Mobile Top 10 M6: Insufficient Supply Chain Security"); + binding.resultText.setVisibility(View.VISIBLE); + Toast.makeText(getContext(), "Correct flag! Lesson completed!", Toast.LENGTH_LONG).show(); + progressTracker.markCompleted(FlagValidator.Module.SUPPLY_CHAIN_LESSON); + binding.flagInput.setEnabled(false); + } else { + binding.submitButton.setEnabled(true); + binding.submissionCard.setCardBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.error_bg)); + binding.resultText.setVisibility(View.GONE); + Toast.makeText(getContext(), "Incorrect flag", Toast.LENGTH_SHORT).show(); + binding.flagInput.setText(""); + binding.getRoot().postDelayed(() -> + binding.submissionCard.setCardBackgroundColor( + getResources().getColor(R.color.card_bg)), 2000); + } + }); + } + + private void toggleFabExpansion(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = !fabExpanded; + if (fabExpanded) { + if (fab1 != null) fab1.setVisibility(View.VISIBLE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_close_clear_cancel); + } else { + collapseFab(mainFab, fab1, fab2); + } + } + + private void collapseFab(FloatingActionButton mainFab, FloatingActionButton fab1, FloatingActionButton fab2) { + fabExpanded = false; + if (fab1 != null) fab1.setVisibility(View.GONE); + if (fab2 != null) fab2.setVisibility(View.GONE); + if (mainFab != null) mainFab.setImageResource(android.R.drawable.ic_menu_help); + } + + + private void showDetailedInfo() { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_lesson_info, null); + TextView moduleBanner = dialogView.findViewById(R.id.module_path_banner); + if (moduleBanner != null) { moduleBanner.setVisibility(View.VISIBLE); moduleBanner.setText("com.owasp.supply_chain"); } + + TextView introText = dialogView.findViewById(R.id.intro_text); + // TextView vulnerabilitiesText = dialogView.findViewById(R.id.vulnerabilities_text); + View hintsSection = dialogView.findViewById(R.id.hints_section); + TextView hintsText = dialogView.findViewById(R.id.hints_text); + // View bestPracticesSection = dialogView.findViewById(R.id.best_practices_section); + View additionalSection = dialogView.findViewById(R.id.additional_section); + + introText.setText(getString(R.string.supply_chain_intro)); + // vulnerabilitiesText.setText(getString(R.string.supply_chain_vulnerabilities)); + + hintsSection.setVisibility(View.GONE); + + // bestPracticesSection.setVisibility(View.GONE); + additionalSection.setVisibility(View.GONE); + + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setTitle("Supply Chain Security\nOWASP M6: Insufficient Supply Chain Security"); + builder.setView(dialogView); + builder.setPositiveButton("Close", null); + builder.show(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + FloatingActionButton fab = requireActivity().findViewById(R.id.fab); + FloatingActionButton fabCommandRef = requireActivity().findViewById(R.id.fab_command_reference); + FloatingActionButton fabOwaspLink = requireActivity().findViewById(R.id.fab_owasp_link); + collapseFab(fab, fabCommandRef, fabOwaspLink); + binding = null; + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/progress/ProgressFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/progress/ProgressFragment.java new file mode 100644 index 000000000..98c1723d9 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/progress/ProgressFragment.java @@ -0,0 +1,216 @@ +package org.owasp.mobileshepherd.ui.progress; + +import android.content.res.ColorStateList; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.databinding.FragmentProgressBinding; +import org.owasp.mobileshepherd.utils.FlagValidator; +import org.owasp.mobileshepherd.utils.ProgressTracker; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class ProgressFragment extends Fragment { + + private FragmentProgressBinding binding; + private ProgressTracker progressTracker; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + binding = FragmentProgressBinding.inflate(inflater, container, false); + View root = binding.getRoot(); + + progressTracker = new ProgressTracker(requireContext()); + + setupProgressOverview(); + setupModuleList(); + + return root; + } + + private void setupProgressOverview() { + int totalModules = FlagValidator.Module.values().length; + int completedModules = progressTracker.getTotalCompletedCount(); + int completionPercentage = progressTracker.getCompletionPercentage(); + + int completedChallenges = progressTracker.getCompletedChallengesCount(); + int totalChallenges = progressTracker.getTotalChallengesCount(); + + int completedLessons = progressTracker.getCompletedLessonsCount(); + int totalLessons = progressTracker.getTotalLessonsCount(); + + binding.overallProgress.setProgress(completionPercentage); + binding.overallPercentageText.setText(completionPercentage + "%"); + binding.completedCountText.setText(completedModules + " / " + totalModules + " modules"); + + binding.challengesProgressText.setText(completedChallenges + " / " + totalChallenges + " completed"); + binding.lessonsProgressText.setText(completedLessons + " / " + totalLessons + " completed"); + + if (completionPercentage == 100) { + binding.congratulationsText.setVisibility(View.VISIBLE); + } + } + + private void setupModuleList() { + List modules = new ArrayList<>(); + + for (FlagValidator.Module module : FlagValidator.Module.values()) { + boolean isCompleted = progressTracker.isCompleted(module); + long completionTime = progressTracker.getFirstCompletionTime(module); + int completionCount = progressTracker.getCompletionCount(module); + + modules.add(new ModuleItem( + module, + getModuleDisplayName(module), + module.getType(), + isCompleted, + completionTime, + completionCount + )); + } + + ModuleAdapter adapter = new ModuleAdapter(modules); + binding.moduleRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + binding.moduleRecyclerView.setAdapter(adapter); + } + + private String getModuleDisplayName(FlagValidator.Module module) { + // Convert enum name to readable format + String name = module.name().replace("_", " "); + StringBuilder result = new StringBuilder(); + for (String word : name.split(" ")) { + if (!word.isEmpty()) { + result.append(Character.toUpperCase(word.charAt(0))) + .append(word.substring(1).toLowerCase()) + .append(" "); + } + } + return result.toString().trim(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + // Data class for module items + static class ModuleItem { + FlagValidator.Module module; + String displayName; + String type; + boolean isCompleted; + long completionTime; + int completionCount; + + ModuleItem(FlagValidator.Module module, String displayName, String type, + boolean isCompleted, long completionTime, int completionCount) { + this.module = module; + this.displayName = displayName; + this.type = type; + this.isCompleted = isCompleted; + this.completionTime = completionTime; + this.completionCount = completionCount; + } + } + + // RecyclerView Adapter + static class ModuleAdapter extends RecyclerView.Adapter { + private final List modules; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy", Locale.US); + + ModuleAdapter(List modules) { + this.modules = modules; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_module_progress, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + ModuleItem item = modules.get(position); + + holder.nameText.setText(item.displayName); + holder.typeText.setText(item.type.toUpperCase()); + + if (item.isCompleted) { + holder.statusIcon.setImageResource(R.drawable.ic_status_complete); + holder.statusIcon.setImageTintList(ColorStateList.valueOf( + holder.itemView.getContext().getColor(R.color.success_green))); + holder.statusIcon.setContentDescription("Completed"); + holder.statusText.setText("Completed"); + holder.statusText.setTextColor(holder.itemView.getContext() + .getColor(R.color.success_green)); + + String completionInfo = dateFormat.format(new Date(item.completionTime)); + if (item.completionCount > 1) { + completionInfo += " (" + item.completionCount + "x)"; + } + holder.dateText.setText(completionInfo); + holder.dateText.setVisibility(View.VISIBLE); + } else { + holder.statusIcon.setImageResource(R.drawable.ic_status_incomplete); + holder.statusIcon.setImageTintList(ColorStateList.valueOf( + holder.itemView.getContext().getColor(R.color.text_secondary))); + holder.statusIcon.setContentDescription( + holder.itemView.getContext().getString(R.string.not_started)); + holder.statusText.setText("Not Started"); + holder.statusText.setTextColor(holder.itemView.getContext() + .getColor(R.color.text_secondary)); + holder.dateText.setVisibility(View.GONE); + } + + // Set background for challenge vs lesson + if (item.type.equals(FlagValidator.TYPE_CHALLENGE)) { + holder.typeText.setBackgroundColor(holder.itemView.getContext() + .getColor(R.color.challenge_badge)); + } else { + holder.typeText.setBackgroundColor(holder.itemView.getContext() + .getColor(R.color.lesson_badge)); + } + } + + @Override + public int getItemCount() { + return modules.size(); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + TextView nameText; + TextView typeText; + ImageView statusIcon; + TextView statusText; + TextView dateText; + + ViewHolder(View itemView) { + super(itemView); + nameText = itemView.findViewById(R.id.module_name); + typeText = itemView.findViewById(R.id.module_type); + statusIcon = (ImageView) itemView.findViewById(R.id.status_icon); + statusText = itemView.findViewById(R.id.status_text); + dateText = itemView.findViewById(R.id.completion_date); + } + } + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/scoreboard/ScoreboardFragment.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/scoreboard/ScoreboardFragment.java new file mode 100644 index 000000000..3ce99084a --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/ui/scoreboard/ScoreboardFragment.java @@ -0,0 +1,173 @@ +package org.owasp.mobileshepherd.ui.scoreboard; + +import android.annotation.SuppressLint; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.CookieManager; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.ProgressBar; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import com.google.android.material.button.MaterialButton; +import org.owasp.mobileshepherd.MainActivity; +import org.owasp.mobileshepherd.R; +import org.owasp.mobileshepherd.utils.AuthManager; + +public class ScoreboardFragment extends Fragment { + + private WebView webView; + private ProgressBar progressBar; + private View offlineView; + + @SuppressLint("SetJavaScriptEnabled") + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + ViewGroup container, Bundle savedInstanceState) { + + View root = inflater.inflate(R.layout.fragment_scoreboard, container, false); + + webView = root.findViewById(R.id.scoreboard_webview); + progressBar = root.findViewById(R.id.scoreboard_progress); + offlineView = root.findViewById(R.id.scoreboard_offline_view); + + MaterialButton signInButton = root.findViewById(R.id.scoreboard_sign_in_button); + signInButton.setOnClickListener(v -> { + if (getActivity() instanceof MainActivity) { + ((MainActivity) getActivity()).openAuthDialog(); + } + }); + + if (AuthManager.isAuthenticated(requireContext())) { + setupWebView(); + loadWithCachedOrFreshSession(); + } else { + offlineView.setVisibility(View.VISIBLE); + } + + return root; + } + + @SuppressLint("SetJavaScriptEnabled") + private void setupWebView() { + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setLoadWithOverviewMode(true); + settings.setUseWideViewPort(true); + settings.setBuiltInZoomControls(true); + settings.setDisplayZoomControls(false); + // Fix 4: deny access to local files and content providers from the WebView + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + + String serverUrl = AuthManager.getServerUrl(requireContext()).replaceAll("/+$", ""); + String serverHost = Uri.parse(serverUrl).getHost(); + + webView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + Uri requestedUri = request.getUrl(); + String requestedHost = requestedUri.getHost(); + + // Fix 3: block navigation to any host other than the configured server + if (requestedHost == null || !requestedHost.equals(serverHost)) { + return true; // block + } + + // Detect session expiry — server redirects back to login.jsp + String path = requestedUri.getPath(); + if (path != null && (path.endsWith("login.jsp") || path.endsWith("index.jsp"))) { + // Session expired: clear cache and re-authenticate silently + AuthManager.clearWebSessionCookie(requireContext()); + CookieManager.getInstance().removeAllCookies(null); + loginAndLoad(); + return true; // we handle the navigation + } + + return false; + } + }); + + webView.setWebChromeClient(new WebChromeClient() { + @Override + public void onProgressChanged(WebView view, int newProgress) { + if (progressBar != null) { + progressBar.setVisibility(newProgress < 100 ? View.VISIBLE : View.GONE); + } + } + }); + } + + /** + * Uses the cached web session cookie if one exists; otherwise falls back to the mobile + * session cookie (same server-side session) and caches it as the web cookie for reuse. + */ + private void loadWithCachedOrFreshSession() { + String cached = AuthManager.getWebSessionCookie(requireContext()); + if (!cached.isEmpty()) { + injectCookieAndLoad(cached); + } else { + loginAndLoad(); + } + } + + /** + * Uses the mobile session cookie (obtained at login) for the scoreboard WebView. + * Caches it as the web session cookie to avoid repeated lookups. + */ + private void loginAndLoad() { + offlineView.setVisibility(View.GONE); + progressBar.setVisibility(View.VISIBLE); + + String mobileCookie = AuthManager.getMobileSessionCookie(requireContext()); + if (!mobileCookie.isEmpty()) { + AuthManager.saveWebSessionCookie(requireContext(), mobileCookie); + } + injectCookieAndLoad(mobileCookie); + } + + private void injectCookieAndLoad(String cookie) { + String serverUrl = AuthManager.getServerUrl(requireContext()).replaceAll("/+$", ""); + + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.setAcceptCookie(true); + if (!cookie.isEmpty()) { + for (String pair : cookie.split(";\\s*")) { + cookieManager.setCookie(serverUrl, pair.trim()); + } + cookieManager.flush(); + } + + offlineView.setVisibility(View.GONE); + webView.setVisibility(View.VISIBLE); + webView.loadUrl(serverUrl + "/scoreboard.jsp"); + } + + @Override + public void onResume() { + super.onResume(); + if (AuthManager.isAuthenticated(requireContext())) { + if (webView.getVisibility() != View.VISIBLE) { + setupWebView(); + } + loadWithCachedOrFreshSession(); + } + } + + @Override + public void onDestroyView() { + if (webView != null) { + webView.destroy(); + } + super.onDestroyView(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/AuthManager.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/AuthManager.java new file mode 100644 index 000000000..59a13fbaf --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/AuthManager.java @@ -0,0 +1,286 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * Manages online/offline authentication state for the Security Shepherd mobile app. + * + *

When a server URL, username, and password are configured in the app preferences and a + * successful login has been completed, the app operates in online mode: flag values are + * fetched from the server as user-specific dynamic strings, and completion is validated + * server-side. + * + *

When no credentials are configured, or when the user explicitly logs out, the app operates in + * offline mode: static flag values are used and progress is tracked only on-device. + */ +public class AuthManager { + + private static final String TAG = "AuthManager"; + + /** + * Preference key used to persist the authenticated state. Cleared on logout. The username, + * password and server URL are stored under their own preference keys so they are also visible + * in the Settings screen. + */ + private static final String PREF_AUTH_LOGGED_IN = "auth_logged_in"; + /** Preference key for the cached web JSESSIONID used by the scoreboard WebView. */ + private static final String PREF_WEB_SESSION_COOKIE = "auth_web_session_cookie"; + /** Preference key for the JSESSIONID captured at mobile login, used in API requests. */ + private static final String PREF_MOBILE_SESSION_COOKIE = "auth_mobile_session_cookie"; + + public interface AuthCallback { + /** Always invoked on the main thread. */ + void onResult(boolean success, String message); + } + + // ------------------------------------------------------------------------- + // State queries + // ------------------------------------------------------------------------- + + /** Returns {@code true} when a session cookie is held AND a successful login has been recorded. */ + public static boolean isAuthenticated(Context ctx) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx); + return prefs.getBoolean(PREF_AUTH_LOGGED_IN, false) + && !getServerUrl(ctx).isEmpty() + && !getUsername(ctx).isEmpty() + && !getMobileSessionCookie(ctx).isEmpty(); + } + + public static String getServerUrl(Context ctx) { + return PreferenceManager.getDefaultSharedPreferences(ctx) + .getString("server_preference", "").trim(); + } + + /** Returns the username of the currently signed-in user, or empty string if not signed in. */ + public static String getUsername(Context ctx) { + return PreferenceManager.getDefaultSharedPreferences(ctx) + .getString("username_preference", "").trim(); + } + + /** + * Returns the JSESSIONID captured during the last successful mobile login, formatted as + * a ready-to-use Cookie header value (e.g. {@code "JSESSIONID=abc123"}), or empty string + * if not yet obtained. + */ + public static String getMobileSessionCookie(Context ctx) { + return PreferenceManager.getDefaultSharedPreferences(ctx) + .getString(PREF_MOBILE_SESSION_COOKIE, ""); + } + + /** + * Returns the cached web JSESSIONID cookie string (name=value) for use in the scoreboard + * WebView, or empty string if not yet obtained. + */ + public static String getWebSessionCookie(Context ctx) { + return PreferenceManager.getDefaultSharedPreferences(ctx) + .getString(PREF_WEB_SESSION_COOKIE, ""); + } + + /** Persists a freshly-obtained web session cookie. Called by ScoreboardFragment. */ + public static void saveWebSessionCookie(Context ctx, String cookie) { + PreferenceManager.getDefaultSharedPreferences(ctx).edit() + .putString(PREF_WEB_SESSION_COOKIE, cookie) + .apply(); + } + + /** Clears the cached web session cookie (e.g. on confirmed session expiry). */ + public static void clearWebSessionCookie(Context ctx) { + PreferenceManager.getDefaultSharedPreferences(ctx).edit() + .remove(PREF_WEB_SESSION_COOKIE) + .apply(); + } + + // ------------------------------------------------------------------------- + // Auth operations + // ------------------------------------------------------------------------- + + /** + * Attempts to authenticate against the Shepherd server. On success, stores the supplied + * credentials in the default shared preferences and sets the logged-in flag. + * + * @param ctx Context used to access preferences. + * @param serverUrl Base URL of the Shepherd server (e.g. {@code http://192.168.1.1:8080}). + * @param username Shepherd username. + * @param password Shepherd password. + * @param callback Receives the result on the main thread. + */ + public static void login( + Context ctx, + String serverUrl, + String username, + String password, + AuthCallback callback) { + + final Handler mainHandler = new Handler(Looper.getMainLooper()); + new Thread(() -> { + HttpURLConnection conn = null; + try { + String endpoint = serverUrl.replaceAll("/+$", "") + "/mobileLogin"; + String body = "login=" + URLEncoder.encode(username, "UTF-8") + + "&pwd=" + URLEncoder.encode(password, "UTF-8"); + + conn = (HttpURLConnection) new URL(endpoint).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) sb.append(line); + } + + String responseBody = sb.toString(); + if (status == HttpURLConnection.HTTP_OK && !responseBody.startsWith("ERROR")) { + // Capture the JSESSIONID for subsequent mobile API calls — avoids re-sending + // the raw password on every flag request. + String mobileCookie = ""; + Map> respHeaders = conn.getHeaderFields(); + if (respHeaders != null) { + List setCookies = respHeaders.get("Set-Cookie"); + if (setCookies != null) { + for (String c : setCookies) { + if (c.regionMatches(true, 0, "JSESSIONID=", 0, 11)) { + mobileCookie = c.split(";")[0]; + break; + } + } + } + } + // Persist server URL, username (for display), and session cookie + PreferenceManager.getDefaultSharedPreferences(ctx).edit() + .putString("server_preference", serverUrl.trim()) + .putString("username_preference", username.trim()) + .putBoolean(PREF_AUTH_LOGGED_IN, true) + .putString(PREF_MOBILE_SESSION_COOKIE, mobileCookie) + .apply(); + Log.d(TAG, "Login successful for: " + username); + mainHandler.post(() -> callback.onResult(true, "Signed in as " + username)); + } else { + Log.d(TAG, "Login rejected for: " + username + " (HTTP " + status + ")"); + mainHandler.post(() -> callback.onResult(false, "Invalid username or password")); + } + } catch (Exception e) { + Log.e(TAG, "Login error: " + e.getMessage()); + mainHandler.post(() -> callback.onResult( + false, "Could not reach server: " + e.getMessage())); + } finally { + if (conn != null) conn.disconnect(); + } + }).start(); + } + + /** + * Attempts to register a new student account on the Shepherd server. On success, automatically + * logs in with the new credentials. + * + * @param ctx Context used to access preferences. + * @param serverUrl Base URL of the Shepherd server. + * @param username Desired username. + * @param password Desired password. + * @param email Email address (may be empty; validated server-side if provided). + * @param callback Receives the result on the main thread. + */ + public static void register( + Context ctx, + String serverUrl, + String username, + String password, + String email, + AuthCallback callback) { + + final Handler mainHandler = new Handler(Looper.getMainLooper()); + new Thread(() -> { + HttpURLConnection conn = null; + try { + String endpoint = serverUrl.replaceAll("/+$", "") + "/mobileRegister"; + String body = "userName=" + URLEncoder.encode(username, "UTF-8") + + "&passWord=" + URLEncoder.encode(password, "UTF-8") + + "&userAddress=" + URLEncoder.encode(email, "UTF-8"); + + conn = (HttpURLConnection) new URL(endpoint).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) sb.append(line); + } + + if (status == HttpURLConnection.HTTP_OK) { + JSONObject json = new JSONObject(sb.toString()); + if (json.optBoolean("success", false)) { + Log.d(TAG, "Registration successful for: " + username); + // Auto-login after successful registration + login(ctx, serverUrl, username, password, callback); + } else { + String msg = json.optString("message", "Registration failed"); + mainHandler.post(() -> callback.onResult(false, msg)); + } + } else { + mainHandler.post(() -> callback.onResult( + false, "Server error (HTTP " + status + ")")); + } + } catch (Exception e) { + Log.e(TAG, "Registration error: " + e.getMessage()); + mainHandler.post(() -> callback.onResult( + false, "Could not reach server: " + e.getMessage())); + } finally { + if (conn != null) conn.disconnect(); + } + }).start(); + } + + /** + * Clears the logged-in flag. Stored credentials are retained in preferences so the user does + * not need to retype them if they sign back in. + */ + public static void logout(Context ctx) { + PreferenceManager.getDefaultSharedPreferences(ctx).edit() + .putBoolean(PREF_AUTH_LOGGED_IN, false) + .remove(PREF_WEB_SESSION_COOKIE) + .remove(PREF_MOBILE_SESSION_COOKIE) + .apply(); + // Clear local lesson progress so the next user starts with a clean slate + ProgressTracker.clearAll(ctx); + // Also clear the WebView cookie store so the scoreboard can't be accessed after logout + android.webkit.CookieManager.getInstance().removeAllCookies(null); + Log.d(TAG, "User logged out"); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/CompletionBadge.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/CompletionBadge.java new file mode 100644 index 000000000..0614f9604 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/CompletionBadge.java @@ -0,0 +1,167 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.graphics.drawable.Drawable; + +import androidx.core.content.ContextCompat; + +import org.owasp.mobileshepherd.R; + +/** + * Manages completion badges and visual indicators for progress tracking. + */ +public class CompletionBadge { + + public enum BadgeType { + FIRST_BLOOD("First Blood", "Complete your first challenge", android.R.drawable.star_on), + LESSON_MASTER("Lesson Master", "Complete all lessons", android.R.drawable.star_on), + CHALLENGE_CRUSHER("Challenge Crusher", "Complete all challenges", android.R.drawable.star_on), + SPEED_RUNNER("Speed Runner", "Complete a challenge in under 5 minutes", android.R.drawable.star_on), + PERFECTIONIST("Perfectionist", "100% completion", android.R.drawable.star_on), + SQL_NINJA("SQL Ninja", "Complete all injection challenges", android.R.drawable.star_on), + CRYPTO_BREAKER("Crypto Breaker", "Complete all cryptography challenges", android.R.drawable.star_on), + REVERSE_ENGINEER("Reverse Engineer", "Complete all RE challenges", android.R.drawable.star_on); + + private final String title; + private final String description; + private final int iconResId; + + BadgeType(String title, String description, int iconResId) { + this.title = title; + this.description = description; + this.iconResId = iconResId; + } + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public int getIconResId() { + return iconResId; + } + } + + private final Context context; + private final ProgressTracker progressTracker; + + public CompletionBadge(Context context) { + this.context = context; + this.progressTracker = new ProgressTracker(context); + } + + /** + * Checks if user has earned a specific badge. + */ + public boolean hasBadge(BadgeType badge) { + switch (badge) { + case FIRST_BLOOD: + return progressTracker.getTotalCompletedCount() >= 1; + + case LESSON_MASTER: + return progressTracker.getCompletedLessonsCount() == + progressTracker.getTotalLessonsCount(); + + case CHALLENGE_CRUSHER: + return progressTracker.getCompletedChallengesCount() == + progressTracker.getTotalChallengesCount(); + + case PERFECTIONIST: + return progressTracker.getCompletionPercentage() == 100; + + case SPEED_RUNNER: + // TODO: Implement time tracking for speed runs + return false; + + case SQL_NINJA: + return isCategoryComplete("injection"); + + case CRYPTO_BREAKER: + return isCategoryComplete("crypto"); + + case REVERSE_ENGINEER: + return isCategoryComplete("re"); + + default: + return false; + } + } + + /** + * Checks if all modules in a category are completed. + */ + private boolean isCategoryComplete(String category) { + int total = 0; + int completed = 0; + + for (FlagValidator.Module module : FlagValidator.Module.values()) { + if (module.getId().contains(category)) { + total++; + if (progressTracker.isCompleted(module)) { + completed++; + } + } + } + + return total > 0 && completed == total; + } + + /** + * Gets the completion icon for a module. + */ + public static Drawable getCompletionIcon(Context context, boolean isCompleted) { + int resId = isCompleted ? android.R.drawable.checkbox_on_background : android.R.drawable.checkbox_off_background; + return ContextCompat.getDrawable(context, resId); + } + + /** + * Gets a progress summary string. + */ + public String getProgressSummary() { + int completed = progressTracker.getTotalCompletedCount(); + int total = FlagValidator.Module.values().length; + int percentage = progressTracker.getCompletionPercentage(); + + StringBuilder summary = new StringBuilder(); + summary.append("Progress: ").append(completed).append("/").append(total); + summary.append(" (").append(percentage).append("%)"); + + if (percentage == 100) { + summary.append(" "); + } else if (percentage >= 75) { + summary.append(" "); + } else if (percentage >= 50) { + summary.append(" [STRONG]"); + } else if (percentage >= 25) { + summary.append(" [START]"); + } else if (percentage > 0) { + summary.append(" "); + } + + return summary.toString(); + } + + /** + * Gets motivational message based on progress. + */ + public String getMotivationalMessage() { + int percentage = progressTracker.getCompletionPercentage(); + + if (percentage == 0) { + return "Start your security journey! "; + } else if (percentage < 25) { + return "Great start! Keep going! [START]"; + } else if (percentage < 50) { + return "You're making progress! [STRONG]"; + } else if (percentage < 75) { + return "Over halfway there! "; + } else if (percentage < 100) { + return "Almost done! Finish strong! ⭐"; + } else { + return "Perfect score! You're a security expert! [TROPHY]"; + } + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/CompletionVisualFeedback.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/CompletionVisualFeedback.java new file mode 100644 index 000000000..98dab9060 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/CompletionVisualFeedback.java @@ -0,0 +1,41 @@ +package org.owasp.mobileshepherd.utils; + +import android.graphics.drawable.GradientDrawable; +import android.view.View; +import androidx.core.content.ContextCompat; +import org.owasp.mobileshepherd.R; + +/** + * Utility class for applying visual feedback to completed lessons and challenges + */ +public class CompletionVisualFeedback { + + public static void applyCompletedBorder(View rootView, boolean isCompleted) { + if (isCompleted) { + // Create a green border drawable + GradientDrawable border = new GradientDrawable(); + border.setShape(GradientDrawable.RECTANGLE); + border.setStroke(8, ContextCompat.getColor(rootView.getContext(), R.color.shepherd_green)); + border.setCornerRadius(16f); + + // Apply the border to the root view + rootView.setBackground(border); + rootView.setPadding(4, 4, 4, 4); + } + } + + public static void applyCompletedBorderToCard(View cardView, boolean isCompleted) { + if (isCompleted && cardView != null) { + // For MaterialCardView, we can set stroke color + try { + com.google.android.material.card.MaterialCardView materialCard = + (com.google.android.material.card.MaterialCardView) cardView; + materialCard.setStrokeColor(ContextCompat.getColor(cardView.getContext(), R.color.shepherd_green)); + materialCard.setStrokeWidth(8); + } catch (ClassCastException e) { + // If it's not a MaterialCardView, apply regular border + applyCompletedBorder(cardView, isCompleted); + } + } + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/FlagProvider.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/FlagProvider.java new file mode 100644 index 000000000..520be4be9 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/FlagProvider.java @@ -0,0 +1,167 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Provides the correct flag string for each mobile module in both offline and online modes. + * + *

Offline mode — when no server credentials are configured, an empty string is returned + * for all modules except the Reverse Engineering lesson, which retains a static offline flag for + * introductory use. Lessons are still explorable offline, but flag submission requires a server + * login so that completion is validated server-side. + * + *

Online mode — when the student is signed in, the server-generated user-specific flag + * is fetched from {@code /mobileFlagGet}. This flag is an HMAC of the base flag keyed with the + * server's ephemeral key and the student's username — unique per user, per server session, and + * unreachable via APK inspection. + * + *

In online mode the challenge DB is seeded with the dynamic flag before the student begins + * searching, so the value they discover via exploitation is always the server-validated flag. + */ +public class FlagProvider { + + private static final String TAG = "FlagProvider"; + + /** + * Offline (static) flag values — only the Reverse Engineering lesson retains a static flag; + * its SHA-256 hash in the APK is the target of the reverse-engineering challenge itself. + * All other modules return an empty string when offline; a live server session is required. + */ + private static final Map OFFLINE_FLAGS = new HashMap<>(); + + static { + OFFLINE_FLAGS.put( + FlagValidator.Module.RE_LESSON, + "Frozen_Clock_Melts_By_Noon"); + } + + public interface FlagCallback { + /** Always invoked on the main thread. */ + void onFlag(String flag); + } + + /** + * Returns the flag for the given module. + * + *

+ * + * @param ctx Context used to read credentials. + * @param module The module whose flag is needed. + * @param callback Receives the flag string on the main thread. + */ + public static void getFlag(Context ctx, FlagValidator.Module module, FlagCallback callback) { + String offlineFlag = OFFLINE_FLAGS.containsKey(module) ? OFFLINE_FLAGS.get(module) : ""; + + if (!AuthManager.isAuthenticated(ctx)) { + if (offlineFlag.isEmpty()) { + Log.d(TAG, "Offline mode — no static flag for " + module.getId() + + "; sign in to a Shepherd server to obtain a flag"); + } else { + Log.d(TAG, "Offline mode — returning static flag for " + module.getId()); + } + new Handler(Looper.getMainLooper()).post(() -> callback.onFlag(offlineFlag)); + return; + } + + String serverUrl = AuthManager.getServerUrl(ctx); + String sessionCookie = AuthManager.getMobileSessionCookie(ctx); + String startEndpoint = serverUrl.replaceAll("/+$", "") + "/mobileModuleStart"; + String endpoint = serverUrl.replaceAll("/+$", "") + "/mobileFlagGet"; + final Handler mainHandler = new Handler(Looper.getMainLooper()); + + new Thread(() -> { + String resultFlag = offlineFlag; + HttpURLConnection conn = null; + try { + // Notify the server that this module has been opened. This must succeed + // before /mobileFlagGet will return a flag (prevents bulk API farming). + try { + String startBody = "moduleId=" + URLEncoder.encode(module.getId(), "UTF-8"); + HttpURLConnection startConn = + (HttpURLConnection) new URL(startEndpoint).openConnection(); + startConn.setRequestMethod("POST"); + startConn.setDoOutput(true); + startConn.setConnectTimeout(5_000); + startConn.setReadTimeout(5_000); + startConn.setRequestProperty( + "Content-Type", "application/x-www-form-urlencoded"); + if (!sessionCookie.isEmpty()) { + startConn.setRequestProperty("Cookie", sessionCookie); + } + try (OutputStream startOs = startConn.getOutputStream()) { + startOs.write(startBody.getBytes(StandardCharsets.UTF_8)); + } + startConn.getResponseCode(); // consume response + startConn.disconnect(); + } catch (Exception startEx) { + Log.w(TAG, "Module start notification failed for " + module.getId() + + ": " + startEx.getMessage()); + } + + String body = "moduleId=" + URLEncoder.encode(module.getId(), "UTF-8"); + + conn = (HttpURLConnection) new URL(endpoint).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + if (!sessionCookie.isEmpty()) { + conn.setRequestProperty("Cookie", sessionCookie); + } + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) sb.append(line); + } + JSONObject json = new JSONObject(sb.toString()); + String fetchedFlag = json.optString("flag", ""); + if (!fetchedFlag.isEmpty()) { + resultFlag = fetchedFlag; + Log.d(TAG, "Dynamic flag fetched for " + module.getId()); + } + } else { + Log.w(TAG, "Flag fetch HTTP " + status + " for " + module.getId() + + " — using offline flag"); + } + } catch (Exception e) { + Log.w(TAG, "Flag fetch failed for " + module.getId() + + ": " + e.getMessage() + " — using offline flag"); + } finally { + if (conn != null) conn.disconnect(); + } + + final String flag = resultFlag; + mainHandler.post(() -> callback.onFlag(flag)); + }).start(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/FlagValidator.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/FlagValidator.java new file mode 100644 index 000000000..64624ccd1 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/FlagValidator.java @@ -0,0 +1,258 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; + +/** + * Centralized flag validation system using SHA-256 hashes. + * Provides secure client-side validation and supports progress tracking. + */ +public class FlagValidator { + + private static final String TAG = "FlagValidator"; + + // Module type constants + public static final String TYPE_LESSON = "lesson"; + public static final String TYPE_CHALLENGE = "challenge"; + + // Module identifiers + public enum Module { + // Reverse Engineering + RE_LESSON("re_lesson", TYPE_LESSON), + RE_CHALLENGE_1("re_challenge_1", TYPE_CHALLENGE), + + // Insecure Data Storage + IDS_LESSON("ids_lesson", TYPE_LESSON), + IDS_CHALLENGE_1("ids_challenge_1", TYPE_CHALLENGE), + + // Poor Authentication + POOR_AUTH_LESSON("poor_auth_lesson", TYPE_LESSON), + POOR_AUTH_CHALLENGE("poor_auth_challenge", TYPE_CHALLENGE), + + // Insecure Authorization + INSECURE_AUTH_LESSON("insecure_auth_lesson", TYPE_LESSON), + + // Supply Chain + SUPPLY_CHAIN_LESSON("supply_chain_lesson", TYPE_LESSON), + + // Insecure Communication + INSECURE_COMM_LESSON("insecure_comm_lesson", TYPE_LESSON), + INSECURE_COMM_CHALLENGE("insecure_comm_challenge", TYPE_CHALLENGE), + + // Insufficient Cryptography + INSUFFICIENT_CRYPTO_LESSON("insufficient_crypto_lesson", TYPE_LESSON), + INSUFFICIENT_CRYPTO_CHALLENGE("insufficient_crypto_challenge", TYPE_CHALLENGE), + + // Security Misconfiguration + SECURITY_MISCONFIG_LESSON("security_misconfig_lesson", TYPE_LESSON), + SECURITY_MISCONFIG_CHALLENGE_2("security_misconfig_challenge_2", TYPE_CHALLENGE), + + // Input Validation + INPUT_VALIDATION_LESSON("input_validation_lesson", TYPE_LESSON), + + // Privacy Controls + PRIVACY_LESSON("privacy_lesson", TYPE_LESSON), + + // Client-Side Injection + CLIENT_SIDE_INJECTION_LESSON("client_side_injection_lesson", TYPE_LESSON), + CLIENT_SIDE_INJECTION_CHALLENGE_1("client_side_injection_challenge_1", TYPE_CHALLENGE), + CLIENT_SIDE_INJECTION_CHALLENGE_2("client_side_injection_challenge_2", TYPE_CHALLENGE); + + private final String id; + private final String type; + + Module(String id, String type) { + this.id = id; + this.type = type; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + } + + // RE_LESSON and RE_CHALLENGE_1 retain local hashes — the SHA-256 in the APK is the + // target of the reverse-engineering challenge itself. All other modules require a live + // server session; no offline fallback is provided. + private static final Map FLAG_HASHES = new HashMap() {{ + put(Module.RE_LESSON, "a0c066b9cd89c084709330a943fb6b333d45c932ed613e428bc52078b5722e57"); + put(Module.RE_CHALLENGE_1, "f04a272a2d82a0168f44559f9957dc9e028ecb13195c12fce17ac08d4af91deb"); + }}; + + /** + * Validates a flag submission using SHA-256 hash comparison. + * + * @param module The module being validated + * @param submittedFlag The flag submitted by the user + * @return true if the flag is correct, false otherwise + */ + public static boolean validateFlag(Module module, String submittedFlag) { + if (submittedFlag == null || submittedFlag.trim().isEmpty()) { + return false; + } + + String expectedHash = FLAG_HASHES.get(module); + if (expectedHash == null) { + Log.e(TAG, "No hash found for module: " + module.getId()); + return false; + } + + String submittedHash = sha256(submittedFlag.trim()); + boolean isValid = expectedHash.equalsIgnoreCase(submittedHash); + + Log.d(TAG, isValid ? "[OK] Correct flag for " + module.getId() + : "[FAIL] Incorrect flag for " + module.getId()); + return isValid; + } + + /** + * Computes SHA-256 hash of the input string. + * + * @param input The string to hash + * @return Hexadecimal representation of the hash + */ + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + // Convert bytes to hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "SHA-256 algorithm not available", e); + return ""; + } + } + + // ------------------------------------------------------------------------- + // Server-side validation + // ------------------------------------------------------------------------- + + /** + * Callback interface for asynchronous flag validation results. + * + *

{@link #onResult(boolean)} is always invoked on the main (UI) thread. + */ + public interface ValidationCallback { + void onResult(boolean correct); + } + + /** + * Validates a flag against the configured Shepherd server when a session is active. + * Falls back to local SHA-256 comparison when no session is available, + * so the app remains usable without a running server instance. + * + *

This method is non-blocking. The result is delivered on the main thread via + * {@code callback}. + * + * @param context Application context used to read shared preferences. + * @param module The module being validated. + * @param flag The flag string submitted by the student. + * @param callback Receives {@code true} when the flag is correct, {@code false} otherwise. + */ + public static void validateFlag( + Context context, + Module module, + String flag, + ValidationCallback callback) { + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String serverUrl = prefs.getString("server_preference", "").trim(); + String sessionCookie = AuthManager.getMobileSessionCookie(context); + + if (serverUrl.isEmpty() || sessionCookie.isEmpty()) { + // No active server session — only RE modules have offline hashes. + Log.d(TAG, "No server session — offline validation for " + module.getId()); + boolean result = validateFlag(module, flag); + new Handler(Looper.getMainLooper()).post(() -> callback.onResult(result)); + return; + } + + final String endpointUrl = serverUrl.replaceAll("/+$", "") + "/mobileFlagSubmit"; + final String moduleId = module.getId(); + final String trimmedFlag = flag.trim(); + final Handler mainHandler = new Handler(Looper.getMainLooper()); + + new Thread(() -> { + boolean correct = false; + HttpURLConnection conn = null; + try { + String body = + "moduleId=" + URLEncoder.encode(moduleId, "UTF-8") + + "&flag=" + URLEncoder.encode(trimmedFlag, "UTF-8"); + + conn = (HttpURLConnection) new URL(endpointUrl).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + conn.setRequestProperty("Cookie", sessionCookie); + + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + if (status == HttpURLConnection.HTTP_OK) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + JSONObject json = new JSONObject(sb.toString()); + correct = json.optBoolean("correct", false); + Log.d(TAG, "Server validation for " + moduleId + ": " + correct); + } else { + Log.w(TAG, "Server returned HTTP " + status + " for " + moduleId); + correct = validateFlag(module, trimmedFlag); + } + } catch (Exception e) { + Log.e(TAG, "Server validation failed for " + moduleId + ": " + e.getMessage()); + correct = validateFlag(module, trimmedFlag); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + + final boolean result = correct; + mainHandler.post(() -> callback.onResult(result)); + }).start(); + } +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/ModuleInfoHelper.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/ModuleInfoHelper.java new file mode 100644 index 000000000..1c7158bf9 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/ModuleInfoHelper.java @@ -0,0 +1,210 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.graphics.Typeface; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; + +import java.util.EnumMap; +import java.util.Map; + +public final class ModuleInfoHelper { + + public static final class ModuleInfo { + public final String levelType; + public final String name; + public final String topTenRisk; + public final String location; + + ModuleInfo(String levelType, String name, String topTenRisk, String location) { + this.levelType = levelType; + this.name = name; + this.topTenRisk = topTenRisk; + this.location = location; + } + + public String toJson() { + return "{\n" + + " \"level_type\": \"" + + levelType + + "\",\n" + + " \"name\": \"" + + name + + "\",\n" + + " \"top_ten_risk\": \"" + + topTenRisk + + "\",\n" + + " \"location\": \"" + + location + + "\"\n" + + "}"; + } + } + + private static final Map REGISTRY = + new EnumMap<>(FlagValidator.Module.class); + + static { + // Lessons + REGISTRY.put( + FlagValidator.Module.RE_LESSON, + new ModuleInfo( + "lesson", + "Reverse Engineering", + "M7", + "org.owasp.mobileshepherd.ui.lessons")); + REGISTRY.put( + FlagValidator.Module.IDS_LESSON, + new ModuleInfo( + "lesson", + "Insecure Data Storage", + "M9", + "org.owasp.mobileshepherd.ui.lessons.insecuredata")); + REGISTRY.put( + FlagValidator.Module.POOR_AUTH_LESSON, + new ModuleInfo( + "lesson", + "Poor Authentication", + "M3", + "org.owasp.mobileshepherd.ui.lessons.poorauth")); + REGISTRY.put( + FlagValidator.Module.INSECURE_AUTH_LESSON, + new ModuleInfo( + "lesson", + "Insecure Authorization", + "M3", + "org.owasp.mobileshepherd.ui.lessons.insecureauthorization")); + REGISTRY.put( + FlagValidator.Module.SUPPLY_CHAIN_LESSON, + new ModuleInfo( + "lesson", + "Supply Chain Security", + "M2", + "org.owasp.mobileshepherd.ui.lessons.supplychain")); + REGISTRY.put( + FlagValidator.Module.INSECURE_COMM_LESSON, + new ModuleInfo( + "lesson", + "Insecure Communication", + "M5", + "org.owasp.mobileshepherd.ui.lessons.insecurecomm")); + REGISTRY.put( + FlagValidator.Module.INSUFFICIENT_CRYPTO_LESSON, + new ModuleInfo( + "lesson", + "Insufficient Cryptography", + "M10", + "org.owasp.mobileshepherd.ui.lessons.crypto")); + REGISTRY.put( + FlagValidator.Module.SECURITY_MISCONFIG_LESSON, + new ModuleInfo( + "lesson", + "Security Misconfiguration", + "M8", + "org.owasp.mobileshepherd.ui.lessons.securitymisconfig")); + REGISTRY.put( + FlagValidator.Module.INPUT_VALIDATION_LESSON, + new ModuleInfo( + "lesson", + "Input Validation", + "M4", + "org.owasp.mobileshepherd.ui.lessons")); + REGISTRY.put( + FlagValidator.Module.PRIVACY_LESSON, + new ModuleInfo( + "lesson", + "Privacy Controls", + "M6", + "org.owasp.mobileshepherd.ui.lessons.privacycontrols")); + REGISTRY.put( + FlagValidator.Module.CLIENT_SIDE_INJECTION_LESSON, + new ModuleInfo( + "lesson", + "Client-Side Injection", + "M4", + "org.owasp.mobileshepherd.ui.lessons.clientsideinjection")); + + // Challenges + REGISTRY.put( + FlagValidator.Module.RE_CHALLENGE_1, + new ModuleInfo( + "challenge", + "Reverse Engineering 1", + "M7", + "org.owasp.mobileshepherd.ui.challenges.reverseengineering")); + REGISTRY.put( + FlagValidator.Module.IDS_CHALLENGE_1, + new ModuleInfo( + "challenge", + "Insecure Data Storage 1", + "M9", + "org.owasp.mobileshepherd.ui.challenges.insecuredata1")); + REGISTRY.put( + FlagValidator.Module.POOR_AUTH_CHALLENGE, + new ModuleInfo( + "challenge", + "Poor Authentication", + "M3", + "org.owasp.mobileshepherd.ui.challenges.poorauth")); + REGISTRY.put( + FlagValidator.Module.INSECURE_COMM_CHALLENGE, + new ModuleInfo( + "challenge", + "Insecure Communication 1", + "M5", + "org.owasp.mobileshepherd.ui.challenges.insecurecomm")); + REGISTRY.put( + FlagValidator.Module.INSUFFICIENT_CRYPTO_CHALLENGE, + new ModuleInfo( + "challenge", + "Insufficient Cryptography 1", + "M10", + "org.owasp.mobileshepherd.ui.challenges.crypto")); + REGISTRY.put( + FlagValidator.Module.SECURITY_MISCONFIG_CHALLENGE_2, + new ModuleInfo( + "challenge", + "Security Misconfiguration", + "M8", + "org.owasp.mobileshepherd.ui.challenges.securitymisconfig")); + REGISTRY.put( + FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_1, + new ModuleInfo( + "challenge", + "Client-Side Injection 1", + "M4", + "org.owasp.mobileshepherd.ui.challenges.clientsideinjection")); + REGISTRY.put( + FlagValidator.Module.CLIENT_SIDE_INJECTION_CHALLENGE_2, + new ModuleInfo( + "challenge", + "Client-Side Injection 2", + "M4", + "org.owasp.mobileshepherd.ui.challenges.clientsideinjection")); + } + + public static void showDialog(Context context, FlagValidator.Module module) { + ModuleInfo info = REGISTRY.get(module); + String json = + info != null ? info.toJson() : "{\n \"error\": \"Module info not found\"\n}"; + + TextView tv = new TextView(context); + tv.setText(json); + tv.setTypeface(Typeface.MONOSPACE); + tv.setPadding(48, 32, 48, 32); + tv.setTextSize(13f); + + ScrollView scrollView = new ScrollView(context); + scrollView.addView(tv); + + new AlertDialog.Builder(context) + .setTitle("Module Info") + .setView(scrollView) + .setPositiveButton("OK", null) + .show(); + } + + private ModuleInfoHelper() {} +} diff --git a/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/ProgressTracker.java b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/ProgressTracker.java new file mode 100644 index 000000000..2ee67b2b8 --- /dev/null +++ b/mobile/app/src/main/java/org/owasp/mobileshepherd/utils/ProgressTracker.java @@ -0,0 +1,281 @@ +package org.owasp.mobileshepherd.utils; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.HashSet; +import java.util.Set; + +/** + * Tracks user progress through lessons and challenges. + * Stores completion status in SharedPreferences. + */ +public class ProgressTracker { + + private static final String PREFS_NAME = "MobileShepherd_Progress"; + private static final String KEY_COMPLETED_MODULES = "completed_modules"; + private static final String KEY_FIRST_COMPLETION_TIME = "first_completion_"; + private static final String KEY_LAST_COMPLETION_TIME = "last_completion_"; + private static final String KEY_COMPLETION_COUNT = "completion_count_"; + + private final SharedPreferences prefs; + private static CompletionChangeListener globalListener; + + public interface CompletionChangeListener { + void onCompletionChanged(); + } + + public static void setGlobalCompletionListener(CompletionChangeListener listener) { + globalListener = listener; + } + + public static CompletionChangeListener getGlobalCompletionListener() { + return globalListener; + } + + public ProgressTracker(Context context) { + this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } + + /** + * Marks a module as completed. + * + * @param module The module that was completed + */ + public void markCompleted(FlagValidator.Module module) { + Set completed = getCompletedModules(); + boolean wasNew = completed.add(module.getId()); + + SharedPreferences.Editor editor = prefs.edit(); + editor.putStringSet(KEY_COMPLETED_MODULES, completed); + + long currentTime = System.currentTimeMillis(); + + // Track first completion time + if (wasNew) { + editor.putLong(KEY_FIRST_COMPLETION_TIME + module.getId(), currentTime); + } + + // Always update last completion time + editor.putLong(KEY_LAST_COMPLETION_TIME + module.getId(), currentTime); + + // Increment completion count + int count = getCompletionCount(module); + editor.putInt(KEY_COMPLETION_COUNT + module.getId(), count + 1); + + editor.apply(); + + // Notify listener of change + if (globalListener != null) { + globalListener.onCompletionChanged(); + } + } + + /** + * Marks a module as not completed (undo completion). + * + * @param module The module to unmark + */ + public void unmarkCompleted(FlagValidator.Module module) { + Set completed = getCompletedModules(); + completed.remove(module.getId()); + + SharedPreferences.Editor editor = prefs.edit(); + editor.putStringSet(KEY_COMPLETED_MODULES, completed); + editor.apply(); + + // Notify listener of change + if (globalListener != null) { + globalListener.onCompletionChanged(); + } + } + + /** + * Toggles completion status of a module. + * + * @param module The module to toggle + * @return true if now marked as completed, false if now marked as incomplete + */ + public boolean toggleCompleted(FlagValidator.Module module) { + if (isCompleted(module)) { + unmarkCompleted(module); + return false; + } else { + markCompleted(module); + return true; + } + } + + /** + * Checks if a module has been completed. + * + * @param module The module to check + * @return true if completed, false otherwise + */ + public boolean isCompleted(FlagValidator.Module module) { + Set completed = getCompletedModules(); + return completed.contains(module.getId()); + } + + /** + * Gets the set of all completed module IDs. + * + * @return Set of completed module IDs + */ + public Set getCompletedModules() { + return new HashSet<>(prefs.getStringSet(KEY_COMPLETED_MODULES, new HashSet<>())); + } + + /** + * Gets the timestamp of when a module was first completed. + * + * @param module The module to check + * @return Timestamp in milliseconds, or 0 if not completed + */ + public long getFirstCompletionTime(FlagValidator.Module module) { + return prefs.getLong(KEY_FIRST_COMPLETION_TIME + module.getId(), 0); + } + + /** + * Gets the timestamp of when a module was last completed. + * + * @param module The module to check + * @return Timestamp in milliseconds, or 0 if not completed + */ + public long getLastCompletionTime(FlagValidator.Module module) { + return prefs.getLong(KEY_LAST_COMPLETION_TIME + module.getId(), 0); + } + + /** + * Gets the number of times a module has been completed. + * + * @param module The module to check + * @return Completion count + */ + public int getCompletionCount(FlagValidator.Module module) { + return prefs.getInt(KEY_COMPLETION_COUNT + module.getId(), 0); + } + + /** + * Gets the total number of completed modules. + * + * @return Count of completed modules + */ + public int getTotalCompletedCount() { + return getCompletedModules().size(); + } + + /** + * Gets the total number of completed challenges. + * + * @return Count of completed challenges + */ + public int getCompletedChallengesCount() { + int count = 0; + for (String moduleId : getCompletedModules()) { + // Check if module is a challenge by looking it up + for (FlagValidator.Module module : FlagValidator.Module.values()) { + if (module.getId().equals(moduleId) && + module.getType().equals(FlagValidator.TYPE_CHALLENGE)) { + count++; + break; + } + } + } + return count; + } + + /** + * Gets the total number of completed lessons. + * + * @return Count of completed lessons + */ + public int getCompletedLessonsCount() { + int count = 0; + for (String moduleId : getCompletedModules()) { + // Check if module is a lesson by looking it up + for (FlagValidator.Module module : FlagValidator.Module.values()) { + if (module.getId().equals(moduleId) && + module.getType().equals(FlagValidator.TYPE_LESSON)) { + count++; + break; + } + } + } + return count; + } + + /** + * Gets the total number of available challenges. + * + * @return Total challenge count + */ + public int getTotalChallengesCount() { + int count = 0; + for (FlagValidator.Module module : FlagValidator.Module.values()) { + if (module.getType().equals(FlagValidator.TYPE_CHALLENGE)) { + count++; + } + } + return count; + } + + /** + * Gets the total number of available lessons. + * + * @return Total lesson count + */ + public int getTotalLessonsCount() { + int count = 0; + for (FlagValidator.Module module : FlagValidator.Module.values()) { + if (module.getType().equals(FlagValidator.TYPE_LESSON)) { + count++; + } + } + return count; + } + + /** + * Calculates the completion percentage. + * + * @return Percentage (0-100) + */ + public int getCompletionPercentage() { + int total = FlagValidator.Module.values().length; + int completed = getTotalCompletedCount(); + if (total == 0) return 0; + return (int) ((completed * 100.0) / total); + } + + /** + * Resets all progress (for testing or user request). + */ + public void resetProgress() { + prefs.edit().clear().apply(); + } + + /** + * Clears all stored progress for any user. Call on logout so the next + * user starts with a clean state. + */ + public static void clearAll(Context context) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().clear().apply(); + } + + /** + * Removes completion status for a specific module. + * + * @param module The module to reset + */ + public void resetModule(FlagValidator.Module module) { + Set completed = getCompletedModules(); + completed.remove(module.getId()); + + SharedPreferences.Editor editor = prefs.edit(); + editor.putStringSet(KEY_COMPLETED_MODULES, completed); + editor.remove(KEY_FIRST_COMPLETION_TIME + module.getId()); + editor.remove(KEY_LAST_COMPLETION_TIME + module.getId()); + editor.remove(KEY_COMPLETION_COUNT + module.getId()); + editor.apply(); + } +} diff --git a/mobile/app/src/main/res/anim/fade_in.xml b/mobile/app/src/main/res/anim/fade_in.xml new file mode 100644 index 000000000..49869de58 --- /dev/null +++ b/mobile/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,6 @@ + + diff --git a/mobile/app/src/main/res/anim/fade_out.xml b/mobile/app/src/main/res/anim/fade_out.xml new file mode 100644 index 000000000..921fefcf7 --- /dev/null +++ b/mobile/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,6 @@ + + diff --git a/mobile/app/src/main/res/anim/scale_down.xml b/mobile/app/src/main/res/anim/scale_down.xml new file mode 100644 index 000000000..05b4e7bf3 --- /dev/null +++ b/mobile/app/src/main/res/anim/scale_down.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/mobile/app/src/main/res/anim/scale_up.xml b/mobile/app/src/main/res/anim/scale_up.xml new file mode 100644 index 000000000..ab7e38836 --- /dev/null +++ b/mobile/app/src/main/res/anim/scale_up.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/mobile/app/src/main/res/anim/slide_in_left.xml b/mobile/app/src/main/res/anim/slide_in_left.xml new file mode 100644 index 000000000..6a893985c --- /dev/null +++ b/mobile/app/src/main/res/anim/slide_in_left.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/mobile/app/src/main/res/anim/slide_in_right.xml b/mobile/app/src/main/res/anim/slide_in_right.xml new file mode 100644 index 000000000..cb6217ed8 --- /dev/null +++ b/mobile/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/mobile/app/src/main/res/anim/slide_out_left.xml b/mobile/app/src/main/res/anim/slide_out_left.xml new file mode 100644 index 000000000..25934a962 --- /dev/null +++ b/mobile/app/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/mobile/app/src/main/res/anim/slide_out_right.xml b/mobile/app/src/main/res/anim/slide_out_right.xml new file mode 100644 index 000000000..c22b0b507 --- /dev/null +++ b/mobile/app/src/main/res/anim/slide_out_right.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/mobile/app/src/main/res/drawable-v24/animation.xml b/mobile/app/src/main/res/drawable-v24/animation.xml new file mode 100644 index 000000000..e3d8c3ec0 --- /dev/null +++ b/mobile/app/src/main/res/drawable-v24/animation.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/mobile/app/src/main/res/drawable-v24/ic_launcher_background.xml b/mobile/app/src/main/res/drawable-v24/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/mobile/app/src/main/res/drawable-v24/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/mobile/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/mobile/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/app/src/main/res/drawable-v24/ic_menu.xml b/mobile/app/src/main/res/drawable-v24/ic_menu.xml new file mode 100644 index 000000000..543cee9e8 --- /dev/null +++ b/mobile/app/src/main/res/drawable-v24/ic_menu.xml @@ -0,0 +1,5 @@ + + + diff --git a/mobile/app/src/main/res/drawable-v24/icon.png b/mobile/app/src/main/res/drawable-v24/icon.png new file mode 100644 index 000000000..5c9ce61a5 Binary files /dev/null and b/mobile/app/src/main/res/drawable-v24/icon.png differ diff --git a/mobile/app/src/main/res/drawable-v24/pxart.png b/mobile/app/src/main/res/drawable-v24/pxart.png new file mode 100644 index 000000000..659873a7b Binary files /dev/null and b/mobile/app/src/main/res/drawable-v24/pxart.png differ diff --git a/mobile/app/src/main/res/drawable/animation.xml b/mobile/app/src/main/res/drawable/animation.xml new file mode 100644 index 000000000..e3d8c3ec0 --- /dev/null +++ b/mobile/app/src/main/res/drawable/animation.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/mobile/app/src/main/res/drawable/button_scale_animation.xml b/mobile/app/src/main/res/drawable/button_scale_animation.xml new file mode 100644 index 000000000..41883dcc7 --- /dev/null +++ b/mobile/app/src/main/res/drawable/button_scale_animation.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/mobile/app/src/main/res/drawable/card_ripple.xml b/mobile/app/src/main/res/drawable/card_ripple.xml new file mode 100644 index 000000000..054f97ceb --- /dev/null +++ b/mobile/app/src/main/res/drawable/card_ripple.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/mobile/app/src/main/res/drawable/fire1.png b/mobile/app/src/main/res/drawable/fire1.png new file mode 100644 index 000000000..c0f73f451 Binary files /dev/null and b/mobile/app/src/main/res/drawable/fire1.png differ diff --git a/mobile/app/src/main/res/drawable/fire2.png b/mobile/app/src/main/res/drawable/fire2.png new file mode 100644 index 000000000..378fe8d96 Binary files /dev/null and b/mobile/app/src/main/res/drawable/fire2.png differ diff --git a/mobile/app/src/main/res/drawable/fire3.png b/mobile/app/src/main/res/drawable/fire3.png new file mode 100644 index 000000000..ba10b5b4d Binary files /dev/null and b/mobile/app/src/main/res/drawable/fire3.png differ diff --git a/mobile/app/src/main/res/drawable/grass_tile_v2.jpeg b/mobile/app/src/main/res/drawable/grass_tile_v2.jpeg new file mode 100644 index 000000000..a9ebe8880 Binary files /dev/null and b/mobile/app/src/main/res/drawable/grass_tile_v2.jpeg differ diff --git a/mobile/app/src/main/res/drawable/grassbackground.xml b/mobile/app/src/main/res/drawable/grassbackground.xml new file mode 100644 index 000000000..4294ea38b --- /dev/null +++ b/mobile/app/src/main/res/drawable/grassbackground.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/mobile/app/src/main/res/drawable/ic_expand_more.xml b/mobile/app/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 000000000..3ebd02eb4 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_launcher_background.xml b/mobile/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_alert.xml b/mobile/app/src/main/res/drawable/ic_menu_alert.xml new file mode 100644 index 000000000..a5d7b6ace --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_alert.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_camera.xml b/mobile/app/src/main/res/drawable/ic_menu_camera.xml new file mode 100644 index 000000000..ad626183e --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_camera.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_code.xml b/mobile/app/src/main/res/drawable/ic_menu_code.xml new file mode 100644 index 000000000..c6c0f110c --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_database.xml b/mobile/app/src/main/res/drawable/ic_menu_database.xml new file mode 100644 index 000000000..1151efa30 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_database.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_gallery.xml b/mobile/app/src/main/res/drawable/ic_menu_gallery.xml new file mode 100644 index 000000000..75c7b1f74 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_gallery.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_home.xml b/mobile/app/src/main/res/drawable/ic_menu_home.xml new file mode 100644 index 000000000..290e8808a --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_key.xml b/mobile/app/src/main/res/drawable/ic_menu_key.xml new file mode 100644 index 000000000..c6b0e0119 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_key.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_link.xml b/mobile/app/src/main/res/drawable/ic_menu_link.xml new file mode 100644 index 000000000..7f158e233 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_link.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_lock.xml b/mobile/app/src/main/res/drawable/ic_menu_lock.xml new file mode 100644 index 000000000..baa139108 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_network.xml b/mobile/app/src/main/res/drawable/ic_menu_network.xml new file mode 100644 index 000000000..d31728a0b --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_network.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_settings.xml b/mobile/app/src/main/res/drawable/ic_menu_settings.xml new file mode 100644 index 000000000..7996d6148 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_shield.xml b/mobile/app/src/main/res/drawable/ic_menu_shield.xml new file mode 100644 index 000000000..2dc9274ae --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_shield.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_slideshow.xml b/mobile/app/src/main/res/drawable/ic_menu_slideshow.xml new file mode 100644 index 000000000..f00c2faba --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_slideshow.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_warning.xml b/mobile/app/src/main/res/drawable/ic_menu_warning.xml new file mode 100644 index 000000000..78b19e650 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_menu_web.xml b/mobile/app/src/main/res/drawable/ic_menu_web.xml new file mode 100644 index 000000000..52eac72c3 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_menu_web.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/app/src/main/res/drawable/ic_status_complete.xml b/mobile/app/src/main/res/drawable/ic_status_complete.xml new file mode 100644 index 000000000..2c6157631 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_status_complete.xml @@ -0,0 +1,10 @@ + + + + diff --git a/mobile/app/src/main/res/drawable/ic_status_incomplete.xml b/mobile/app/src/main/res/drawable/ic_status_incomplete.xml new file mode 100644 index 000000000..8f70e96d1 --- /dev/null +++ b/mobile/app/src/main/res/drawable/ic_status_incomplete.xml @@ -0,0 +1,10 @@ + + + + diff --git a/mobile/app/src/main/res/drawable/icon.png b/mobile/app/src/main/res/drawable/icon.png new file mode 100644 index 000000000..5c9ce61a5 Binary files /dev/null and b/mobile/app/src/main/res/drawable/icon.png differ diff --git a/mobile/app/src/main/res/drawable/nav_header_background.xml b/mobile/app/src/main/res/drawable/nav_header_background.xml new file mode 100644 index 000000000..c22421ca4 --- /dev/null +++ b/mobile/app/src/main/res/drawable/nav_header_background.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/app/src/main/res/drawable/shape.xml b/mobile/app/src/main/res/drawable/shape.xml new file mode 100644 index 000000000..c9c5a2792 --- /dev/null +++ b/mobile/app/src/main/res/drawable/shape.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mobile/app/src/main/res/drawable/shepherd_and_sheep_v2.png b/mobile/app/src/main/res/drawable/shepherd_and_sheep_v2.png new file mode 100644 index 000000000..d4e00a911 Binary files /dev/null and b/mobile/app/src/main/res/drawable/shepherd_and_sheep_v2.png differ diff --git a/mobile/app/src/main/res/drawable/shepherd_background.xml b/mobile/app/src/main/res/drawable/shepherd_background.xml new file mode 100644 index 000000000..85ce6f777 --- /dev/null +++ b/mobile/app/src/main/res/drawable/shepherd_background.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + diff --git a/mobile/app/src/main/res/drawable/side_nav_bar.xml b/mobile/app/src/main/res/drawable/side_nav_bar.xml new file mode 100644 index 000000000..6d81870b0 --- /dev/null +++ b/mobile/app/src/main/res/drawable/side_nav_bar.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/mobile/app/src/main/res/layout/activity_landing.xml b/mobile/app/src/main/res/layout/activity_landing.xml new file mode 100644 index 000000000..fac52e494 --- /dev/null +++ b/mobile/app/src/main/res/layout/activity_landing.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + +