Build an AI Recon Chrome Extension

Build a Chrome extension that uses Gemini AI to analyze any website.

Introduction

30 Second Summary

Every website you visit is quietly broadcasting information about itself. Attackers collect these digital breadcrumbs to find weaknesses before they ever try to break in.

In this project, you will build ReconGhost, a Chrome extension that performs passive web reconnaissance on any website. It uses the Gemini AI API to analyze findings through a real cybersecurity framework called MITRE ATT&CK.

What You'll Build

ReconGhost is a Chrome Manifest V3 extension that extracts security intelligence from any website and uses the Gemini API to map findings to real MITRE ATT&CK Reconnaissance technique IDs.

By the end of this project, you'll have:

  • A reconnaissance engine that silently extracts meta tags, scripts, forms, links, cookies, and technology fingerprints from any page you visit.
  • An AI analysis pipeline that sends raw data to the Gemini API and returns a structured threat report with severity ratings and MITRE ATT&CK technique mappings.
  • A dark-themed popup dashboard with an animated threat score ring, color-coded severity badges, and an exportable JSON report.
  • Secret Mission: A scan history panel that stores past scans and lets you compare threat scores across different websites.

Are there any prerequisites?

You should be comfortable with basic HTML, CSS, and JavaScript. You will also need a Google account to get a free Gemini API key (no billing required).

Set Up Your Project and Get Your Gemini API Key

Every Chrome extension is just a folder of files that Chrome reads directly from your computer. There is no compilation step and no special installer. You create the files, point Chrome at the folder, and your extension comes to life.

In this step, you will create the project folder, grab a free Gemini API key to power the AI analysis, and load your extension shell into Chrome for the first time.

In this step, get ready to:
  • Create the recon-ghost project folder with all required files and subfolders.
  • Get a free Gemini API key from Google AI Studio.
  • Configure manifest.json and load the extension in Chrome.
Create the project folder structure

ReconGhost needs a specific set of files to work as a Chrome extension. You will create a main project folder with a popup/ subfolder for the dashboard UI and an icons/ subfolder for extension icons.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the recon-ghost folder with all subfolders and empty files by running these commands:

macOS

mkdir -p recon-ghost/popup recon-ghost/icons
cd recon-ghost
touch manifest.json content.js background.js popup/popup.html popup/popup.css popup/popup.js

Windows

mkdir recon-ghost\popup
mkdir recon-ghost\icons
cd recon-ghost
New-Item manifest.json, content.js, background.js, popup\popup.html, popup\popup.css, popup\popup.js
  • Confirm the files exist by running:
ls

You should see background.js, content.js, manifest.json, an icons folder, and a popup folder in the output.

Don't see all the files?

Make sure you ran cd recon-ghost before running ls. If you are still in the Desktop directory, you will see your Desktop files instead of the project files.

If the popup folder is missing, re-run the mkdir command that creates it. Help me fix my project folder structure.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code (or Antigravity if you use Google Antigravity IDE) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the recon-ghost folder, and click Open.

You should see the recon-ghost folder structure in the Explorer sidebar on the left, with all your files and folders listed.

Get your Gemini API key

ReconGhost sends the raw reconnaissance data it collects to the Gemini API for AI-powered analysis. To call the API, you need a free key from Google AI Studio.

  • Open your browser and go to aistudio.google.com.
  • Sign in with your Google account.
  • Click Get API Key in the left sidebar.
  • Click Create API key.
  • Copy the generated key and paste it into a safe place, like a text file on your Desktop. You will need it in a later step.

The free tier is generous

The Gemini API free tier gives you 10 requests per minute and 250 requests per day with gemini-2.5-flash. No billing account is required. You will not come close to hitting these limits during this project.

Keep your API key private

Never share your API key publicly or commit it to a Git repository. Anyone who has your key can make API calls on your behalf.

For this project, you will paste it directly into your code, which is fine for learning. In production, you would store it securely using an options page with chrome.storage.

Can't find the Get API Key button?

Make sure you are signed in to your Google account. The left sidebar should show navigation options including Get API Key. If you do not see it, try refreshing the page.

Help me get my Gemini API key.

Configure the extension manifest and load it in Chrome

