Or press ESC to close.

Building an Appium 3 Security Dashboard (Part I): Plugin Architecture

Oct 5th 2025 23 min read
hard
javascriptES6
appium3.0.2
mobile
security
reporting
architecture

Mobile test automation has come a long way, but there's a blind spot most QA teams don't think about: what security-sensitive operations are your tests actually performing? When your Appium tests execute shell commands, modify file permissions, or grant dangerous capabilities to test apps, you're essentially running with elevated privileges—and without proper monitoring, you have no visibility into potential security risks or compliance violations.

In this deep dive, we'll build a production-grade Appium 3 plugin that monitors security events in real-time, surfaces risk assessments through an interactive dashboard, and generates actionable compliance recommendations. You'll learn advanced plugin development techniques, including handling Appium's internal command interception, managing shared state across plugin instances, and building real-time UIs with Socket.IO. By the end, you'll have a fully functional security monitoring solution and the knowledge to build your own custom Appium extensions.

Understanding Appium Plugins

Before we dive into building our security dashboard, we need to understand how Appium's plugin architecture works. Plugins are one of Appium's most powerful features, allowing you to intercept and modify virtually any operation in the test execution lifecycle—from session creation to individual WebDriver commands.

The Plugin Lifecycle

At its core, an Appium plugin acts as middleware between the client (your test code) and the driver (UiAutomator2, XCUITest, etc.). When a WebDriver command flows through Appium, it passes through each active plugin's interception methods. Think of it as a chain of responsibility pattern where each plugin can:

This interception happens through specific lifecycle methods that you override in your plugin class.

Key Plugin Methods

The most important methods for our security dashboard are:

createSession() - Called when a new Appium session starts. This is where we'll extract security-relevant capabilities and begin tracking the session:

                
class SecurityDashboardPlugin extends BasePlugin {
  async createSession(next, driver, w3cCapabilities1, w3cCapabilities2, w3cCapabilities3, driverData) {
    // Call next() to let Appium create the session
    const result = await next();
    
    // Extract session details from the result
    const sessionId = result.value[0];
    const capabilities = result.value[1];
    
    // Track this session and its security features
    this.trackSession(sessionId, capabilities);
    
    return result;
  }
}
                

deleteSession() - Called when a session ends. Perfect for cleanup and final reporting:

                
async deleteSession(next, driver) {
  const sessionId = driver.sessionId;
  
  // Generate final security report for this session
  this.generateSessionReport(sessionId);
  
  // Clean up tracked data
  this.sessions.delete(sessionId);
  
  return await next();
}
                

handle() - The workhorse method. Called for every WebDriver command during a session. This is where we'll monitor for security-sensitive operations:

                
async handle(next, driver, cmdName, ...args) {
  const startTime = Date.now();
  
  // Execute the command
  const result = await next();
  
  // Analyze if this command has security implications
  const securityRisk = this.assessCommandSecurity(cmdName, args);
  
  if (securityRisk.level > 0) {
    this.logSecurityEvent({
      sessionId: driver.sessionId,
      command: cmdName,
      riskLevel: securityRisk.level,
      riskReason: securityRisk.reason,
      duration: Date.now() - startTime
    });
  }
  
  return result;
}
                

updateServer() (static) - A special static method that runs when Appium starts, before any sessions are created. This is crucial for our dashboard because we need to set up the web server at the application level, not per-session:

                
static async updateServer(expressApp, httpServer, cliArgs) {
  // Access to the main Appium Express app
  // Perfect for adding custom routes or starting services
  
  const dashboardPort = cliArgs.securityDashboardPort || 4724;
  
  // Initialize our dashboard server here
  this.initializeDashboard(dashboardPort);
}
                
The next() Pattern

Notice the next parameter in each method? This is how you pass control to the next handler in the chain—either the next plugin or the actual driver. Always call await next() to ensure the command executes, unless you explicitly want to block it. The pattern is:

                
// Do something before
const result = await next();  // Let the command execute
// Do something after
return result;  // Return the result to the client
                
Plugin Configuration

Plugins can accept custom CLI arguments. Define them with the cliArgsExtensionDesc getter:

                
static get cliArgsExtensionDesc() {
  return {
    'security-dashboard-port': {
      dest: 'securityDashboardPort',
      defaultValue: 4724,
      type: 'int',
      help: 'Port for the security dashboard web interface'
    }
  };
}
                

