From 7e49510a4f4d35e5035b7493159256552930cb8b Mon Sep 17 00:00:00 2001 From: devploit Date: Wed, 4 Feb 2026 19:54:18 +0100 Subject: [PATCH 1/6] fix: reduce false positives in path and param/header detection (v2.0.2) - Add redirect detection: compare path redirects with catch-all redirect from random path probe - Add natural variance measurement: verify ambiguous signals with control request on dynamic sites - Improve soft-404 detection: filter paths with nearly identical content length (within 3%) Co-Authored-By: Claude Opus 4.5 --- background.js | 229 +++++++++++++++++++++++++++++++++++++++++++++----- manifest.json | 2 +- 2 files changed, 208 insertions(+), 23 deletions(-) diff --git a/background.js b/background.js index 1bfbaa1..0baf165 100644 --- a/background.js +++ b/background.js @@ -1,6 +1,8 @@ /** - * debugHunter v2.0.1 - Background Service Worker + * debugHunter v2.0.2 - Background Service Worker * Multi-factor detection with configurable comparison strategies + * - Added redirect detection to filter false positives on paths + * - Added natural variance measurement to filter false positives on dynamic sites */ import { stringSimilarity } from './similarity.js'; @@ -325,7 +327,7 @@ function compareHeaders(original, modified) { // MULTI-FACTOR COMPARISON // ============================================================================ -async function analyzeResponseDifference(originalResponse, modifiedResponse, originalText, modifiedText, settings) { +async function analyzeResponseDifference(originalResponse, modifiedResponse, originalText, modifiedText, settings, naturalVariance = null) { const result = { isDifferent: false, confidence: 0, @@ -333,6 +335,7 @@ async function analyzeResponseDifference(originalResponse, modifiedResponse, ori severity: 'low', debugIndicators: null, headerChanges: [], + requiresVarianceCheck: false, // Flag to trigger control request verification }; // 1. Status code change detection @@ -362,9 +365,14 @@ async function analyzeResponseDifference(originalResponse, modifiedResponse, ori } } - // 3. Content length difference + // 3. Content length difference (variance-aware) const lengthDiff = Math.abs(modifiedText.length - originalText.length); - if (lengthDiff >= settings.minLengthDiff) { + // If we know the site's natural variance, use it as minimum threshold + const effectiveLengthThreshold = naturalVariance + ? Math.max(settings.minLengthDiff, naturalVariance.lengthDiff * 1.5) + : settings.minLengthDiff; + + if (lengthDiff >= effectiveLengthThreshold) { result.reasons.push(`Content length diff: ${lengthDiff} bytes`); result.confidence += Math.min(lengthDiff / 100, 25); } @@ -381,7 +389,7 @@ async function analyzeResponseDifference(originalResponse, modifiedResponse, ori else if (debugCheck.level === 'medium' && !['critical', 'high'].includes(result.severity)) result.severity = 'medium'; } - // 5. Similarity check (after filtering dynamic content) + // 5. Similarity check (after filtering dynamic content, variance-aware) let originalFiltered = originalText; let modifiedFiltered = modifiedText; @@ -391,9 +399,21 @@ async function analyzeResponseDifference(originalResponse, modifiedResponse, ori } const similarity = stringSimilarity.compareTwoStrings(originalFiltered, modifiedFiltered); - if (similarity < settings.similarityThreshold) { + + // Adjust threshold based on natural variance (if measured) + // If site naturally varies by 5%, we need more than 5% difference to flag + const effectiveSimilarityThreshold = naturalVariance + ? Math.min(settings.similarityThreshold, naturalVariance.similarity - 0.05) + : settings.similarityThreshold; + + if (similarity < effectiveSimilarityThreshold) { result.reasons.push(`Similarity: ${(similarity * 100).toFixed(1)}%`); result.confidence += (1 - similarity) * 30; + + // If flagging based on similarity alone (no debug indicators), mark for variance verification + if (!naturalVariance && !debugCheck.found) { + result.requiresVarianceCheck = true; + } } // Determine if response is different based on mode @@ -597,6 +617,53 @@ async function getUrlBaseline(url) { } } +// ============================================================================ +// NATURAL VARIANCE MEASUREMENT (for dynamic sites) +// ============================================================================ + +const varianceCache = new Map(); + +async function measureNaturalVariance(url, baselineText, settings) { + // Check cache first (valid for 2 minutes) + if (varianceCache.has(url)) { + const cached = varianceCache.get(url); + if (Date.now() - cached.timestamp < 120000) { + return cached.variance; + } + } + + try { + // Make a control request (identical to baseline - no params/headers) + const controlResponse = await rateLimitedFetch(url); + const controlText = await controlResponse.text(); + + // Filter dynamic content before comparison + let baselineFiltered = baselineText; + let controlFiltered = controlText; + + if (settings.filterDynamicContent) { + baselineFiltered = filterDynamicContent(baselineText, settings.dynamicPatterns); + controlFiltered = filterDynamicContent(controlText, settings.dynamicPatterns); + } + + // Calculate natural variance between two identical requests + const naturalSimilarity = stringSimilarity.compareTwoStrings(baselineFiltered, controlFiltered); + const naturalLengthDiff = Math.abs(controlText.length - baselineText.length); + + const variance = { + similarity: naturalSimilarity, + lengthDiff: naturalLengthDiff, + // Site is "highly dynamic" if two identical requests differ significantly + isHighlyDynamic: naturalSimilarity < 0.95, + }; + + varianceCache.set(url, { variance, timestamp: Date.now() }); + return variance; + } catch (e) { + return null; + } +} + // ============================================================================ // PARAMETER CHECKING (uses cached baseline) // ============================================================================ @@ -609,7 +676,6 @@ function appendParam(url, param) { async function checkParams(url, baseline = null) { const settings = await getSettings(); - const allParams = [...debugParams.high, ...debugParams.medium]; try { // Use provided baseline or fetch new one @@ -624,6 +690,9 @@ async function checkParams(url, baseline = null) { ...debugParams.medium.map(p => ({ ...p, confidence: 'medium' })), ]; + // Track if we've measured variance for this URL (lazy - only when needed) + let measuredVariance = null; + for (const param of sortedParams) { const modifiedUrl = appendParam(url, param); @@ -631,12 +700,30 @@ async function checkParams(url, baseline = null) { const modifiedResponse = await rateLimitedFetch(modifiedUrl); const modifiedText = await modifiedResponse.text(); - const analysis = await analyzeResponseDifference( + // First analysis without variance + let analysis = await analyzeResponseDifference( baseline.mockResponse, modifiedResponse, baseline.text, modifiedText, - settings + settings, + measuredVariance ); + // If flagged but needs variance verification (ambiguous signal) + if (analysis.isDifferent && analysis.requiresVarianceCheck && !measuredVariance) { + // Measure natural variance with a control request + measuredVariance = await measureNaturalVariance(url, baseline.text, settings); + + if (measuredVariance && measuredVariance.isHighlyDynamic) { + // Re-analyze with variance knowledge + analysis = await analyzeResponseDifference( + baseline.mockResponse, modifiedResponse, + baseline.text, modifiedText, + settings, + measuredVariance + ); + } + } + if (analysis.isDifferent) { await addFinding('params', { url: modifiedUrl, @@ -672,6 +759,9 @@ async function checkHeaders(url, baseline = null) { } if (!baseline) return; + // Track if we've measured variance for this URL (lazy - only when needed) + let measuredVariance = null; + for (const header of customHeaders) { try { const headers = new Headers(); @@ -680,12 +770,30 @@ async function checkHeaders(url, baseline = null) { const modifiedResponse = await rateLimitedFetch(url, { headers }); const modifiedText = await modifiedResponse.text(); - const analysis = await analyzeResponseDifference( + // First analysis without variance + let analysis = await analyzeResponseDifference( baseline.mockResponse, modifiedResponse, baseline.text, modifiedText, - settings + settings, + measuredVariance ); + // If flagged but needs variance verification (ambiguous signal) + if (analysis.isDifferent && analysis.requiresVarianceCheck && !measuredVariance) { + // Measure natural variance with a control request + measuredVariance = await measureNaturalVariance(url, baseline.text, settings); + + if (measuredVariance && measuredVariance.isHighlyDynamic) { + // Re-analyze with variance knowledge + analysis = await analyzeResponseDifference( + baseline.mockResponse, modifiedResponse, + baseline.text, modifiedText, + settings, + measuredVariance + ); + } + } + if (analysis.isDifferent) { await addFinding('headers', { url, @@ -712,6 +820,24 @@ async function checkHeaders(url, baseline = null) { // Cache for domain baselines and soft-404 fingerprints const domainCache = new Map(); +// Normalize redirect URL for comparison (resolves relative URLs, removes trailing slashes) +function normalizeRedirectUrl(location, baseUrl) { + try { + const resolved = new URL(location, baseUrl); + // Return pathname without trailing slash for consistent comparison + return resolved.pathname.replace(/\/$/, '') || '/'; + } catch (e) { + return location; + } +} + +// Check if a redirect is just URL normalization (trailing slash, case change) +function isNormalizationRedirect(originalPath, redirectPath) { + const normalizedOriginal = originalPath.replace(/\/$/, '').toLowerCase(); + const normalizedRedirect = redirectPath.replace(/\/$/, '').toLowerCase(); + return normalizedOriginal === normalizedRedirect; +} + async function getDomainBaseline(baseUrl) { if (domainCache.has(baseUrl)) { const cached = domainCache.get(baseUrl); @@ -725,18 +851,32 @@ async function getDomainBaseline(baseUrl) { const baseResponse = await rateLimitedFetch(baseUrl); const baseText = await baseResponse.text(); - // Get soft-404 fingerprint (request a random non-existent path) + // Get soft-404 fingerprint and catch-all redirect (request a random non-existent path) const randomPath = `/${Math.random().toString(36).substring(7)}-${Date.now()}`; let soft404Fingerprint = null; let soft404Length = 0; + let catchAllRedirect = null; try { - const soft404Response = await rateLimitedFetch(baseUrl + randomPath); - const soft404Text = await soft404Response.text(); + // Use redirect: 'manual' to detect catch-all redirects + const soft404Response = await rateLimitedFetch(baseUrl + randomPath, { redirect: 'manual' }); + + // Check if the random path redirects somewhere (catch-all redirect pattern) + if (soft404Response.status >= 300 && soft404Response.status < 400) { + const location = soft404Response.headers.get('location'); + if (location) { + // Normalize the redirect URL for comparison + catchAllRedirect = normalizeRedirectUrl(location, baseUrl); + } + } + + // For fingerprinting, follow the redirect to get actual content + const finalResponse = await rateLimitedFetch(baseUrl + randomPath); + const soft404Text = await finalResponse.text(); soft404Length = soft404Text.length; // Create a fingerprint based on content structure, not exact content soft404Fingerprint = { - status: soft404Response.status, + status: finalResponse.status, length: soft404Text.length, hasTitle: //i.test(soft404Text), isSoft404: isSoft404(soft404Text), @@ -750,6 +890,7 @@ async function getDomainBaseline(baseUrl) { baseLength: baseText.length, soft404Fingerprint, soft404Length, + catchAllRedirect, timestamp: Date.now(), }; @@ -763,26 +904,53 @@ async function getDomainBaseline(baseUrl) { function matchesSoft404(response, text, fingerprint) { if (!fingerprint) return false; - // If it returned the same status as our random 404 probe + // If it returned the same status as our random 404 probe (non-200) if (fingerprint.status === response.status && fingerprint.status !== 200) { return true; } - // If content length is very similar to soft-404 (within 10%) const lengthDiff = Math.abs(text.length - fingerprint.length); - if (lengthDiff < fingerprint.length * 0.1 && fingerprint.isSoft404) { + const lengthRatio = lengthDiff / fingerprint.length; + + // If content length is nearly identical (within 3%), very likely the same page + // This catches soft-404s that return 200 without "404" text + if (lengthRatio < 0.03) { + return true; + } + + // If content length is similar (within 10%) AND has soft-404 indicators + if (lengthRatio < 0.1 && fingerprint.isSoft404) { return true; } return false; } -async function checkPathWithHead(baseUrl, path, settings) { +async function checkPathWithHead(baseUrl, path, settings, catchAllRedirect = null) { const testUrl = baseUrl + path; try { - // First, try HEAD request to check existence (saves bandwidth) - const headResponse = await rateLimitedFetch(testUrl, { method: 'HEAD' }); + // First, try HEAD request with redirect: manual to detect redirects + const headResponse = await rateLimitedFetch(testUrl, { method: 'HEAD', redirect: 'manual' }); + + // Check for redirects (3xx status codes) + if (headResponse.status >= 300 && headResponse.status < 400) { + const location = headResponse.headers.get('location'); + if (location) { + const redirectPath = normalizeRedirectUrl(location, baseUrl); + + // Allow URL normalization redirects (trailing slash, etc.) + if (isNormalizationRedirect(path, redirectPath)) { + // Continue checking - this is just a trailing slash redirect + } else if (catchAllRedirect && redirectPath === catchAllRedirect) { + // This path redirects to the same place as random paths - false positive + return null; + } else { + // Redirects to a different specific location - likely auth/error page + return null; + } + } + } // Only proceed if status indicates potential content if (headResponse.status === 200 || headResponse.status === 403) { @@ -800,6 +968,23 @@ async function checkPathWithHead(baseUrl, path, settings) { } catch (e) { // Try direct GET if HEAD fails (some servers don't support HEAD) try { + // Also check for redirects on GET + const getResponse = await rateLimitedFetch(testUrl, { redirect: 'manual' }); + + if (getResponse.status >= 300 && getResponse.status < 400) { + const location = getResponse.headers.get('location'); + if (location) { + const redirectPath = normalizeRedirectUrl(location, baseUrl); + if (!isNormalizationRedirect(path, redirectPath)) { + if (catchAllRedirect && redirectPath === catchAllRedirect) { + return null; // Same as catch-all - false positive + } + return null; // Different redirect - skip + } + } + } + + // If not a redirect, or a normalization redirect, follow it const response = await rateLimitedFetch(testUrl); if (response.status === 200) { return { response, url: testUrl }; @@ -837,7 +1022,7 @@ async function checkPaths(url) { const results = await Promise.all( batch.map(({ path, severity }) => - checkPathWithHead(baseUrl, path, settings).then(result => + checkPathWithHead(baseUrl, path, settings, baseline.catchAllRedirect).then(result => result ? { ...result, severity, path } : null ) ) diff --git a/manifest.json b/manifest.json index 8647dd8..8f51195 100755 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "debugHunter", - "version": "2.0.1", + "version": "2.0.2", "description": "Discover hidden debugging parameters, headers, and sensitive paths to access dev/sandbox/pre-production environments", "options_page": "options.html", "icons": { From 6f4c48e3e897eb11b005adb53e670c7fab04d612 Mon Sep 17 00:00:00 2001 From: devploit <danielpuasec@gmail.com> Date: Wed, 4 Feb 2026 19:57:32 +0100 Subject: [PATCH 2/6] fix: require variance check for all detections without debug indicators (v2.0.3) Previously, variance check was only triggered for similarity-based signals. Now it triggers for ANY detection that lacks debug indicators, preventing false positives on dynamic pages like login.microsoftonline.com. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --- background.js | 12 +++++++----- manifest.json | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/background.js b/background.js index 0baf165..eb82151 100644 --- a/background.js +++ b/background.js @@ -1,8 +1,9 @@ /** - * debugHunter v2.0.2 - Background Service Worker + * debugHunter v2.0.3 - Background Service Worker * Multi-factor detection with configurable comparison strategies * - Added redirect detection to filter false positives on paths * - Added natural variance measurement to filter false positives on dynamic sites + * - Require variance check for all detections without debug indicators */ import { stringSimilarity } from './similarity.js'; @@ -409,11 +410,12 @@ async function analyzeResponseDifference(originalResponse, modifiedResponse, ori if (similarity < effectiveSimilarityThreshold) { result.reasons.push(`Similarity: ${(similarity * 100).toFixed(1)}%`); result.confidence += (1 - similarity) * 30; + } - // If flagging based on similarity alone (no debug indicators), mark for variance verification - if (!naturalVariance && !debugCheck.found) { - result.requiresVarianceCheck = true; - } + // If we have confidence but NO debug indicators, always verify with variance check + // This prevents false positives on dynamic sites (login pages, news sites, etc.) + if (!naturalVariance && !debugCheck.found && result.confidence > 0) { + result.requiresVarianceCheck = true; } // Determine if response is different based on mode diff --git a/manifest.json b/manifest.json index 8f51195..de32908 100755 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "debugHunter", - "version": "2.0.2", + "version": "2.0.3", "description": "Discover hidden debugging parameters, headers, and sensitive paths to access dev/sandbox/pre-production environments", "options_page": "options.html", "icons": { From b08e37f16180a32e6ce3bdfc0f41ecf10fc914cb Mon Sep 17 00:00:00 2001 From: devploit <danielpuasec@gmail.com> Date: Wed, 4 Feb 2026 20:01:20 +0100 Subject: [PATCH 3/6] fix: always re-analyze with variance, not just for highly dynamic sites (v2.0.4) Previously only re-analyzed if site had < 95% natural similarity. Now always re-analyzes with measured variance to catch subtler false positives. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --- background.js | 14 +++++++------- manifest.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/background.js b/background.js index eb82151..a88eb5b 100644 --- a/background.js +++ b/background.js @@ -1,5 +1,5 @@ /** - * debugHunter v2.0.3 - Background Service Worker + * debugHunter v2.0.4 - Background Service Worker * Multi-factor detection with configurable comparison strategies * - Added redirect detection to filter false positives on paths * - Added natural variance measurement to filter false positives on dynamic sites @@ -710,13 +710,13 @@ async function checkParams(url, baseline = null) { measuredVariance ); - // If flagged but needs variance verification (ambiguous signal) + // If flagged but needs variance verification (no debug indicators found) if (analysis.isDifferent && analysis.requiresVarianceCheck && !measuredVariance) { // Measure natural variance with a control request measuredVariance = await measureNaturalVariance(url, baseline.text, settings); - if (measuredVariance && measuredVariance.isHighlyDynamic) { - // Re-analyze with variance knowledge + if (measuredVariance) { + // Re-analyze with variance knowledge - always re-check, not just for highly dynamic sites analysis = await analyzeResponseDifference( baseline.mockResponse, modifiedResponse, baseline.text, modifiedText, @@ -780,13 +780,13 @@ async function checkHeaders(url, baseline = null) { measuredVariance ); - // If flagged but needs variance verification (ambiguous signal) + // If flagged but needs variance verification (no debug indicators found) if (analysis.isDifferent && analysis.requiresVarianceCheck && !measuredVariance) { // Measure natural variance with a control request measuredVariance = await measureNaturalVariance(url, baseline.text, settings); - if (measuredVariance && measuredVariance.isHighlyDynamic) { - // Re-analyze with variance knowledge + if (measuredVariance) { + // Re-analyze with variance knowledge - always re-check, not just for highly dynamic sites analysis = await analyzeResponseDifference( baseline.mockResponse, modifiedResponse, baseline.text, modifiedText, diff --git a/manifest.json b/manifest.json index de32908..6b1871e 100755 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "debugHunter", - "version": "2.0.3", + "version": "2.0.4", "description": "Discover hidden debugging parameters, headers, and sensitive paths to access dev/sandbox/pre-production environments", "options_page": "options.html", "icons": { From 3928c936c7b23208983beb279f23731dc589d89d Mon Sep 17 00:00:00 2001 From: devploit <danielpuasec@gmail.com> Date: Wed, 4 Feb 2026 20:05:27 +0100 Subject: [PATCH 4/6] fix: only skip paths that redirect to catch-all destination (v2.0.5) Previously all redirecting paths were skipped. Now only paths that redirect to the SAME destination as the random probe are filtered. Other redirects (like /admin -> /admin/login) are followed and checked. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --- background.js | 44 ++++++++++++++++++-------------------------- manifest.json | 2 +- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/background.js b/background.js index a88eb5b..7f8009a 100644 --- a/background.js +++ b/background.js @@ -1,5 +1,5 @@ /** - * debugHunter v2.0.4 - Background Service Worker + * debugHunter v2.0.5 - Background Service Worker * Multi-factor detection with configurable comparison strategies * - Added redirect detection to filter false positives on paths * - Added natural variance measurement to filter false positives on dynamic sites @@ -932,36 +932,31 @@ async function checkPathWithHead(baseUrl, path, settings, catchAllRedirect = nul const testUrl = baseUrl + path; try { - // First, try HEAD request with redirect: manual to detect redirects + // First, try HEAD request with redirect: manual to detect catch-all redirects const headResponse = await rateLimitedFetch(testUrl, { method: 'HEAD', redirect: 'manual' }); // Check for redirects (3xx status codes) if (headResponse.status >= 300 && headResponse.status < 400) { const location = headResponse.headers.get('location'); - if (location) { + if (location && catchAllRedirect) { const redirectPath = normalizeRedirectUrl(location, baseUrl); - - // Allow URL normalization redirects (trailing slash, etc.) - if (isNormalizationRedirect(path, redirectPath)) { - // Continue checking - this is just a trailing slash redirect - } else if (catchAllRedirect && redirectPath === catchAllRedirect) { - // This path redirects to the same place as random paths - false positive - return null; - } else { - // Redirects to a different specific location - likely auth/error page - return null; + // Only skip if it redirects to the SAME place as the random path probe + if (redirectPath === catchAllRedirect && !isNormalizationRedirect(path, redirectPath)) { + return null; // Catch-all redirect - false positive } } + // For other redirects, continue and follow them (could be legit /admin -> /admin/login) } - // Only proceed if status indicates potential content - if (headResponse.status === 200 || headResponse.status === 403) { + // Only proceed if status indicates potential content (or redirect that we'll follow) + if (headResponse.status === 200 || headResponse.status === 403 || + (headResponse.status >= 300 && headResponse.status < 400)) { const contentLength = parseInt(headResponse.headers.get('content-length') || '0'); - // Skip if too small (likely empty or error) - if (contentLength > 0 && contentLength < 30) return null; + // Skip if too small (likely empty or error) - but only for 200 responses + if (headResponse.status === 200 && contentLength > 0 && contentLength < 30) return null; - // Now do full GET to analyze content + // Now do full GET to analyze content (this will follow redirects) const response = await rateLimitedFetch(testUrl); if (response.status === 200) { return { response, url: testUrl }; @@ -970,23 +965,20 @@ async function checkPathWithHead(baseUrl, path, settings, catchAllRedirect = nul } catch (e) { // Try direct GET if HEAD fails (some servers don't support HEAD) try { - // Also check for redirects on GET + // Check for catch-all redirect first const getResponse = await rateLimitedFetch(testUrl, { redirect: 'manual' }); - if (getResponse.status >= 300 && getResponse.status < 400) { + if (getResponse.status >= 300 && getResponse.status < 400 && catchAllRedirect) { const location = getResponse.headers.get('location'); if (location) { const redirectPath = normalizeRedirectUrl(location, baseUrl); - if (!isNormalizationRedirect(path, redirectPath)) { - if (catchAllRedirect && redirectPath === catchAllRedirect) { - return null; // Same as catch-all - false positive - } - return null; // Different redirect - skip + if (redirectPath === catchAllRedirect && !isNormalizationRedirect(path, redirectPath)) { + return null; // Catch-all redirect - false positive } } } - // If not a redirect, or a normalization redirect, follow it + // Follow the redirect and get final content const response = await rateLimitedFetch(testUrl); if (response.status === 200) { return { response, url: testUrl }; diff --git a/manifest.json b/manifest.json index 6b1871e..2dd3f23 100755 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "debugHunter", - "version": "2.0.4", + "version": "2.0.5", "description": "Discover hidden debugging parameters, headers, and sensitive paths to access dev/sandbox/pre-production environments", "options_page": "options.html", "icons": { From 2430682a8441a2150a05f6007ef7a06975a5f4a5 Mon Sep 17 00:00:00 2001 From: devploit <danielpuasec@gmail.com> Date: Wed, 4 Feb 2026 20:19:12 +0100 Subject: [PATCH 5/6] fix: include port in baseUrl for path checking (v2.0.6) Changed urlObj.hostname to urlObj.host to include the port number. Without this, paths on localhost:9000 were being checked on localhost:80. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --- background.js | 4 ++-- manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/background.js b/background.js index 7f8009a..5ed8b09 100644 --- a/background.js +++ b/background.js @@ -1,5 +1,5 @@ /** - * debugHunter v2.0.5 - Background Service Worker + * debugHunter v2.0.6 - Background Service Worker * Multi-factor detection with configurable comparison strategies * - Added redirect detection to filter false positives on paths * - Added natural variance measurement to filter false positives on dynamic sites @@ -996,7 +996,7 @@ async function checkPaths(url) { try { const urlObj = new URL(url); - const baseUrl = `${urlObj.protocol}//${urlObj.hostname}`; + const baseUrl = `${urlObj.protocol}//${urlObj.host}`; // Use .host to include port // Get cached domain baseline const baseline = await getDomainBaseline(baseUrl); diff --git a/manifest.json b/manifest.json index 2dd3f23..f6ad4db 100755 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "debugHunter", - "version": "2.0.5", + "version": "2.0.6", "description": "Discover hidden debugging parameters, headers, and sensitive paths to access dev/sandbox/pre-production environments", "options_page": "options.html", "icons": { From 5d7eaefd70ffb2718e3eeab9b68268dc8fa035d8 Mon Sep 17 00:00:00 2001 From: devploit <danielpuasec@gmail.com> Date: Wed, 4 Feb 2026 20:42:05 +0100 Subject: [PATCH 6/6] fix: reduce false positives in path and param/header detection (v2.0.6) Major changes to reduce false positives: - Redirect detection: compare with catch-all redirect from random path probe - Natural variance measurement for dynamic sites - Smart mode requires clear evidence (debug indicators or status changes) - Debug indicators must be NEW (not present in original response) - Fixed path detection on non-standard ports (use host instead of hostname) - Improved soft-404 detection with tighter content length comparison Added dynamic test server (test/server.py) that serves different content based on debug params/headers, mimicking real-world behavior. Updated README with new test server instructions and changelog. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --- README.md | 30 +++++++- background.js | 97 ++++++++++++++++------- test/server.py | 205 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+), 31 deletions(-) create mode 100644 test/server.py diff --git a/README.md b/README.md index b45b8f3..699e060 100755 --- a/README.md +++ b/README.md @@ -143,15 +143,29 @@ Access settings via the **gear icon** in the popup: ## Testing -A test environment is included to verify the extension works correctly: +A dynamic test server is included to verify the extension works correctly: ```bash cd test/ -./start-server-macos.command # macOS (opens browser automatically) +python3 server.py # Recommended: Dynamic server +``` + +Alternative (static server, limited functionality): +```bash +./start-server-macos.command # macOS ./start-server.sh # Linux/other ``` -This starts a local server on port 9000 with fake sensitive files and debug endpoints. +The dynamic server (`server.py`) serves different content based on debug params/headers: +- **Normal request**: Clean production page (no sensitive data) +- **With debug params**: Debug page with exposed credentials, stack traces, etc. + +This mimics real-world behavior where debug endpoints only expose sensitive data when triggered. + +Test URLs: +- `http://localhost:9000/` — Normal page +- `http://localhost:9000/?debug=1` — Debug mode triggered +- `http://localhost:9000/.env` — Sensitive path ## Technical Details @@ -162,6 +176,16 @@ This starts a local server on port 9000 with fake sensitive files and debug endp ## Changelog +### v2.0.6 +- **Reduced false positives on dynamic sites** (login pages, news sites, etc.) + - Redirect detection: Filters paths that redirect to catch-all destinations + - Natural variance measurement: Detects highly dynamic sites + - Smart mode now requires clear evidence (debug indicators or status changes) + - Debug indicators must be NEW (not present in original response) +- **Fixed path detection on non-standard ports** (e.g., localhost:9000) +- **New dynamic test server** (`test/server.py`) that mimics real-world behavior +- Improved soft-404 detection (content length comparison) + ### v2.0.0 - Complete rewrite with Manifest V3 - Multi-factor detection engine diff --git a/background.js b/background.js index 5ed8b09..ea4cb92 100644 --- a/background.js +++ b/background.js @@ -298,6 +298,11 @@ function containsDebugIndicators(text) { return { found: false, level: null }; } +function getLevelPriority(level) { + const priorities = { critical: 4, high: 3, medium: 2, low: 1 }; + return priorities[level] || 0; +} + function extractInterestingHeaders(response) { const found = {}; for (const header of debugHeaders) { @@ -368,18 +373,26 @@ async function analyzeResponseDifference(originalResponse, modifiedResponse, ori // 3. Content length difference (variance-aware) const lengthDiff = Math.abs(modifiedText.length - originalText.length); - // If we know the site's natural variance, use it as minimum threshold - const effectiveLengthThreshold = naturalVariance - ? Math.max(settings.minLengthDiff, naturalVariance.lengthDiff * 1.5) - : settings.minLengthDiff; + // If we know the site's natural variance, only count if difference EXCEEDS natural variance + const isLengthWithinVariance = naturalVariance && lengthDiff <= naturalVariance.lengthDiff * 1.2; - if (lengthDiff >= effectiveLengthThreshold) { + if (!isLengthWithinVariance && lengthDiff >= settings.minLengthDiff) { result.reasons.push(`Content length diff: ${lengthDiff} bytes`); result.confidence += Math.min(lengthDiff / 100, 25); } - // 4. Debug indicator detection - const debugCheck = containsDebugIndicators(modifiedText); + // 4. Debug indicator detection - only count if NEW (not present in original) + const debugCheckModified = containsDebugIndicators(modifiedText); + const debugCheckOriginal = containsDebugIndicators(originalText); + + // Only consider debug indicators that are NEW (caused by the param/header) + // If the same level of indicator exists in original, it's not caused by our test + const debugCheck = { + found: debugCheckModified.found && (!debugCheckOriginal.found || + getLevelPriority(debugCheckModified.level) > getLevelPriority(debugCheckOriginal.level)), + level: debugCheckModified.level, + }; + if (debugCheck.found) { result.debugIndicators = debugCheck; result.reasons.push(`Debug indicators found: ${debugCheck.level}`); @@ -401,13 +414,11 @@ async function analyzeResponseDifference(originalResponse, modifiedResponse, ori const similarity = stringSimilarity.compareTwoStrings(originalFiltered, modifiedFiltered); - // Adjust threshold based on natural variance (if measured) - // If site naturally varies by 5%, we need more than 5% difference to flag - const effectiveSimilarityThreshold = naturalVariance - ? Math.min(settings.similarityThreshold, naturalVariance.similarity - 0.05) - : settings.similarityThreshold; + // If we know the site's natural variance, only count if similarity is WORSE than natural variance + // E.g., if site naturally has 92% similarity between requests, only flag if this request is < 90% + const isSimilarityWithinVariance = naturalVariance && similarity >= naturalVariance.similarity - 0.02; - if (similarity < effectiveSimilarityThreshold) { + if (!isSimilarityWithinVariance && similarity < settings.similarityThreshold) { result.reasons.push(`Similarity: ${(similarity * 100).toFixed(1)}%`); result.confidence += (1 - similarity) * 30; } @@ -418,6 +429,9 @@ async function analyzeResponseDifference(originalResponse, modifiedResponse, ori result.requiresVarianceCheck = true; } + // Only critical/high/medium indicators count as significant (low like "Warning:" can appear in normal pages) + const hasSignificantDebugIndicators = debugCheck.found && ['critical', 'high', 'medium'].includes(debugCheck.level); + // Determine if response is different based on mode switch (settings.detectionMode) { case 'aggressive': @@ -437,12 +451,26 @@ async function analyzeResponseDifference(originalResponse, modifiedResponse, ori case 'smart': default: - // Multi-factor: needs significant confidence - // If debug indicators found, lower threshold - if (settings.requireDebugIndicators) { - result.isDifferent = result.confidence >= 40 && debugCheck.found; + // Smart mode: require clear evidence to avoid false positives on dynamic sites + const hasStatusBypass = originalResponse.status === 403 && modifiedResponse.status === 200; + const hasServerError = modifiedResponse.status >= 500; + // Check if content is significantly different (not just dynamic variation) + const isSignificantlyDifferent = similarity < 0.70; + // Debug indicators in modified response (even if also in original) + const hasAnyDebugIndicators = debugCheckModified.found && ['critical', 'high', 'medium'].includes(debugCheckModified.level); + + if (hasStatusBypass || hasServerError) { + // Clear signal - status change is strong evidence + result.isDifferent = true; + } else if (hasSignificantDebugIndicators) { + // NEW debug indicators found - report + result.isDifferent = result.confidence >= 40; + } else if (hasAnyDebugIndicators && isSignificantlyDifferent) { + // Debug indicators exist AND content is very different - likely more debug info triggered + result.isDifferent = true; } else { - result.isDifferent = result.confidence >= 50; + // No clear evidence - don't report to avoid FPs on dynamic sites + result.isDifferent = false; } break; } @@ -625,18 +653,30 @@ async function getUrlBaseline(url) { const varianceCache = new Map(); -async function measureNaturalVariance(url, baselineText, settings) { +async function measureNaturalVariance(url, baselineText, settings, useRandomParam = false) { + // Cache key includes whether we're measuring with params + const cacheKey = useRandomParam ? `${url}#withParam` : url; + // Check cache first (valid for 2 minutes) - if (varianceCache.has(url)) { - const cached = varianceCache.get(url); + if (varianceCache.has(cacheKey)) { + const cached = varianceCache.get(cacheKey); if (Date.now() - cached.timestamp < 120000) { return cached.variance; } } try { - // Make a control request (identical to baseline - no params/headers) - const controlResponse = await rateLimitedFetch(url); + // For params, measure variance by adding a random param to see how the site responds + // This catches sites that return different content when ANY query param is present + let controlUrl = url; + if (useRandomParam) { + const randomParam = `_rnd${Math.random().toString(36).substring(7)}`; + const urlObj = new URL(url); + urlObj.searchParams.set(randomParam, '1'); + controlUrl = urlObj.href; + } + + const controlResponse = await rateLimitedFetch(controlUrl); const controlText = await controlResponse.text(); // Filter dynamic content before comparison @@ -648,18 +688,18 @@ async function measureNaturalVariance(url, baselineText, settings) { controlFiltered = filterDynamicContent(controlText, settings.dynamicPatterns); } - // Calculate natural variance between two identical requests + // Calculate natural variance between baseline and control const naturalSimilarity = stringSimilarity.compareTwoStrings(baselineFiltered, controlFiltered); const naturalLengthDiff = Math.abs(controlText.length - baselineText.length); const variance = { similarity: naturalSimilarity, lengthDiff: naturalLengthDiff, - // Site is "highly dynamic" if two identical requests differ significantly + // Site is "highly dynamic" if requests differ significantly isHighlyDynamic: naturalSimilarity < 0.95, }; - varianceCache.set(url, { variance, timestamp: Date.now() }); + varianceCache.set(cacheKey, { variance, timestamp: Date.now() }); return variance; } catch (e) { return null; @@ -712,8 +752,9 @@ async function checkParams(url, baseline = null) { // If flagged but needs variance verification (no debug indicators found) if (analysis.isDifferent && analysis.requiresVarianceCheck && !measuredVariance) { - // Measure natural variance with a control request - measuredVariance = await measureNaturalVariance(url, baseline.text, settings); + // Measure variance with a random param to see how site responds to ANY query param + // This catches sites that return different content when params are present (vs absent) + measuredVariance = await measureNaturalVariance(url, baseline.text, settings, true); if (measuredVariance) { // Re-analyze with variance knowledge - always re-check, not just for highly dynamic sites diff --git a/test/server.py b/test/server.py new file mode 100644 index 0000000..14b3a06 --- /dev/null +++ b/test/server.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +debugHunter Test Server - Dynamic version +Serves different content based on debug params/headers +""" + +import http.server +import socketserver +import os +from urllib.parse import urlparse, parse_qs + +PORT = 9000 +DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + +# Debug params that trigger debug mode +DEBUG_PARAMS = [ + '_debug', 'debug', 'debug_mode', 'XDEBUG_SESSION_START', 'XDEBUG_SESSION', + 'debugbar', 'profiler', 'trace', 'verbose', 'show_errors', 'display_errors', + 'dev_mode', 'phpinfo', 'error_reporting', 'env', 'environment', 'staging', + 'beta', 'internal', 'test', 'admin' +] + +# Debug headers that trigger debug mode +DEBUG_HEADERS = [ + 'x-debug', 'x-forwarded-host', 'x-forwarded-for', 'x-original-url', + 'x-env', 'env', 'x-real-ip' +] + +class DebugHunterHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=DIRECTORY, **kwargs) + + def do_GET(self): + parsed = urlparse(self.path) + query_params = parse_qs(parsed.query) + + # Check for debug params + detected_params = [] + for param in DEBUG_PARAMS: + if param in query_params: + detected_params.append(f"{param}={query_params[param][0]}") + + # Check for debug headers + detected_headers = [] + for header in DEBUG_HEADERS: + value = self.headers.get(header) + if value: + detected_headers.append(f"{header}: {value}") + + is_debug_mode = len(detected_params) > 0 or len(detected_headers) > 0 + + # Serve dynamic index for root path + if parsed.path == '/' or parsed.path == '/index.html': + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + html = self.generate_html(is_debug_mode, detected_params, detected_headers) + self.wfile.write(html.encode()) + return + + # Serve static files for other paths + super().do_GET() + + def do_HEAD(self): + parsed = urlparse(self.path) + + if parsed.path == '/' or parsed.path == '/index.html': + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + return + + super().do_HEAD() + + def generate_html(self, is_debug_mode, detected_params, detected_headers): + if is_debug_mode: + return self.generate_debug_html(detected_params, detected_headers) + else: + return self.generate_normal_html() + + def generate_normal_html(self): + return '''<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Production App + + + +
+

Welcome to Our App

+
Production Mode - Everything is secure
+

This is a normal production page with no sensitive information.

+
+ +''' + + def generate_debug_html(self, detected_params, detected_headers): + params_str = ', '.join(detected_params) if detected_params else 'none' + headers_str = ', '.join(detected_headers) if detected_headers else 'none' + + return f''' + + + + DEBUG MODE ACTIVE + + + +
+

DEBUG MODE ACTIVE

+
WARNING: Sensitive information exposed!
+ +
+
[Detected Debug Params]
+
{params_str}
+
+ +
+
[Detected Debug Headers]
+
{headers_str}
+
+ +
+
[Environment Variables]
+
+DB_HOST=localhost
+DB_NAME=production_db
+DB_PASSWORD=super_secret_password_123!
+API_KEY=sk-1234567890abcdef1234567890abcdef
+AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
+SECRET_KEY=my-super-secret-application-key
+      
+
+ +
+
[Server Configuration]
+
+PHP Version: PHP/8.2.0
+Server: Apache/2.4.52
+Document Root: /var/www/html
+Server User: www-data
+Debug Mode: true
+      
+
+ +
+
[Stack Trace]
+
+Fatal error: Uncaught Exception in /var/www/html/app/core.php:142
+Stack trace:
+#0 /var/www/html/app/core.php(142): Database->connect()
+#1 /var/www/html/app/bootstrap.php(28): Application->init()
+#2 /var/www/html/index.php(5): require_once('/var/www/html/...')
+#3 {{main}}
+      
+
+
+ +''' + +def run_server(): + print("") + print(" " + "=" * 60) + print(" debugHunter Test Server (Dynamic)") + print(" " + "=" * 60) + print(f" Server: http://localhost:{PORT}") + print("") + print(" Test URLs:") + print(f" Main page: http://localhost:{PORT}/") + print(f" With debug: http://localhost:{PORT}/?debug=1") + print(f" With env: http://localhost:{PORT}/?env=dev") + print("") + print(" Sensitive paths (debugHunter should detect these):") + print(f" /.env Credentials, API keys") + print(f" /.git/config Git repository info") + print(f" /config.json Database passwords") + print(f" /phpinfo.php PHP configuration") + print(f" /debug Debug console") + print("") + print(" Press Ctrl+C to stop the server") + print("") + + with socketserver.TCPServer(("", PORT), DebugHunterHandler) as httpd: + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nServer stopped.") + +if __name__ == "__main__": + run_server()