Every Chrome extension needs a manifest.json file. This is the configuration file that tells Chrome what your extension is called, what permissions it needs, and which files make up the extension.

  • In your code editor, click on manifest.json in the Explorer sidebar to open it.
  • Paste the following configuration into the file:
{
  "manifest_version": 3,
  "name": "ReconGhost",
  "version": "1.0.0",
  "description": "AI-powered web reconnaissance with MITRE ATT&CK mapping",
  "permissions": ["activeTab", "scripting", "storage"],
  "action": {
    "default_popup": "popup/popup.html",
    "default_icon": {
      "48": "icons/icon48.png"
    }
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "icons": {
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

What does this configuration do?

  • manifest_version: 3 tells Chrome this extension uses Manifest V3, the current required standard for all new Chrome extensions.
  • permissions requests three capabilities: activeTab to interact with the current tab, scripting to inject scripts, and storage to save data locally.
  • action.default_popup points to the HTML file that appears when you click the extension icon in the toolbar.
  • background.service_worker registers background.js as the service worker that handles API calls behind the scenes.
  • content_scripts injects content.js into every page you visit. This content script is what reads the page and extracts reconnaissance data.
  • Save manifest.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing a JSON syntax error?

JSON is strict about formatting. Check that every opening brace { has a matching closing brace } and every string value is wrapped in double quotes. A stray comma after the last item in an array or object is the most common mistake.

Help me fix my manifest.json.

✔️ Awesome, I've got everything!

Double check you have saved manifest.json. Time to load the extension in Chrome.

ⓧ I'd like to double check the full code

Here is the full manifest.json file. Make sure yours matches exactly.

{
  "manifest_version": 3,
  "name": "ReconGhost",
  "version": "1.0.0",
  "description": "AI-powered web reconnaissance with MITRE ATT&CK mapping",
  "permissions": ["activeTab", "scripting", "storage"],
  "action": {
    "default_popup": "popup/popup.html",
    "default_icon": {
      "48": "icons/icon48.png"
    }
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "icons": {
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

Now you will load the extension into Chrome so it appears in your browser toolbar.

  • Open Google Chrome.
  • Type chrome://extensions into the address bar and press Enter.
  • Turn on the Developer mode toggle in the top right corner of the page.
  • Click Load unpacked in the top left corner.
  • Navigate to your Desktop, select the recon-ghost folder, and click Select Folder (Windows) or Open (macOS).

You should see ReconGhost appear as a card on the Extensions page with its name, version 1.0.0, and description listed.

What about the missing icon warnings?

You may see warnings about missing icon files (icon48.png, icon128.png). This is expected. The icons/ folder is empty for now. Chrome uses a default puzzle piece icon instead, and the extension works fine without custom icons.

Extension not loading?

Make sure you selected the recon-ghost folder itself, not a file inside it or a parent folder. Chrome expects to find manifest.json directly inside the folder you select.

If you see a red error banner, click Errors on the extension card to read the details. A typo in manifest.json is the most likely cause. Help me load my Chrome extension.

Your workspace is ready and Chrome recognizes ReconGhost as a real extension. Next up, you will build the dark, cyber-themed popup dashboard that displays your scan results.

Build the Cyber-Themed Popup Dashboard

Every reconnaissance tool needs a control center. ReconGhost's popup is the dashboard you'll see every time you click the extension icon in Chrome.

In this step, you'll build the popup's full UI from scratch. The layout includes a ghost-themed header, a SCAN button, an animated threat score ring, and a findings area that will later display the AI analysis results.

In this step, get ready to:
  • Build the HTML structure for the popup dashboard.
  • Style the popup with a dark, cyber-themed color scheme and animations.
  • Wire up the JavaScript event listeners and export functions.
Create the popup HTML structure

The popup is a regular HTML page that Chrome renders inside a small panel when you click the extension icon. It needs every UI element ready in the markup before you add styling or behavior.

  • Open popup/popup.html in your editor and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>ReconGhost</title>
  <link rel="stylesheet" href="popup.css" />
</head>
<body>
  <div class="container">
    <header class="header">
      <div class="logo">
        <span class="logo-icon">👻</span>
        <span class="logo-text">ReconGhost</span>
      </div>
      <span class="version">v1.0</span>
    </header>

    <div class="scan-section">
      <div class="threat-score-container" id="threatScoreContainer" style="display:none;">
        <svg class="score-ring" viewBox="0 0 120 120">
          <circle class="score-ring-bg" cx="60" cy="60" r="52" />
          <circle class="score-ring-fill" id="scoreRingFill" cx="60" cy="60" r="52" />
        </svg>
        <div class="score-value" id="scoreValue">0</div>
        <div class="score-label">THREAT SCORE</div>
      </div>

      <button class="scan-btn" id="scanBtn">
        <span class="scan-btn-text">SCAN TARGET</span>
      </button>

      <div class="status" id="status"></div>
    </div>

    <div class="results" id="results" style="display:none;">
      <div class="summary-card" id="summaryCard"></div>
      <div class="findings-list" id="findingsList"></div>
    </div>

    <div class="actions" id="actions" style="display:none;">
      <button class="action-btn" id="exportBtn">Export JSON</button>
      <button class="action-btn" id="copyBtn">Copy to Clipboard</button>
    </div>
  </div>

  <script src="popup.js"></script>
</body>
</html>

What does this HTML do?

The header section displays the ghost logo and a version tag. The scan-section contains a hidden threat score ring (an SVG circle), the SCAN TARGET button, and a status line for progress updates.

The results and actions divs are hidden with style="display:none;" because they only appear after a scan completes. The script tag loads popup.js from an external file because Manifest V3 blocks all inline JavaScript.

  • Save popup/popup.html.

You won't see a visual change yet. The popup needs CSS styling before it looks like anything.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>ReconGhost</title>
  <link rel="stylesheet" href="popup.css" />
</head>
<body>
  <div class="container">
    <header class="header">
      <div class="logo">
        <span class="logo-icon">👻</span>
        <span class="logo-text">ReconGhost</span>
      </div>
      <span class="version">v1.0</span>
    </header>

    <div class="scan-section">
      <div class="threat-score-container" id="threatScoreContainer" style="display:none;">
        <svg class="score-ring" viewBox="0 0 120 120">
          <circle class="score-ring-bg" cx="60" cy="60" r="52" />
          <circle class="score-ring-fill" id="scoreRingFill" cx="60" cy="60" r="52" />
        </svg>
        <div class="score-value" id="scoreValue">0</div>
        <div class="score-label">THREAT SCORE</div>
      </div>

      <button class="scan-btn" id="scanBtn">
        <span class="scan-btn-text">SCAN TARGET</span>
      </button>

      <div class="status" id="status"></div>
    </div>

    <div class="results" id="results" style="display:none;">
      <div class="summary-card" id="summaryCard"></div>
      <div class="findings-list" id="findingsList"></div>
    </div>

    <div class="actions" id="actions" style="display:none;">
      <button class="action-btn" id="exportBtn">Export JSON</button>
      <button class="action-btn" id="copyBtn">Copy to Clipboard</button>
    </div>
  </div>

  <script src="popup.js"></script>
</body>
</html>
Style the popup with the dark theme

The raw HTML is just structure. Now you'll add CSS to transform it into a dark, hacker-themed dashboard with cyber green accents and purple highlights.

  • Open popup/popup.css and paste the following base layout styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  width: 420px;
  max-height: 580px;
  overflow-y: auto;
  font-family: "Segoe UI", "Consolas", monospace;
  background: #0a0a0f;
  color: #c0c0c0;
}

.container {
  padding: 16px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid #1a1a2e;
}

.logo {
  display: flex;
  align-items: center;
  gap: 8px;
}

.logo-icon {
  font-size: 24px;
}

.logo-text {
  font-size: 18px;
  font-weight: 700;
  color: #00ff41;
  text-transform: uppercase;
  letter-spacing: 2px;
}

.version {
  font-size: 11px;
  color: #555;
}

What does this code do?

The universal reset (*) strips default browser margins and padding. The body is locked to 420px wide, which is the popup panel width in Chrome.

The background color #0a0a0f is near-black. The logo text uses #00ff41 (cyber green) with uppercase and letter spacing for the hacker-terminal aesthetic.

  • Below the code you just added, paste the scan section and threat score ring styles:
.scan-section {
  text-align: center;
  margin-bottom: 16px;
}

.scan-btn {
  width: 100%;
  padding: 14px 24px;
  background: linear-gradient(135deg, #0a2a0a, #0d3d0d);
  border: 1px solid #00ff41;
  color: #00ff41;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 3px;
  cursor: pointer;
  text-transform: uppercase;
  transition: all 0.3s ease;
  font-family: inherit;
}

.scan-btn:hover {
  background: linear-gradient(135deg, #0d3d0d, #0f4f0f);
  box-shadow: 0 0 20px rgba(0, 255, 65, 0.3);
}

.scan-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.scan-btn.scanning .scan-btn-text {
  animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.status {
  margin-top: 10px;
  font-size: 12px;
  color: #00ff41;
  min-height: 18px;
  font-family: "Consolas", monospace;
}

.threat-score-container {
  position: relative;
  width: 130px;
  height: 130px;
  margin: 0 auto 16px;
}

.score-ring {
  width: 130px;
  height: 130px;
  transform: rotate(-90deg);
}

.score-ring-bg {
  fill: none;
  stroke: #1a1a2e;
  stroke-width: 8;
}

.score-ring-fill {
  fill: none;
  stroke: #00ff41;
  stroke-width: 8;
  stroke-linecap: round;
  stroke-dasharray: 326.73;
  stroke-dashoffset: 326.73;
  transition: stroke-dashoffset 1.5s ease-out, stroke 0.5s ease;
}

.score-value {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -60%);
  font-size: 32px;
  font-weight: 700;
  color: #00ff41;
}

.score-label {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, 60%);
  font-size: 9px;
  color: #555;
  letter-spacing: 2px;
  text-transform: uppercase;
}

What's happening here?

The scan button uses a dark green gradient with a green border. On hover, it glows with a box-shadow. When the .scanning class is added (by JavaScript later), the button text pulses in and out using the @keyframes pulse animation.

The threat score ring is an SVG circle with a circumference of 326.73 (2 * pi * 52). The stroke-dasharray and stroke-dashoffset start equal, making the ring invisible. JavaScript will later reduce the offset to "fill" the ring based on the threat score.

  • Below the code you just added, paste the results and actions styles:
.summary-card {
  background: #111128;
  border: 1px solid #1a1a2e;
  padding: 12px;
  margin-bottom: 12px;
  font-size: 13px;
  line-height: 1.5;
  color: #a0a0c0;
}

.findings-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.finding-card {
  background: #0d0d1a;
  border: 1px solid #1a1a2e;
  padding: 12px;
  transition: border-color 0.2s;
}

.finding-card:hover {
  border-color: #333366;
}

.finding-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 6px;
}

.finding-title {
  font-size: 13px;
  font-weight: 600;
  color: #e0e0e0;
}

.severity-badge {
  font-size: 10px;
  font-weight: 700;
  padding: 2px 8px;
  letter-spacing: 1px;
  text-transform: uppercase;
}

.severity-CRITICAL { background: #3d0a0a; color: #ff4444; border: 1px solid #ff4444; }
.severity-HIGH { background: #3d2a0a; color: #ff8c00; border: 1px solid #ff8c00; }
.severity-MEDIUM { background: #3d3d0a; color: #ffd700; border: 1px solid #ffd700; }
.severity-LOW { background: #0a3d0a; color: #00ff41; border: 1px solid #00ff41; }

.finding-description {
  font-size: 12px;
  color: #888;
  margin-bottom: 8px;
  line-height: 1.4;
}

.mitre-tag {
  display: inline-block;
  font-size: 10px;
  padding: 3px 8px;
  background: #1a0a2e;
  color: #b829e3;
  border: 1px solid #b829e3;
  letter-spacing: 0.5px;
}

.actions {
  display: flex;
  gap: 8px;
  margin-top: 12px;
}

.action-btn {
  flex: 1;
  padding: 10px;
  background: #1a1a2e;
  border: 1px solid #333366;
  color: #a0a0c0;
  font-size: 12px;
  cursor: pointer;
  font-family: inherit;
  text-transform: uppercase;
  letter-spacing: 1px;
  transition: all 0.2s;
}

.action-btn:hover {
  background: #252545;
  color: #e0e0ff;
}

What does this code do?

Each finding from the AI analysis will display as a .finding-card with a title, a color-coded severity badge, a description, and a purple .mitre-tag showing the MITRE ATT&CK technique ID.

The four .severity- classes map to threat levels: red for CRITICAL, orange for HIGH, yellow for MEDIUM, and green for LOW. The action buttons at the bottom handle JSON export and clipboard copy.

  • Save popup/popup.css.
  • Go to chrome://extensions in Chrome and click the reload icon on the ReconGhost card.
  • Click the ReconGhost extension icon in your Chrome toolbar. You should see a dark popup with the ghost emoji, green "RECONCHOST" text, and a green-bordered SCAN TARGET button.

Don't see the styled popup?

Make sure you saved both popup.html and popup.css. Unsaved changes won't load.

Check that popup.css is in the same popup/ folder as popup.html. The <link> tag uses a relative path, so both files must be siblings.

If the extension icon is missing from the toolbar, click the puzzle piece icon in Chrome and pin ReconGhost.

Help me debug why my ReconGhost popup is not showing the dark theme.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  width: 420px;
  max-height: 580px;
  overflow-y: auto;
  font-family: "Segoe UI", "Consolas", monospace;
  background: #0a0a0f;
  color: #c0c0c0;
}

.container {
  padding: 16px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid #1a1a2e;
}

.logo {
  display: flex;
  align-items: center;
  gap: 8px;
}

.logo-icon {
  font-size: 24px;
}

.logo-text {
  font-size: 18px;
  font-weight: 700;
  color: #00ff41;
  text-transform: uppercase;
  letter-spacing: 2px;
}

.version {
  font-size: 11px;
  color: #555;
}

.scan-section {
  text-align: center;
  margin-bottom: 16px;
}

.scan-btn {
  width: 100%;
  padding: 14px 24px;
  background: linear-gradient(135deg, #0a2a0a, #0d3d0d);
  border: 1px solid #00ff41;
  color: #00ff41;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 3px;
  cursor: pointer;
  text-transform: uppercase;
  transition: all 0.3s ease;
  font-family: inherit;
}

.scan-btn:hover {
  background: linear-gradient(135deg, #0d3d0d, #0f4f0f);
  box-shadow: 0 0 20px rgba(0, 255, 65, 0.3);
}

.scan-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.scan-btn.scanning .scan-btn-text {
  animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.status {
  margin-top: 10px;
  font-size: 12px;
  color: #00ff41;
  min-height: 18px;
  font-family: "Consolas", monospace;
}

.threat-score-container {
  position: relative;
  width: 130px;
  height: 130px;
  margin: 0 auto 16px;
}

.score-ring {
  width: 130px;
  height: 130px;
  transform: rotate(-90deg);
}

.score-ring-bg {
  fill: none;
  stroke: #1a1a2e;
  stroke-width: 8;
}

.score-ring-fill {
  fill: none;
  stroke: #00ff41;
  stroke-width: 8;
  stroke-linecap: round;
  stroke-dasharray: 326.73;
  stroke-dashoffset: 326.73;
  transition: stroke-dashoffset 1.5s ease-out, stroke 0.5s ease;
}

.score-value {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -60%);
  font-size: 32px;
  font-weight: 700;
  color: #00ff41;
}

.score-label {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, 60%);
  font-size: 9px;
  color: #555;
  letter-spacing: 2px;
  text-transform: uppercase;
}

.summary-card {
  background: #111128;
  border: 1px solid #1a1a2e;
  padding: 12px;
  margin-bottom: 12px;
  font-size: 13px;
  line-height: 1.5;
  color: #a0a0c0;
}

.findings-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.finding-card {
  background: #0d0d1a;
  border: 1px solid #1a1a2e;
  padding: 12px;
  transition: border-color 0.2s;
}

.finding-card:hover {
  border-color: #333366;
}

.finding-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 6px;
}

.finding-title {
  font-size: 13px;
  font-weight: 600;
  color: #e0e0e0;
}

.severity-badge {
  font-size: 10px;
  font-weight: 700;
  padding: 2px 8px;
  letter-spacing: 1px;
  text-transform: uppercase;
}

.severity-CRITICAL { background: #3d0a0a; color: #ff4444; border: 1px solid #ff4444; }
.severity-HIGH { background: #3d2a0a; color: #ff8c00; border: 1px solid #ff8c00; }
.severity-MEDIUM { background: #3d3d0a; color: #ffd700; border: 1px solid #ffd700; }
.severity-LOW { background: #0a3d0a; color: #00ff41; border: 1px solid #00ff41; }

.finding-description {
  font-size: 12px;
  color: #888;
  margin-bottom: 8px;
  line-height: 1.4;
}

.mitre-tag {
  display: inline-block;
  font-size: 10px;
  padding: 3px 8px;
  background: #1a0a2e;
  color: #b829e3;
  border: 1px solid #b829e3;
  letter-spacing: 0.5px;
}

.actions {
  display: flex;
  gap: 8px;
  margin-top: 12px;
}

.action-btn {
  flex: 1;
  padding: 10px;
  background: #1a1a2e;
  border: 1px solid #333366;
  color: #a0a0c0;
  font-size: 12px;
  cursor: pointer;
  font-family: inherit;
  text-transform: uppercase;
  letter-spacing: 1px;
  transition: all 0.2s;
}

.action-btn:hover {
  background: #252545;
  color: #e0e0ff;
}
Wire up the popup JavaScript

The popup looks great, but clicking the buttons does nothing yet. You need JavaScript to connect each button to a function. For now you'll wire up the event listeners and build the export functions. The scan logic comes in a later step.

  • Open popup/popup.js and paste the following setup code:
let currentReport = null;

document.addEventListener("DOMContentLoaded", () => {
  const scanBtn = document.getElementById("scanBtn");
  const exportBtn = document.getElementById("exportBtn");
  const copyBtn = document.getElementById("copyBtn");

  scanBtn.addEventListener("click", startScan);
  exportBtn.addEventListener("click", exportJSON);
  copyBtn.addEventListener("click", copyToClipboard);
});

async function startScan() {
  // placeholder
}

What does this code do?

The currentReport variable will hold the full scan results once a scan completes. It starts as null because no scan has happened yet.

The DOMContentLoaded listener waits for the HTML to fully load before wiring up buttons. Each button gets connected to its handler function using addEventListener. The startScan function is a placeholder you'll fill in when you build the content script.

  • Below the startScan function, paste the export and clipboard functions:
function exportJSON() {
  if (!currentReport) return;
  const blob = new Blob([JSON.stringify(currentReport, null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `recon-ghost-${Date.now()}.json`;
  a.click();
  URL.revokeObjectURL(url);
}

function copyToClipboard() {
  if (!currentReport) return;
  navigator.clipboard.writeText(JSON.stringify(currentReport, null, 2)).then(() => {
    const copyBtn = document.getElementById("copyBtn");
    copyBtn.textContent = "COPIED!";
    setTimeout(() => { copyBtn.textContent = "Copy to Clipboard"; }, 2000);
  });
}

What does this code do?

Both functions check if currentReport exists before doing anything. If no scan has been run yet, they exit early.

The exportJSON function converts the report to a JSON string, wraps it in a Blob, creates a temporary download link, clicks it, and cleans up. The copyToClipboard function writes the JSON to the clipboard and briefly changes the button text to "COPIED!" as visual feedback.

  • Save popup/popup.js.
  • Go to chrome://extensions and click the reload icon on the ReconGhost card.
  • Click the ReconGhost extension icon in Chrome. The popup should open with the same dark theme as before. Hover over the SCAN TARGET button to confirm the green glow effect appears.

Popup not opening after adding JavaScript?

If the popup stops appearing entirely, there may be a syntax error in popup.js. Right-click the extension icon, select Inspect Popup, and check the Console tab for red error messages.

Make sure the <script src="popup.js"> tag in your HTML points to popup.js (not popup/popup.js). Since the HTML file is already inside the popup/ folder, the path is relative to that folder.

Help me debug why my ReconGhost popup stopped opening after I added popup.js.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

let currentReport = null;

document.addEventListener("DOMContentLoaded", () => {
  const scanBtn = document.getElementById("scanBtn");
  const exportBtn = document.getElementById("exportBtn");
  const copyBtn = document.getElementById("copyBtn");

  scanBtn.addEventListener("click", startScan);
  exportBtn.addEventListener("click", exportJSON);
  copyBtn.addEventListener("click", copyToClipboard);
});

async function startScan() {
  // placeholder
}

function exportJSON() {
  if (!currentReport) return;
  const blob = new Blob([JSON.stringify(currentReport, null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `recon-ghost-${Date.now()}.json`;
  a.click();
  URL.revokeObjectURL(url);
}

function copyToClipboard() {
  if (!currentReport) return;
  navigator.clipboard.writeText(JSON.stringify(currentReport, null, 2)).then(() => {
    const copyBtn = document.getElementById("copyBtn");
    copyBtn.textContent = "COPIED!";
    setTimeout(() => { copyBtn.textContent = "Copy to Clipboard"; }, 2000);
  });
}

Your popup dashboard is fully built. It has the dark theme, animated threat score ring, severity badges, and export buttons all ready to go. Next up, you'll build the content script that silently gathers reconnaissance data from any page you visit.

Build the Content Script Reconnaissance Engine

Your popup looks great, but clicking SCAN TARGET does nothing yet. The extension needs a way to reach into the current webpage and extract security-relevant data from it.

In Chrome extension architecture, a content script is the piece that runs inside every webpage the user visits. It can read the page's DOM, scrape meta tags, find forms, and catalog every script loaded on the page. In this step, you will build content.js to do exactly that.

In this step, get ready to:
  • Build a message listener that triggers data collection when the popup sends a scan command.
  • Write data collector functions that extract meta tags, scripts, links, forms, and cookies from any webpage.
  • Add technology fingerprinting that detects frameworks like React, WordPress, and jQuery.
Set up the message listener and basic data collectors

The popup and the content script live in separate worlds. They communicate through Chrome's message passing system. When the popup sends a message with action: "scan", the content script needs to listen for that message, collect data, and send it back.

  • Open content.js in your editor. This file is currently empty.
  • Add the message listener, the main data-gathering function, and the first two collector functions by pasting this code:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "scan") {
    const reconData = gatherReconData();
    sendResponse(reconData);
  }
  return true;
});

function gatherReconData() {
  return {
    url: window.location.href,
    domain: window.location.hostname,
    title: document.title,
    timestamp: new Date().toISOString(),
    metaTags: getMetaTags(),
    scripts: getScripts(),
    links: getLinks(),
    forms: getForms(),
    cookies: getCookies(),
    technologies: detectTechnologies()
  };
}

function getMetaTags() {
  const metas = {};
  document.querySelectorAll("meta").forEach((meta) => {
    const name = meta.getAttribute("name") || meta.getAttribute("property") || meta.getAttribute("http-equiv");
    const content = meta.getAttribute("content");
    if (name && content) {
      metas[name] = content;
    }
  });
  return metas;
}

function getScripts() {
  const scripts = [];
  document.querySelectorAll("script[src]").forEach((script) => {
    scripts.push(script.src);
  });
  return scripts;
}

What does this code do?

chrome.runtime.onMessage.addListener registers a listener that fires whenever the popup (or background script) sends a message. When the message's action is "scan", it calls gatherReconData() and sends the result back. The return true at the end keeps the message channel open for the async response.

gatherReconData() is the master function that calls every collector and bundles results into one object. getMetaTags() reads every <meta> element on the page and pulls its name and content attributes. getScripts() collects the src URL of every external script loaded on the page.

You will not see a change yet. The next chunk adds the remaining collector functions that gatherReconData() depends on.

Links, forms, and cookies are some of the most valuable reconnaissance targets on a web page. An attacker can learn where a site connects to, what data it collects, and how its sessions are managed.

  • Add the link, form, and cookie collector functions below your getScripts() function in content.js by pasting this code:
function getLinks() {
  const internal = [];
  const external = [];
  const currentHost = window.location.hostname;

  document.querySelectorAll("a[href]").forEach((link) => {
    try {
      const url = new URL(link.href, window.location.origin);
      if (url.hostname === currentHost) {
        if (!internal.includes(url.href)) {
          internal.push(url.href);
        }
      } else {
        if (!external.includes(url.href)) {
          external.push(url.href);
        }
      }
    } catch (e) {
      // skip invalid URLs
    }
  });

  return { internal: internal.slice(0, 50), external: external.slice(0, 50) };
}

function getForms() {
  const forms = [];
  document.querySelectorAll("form").forEach((form) => {
    const inputs = [];
    form.querySelectorAll("input, select, textarea").forEach((input) => {
      inputs.push({
        type: input.type || "text",
        name: input.name || "",
        id: input.id || ""
      });
    });
    forms.push({
      action: form.action || "none",
      method: (form.method || "GET").toUpperCase(),
      inputs: inputs
    });
  });
  return forms;
}

function getCookies() {
  const cookieString = document.cookie;
  if (!cookieString) return [];
  return cookieString.split(";").map((c) => c.trim().split("=")[0]);
}

What does this code do?

  • getLinks() finds every <a> tag on the page and splits them into internal (same domain) and external (different domain) arrays. It deduplicates and caps each at 50 entries to keep the data manageable.
  • getForms() catalogs every form element on the page, recording the form's action URL, HTTP method, and every input field's type, name, and ID. Forms reveal what data a site collects from users.
  • getCookies() reads document.cookie and extracts just the cookie names. This reveals how the site manages sessions and tracking.

You still will not see a change yet. One more collector function remains: detectTechnologies().

Add technology fingerprinting

Knowing what technology a website runs on is one of the first things an attacker looks for. A site running an outdated version of WordPress or an old jQuery library is a very different target than one built on a modern framework. This maps directly to MITRE ATT&CK technique T1592.002 (Gather Victim Host Information: Software).

  • Add the detectTechnologies() function below your getCookies() function in content.js by pasting this code:
function detectTechnologies() {
  const techs = [];

  // Strategy 1: Check the meta generator tag
  const generatorMeta = document.querySelector('meta[name="generator"]');
  if (generatorMeta) {
    techs.push(generatorMeta.getAttribute("content"));
  }

  // Strategy 2: Check for known JavaScript globals and DOM attributes
  const techChecks = [
    { name: "jQuery", check: () => typeof window.jQuery !== "undefined" },
    { name: "React", check: () => typeof window.__REACT_DEVTOOLS_GLOBAL_HOOK__ !== "undefined" || document.querySelector("[data-reactroot]") !== null },
    { name: "Vue.js", check: () => typeof window.__VUE__ !== "undefined" || document.querySelector("[data-v-]") !== null },
    { name: "Angular", check: () => document.querySelector("[ng-version]") !== null },
    { name: "Next.js", check: () => typeof window.__NEXT_DATA__ !== "undefined" },
    { name: "Nuxt.js", check: () => typeof window.__NUXT__ !== "undefined" }
  ];

  techChecks.forEach((tech) => {
    try {
      if (tech.check()) {
        techs.push(tech.name);
      }
    } catch (e) {
      // skip detection errors
    }
  });

  // Strategy 3: Scan script src URLs for known library patterns
  const scriptSources = Array.from(document.querySelectorAll("script[src]")).map((s) => s.src.toLowerCase());
  const scriptPatterns = [
    { name: "Bootstrap", pattern: "bootstrap" },
    { name: "Lodash", pattern: "lodash" },
    { name: "Moment.js", pattern: "moment" },
    { name: "Google Analytics", pattern: "google-analytics.com" },
    { name: "Google Tag Manager", pattern: "googletagmanager.com" },
    { name: "Facebook Pixel", pattern: "connect.facebook.net" },
    { name: "Cloudflare", pattern: "cloudflare" },
    { name: "WordPress", pattern: "wp-content" },
    { name: "Shopify", pattern: "shopify" }
  ];

  scriptPatterns.forEach((sp) => {
    if (scriptSources.some((src) => src.includes(sp.pattern))) {
      if (!techs.includes(sp.name)) {
        techs.push(sp.name);
      }
    }
  });

  return techs;
}

What does this code do?

This function uses three detection strategies, the same approach professional tools like Wappalyzer and TraceGlyph use.

  • Strategy 1 checks the meta[name="generator"] tag. Many CMS platforms like WordPress and Drupal stamp this tag automatically.
  • Strategy 2 checks for known JavaScript globals (like window.jQuery or window.__NEXT_DATA__) and DOM attribute signatures (like data-reactroot or ng-version).
  • Strategy 3 scans all script src URLs for known patterns like wp-content (WordPress), googletagmanager.com (Google Tag Manager), and shopify (Shopify).

Why might some technology checks miss results?

Chrome content scripts run in an "isolated world." They share the page's DOM but NOT the page's JavaScript scope. This means checks like typeof window.jQuery !== "undefined" may not detect jQuery even if it is loaded on the page, because window in the content script is a different global object.

DOM-based detections (like checking for data-reactroot attributes or scanning script src URLs) work reliably. The window-global checks are a best-effort bonus.

  • Save content.js.

Seeing red text or a syntax error?

  • Check that you pasted all three code chunks in order, one after the other. Each function should be its own standalone block, not nested inside another function.
  • Make sure every opening brace { has a matching closing brace }. A missing brace at the end of one function will break everything below it.
  • Verify that the quotes around attribute selectors like 'meta[name="generator"]' use single quotes on the outside and double quotes on the inside. Mismatched quotes cause silent failures.

Help me debug a syntax error in my content.js file

Test the reconnaissance engine end to end

All six collector functions are in place and gatherReconData() bundles their results into a single reconData object. Time to reload the extension and prove the content script is collecting real data.

  • Open a new Chrome tab and navigate to any website (for example, https://en.wikipedia.org).
  • Go to chrome://extensions in another tab.
  • Click the reload icon on the ReconGhost extension card to pick up your new content.js file.
  • Go back to the website tab you opened and refresh the page. This ensures the updated content script is injected.
  • Right-click anywhere on the page and select Inspect to open Chrome DevTools.
  • Click the Console tab in DevTools.
  • Test the content script manually by pasting this into the DevTools console and pressing Enter:
chrome.runtime.sendMessage({action: "ping"}, (r) => console.log("Extension active:", !!chrome.runtime.id))

Why test from the page console?

The content script runs in the context of the web page, not the extension popup. Testing from the page's DevTools console confirms the content script was injected and is listening for messages.

  • Now test the full data collection by pasting this into the same console:
chrome.runtime.sendMessage({action: "scan"}, (response) => console.log(response))

What should I see?

Wait. That command sends a message with action "scan" to the extension's background script, not the content script. The content script uses chrome.runtime.onMessage which listens for messages from other extension contexts.

To test the content script directly, you can click the ReconGhost extension icon, then right-click the popup and select Inspect to open the popup's DevTools console. The startScan() function is still a placeholder, so we will fully test the content script in the next step when we wire the popup to the content script.

  • Confirm the extension loaded without errors by checking chrome://extensions. The ReconGhost card should show no red error badges.

Seeing an error badge on the extension card?

  • Click Errors on the ReconGhost card to read the specific error message. A missing function name or unmatched brace is the most common cause.
  • Make sure content.js is saved in the root of your recon-ghost folder, not inside the popup/ folder. The manifest.json expects it at content.js, not popup/content.js.
  • After fixing any issues, click the reload icon on the extension card again.

Help me fix an error in my Chrome extension content script

✔️ Awesome, I've got everything!

Make sure you have saved content.js and reloaded the extension at chrome://extensions.

ⓧ I'd like to double check the full code

Here is the complete content.js file. Make sure yours matches exactly.

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "scan") {
    const reconData = gatherReconData();
    sendResponse(reconData);
  }
  return true;
});

function gatherReconData() {
  return {
    url: window.location.href,
    domain: window.location.hostname,
    title: document.title,
    timestamp: new Date().toISOString(),
    metaTags: getMetaTags(),
    scripts: getScripts(),
    links: getLinks(),
    forms: getForms(),
    cookies: getCookies(),
    technologies: detectTechnologies()
  };
}

function getMetaTags() {
  const metas = {};
  document.querySelectorAll("meta").forEach((meta) => {
    const name = meta.getAttribute("name") || meta.getAttribute("property") || meta.getAttribute("http-equiv");
    const content = meta.getAttribute("content");
    if (name && content) {
      metas[name] = content;
    }
  });
  return metas;
}

function getScripts() {
  const scripts = [];
  document.querySelectorAll("script[src]").forEach((script) => {
    scripts.push(script.src);
  });
  return scripts;
}

function getLinks() {
  const internal = [];
  const external = [];
  const currentHost = window.location.hostname;

  document.querySelectorAll("a[href]").forEach((link) => {
    try {
      const url = new URL(link.href, window.location.origin);
      if (url.hostname === currentHost) {
        if (!internal.includes(url.href)) {
          internal.push(url.href);
        }
      } else {
        if (!external.includes(url.href)) {
          external.push(url.href);
        }
      }
    } catch (e) {
      // skip invalid URLs
    }
  });

  return { internal: internal.slice(0, 50), external: external.slice(0, 50) };
}

function getForms() {
  const forms = [];
  document.querySelectorAll("form").forEach((form) => {
    const inputs = [];
    form.querySelectorAll("input, select, textarea").forEach((input) => {
      inputs.push({
        type: input.type || "text",
        name: input.name || "",
        id: input.id || ""
      });
    });
    forms.push({
      action: form.action || "none",
      method: (form.method || "GET").toUpperCase(),
      inputs: inputs
    });
  });
  return forms;
}

function getCookies() {
  const cookieString = document.cookie;
  if (!cookieString) return [];
  return cookieString.split(";").map((c) => c.trim().split("=")[0]);
}

function detectTechnologies() {
  const techs = [];

  const generatorMeta = document.querySelector('meta[name="generator"]');
  if (generatorMeta) {
    techs.push(generatorMeta.getAttribute("content"));
  }

  const techChecks = [
    { name: "jQuery", check: () => typeof window.jQuery !== "undefined" },
    { name: "React", check: () => typeof window.__REACT_DEVTOOLS_GLOBAL_HOOK__ !== "undefined" || document.querySelector("[data-reactroot]") !== null },
    { name: "Vue.js", check: () => typeof window.__VUE__ !== "undefined" || document.querySelector("[data-v-]") !== null },
    { name: "Angular", check: () => document.querySelector("[ng-version]") !== null },
    { name: "Next.js", check: () => typeof window.__NEXT_DATA__ !== "undefined" },
    { name: "Nuxt.js", check: () => typeof window.__NUXT__ !== "undefined" }
  ];

  techChecks.forEach((tech) => {
    try {
      if (tech.check()) {
        techs.push(tech.name);
      }
    } catch (e) {
      // skip detection errors
    }
  });

  const scriptSources = Array.from(document.querySelectorAll("script[src]")).map((s) => s.src.toLowerCase());
  const scriptPatterns = [
    { name: "Bootstrap", pattern: "bootstrap" },
    { name: "Lodash", pattern: "lodash" },
    { name: "Moment.js", pattern: "moment" },
    { name: "Google Analytics", pattern: "google-analytics.com" },
    { name: "Google Tag Manager", pattern: "googletagmanager.com" },
    { name: "Facebook Pixel", pattern: "connect.facebook.net" },
    { name: "Cloudflare", pattern: "cloudflare" },
    { name: "WordPress", pattern: "wp-content" },
    { name: "Shopify", pattern: "shopify" }
  ];

  scriptPatterns.forEach((sp) => {
    if (scriptSources.some((src) => src.includes(sp.pattern))) {
      if (!techs.includes(sp.name)) {
        techs.push(sp.name);
      }
    }
  });

  return techs;
}

Your reconnaissance engine is ready and waiting for commands. Next up, you will connect the Gemini API so that all this raw data gets analyzed by an AI cybersecurity analyst.

Connect the Gemini API for AI-Powered Analysis

Your content script can now silently harvest reconnaissance data from any web page. That raw data is useful, but an attacker would still need to manually sift through it to find what matters.

In this step, you will connect the Gemini API to analyze that raw data through a cybersecurity lens. Gemini will score the target's exposure, categorize each finding by severity, and map every discovery to a real MITRE ATT&CK technique ID.

In this step, get ready to:
  • Build a cybersecurity prompt that instructs Gemini to return structured ATT&CK-mapped analysis.
  • Create the background service worker that calls the Gemini REST API and handles errors.
  • Wire up the popup to orchestrate the full scan flow and render the intelligence report.
Build the cybersecurity prompt and API configuration

The quality of Gemini's analysis depends entirely on how you prompt it. You need to tell Gemini exactly what role to play, what data to analyze, and what structure to return.

Why a background service worker instead of the content script?

Content scripts can read the DOM, but they cannot make cross-origin API calls due to CORS restrictions. The background service worker runs outside the page context and can freely call external APIs like Gemini.

This is the standard Chrome Manifest V3 pattern: the content script collects data, then passes it to the background worker for processing.

  • Open background.js in your editor. This file is currently empty.
  • Paste in the API configuration constants and the buildPrompt() function by copying this code:
const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";

function buildPrompt(reconData) {
  return `You are a cybersecurity analyst performing passive web reconnaissance analysis.
Analyze the following data collected from a website and provide a security assessment using the MITRE ATT&CK framework (Tactic TA0043: Reconnaissance).

Website Reconnaissance Data:
${JSON.stringify(reconData, null, 2)}

Return your analysis as a JSON object with this exact structure:
{
  "threatScore": <number 0-100, where 0 is minimal exposure and 100 is critical exposure>,
  "summary": "<2-3 sentence overview of the site's reconnaissance exposure>",
  "findings": [
    {
      "category": "<one of: Headers, Technologies, Forms, Scripts, Links, Metadata>",
      "title": "<short finding title>",
      "description": "<what was found and why it matters for an attacker>",
      "severity": "<CRITICAL, HIGH, MEDIUM, or LOW>",
      "mitreTechnique": {
        "id": "<ATT&CK technique ID like T1592, T1594, T1595, or T1596>",
        "name": "<technique name like Gather Victim Host Information>"
      }
    }
  ]
}

Use these real MITRE ATT&CK Reconnaissance techniques where applicable:
- T1592: Gather Victim Host Information (software versions, technologies, server info)
- T1592.002: Gather Victim Host Information: Software (specific software/versions detected)
- T1594: Search Victim-Owned Websites (exposed directories, sensitive pages, forms, metadata)
- T1595.002: Active Scanning: Vulnerability Scanning (missing security headers, misconfigurations)
- T1596.001: Search Open Technical Databases: DNS/Passive DNS (domain/DNS information exposed)
- T1596.005: Search Open Technical Databases: Scan Databases (exposed technologies that appear in scan databases)
- T1593.002: Search Open Websites/Domains: Search Engines (information indexed by search engines via meta tags)

Provide between 3 and 8 findings. Focus on actionable intelligence an attacker could use. Be specific about what data is exposed and how it maps to the ATT&CK technique.`;
}
  • Replace your-api-key-here with the actual Gemini API key you saved in Step 1.

What does this code do?

GEMINI_API_KEY stores your API key. GEMINI_API_URL points to the Gemini 2.5-flash REST endpoint, which gives you 250 free requests per day.

The buildPrompt() function is the core of the intelligence analysis. It injects the raw reconData into a carefully structured prompt that tells Gemini to act as a cybersecurity analyst and return a JSON report with a threat score, severity-rated findings, and real MITRE ATT&CK technique IDs like T1592 and T1594.

Keep your API key safe

Storing the key directly in code is fine for learning. In a production extension, you would store it in chrome.storage.local and let the user enter it through an options page.

You will not see a visible change yet. The next chunk adds the function that actually calls the API.

Create the API call and message handler

The prompt is ready, but nothing calls Gemini yet. You need a function that sends the prompt to the REST API and a message listener that connects the popup to this background worker.

  • In background.js, add the message listener and the analyzeWithGemini() function above the buildPrompt() function you just added. Copy this code directly below the two const lines at the top of the file:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "analyze") {
    analyzeWithGemini(message.reconData)
      .then((result) => sendResponse({ success: true, analysis: result }))
      .catch((error) => sendResponse({ success: false, error: error.message }));
    return true;
  }
});

async function analyzeWithGemini(reconData) {
  // Build the prompt from the recon data
  const prompt = buildPrompt(reconData);

  // Call the Gemini REST API
  const response = await fetch(`${GEMINI_API_URL}?key=${GEMINI_API_KEY}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      contents: [{ parts: [{ text: prompt }] }],
      generationConfig: {
        responseMimeType: "application/json",
        temperature: 0.2
      }
    })
  });

  // Handle HTTP errors from the API
  if (!response.ok) {
    const errText = await response.text();
    throw new Error(`Gemini API error (${response.status}): ${errText}`);
  }

  // Parse the structured JSON from Gemini's response
  const data = await response.json();
  const text = data.candidates[0].content.parts[0].text;
  return JSON.parse(text);
}

What does this code do?

The chrome.runtime.onMessage listener waits for messages from the popup. When it receives one with action: "analyze", it runs analyzeWithGemini() and sends back either a success result or an error message.

The return true inside the listener is critical. It tells Chrome to keep the message channel open while the async API call completes. Without it, sendResponse would silently fail.

The responseMimeType: "application/json" setting forces Gemini to return valid JSON instead of markdown-wrapped text. The low temperature: 0.2 keeps the analysis consistent and factual.

  • Save background.js.
  • Go to chrome://extensions and click the reload icon on ReconGhost to pick up the new service worker.
  • Click the Service worker link on the extension card. A new DevTools window opens for the background script.
  • Check the Console tab. If there are no errors, your service worker loaded successfully.

Seeing an error in the service worker console?

If you see a syntax error, make sure the message listener block is between the two const lines at the top and the buildPrompt() function at the bottom. Check for missing closing braces or parentheses.

If you do not see a Service worker link on the extension card, make sure your manifest.json still has "service_worker": "background.js" inside the "background" object.

Help me debug my background service worker

✔️ Awesome, I've got everything!

Great. Double check you have saved background.js and that the service worker console shows no errors.

ⓧ I'd like to double check the full code

Here is the complete background.js file. Compare it against yours:

const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "analyze") {
    analyzeWithGemini(message.reconData)
      .then((result) => sendResponse({ success: true, analysis: result }))
      .catch((error) => sendResponse({ success: false, error: error.message }));
    return true;
  }
});

async function analyzeWithGemini(reconData) {
  const prompt = buildPrompt(reconData);

  const response = await fetch(`${GEMINI_API_URL}?key=${GEMINI_API_KEY}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      contents: [{ parts: [{ text: prompt }] }],
      generationConfig: {
        responseMimeType: "application/json",
        temperature: 0.2
      }
    })
  });

  if (!response.ok) {
    const errText = await response.text();
    throw new Error(`Gemini API error (${response.status}): ${errText}`);
  }

  const data = await response.json();
  const text = data.candidates[0].content.parts[0].text;
  return JSON.parse(text);
}

function buildPrompt(reconData) {
  return `You are a cybersecurity analyst performing passive web reconnaissance analysis.
Analyze the following data collected from a website and provide a security assessment using the MITRE ATT&CK framework (Tactic TA0043: Reconnaissance).

Website Reconnaissance Data:
${JSON.stringify(reconData, null, 2)}

Return your analysis as a JSON object with this exact structure:
{
  "threatScore": <number 0-100, where 0 is minimal exposure and 100 is critical exposure>,
  "summary": "<2-3 sentence overview of the site's reconnaissance exposure>",
  "findings": [
    {
      "category": "<one of: Headers, Technologies, Forms, Scripts, Links, Metadata>",
      "title": "<short finding title>",
      "description": "<what was found and why it matters for an attacker>",
      "severity": "<CRITICAL, HIGH, MEDIUM, or LOW>",
      "mitreTechnique": {
        "id": "<ATT&CK technique ID like T1592, T1594, T1595, or T1596>",
        "name": "<technique name like Gather Victim Host Information>"
      }
    }
  ]
}

Use these real MITRE ATT&CK Reconnaissance techniques where applicable:
- T1592: Gather Victim Host Information (software versions, technologies, server info)
- T1592.002: Gather Victim Host Information: Software (specific software/versions detected)
- T1594: Search Victim-Owned Websites (exposed directories, sensitive pages, forms, metadata)
- T1595.002: Active Scanning: Vulnerability Scanning (missing security headers, misconfigurations)
- T1596.001: Search Open Technical Databases: DNS/Passive DNS (domain/DNS information exposed)
- T1596.005: Search Open Technical Databases: Scan Databases (exposed technologies that appear in scan databases)
- T1593.002: Search Open Websites/Domains: Search Engines (information indexed by search engines via meta tags)

Provide between 3 and 8 findings. Focus on actionable intelligence an attacker could use. Be specific about what data is exposed and how it maps to the ATT&CK technique.`;
}
Wire up the full scan flow in the popup

The background worker is ready to receive data and return analysis. Now the popup needs to orchestrate the entire pipeline: trigger the content script, forward the results to the background worker, and display the final report.

Right now, startScan() in popup.js is a placeholder. You will replace it with the real orchestration logic.

  • In popup/popup.js, find the current placeholder startScan() function:
async function startScan() {
  // placeholder
}
  • Delete the entire placeholder function and replace it with this code:
async function startScan() {
  const scanBtn = document.getElementById("scanBtn");
  const status = document.getElementById("status");
  const results = document.getElementById("results");
  const actions = document.getElementById("actions");
  const threatScoreContainer = document.getElementById("threatScoreContainer");

  // Disable the button and show scanning animation
  scanBtn.disabled = true;
  scanBtn.classList.add("scanning");
  scanBtn.querySelector(".scan-btn-text").textContent = "SCANNING...";
  results.style.display = "none";
  actions.style.display = "none";
  threatScoreContainer.style.display = "none";
  status.textContent = "> Initiating passive reconnaissance...";

  try {
    // Step 1: Get the active tab
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

    // Step 2: Ask the content script to gather recon data
    status.textContent = "> Extracting page intelligence...";
    const reconData = await chrome.tabs.sendMessage(tab.id, { action: "scan" });

    if (!reconData) {
      throw new Error("No data received from content script. Try refreshing the page.");
    }

    // Step 3: Forward recon data to background worker for AI analysis
    status.textContent = "> Sending data to AI analyst (Gemini)...";
    const response = await chrome.runtime.sendMessage({
      action: "analyze",
      reconData: reconData
    });

    if (!response.success) {
      throw new Error(response.error || "AI analysis failed.");
    }

    status.textContent = "> Analysis complete. Rendering report...";

    // Step 4: Store the full report for export
    currentReport = {
      reconData: reconData,
      analysis: response.analysis,
      generatedAt: new Date().toISOString()
    };

    // Step 5: Render the visual report
    renderReport(response.analysis);

    threatScoreContainer.style.display = "block";
    results.style.display = "block";
    actions.style.display = "flex";
    status.textContent = "> Scan complete.";

  } catch (error) {
    status.textContent = `> ERROR: ${error.message}`;
    status.style.color = "#ff4444";
    setTimeout(() => { status.style.color = "#00ff41"; }, 5000);
  } finally {
    // Re-enable the button regardless of success or failure
    scanBtn.disabled = false;
    scanBtn.classList.remove("scanning");
    scanBtn.querySelector(".scan-btn-text").textContent = "SCAN TARGET";
  }
}

What does this code do?

This function is the conductor of the entire scan pipeline. It follows five steps in sequence:

  • chrome.tabs.query() finds the tab the user is currently viewing.
  • chrome.tabs.sendMessage() tells the content script on that tab to run gatherReconData() and return the results.
  • chrome.runtime.sendMessage() forwards that data to the background worker for Gemini analysis.
  • The result is stored in currentReport for later JSON export.
  • renderReport() displays the findings in the popup UI. You will add this function next.

The scan will not work yet because renderReport() does not exist. Add it now.

  • Add the report rendering functions at the bottom of popup/popup.js, directly below the startScan() function you just pasted. Copy this code:
function renderReport(analysis) {
  const scoreValue = document.getElementById("scoreValue");
  const scoreRingFill = document.getElementById("scoreRingFill");
  const summaryCard = document.getElementById("summaryCard");
  const findingsList = document.getElementById("findingsList");

  // Calculate the ring fill based on the threat score
  const score = analysis.threatScore || 0;
  const circumference = 326.73;
  const offset = circumference - (score / 100) * circumference;

  // Animate the ring from empty to the target offset
  scoreRingFill.style.strokeDashoffset = circumference;
  setTimeout(() => {
    scoreRingFill.style.strokeDashoffset = offset;
  }, 100);

  // Color the score based on severity thresholds
  let scoreColor = "#00ff41";
  if (score > 80) scoreColor = "#ff4444";
  else if (score > 60) scoreColor = "#ff8c00";
  else if (score > 30) scoreColor = "#ffd700";

  scoreRingFill.style.stroke = scoreColor;
  scoreValue.style.color = scoreColor;

  // Animate the number counting up
  animateScore(0, score, 1500);

  // Display the AI-generated summary
  summaryCard.textContent = analysis.summary || "No summary available.";

  // Build a card for each finding
  findingsList.innerHTML = "";
  const findings = analysis.findings || [];
  findings.forEach((finding) => {
    const card = document.createElement("div");
    card.className = "finding-card";
    card.innerHTML = `
      <div class="finding-header">
        <span class="finding-title">${escapeHtml(finding.title)}</span>
        <span class="severity-badge severity-${finding.severity}">${finding.severity}</span>
      </div>
      <div class="finding-description">${escapeHtml(finding.description)}</div>
      <span class="mitre-tag">${escapeHtml(finding.mitreTechnique.id)} - ${escapeHtml(finding.mitreTechnique.name)}</span>
    `;
    findingsList.appendChild(card);
  });
}

function animateScore(from, to, duration) {
  const scoreValue = document.getElementById("scoreValue");
  const startTime = performance.now();

  function update(currentTime) {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / duration, 1);
    // Cubic ease-out for a smooth deceleration
    const eased = 1 - Math.pow(1 - progress, 3);
    const current = Math.round(from + (to - from) * eased);
    scoreValue.textContent = current;
    if (progress < 1) {
      requestAnimationFrame(update);
    }
  }

  requestAnimationFrame(update);
}

function escapeHtml(text) {
  // Safely convert user-supplied text to HTML-safe strings
  const div = document.createElement("div");
  div.textContent = text;
  return div.innerHTML;
}

What does this code do?

renderReport() takes Gemini's structured analysis and turns it into the visual dashboard. The threat score ring uses SVG stroke-dashoffset to animate from empty to the target score. The color shifts from green (safe) to red (critical) based on severity thresholds.

Each finding becomes a card with a color-coded severity badge and a purple MITRE ATT&CK technique tag. escapeHtml() prevents any text from the scanned page from being interpreted as HTML in the popup.

One last small update. The JSON export filename should include the target domain so you can tell reports apart.

  • In popup/popup.js, scroll down to the exportJSON() function and find this line:
  a.download = `recon-ghost-${Date.now()}.json`;
  • Replace it with this line that includes the target domain in the filename:
  a.download = `recon-ghost-${currentReport.reconData.domain}-${Date.now()}.json`;
  • Save popup/popup.js.
  • Go to chrome://extensions and click the reload icon on ReconGhost.
  • Open any website in a regular tab (try a news site or a blog).
  • Click the ReconGhost extension icon in the Chrome toolbar to open the popup.
  • Click SCAN TARGET.

You should see the status messages cycle through each stage of the pipeline. After a few seconds, the threat score ring appears and animates to a number between 0 and 100. Below it, finding cards display with color-coded severity badges and purple MITRE ATT&CK technique tags.

Scan button shows an error?

If you see No data received from content script, refresh the website tab first, then try scanning again. The content script only loads after the page finishes loading.

If you see Gemini API error (400), double check that you replaced your-api-key-here in background.js with your actual Gemini API key.

If you see Gemini API error (429), you have hit the rate limit. Wait 60 seconds and try again. The free tier allows 10 requests per minute.

My ReconGhost scan is failing with an error

✔️ Awesome, I've got everything!

Great. Double check you have saved popup/popup.js and that your scan completes successfully.

ⓧ I'd like to double check the full code

Here is the complete popup/popup.js file. Compare it against yours:

let currentReport = null;

document.addEventListener("DOMContentLoaded", () => {
  const scanBtn = document.getElementById("scanBtn");
  const exportBtn = document.getElementById("exportBtn");
  const copyBtn = document.getElementById("copyBtn");

  scanBtn.addEventListener("click", startScan);
  exportBtn.addEventListener("click", exportJSON);
  copyBtn.addEventListener("click", copyToClipboard);
});

async function startScan() {
  const scanBtn = document.getElementById("scanBtn");
  const status = document.getElementById("status");
  const results = document.getElementById("results");
  const actions = document.getElementById("actions");
  const threatScoreContainer = document.getElementById("threatScoreContainer");

  scanBtn.disabled = true;
  scanBtn.classList.add("scanning");
  scanBtn.querySelector(".scan-btn-text").textContent = "SCANNING...";
  results.style.display = "none";
  actions.style.display = "none";
  threatScoreContainer.style.display = "none";
  status.textContent = "> Initiating passive reconnaissance...";

  try {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

    status.textContent = "> Extracting page intelligence...";
    const reconData = await chrome.tabs.sendMessage(tab.id, { action: "scan" });

    if (!reconData) {
      throw new Error("No data received from content script. Try refreshing the page.");
    }

    status.textContent = "> Sending data to AI analyst (Gemini)...";
    const response = await chrome.runtime.sendMessage({
      action: "analyze",
      reconData: reconData
    });

    if (!response.success) {
      throw new Error(response.error || "AI analysis failed.");
    }

    status.textContent = "> Analysis complete. Rendering report...";

    currentReport = {
      reconData: reconData,
      analysis: response.analysis,
      generatedAt: new Date().toISOString()
    };

    renderReport(response.analysis);

    threatScoreContainer.style.display = "block";
    results.style.display = "block";
    actions.style.display = "flex";
    status.textContent = "> Scan complete.";

  } catch (error) {
    status.textContent = `> ERROR: ${error.message}`;
    status.style.color = "#ff4444";
    setTimeout(() => { status.style.color = "#00ff41"; }, 5000);
  } finally {
    scanBtn.disabled = false;
    scanBtn.classList.remove("scanning");
    scanBtn.querySelector(".scan-btn-text").textContent = "SCAN TARGET";
  }
}

function renderReport(analysis) {
  const scoreValue = document.getElementById("scoreValue");
  const scoreRingFill = document.getElementById("scoreRingFill");
  const summaryCard = document.getElementById("summaryCard");
  const findingsList = document.getElementById("findingsList");

  const score = analysis.threatScore || 0;
  const circumference = 326.73;
  const offset = circumference - (score / 100) * circumference;

  scoreRingFill.style.strokeDashoffset = circumference;
  setTimeout(() => {
    scoreRingFill.style.strokeDashoffset = offset;
  }, 100);

  let scoreColor = "#00ff41";
  if (score > 80) scoreColor = "#ff4444";
  else if (score > 60) scoreColor = "#ff8c00";
  else if (score > 30) scoreColor = "#ffd700";

  scoreRingFill.style.stroke = scoreColor;
  scoreValue.style.color = scoreColor;

  animateScore(0, score, 1500);

  summaryCard.textContent = analysis.summary || "No summary available.";

  findingsList.innerHTML = "";
  const findings = analysis.findings || [];
  findings.forEach((finding) => {
    const card = document.createElement("div");
    card.className = "finding-card";
    card.innerHTML = `
      <div class="finding-header">
        <span class="finding-title">${escapeHtml(finding.title)}</span>
        <span class="severity-badge severity-${finding.severity}">${finding.severity}</span>
      </div>
      <div class="finding-description">${escapeHtml(finding.description)}</div>
      <span class="mitre-tag">${escapeHtml(finding.mitreTechnique.id)} - ${escapeHtml(finding.mitreTechnique.name)}</span>
    `;
    findingsList.appendChild(card);
  });
}

function animateScore(from, to, duration) {
  const scoreValue = document.getElementById("scoreValue");
  const startTime = performance.now();

  function update(currentTime) {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const eased = 1 - Math.pow(1 - progress, 3);
    const current = Math.round(from + (to - from) * eased);
    scoreValue.textContent = current;
    if (progress < 1) {
      requestAnimationFrame(update);
    }
  }

  requestAnimationFrame(update);
}

function escapeHtml(text) {
  const div = document.createElement("div");
  div.textContent = text;
  return div.innerHTML;
}

function exportJSON() {
  if (!currentReport) return;
  const blob = new Blob([JSON.stringify(currentReport, null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `recon-ghost-${currentReport.reconData.domain}-${Date.now()}.json`;
  a.click();
  URL.revokeObjectURL(url);
}

function copyToClipboard() {
  if (!currentReport) return;
  navigator.clipboard.writeText(JSON.stringify(currentReport, null, 2)).then(() => {
    const copyBtn = document.getElementById("copyBtn");
    copyBtn.textContent = "COPIED!";
    setTimeout(() => { copyBtn.textContent = "Copy to Clipboard"; }, 2000);
  });
}

Your entire pipeline is working. The content script gathers intelligence, the background worker analyzes it with Gemini, and the popup displays a professional threat report. Next up, you will polish the report display and test it on real-world websites.

Display the Intelligence Report and Test End-to-End

In the previous step, you wired up the entire scan pipeline. The popup talks to the content script, the content script gathers reconnaissance data, and the background service worker sends it to Gemini for analysis.

Now it is time to see everything come together. In this step, you will walk through how ReconGhost renders the intelligence report, then test the full pipeline on real websites to watch your extension perform live reconnaissance.

In this step, get ready to:
  • Understand how the threat score ring, severity badges, and MITRE ATT&CK technique tags render in the popup.
  • Test the full scan pipeline end-to-end on real websites.
  • Verify the Export JSON and Copy to Clipboard features work correctly.
Walk through how the intelligence report renders

Before you test, it helps to understand what your code is actually doing when a scan completes. The report rendering happens across several functions in popup.js that you built in the previous step.

  • Open popup/popup.js in your editor and scroll to the renderReport() function.

How does renderReport() work?

The renderReport() function takes the structured analysis from Gemini and populates every section of the popup dashboard. Here is what each part does:

  • Threat score ring: The SVG circle uses stroke-dashoffset to animate from empty to the returned score. A circumference of 326.73 pixels maps 0-100 into a visual arc.
  • Color coding: Scores above 80 turn red, above 60 turn orange, above 30 turn yellow, and 30 or below stay green.
  • Finding cards: Each finding from Gemini becomes a card with a severity badge (CRITICAL, HIGH, MEDIUM, LOW) and a purple MITRE ATT&CK technique tag showing the real technique ID and name.
  • The escapeHtml() helper prevents XSS by sanitizing any text from the AI before injecting it into the DOM.
Review the export and clipboard features

After a scan completes, two buttons appear at the bottom of the popup. These let you save and share the reconnaissance report.

  • Scroll down to the exportJSON() and copyToClipboard() functions at the bottom of popup/popup.js.

What do these functions do?

  • exportJSON() creates a downloadable .json file named recon-ghost-{domain}-{timestamp}.json. It bundles the raw reconnaissance data, the AI analysis, and a generation timestamp into one file.
  • copyToClipboard() writes the same JSON to your clipboard and swaps the button text to "COPIED!" for 2 seconds as confirmation.
Test the full pipeline on real websites

Every piece of the extension is in place. The content script extracts data, the background worker calls Gemini, and the popup renders the results. Time to run it for real.

  • Open Chrome and go to chrome://extensions.
  • Click the reload icon on the ReconGhost extension card to pick up any recent changes.

Always reload after changes

Any time you edit your extension files, you need to reload the extension at chrome://extensions for the changes to take effect. This applies to all files: the manifest, service worker, content script, popup HTML, CSS, and JS.

  • Navigate to a real website in a new tab. Start with a news site like https://www.bbc.com or a government site like https://www.usa.gov.
  • Click the ReconGhost extension icon in your Chrome toolbar to open the popup.
  • Click the SCAN TARGET button.

You should see the status text cycle through several messages: "Initiating passive reconnaissance," "Extracting page intelligence," "Sending data to AI analyst," and finally "Scan complete."

  • Verify the threat score ring appears above the scan button and animates from 0 up to the returned score. The ring color should match the score range (green for low, yellow, orange, or red for high).
  • Scroll down in the popup. You should see a summary card with a 2-3 sentence overview of the site's reconnaissance exposure.
  • Below the summary, verify each finding card has a title, a color-coded severity badge, a description, and a purple MITRE ATT&CK technique tag like T1592 - Gather Victim Host Information.

What should I see?

Every website produces different results. A large news site will likely show many technologies detected (Google Analytics, Google Tag Manager, maybe WordPress), exposed meta tags, and external links. A simple static site might score much lower.

Expect between 3 and 8 findings. Each one maps to a real MITRE ATT&CK technique ID like T1592, T1594, or T1596. The Gemini AI picks the most relevant technique for each discovery.

  • Click the Export JSON button at the bottom of the popup.
  • Verify a file downloads with a name like recon-ghost-www.bbc.com-1746200000000.json.
  • Open the downloaded file in your editor. It should contain three top-level keys: reconData (the raw data your content script collected), analysis (the Gemini AI report), and generatedAt (a timestamp).
  • Click the Copy to Clipboard button. The button text should briefly change to "COPIED!" for 2 seconds.

Seeing an error in the status bar?

If the status shows ERROR: No data received from content script, the content script is not loaded on the current page. Refresh the page you are scanning, then try the scan again. Content scripts only inject after a full page load.

If you see a Gemini API error, double check that the API key in background.js is correct. Also confirm you have not exceeded the free tier limit of 10 requests per minute or 250 requests per day.

If nothing happens at all when you click Scan, reload the extension at chrome://extensions and try again.

Help me debug my ReconGhost extension scan errors.

  • Try scanning 2-3 different types of websites to see how the results differ. A personal blog, an e-commerce site, and a government site will each reveal different security exposures and technologies.

How do I open DevTools for the popup?

Chrome extension popups have their own DevTools window. Right-click anywhere inside the ReconGhost popup and select Inspect. This opens the popup's developer console where you can see console.log output, network requests, and any JavaScript errors.

For the background service worker, go to chrome://extensions and click the Service Worker link on the ReconGhost card. This opens a separate DevTools window for the service worker, where you can see Gemini API call errors.

Why might some technology detections be missing?

Chrome content scripts run in an "isolated world." They share the page's DOM but not its JavaScript scope. Checks like typeof window.jQuery !== "undefined" may not detect libraries that the page loaded, because the content script's window object is separate from the page's window.

DOM-based detections (checking for attributes like data-reactroot or ng-version) and script URL pattern matching (looking for wp-content or shopify in script src URLs) work reliably. This is the same approach tools like Wappalyzer use for browser-based detection.

✔️ Awesome, I've got everything!

Double check that all your files are saved. Your extension is fully functional.

ⓧ I'd like to double check the full code

Here is the complete popup/popup.js file for reference. Make sure your version matches exactly.

let currentReport = null;

document.addEventListener("DOMContentLoaded", () => {
  const scanBtn = document.getElementById("scanBtn");
  const exportBtn = document.getElementById("exportBtn");
  const copyBtn = document.getElementById("copyBtn");

  scanBtn.addEventListener("click", startScan);
  exportBtn.addEventListener("click", exportJSON);
  copyBtn.addEventListener("click", copyToClipboard);
});

async function startScan() {
  const scanBtn = document.getElementById("scanBtn");
  const status = document.getElementById("status");
  const results = document.getElementById("results");
  const actions = document.getElementById("actions");
  const threatScoreContainer = document.getElementById("threatScoreContainer");

  scanBtn.disabled = true;
  scanBtn.classList.add("scanning");
  scanBtn.querySelector(".scan-btn-text").textContent = "SCANNING...";
  results.style.display = "none";
  actions.style.display = "none";
  threatScoreContainer.style.display = "none";
  status.textContent = "> Initiating passive reconnaissance...";

  try {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

    status.textContent = "> Extracting page intelligence...";
    const reconData = await chrome.tabs.sendMessage(tab.id, { action: "scan" });

    if (!reconData) {
      throw new Error("No data received from content script. Try refreshing the page.");
    }

    status.textContent = "> Sending data to AI analyst (Gemini)...";
    const response = await chrome.runtime.sendMessage({
      action: "analyze",
      reconData: reconData
    });

    if (!response.success) {
      throw new Error(response.error || "AI analysis failed.");
    }

    status.textContent = "> Analysis complete. Rendering report...";

    currentReport = {
      reconData: reconData,
      analysis: response.analysis,
      generatedAt: new Date().toISOString()
    };

    renderReport(response.analysis);

    threatScoreContainer.style.display = "block";
    results.style.display = "block";
    actions.style.display = "flex";
    status.textContent = "> Scan complete.";

  } catch (error) {
    status.textContent = `> ERROR: ${error.message}`;
    status.style.color = "#ff4444";
    setTimeout(() => { status.style.color = "#00ff41"; }, 5000);
  } finally {
    scanBtn.disabled = false;
    scanBtn.classList.remove("scanning");
    scanBtn.querySelector(".scan-btn-text").textContent = "SCAN TARGET";
  }
}

function renderReport(analysis) {
  const scoreValue = document.getElementById("scoreValue");
  const scoreRingFill = document.getElementById("scoreRingFill");
  const summaryCard = document.getElementById("summaryCard");
  const findingsList = document.getElementById("findingsList");

  const score = analysis.threatScore || 0;
  const circumference = 326.73;
  const offset = circumference - (score / 100) * circumference;

  scoreRingFill.style.strokeDashoffset = circumference;
  setTimeout(() => {
    scoreRingFill.style.strokeDashoffset = offset;
  }, 100);

  let scoreColor = "#00ff41";
  if (score > 80) scoreColor = "#ff4444";
  else if (score > 60) scoreColor = "#ff8c00";
  else if (score > 30) scoreColor = "#ffd700";

  scoreRingFill.style.stroke = scoreColor;
  scoreValue.style.color = scoreColor;

  animateScore(0, score, 1500);

  summaryCard.textContent = analysis.summary || "No summary available.";

  findingsList.innerHTML = "";
  const findings = analysis.findings || [];
  findings.forEach((finding) => {
    const card = document.createElement("div");
    card.className = "finding-card";
    card.innerHTML = `
      <div class="finding-header">
        <span class="finding-title">${escapeHtml(finding.title)}</span>
        <span class="severity-badge severity-${finding.severity}">${finding.severity}</span>
      </div>
      <div class="finding-description">${escapeHtml(finding.description)}</div>
      <span class="mitre-tag">${escapeHtml(finding.mitreTechnique.id)} - ${escapeHtml(finding.mitreTechnique.name)}</span>
    `;
    findingsList.appendChild(card);
  });
}

function animateScore(from, to, duration) {
  const scoreValue = document.getElementById("scoreValue");
  const startTime = performance.now();

  function update(currentTime) {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const eased = 1 - Math.pow(1 - progress, 3);
    const current = Math.round(from + (to - from) * eased);
    scoreValue.textContent = current;
    if (progress < 1) {
      requestAnimationFrame(update);
    }
  }

  requestAnimationFrame(update);
}

function escapeHtml(text) {
  const div = document.createElement("div");
  div.textContent = text;
  return div.innerHTML;
}

function exportJSON() {
  if (!currentReport) return;
  const blob = new Blob([JSON.stringify(currentReport, null, 2)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `recon-ghost-${currentReport.reconData.domain}-${Date.now()}.json`;
  a.click();
  URL.revokeObjectURL(url);
}

function copyToClipboard() {
  if (!currentReport) return;
  navigator.clipboard.writeText(JSON.stringify(currentReport, null, 2)).then(() => {
    const copyBtn = document.getElementById("copyBtn");
    copyBtn.textContent = "COPIED!";
    setTimeout(() => { copyBtn.textContent = "Copy to Clipboard"; }, 2000);
  });
}

And here is the complete background.js file.

const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "analyze") {
    analyzeWithGemini(message.reconData)
      .then((result) => sendResponse({ success: true, analysis: result }))
      .catch((error) => sendResponse({ success: false, error: error.message }));
    return true;
  }
});

async function analyzeWithGemini(reconData) {
  const prompt = buildPrompt(reconData);

  const response = await fetch(`${GEMINI_API_URL}?key=${GEMINI_API_KEY}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      contents: [{ parts: [{ text: prompt }] }],
      generationConfig: {
        responseMimeType: "application/json",
        temperature: 0.2
      }
    })
  });

  if (!response.ok) {
    const errText = await response.text();
    throw new Error(`Gemini API error (${response.status}): ${errText}`);
  }

  const data = await response.json();
  const text = data.candidates[0].content.parts[0].text;
  return JSON.parse(text);
}

function buildPrompt(reconData) {
  return `You are a cybersecurity analyst performing passive web reconnaissance analysis.
Analyze the following data collected from a website and provide a security assessment using the MITRE ATT&CK framework (Tactic TA0043: Reconnaissance).

Website Reconnaissance Data:
${JSON.stringify(reconData, null, 2)}

Return your analysis as a JSON object with this exact structure:
{
  "threatScore": <number 0-100, where 0 is minimal exposure and 100 is critical exposure>,
  "summary": "<2-3 sentence overview of the site's reconnaissance exposure>",
  "findings": [
    {
      "category": "<one of: Headers, Technologies, Forms, Scripts, Links, Metadata>",
      "title": "<short finding title>",
      "description": "<what was found and why it matters for an attacker>",
      "severity": "<CRITICAL, HIGH, MEDIUM, or LOW>",
      "mitreTechnique": {
        "id": "<ATT&CK technique ID like T1592, T1594, T1595, or T1596>",
        "name": "<technique name like Gather Victim Host Information>"
      }
    }
  ]
}

Use these real MITRE ATT&CK Reconnaissance techniques where applicable:
- T1592: Gather Victim Host Information (software versions, technologies, server info)
- T1592.002: Gather Victim Host Information: Software (specific software/versions detected)
- T1594: Search Victim-Owned Websites (exposed directories, sensitive pages, forms, metadata)
- T1595.002: Active Scanning: Vulnerability Scanning (missing security headers, misconfigurations)
- T1596.001: Search Open Technical Databases: DNS/Passive DNS (domain/DNS information exposed)
- T1596.005: Search Open Technical Databases: Scan Databases (exposed technologies that appear in scan databases)
- T1593.002: Search Open Websites/Domains: Search Engines (information indexed by search engines via meta tags)

Provide between 3 and 8 findings. Focus on actionable intelligence an attacker could use. Be specific about what data is exposed and how it maps to the ATT&CK technique.`;
}

And here is the complete content.js file.

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "scan") {
    const reconData = gatherReconData();
    sendResponse(reconData);
  }
  return true;
});

function gatherReconData() {
  return {
    url: window.location.href,
    domain: window.location.hostname,
    title: document.title,
    timestamp: new Date().toISOString(),
    metaTags: getMetaTags(),
    scripts: getScripts(),
    links: getLinks(),
    forms: getForms(),
    cookies: getCookies(),
    technologies: detectTechnologies()
  };
}

function getMetaTags() {
  const metas = {};
  document.querySelectorAll("meta").forEach((meta) => {
    const name = meta.getAttribute("name") || meta.getAttribute("property") || meta.getAttribute("http-equiv");
    const content = meta.getAttribute("content");
    if (name && content) {
      metas[name] = content;
    }
  });
  return metas;
}

function getScripts() {
  const scripts = [];
  document.querySelectorAll("script[src]").forEach((script) => {
    scripts.push(script.src);
  });
  return scripts;
}

function getLinks() {
  const internal = [];
  const external = [];
  const currentHost = window.location.hostname;

  document.querySelectorAll("a[href]").forEach((link) => {
    try {
      const url = new URL(link.href, window.location.origin);
      if (url.hostname === currentHost) {
        if (!internal.includes(url.href)) {
          internal.push(url.href);
        }
      } else {
        if (!external.includes(url.href)) {
          external.push(url.href);
        }
      }
    } catch (e) {
      // skip invalid URLs
    }
  });

  return { internal: internal.slice(0, 50), external: external.slice(0, 50) };
}

function getForms() {
  const forms = [];
  document.querySelectorAll("form").forEach((form) => {
    const inputs = [];
    form.querySelectorAll("input, select, textarea").forEach((input) => {
      inputs.push({
        type: input.type || "text",
        name: input.name || "",
        id: input.id || ""
      });
    });
    forms.push({
      action: form.action || "none",
      method: (form.method || "GET").toUpperCase(),
      inputs: inputs
    });
  });
  return forms;
}

function getCookies() {
  const cookieString = document.cookie;
  if (!cookieString) return [];
  return cookieString.split(";").map((c) => c.trim().split("=")[0]);
}

function detectTechnologies() {
  const techs = [];

  const generatorMeta = document.querySelector('meta[name="generator"]');
  if (generatorMeta) {
    techs.push(generatorMeta.getAttribute("content"));
  }

  const techChecks = [
    { name: "jQuery", check: () => typeof window.jQuery !== "undefined" },
    { name: "React", check: () => typeof window.__REACT_DEVTOOLS_GLOBAL_HOOK__ !== "undefined" || document.querySelector("[data-reactroot]") !== null },
    { name: "Vue.js", check: () => typeof window.__VUE__ !== "undefined" || document.querySelector("[data-v-]") !== null },
    { name: "Angular", check: () => document.querySelector("[ng-version]") !== null },
    { name: "Next.js", check: () => typeof window.__NEXT_DATA__ !== "undefined" },
    { name: "Nuxt.js", check: () => typeof window.__NUXT__ !== "undefined" }
  ];

  techChecks.forEach((tech) => {
    try {
      if (tech.check()) {
        techs.push(tech.name);
      }
    } catch (e) {
      // skip detection errors
    }
  });

  const scriptSources = Array.from(document.querySelectorAll("script[src]")).map((s) => s.src.toLowerCase());
  const scriptPatterns = [
    { name: "Bootstrap", pattern: "bootstrap" },
    { name: "Lodash", pattern: "lodash" },
    { name: "Moment.js", pattern: "moment" },
    { name: "Google Analytics", pattern: "google-analytics.com" },
    { name: "Google Tag Manager", pattern: "googletagmanager.com" },
    { name: "Facebook Pixel", pattern: "connect.facebook.net" },
    { name: "Cloudflare", pattern: "cloudflare" },
    { name: "WordPress", pattern: "wp-content" },
    { name: "Shopify", pattern: "shopify" }
  ];

  scriptPatterns.forEach((sp) => {
    if (scriptSources.some((src) => src.includes(sp.pattern))) {
      if (!techs.includes(sp.name)) {
        techs.push(sp.name);
      }
    }
  });

  return techs;
}

API key security in production

Your API key is stored directly in background.js right now. That is fine for learning, but in a production extension you would use chrome.storage.local to store the key and let the user enter it through an options page. This keeps the key out of your source code.

Secret mission

Add Scan History with Comparison

Your extension scans one page at a time, but what if you could track threat scores across every site you visit? Build a History panel that stores past scans using chrome.storage and lets you compare reconnaissance results over time.

Before you go

Clean Up Your Resources

This project runs entirely on your local machine. The Gemini API free tier does not require billing, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The recon-ghost project folder on your Desktop with all extension files.
  • The ReconGhost Chrome extension loaded in developer mode.
  • A Gemini API key from Google AI Studio.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep scanning websites with ReconGhost.

  • Your extension stays loaded in Chrome and ready to use.
  • Your Gemini API key remains active on the free tier with no charges.
  • Your project files stay on your Desktop.

Pause - I'll come back to this later

Disable the extension in Chrome to keep it from running in the background. Your files and API key stay intact so you can pick up where you left off.

  • Open chrome://extensions in your browser.
  • Find ReconGhost and toggle the switch to disable it.
  • When you are ready to resume, toggle the switch back on and click the refresh icon on the extension card.

Delete - I don't want to use this again

Remove all project resources and start fresh. This removes the extension from Chrome, deletes your local files, and optionally revokes your API key.

  • Open chrome://extensions in your browser.
  • Find ReconGhost and click Remove.
  • Confirm the removal when prompted.
  • Delete the recon-ghost folder from your Desktop.
  • To revoke your Gemini API key, go to aistudio.google.com, click Get API Key in the left sidebar, find your key, and delete it.

Mission accomplished

That's a Wrap!

You just built ReconGhost, your own AI-powered cybersecurity reconnaissance tool. That is seriously impressive.

You've learned how to:

  • Build a Chrome Manifest V3 extension from scratch with content scripts, a background service worker, and a dark, hacker-themed popup dashboard with an animated threat score ring.
  • Build a passive reconnaissance engine that extracts technologies, scripts, forms, links, and metadata from any website, applying real MITRE ATT&CK Reconnaissance techniques (TA0043) to understand how attackers gather information about targets.
  • Integrate the Gemini AI REST API to analyze security data and map findings to real ATT&CK technique IDs like T1592, T1594, T1595, and T1596, with an exportable JSON report.
  • Secret Mission: Added persistent scan history with chrome.storage and cross-site threat score comparison.

Ready to quiz yourself?