Users can then start Appium with:

                
appium --use-plugins security-dashboard --plugin-security-dashboard-port 8080
                
BasePlugin Class

All plugins extend BasePlugin from appium/plugin, which provides:

With this foundation in place, we're ready to tackle the first major challenge: designing an architecture that handles multiple plugin instances and maintains consistent state across all of them.

Architecture Overview

Building a monitoring dashboard for Appium presents a unique architectural challenge: plugin instances are created multiple times during the application lifecycle. Understanding this is critical to building a solution that actually works in production.

The Dual-Instance Problem

When Appium starts, it creates plugin instances in two distinct contexts:

Here's the catch: these instances don't share memory by default. If you store session data in a session-level instance, the server-level instance (which serves your dashboard API) won't see it. You'll end up with a dashboard that always shows zero active sessions, despite tests running successfully.

The Shared State Solution

To solve this, we need a shared state that exists outside individual plugin instances. We accomplish this using a static property on the plugin class itself:

                
class SecurityDashboardPlugin extends BasePlugin {
  constructor(pluginName, cliArgs = {}) {
    super(pluginName, cliArgs);

    // Initialize shared state once, reuse across all instances
    if (!SecurityDashboardPlugin.sharedState) {
      SecurityDashboardPlugin.sharedState = {
        securityFeatures: new Map(),
        sessions: new Map(),
        securityEvents: []
      };
    }
    
    // Point instance properties to shared state
    this.sessions = SecurityDashboardPlugin.sharedState.sessions;
    this.securityEvents = SecurityDashboardPlugin.sharedState.securityEvents;
  }
}
                

This pattern ensures that:

Component Architecture

Our complete system consists of four layers:

Why Separate Servers?

You might wonder why we create a separate Express server instead of adding routes to Appium's existing server. The reason is flexibility and isolation. By running our dashboard on a different port, we:

The tradeoff is slightly more complex initialization, but the architectural clarity is worth it.

Data Flow Example

When a test executes a risky command:

This dual-channel approach (Socket.IO + polling) ensures the dashboard remains responsive even if WebSocket connections drop.

Capturing Security Events

The heart of our security dashboard is the ability to identify and log operations that have security implications. Not all Appium commands are created equal—some are routine UI interactions, while others provide direct access to the device's file system or shell. Our goal is to intercept every command, assess its risk level, and track anything suspicious.

Implementing Command Interception

The handle() method is called for every WebDriver command that flows through Appium. This gives us a perfect hook to analyze each operation:

                
async handle(next, driver, cmdName, ...args) {
  const sessionId = driver.sessionId;
  const start = Date.now();

  try {
    // Execute the actual command
    const result = await next();
    const duration = Date.now() - start;

    // Assess security risk
    const securityRisk = this.assessCommandSecurity(cmdName, args);

    // Log if it's security-relevant
    if (securityRisk.level > 0 || cmdName.startsWith("mobile:")) {
      const event = {
        type: "security_command",
        sessionId,
        command: cmdName,
        args: this.sanitizeArgs(args),
        riskLevel: securityRisk.level,
        riskReason: securityRisk.reason,
        timestamp: Date.now(),
        duration
      };

      this.logSecurityEvent(event);
      
      // Add to session's event history
      if (this.sessions.has(sessionId)) {
        this.sessions.get(sessionId).events.push(event);
      }
    }

    return result;
  } catch (error) {
    // Even errors are security-relevant
    this.logSecurityEvent({
      type: "security_error",
      sessionId,
      command: cmdName,
      error: error.message,
      timestamp: Date.now(),
      riskLevel: 2
    });
    throw error;
  }
}
                

Notice we wrap everything in a try-catch. Failed commands can indicate security issues too—repeated failures might suggest reconnaissance attempts or misconfigured permissions.

Risk Assessment Logic

Not all commands pose the same risk. We categorize them into tiers:

                
assessCommandSecurity(cmdName, args) {
  const highRiskCommands = {
    execute: { level: 3, reason: "Arbitrary code execution" },
    executeAsync: { level: 3, reason: "Arbitrary async code execution" },
    "mobile: shell": { level: 4, reason: "Direct shell access" },
    "mobile: changePermissions": { level: 3, reason: "Permission modification" },
    installApp: { level: 3, reason: "App installation" },
    removeApp: { level: 2, reason: "App removal" },
    pushFile: { level: 2, reason: "File system write" },
    pullFile: { level: 1, reason: "File system read" }
  };

  const mediumRiskCommands = {
    setClipboard: { level: 2, reason: "Clipboard access" },
    getClipboard: { level: 1, reason: "Clipboard read" },
    takeScreenshot: { level: 1, reason: "Screen capture" },
    getPageSource: { level: 1, reason: "UI structure access" },
    getCurrentActivity: { level: 1, reason: "App state access" }
  };

  if (highRiskCommands[cmdName]) {
    return highRiskCommands[cmdName];
  } else if (mediumRiskCommands[cmdName]) {
    return mediumRiskCommands[cmdName];
  } else if (cmdName.startsWith("mobile:")) {
    return { level: 1, reason: "Mobile-specific command" };
  }

  return { level: 0, reason: "Standard WebDriver command" };
}
                

This categorization is customizable. Your organization might consider screenshot capture high-risk if you're testing banking apps, or you might whitelist certain shell commands that are known to be safe.

Sanitizing Arguments

Command arguments often contain sensitive data—passwords, API keys, file contents. We need to log enough information to understand what happened without exposing secrets:

                
sanitizeArgs(args) {
  if (!args || args.length === 0) return [];
  
  return args.map(arg => {
    // Truncate long strings (likely file contents)
    if (typeof arg === 'string' && arg.length > 100) {
      return arg.substring(0, 100) + '... (truncated)';
    }
    // Summarize objects instead of logging full content
    if (typeof arg === 'object' && arg !== null) {
      return { type: 'object', keys: Object.keys(arg) };
    }
    return arg;
  });
}
                
Event Storage and Broadcasting

Once we've assessed a command, we need to store and broadcast the event:

                
logSecurityEvent(event) {
  // Add to shared event history
  this.securityEvents.push(event);

  // Prevent memory leaks - keep only last 1000 events
  if (this.securityEvents.length > 1000) {
    this.securityEvents = this.securityEvents.slice(-1000);
  }

  // Broadcast to dashboard in real-time
  if (this.io) {
    this.io.emit('security-event', event);
  }
  
  // Also broadcast via server-level Socket.IO if available
  if (SecurityDashboardPlugin.dashboardIO) {
    SecurityDashboardPlugin.dashboardIO.emit('security-event', event);
  }

  // Console logging for high-risk events
  if (event.riskLevel && event.riskLevel >= 3) {
    console.log(`🚨 HIGH RISK: ${event.type} - ${event.riskReason}`);
  }
}
                

We broadcast through both instance-level and static Socket.IO connections because we can't guarantee which one will be active when the event occurs.

Extracting Security Features from Capabilities

Not all security risks come from commands—some capabilities themselves are dangerous:

                
extractSecurityFeatures(capabilities) {
  const securityFeatures = [];
  
  const dangerousCapabilities = [
    "autoGrantPermissions",    // Bypasses permission prompts
    "allowTestPackages",       // Allows debug apps
    "skipServerInstallation",  // Skips security checks
    "adbPort",                 // Custom ADB access
  ];

  dangerousCapabilities.forEach(cap => {
    if (capabilities[cap] !== undefined) {
      securityFeatures.push({
        name: cap,
        value: capabilities[cap],
        riskLevel: this.getCapabilityRiskLevel(cap, capabilities[cap])
      });
    }
  });

  return securityFeatures;
}
                

These features are tracked at session creation time, giving us immediate visibility into high-risk configurations before any commands execute.

Security Dashboard in Action

Security Dashboard in Action

With event capture in place, we're gathering rich security telemetry. Next, we'll tackle the trickiest part of this entire project: properly managing sessions across plugin instances.

Session Management Challenge

Session tracking should be straightforward—store session data when it's created, retrieve it when needed, delete it when the session ends. But in practice, this became the most frustrating debugging challenge of the entire project. For hours, the dashboard stubbornly displayed "Active Sessions: 0" despite tests running successfully and generating events.

The W3C Protocol Surprise

The root cause was an assumption about data structure. I expected createSession() to return a simple array:

                
[sessionId, capabilities]
                

But Appium 3 with W3C protocol actually returns:

                
{
  protocol: 'W3C',
  value: [sessionId, capabilities, 'W3C']
}
                

My original code checking if (result && result.length >= 2) failed silently because objects don't have a length property. The session was being created successfully, but my tracking logic never fired. The fix required handling both formats:

                
async createSession(next, driver, w3cCapabilities1, w3cCapabilities2, w3cCapabilities3, driverData) {
  const result = await next();
  
  let sessionId, capabilities;
  
  // Handle W3C format
  if (result?.value && Array.isArray(result.value) && result.value.length >= 2) {
    sessionId = result.value[0];
    capabilities = result.value[1];
  } 
  // Handle direct array format (fallback)
  else if (result?.length >= 2) {
    sessionId = result[0];
    capabilities = result[1];
  } 
  else {
    console.log("Unexpected session format:", result);
    return result;
  }
  
  // Now we can safely track the session
  const sessionInfo = {
    sessionId,
    driverName: driver.constructor.name,
    capabilities,
    securityFeatures: this.extractSecurityFeatures(capabilities),
    startTime: Date.now(),
    events: []
  };
  
  this.sessions.set(sessionId, sessionInfo);
  
  return result;
}
                
Debugging with Strategic Logging

Finding this bug required methodical debugging. I added verbose logging at every step:

                
console.log("Session creation result:", result);
console.log("Sessions Map size before add:", this.sessions.size);
console.log("Sessions Map size after add:", this.sessions.size);
console.log("SharedState sessions size:", SecurityDashboardPlugin.sharedState.sessions.size);
console.log("Are they the same object?", this.sessions === SecurityDashboardPlugin.sharedState.sessions);
                

This revealed that:

The logs showed result was an object with a value property containing the actual array—a classic case of needing to inspect the actual data structure rather than assuming.

The Polling Workaround

Even with correct session tracking, there was another issue: the dashboard loaded before the test started, so it showed zero sessions initially. By the time the user looked at it, the test had finished and the session was deleted. The solution was continuous polling:

                
init() {
  this.loadInitialData();
  // Poll every 2 seconds to catch active sessions
  setInterval(() => this.loadInitialData(), 2000);
}
                

This creates a 2-second window where active sessions will be visible. For longer-running tests (integration suites, marathon testing), sessions appear consistently.

Session Cleanup Strategy

Initially, I deleted sessions immediately when deleteSession() was called. But this meant the dashboard would briefly show a session, then it would vanish. For better visibility, consider keeping ended sessions for a grace period:

                
async deleteSession(next, driver) {
  const sessionId = driver.sessionId;
  
  if (this.sessions.has(sessionId)) {
    const sessionInfo = this.sessions.get(sessionId);
    sessionInfo.endTime = Date.now();
    sessionInfo.status = 'ended';
    
    // Keep the session visible for 5 minutes
    setTimeout(() => {
      this.sessions.delete(sessionId);
    }, 5 * 60 * 1000);
  }
  
  return await next();
}
                

This gives teams time to review session details even after tests complete.

Key Takeaways

Session management taught me three crucial lessons:

With sessions properly tracked, we can now build the server infrastructure that exposes this data to the dashboard UI.

Building the Dashboard Server

The dashboard needs a web server to serve the UI and provide API endpoints for security data. We can't simply add routes to Appium's existing Express server—we need our own isolated server that runs alongside Appium. This is where the updateServer() static method becomes critical.

Server Initialization at Startup

The updateServer() method is called once when Appium starts, before any test sessions exist. This is the perfect place to spin up our dashboard server:

                
static async updateServer(expressApp, httpServer, cliArgs) {
  const dashboardPort = cliArgs.securityDashboardPort || 4724;
  
  // Create our own Express app
  const dashboardApp = express();
  const dashboardServer = http.createServer(dashboardApp);
  
  // Attach Socket.IO for real-time updates
  const io = new Server(dashboardServer, {
    cors: {
      origin: "*",
      methods: ["GET", "POST"]
    }
  });
  
  // Configure middleware
  dashboardApp.use(cors());
  dashboardApp.use(express.json());
  dashboardApp.use(express.static(path.join(__dirname, '../dashboard')));
  
  // Start listening
  dashboardServer.listen(dashboardPort, () => {
    console.log(`Security Dashboard: http://localhost:${dashboardPort}`);
  });
  
  // Store references for later use
  SecurityDashboardPlugin.dashboardServer = dashboardServer;
  SecurityDashboardPlugin.dashboardApp = dashboardApp;
  SecurityDashboardPlugin.dashboardIO = io;
}
                

Notice we store the server instances as static properties. This lets session-level plugin instances access the same server and Socket.IO connection.

API Endpoints

The dashboard needs two primary endpoints—one for current status and one for compliance reporting:

                
dashboardApp.get('/api/security-status', (req, res) => {
  const sharedState = SecurityDashboardPlugin.sharedState || {
    sessions: new Map(),
    securityEvents: []
  };
  
  res.json({
    status: 'active',
    timestamp: Date.now(),
    sessions: Array.from(sharedState.sessions.entries()),
    events: sharedState.securityEvents.slice(-50) // Last 50 events
  });
});

dashboardApp.get('/api/compliance-report', (req, res) => {
  const now = Date.now();
  const last24h = now - (24 * 60 * 60 * 1000);
  
  const recentEvents = sharedState.securityEvents.filter(
    e => e.timestamp > last24h
  );
  
  const recommendations = generateRecommendations(recentEvents, sharedState.sessions);
  
  res.json({
    timestamp: now,
    period: '24h',
    totalEvents: recentEvents.length,
    activeSessions: sharedState.sessions.size,
    recommendations
  });
});
                

The key detail here is reading from SecurityDashboardPlugin.sharedState—the same shared state that session-level instances write to. This ensures the API always returns current data.

Socket.IO Connection Handling

When a dashboard client connects, we immediately send them the current state:

                
io.on('connection', (socket) => {
  console.log('Dashboard client connected');
  
  const sharedState = SecurityDashboardPlugin.sharedState;
  
  // Send initial state
  socket.emit('security-status', {
    sessions: Array.from(sharedState.sessions.entries()),
    events: sharedState.securityEvents.slice(-10)
  });
  
  socket.on('disconnect', () => {
    console.log('Dashboard client disconnected');
  });
});
                

This ensures the dashboard displays data immediately upon loading, rather than waiting for the next polling interval or new event.

Handling the Dual-Instance Scenario

Remember that plugins are instantiated at both server-level and session-level. To prevent creating multiple dashboard servers, we add a check in the constructor:

                
constructor(pluginName, cliArgs = {}) {
  super(pluginName, cliArgs);
  
  // Initialize shared state if needed
  if (!SecurityDashboardPlugin.sharedState) {
    SecurityDashboardPlugin.sharedState = {
      sessions: new Map(),
      securityEvents: []
    };
  }
  
  this.sessions = SecurityDashboardPlugin.sharedState.sessions;
  this.securityEvents = SecurityDashboardPlugin.sharedState.securityEvents;
  
  // Only create server if it doesn't exist
  if (SecurityDashboardPlugin.dashboardServer) {
    console.log('Dashboard server already running');
    this.server = SecurityDashboardPlugin.dashboardServer;
    this.io = SecurityDashboardPlugin.dashboardIO;
  } else {
    // This path typically won't execute because updateServer() runs first
    this.initializeDashboard(cliArgs.securityDashboardPort || 4724);
  }
}
                

In practice, updateServer() creates the server before any constructors run, so session-level instances just grab references to the existing server.

Serving Static Files

The dashboard UI (HTML, CSS, JavaScript) needs to be served from somewhere. We place these files in a dashboard/ directory alongside the plugin code:

  • 📁 appium-security-dashboard/
    • 📁 lib/
      • 📄 plugin.js
    • 📁 dashboard/
      • 📄 index.html
      • 📄 styles.css
      • 📄 app.js
    • 📄 package.json

The express.static() middleware handles serving these files:

                
dashboardApp.use(express.static(path.join(__dirname, '../dashboard')));
                

Now navigating to http://localhost:4724 automatically serves dashboard/index.html.

Port Configuration

Making the port configurable is crucial for environments where port 4724 might be occupied. Users can override it via CLI:

                
appium --use-plugins security-dashboard --plugin-security-dashboard-port 8080
                

This flexibility prevents conflicts and allows running multiple Appium instances with separate dashboards.

With the server infrastructure complete, we can now implement real-time updates to keep the dashboard synchronized with ongoing test activity.

That's all for Part 1 of this topic! There's much more to cover, so be sure to check back soon for the next post where we'll continue the discussion.