mirror of
https://github.com/momenbasel/keyFinder.git
synced 2026-06-08 00:53:55 +02:00
Security hardening, bug fixes, and per-tab alert icon
- Prevent fake finding injection via per-session nonce validation between MAIN world interceptor and ISOLATED world content script - Fix CSV formula injection in export by sanitizing cell values - Serialize storage writes to prevent race conditions across tabs - Cap findings at 5000 with oldest-first eviction - Delete findings by unique ID instead of URL to avoid collateral removal - Validate keyword length (50 chars) and count (50 max) - Add MutationObserver for SPA support (dynamic DOM scanning) - Add explicit CSP to manifest - Add per-tab alert icon with red dot overlay when secrets are found
This commit is contained in:
+91
-7
@@ -1,5 +1,8 @@
|
||||
const KEYWORDS_KEY = "kf_keywords";
|
||||
const FINDINGS_KEY = "kf_findings";
|
||||
const MAX_FINDINGS = 5000;
|
||||
const MAX_KEYWORDS = 50;
|
||||
const MAX_KEYWORD_LENGTH = 50;
|
||||
|
||||
const DEFAULT_KEYWORDS = [
|
||||
"key", "api_key", "apikey", "api-key", "secret", "token",
|
||||
@@ -7,6 +10,76 @@ const DEFAULT_KEYWORDS = [
|
||||
"client_id", "client_secret"
|
||||
];
|
||||
|
||||
// Serialize all storage writes to prevent race conditions
|
||||
let storageQueue = Promise.resolve();
|
||||
function enqueue(fn) {
|
||||
storageQueue = storageQueue.then(fn, fn);
|
||||
return storageQueue;
|
||||
}
|
||||
|
||||
// --- Per-tab alert icon ---
|
||||
const alertTabs = new Set();
|
||||
let alertIconCache = null;
|
||||
|
||||
async function buildAlertIcons() {
|
||||
if (alertIconCache) return alertIconCache;
|
||||
const sizes = [16, 48];
|
||||
const imageData = {};
|
||||
for (const size of sizes) {
|
||||
const resp = await fetch(chrome.runtime.getURL(`icons/icon${size}.png`));
|
||||
const blob = await resp.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = new OffscreenCanvas(size, size);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(bitmap, 0, 0, size, size);
|
||||
// Red alert dot in top-right
|
||||
const r = Math.max(3, Math.round(size * 0.22));
|
||||
const cx = size - r - 1;
|
||||
const cy = r + 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#ff4444";
|
||||
ctx.fill();
|
||||
ctx.lineWidth = size >= 48 ? 2 : 1;
|
||||
ctx.strokeStyle = "#0f0f0f";
|
||||
ctx.stroke();
|
||||
imageData[size] = ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
alertIconCache = imageData;
|
||||
return imageData;
|
||||
}
|
||||
|
||||
async function setAlertIcon(tabId) {
|
||||
if (alertTabs.has(tabId)) return;
|
||||
alertTabs.add(tabId);
|
||||
try {
|
||||
const imageData = await buildAlertIcons();
|
||||
await chrome.action.setIcon({ tabId, imageData });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function resetTabIcon(tabId) {
|
||||
if (!alertTabs.delete(tabId)) return;
|
||||
try {
|
||||
chrome.action.setIcon({
|
||||
tabId,
|
||||
path: { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Reset icon when a tab navigates to a new page
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
||||
if (changeInfo.status === "loading") {
|
||||
resetTabIcon(tabId);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up when a tab is closed
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
alertTabs.delete(tabId);
|
||||
});
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async (details) => {
|
||||
if (details.reason === "install") {
|
||||
await chrome.storage.local.set({
|
||||
@@ -18,7 +91,8 @@ chrome.runtime.onInstalled.addListener(async (details) => {
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.type === "finding") {
|
||||
saveFinding(request.data).then(() => sendResponse({ ok: true }));
|
||||
if (sender.tab?.id) setAlertIcon(sender.tab.id);
|
||||
enqueue(() => saveFinding(request.data)).then(() => sendResponse({ ok: true }));
|
||||
return true;
|
||||
}
|
||||
if (request.type === "getKeywords") {
|
||||
@@ -30,19 +104,19 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
return true;
|
||||
}
|
||||
if (request.type === "addKeyword") {
|
||||
addKeyword(request.keyword).then((result) => sendResponse(result));
|
||||
enqueue(() => addKeyword(request.keyword)).then((result) => sendResponse(result));
|
||||
return true;
|
||||
}
|
||||
if (request.type === "removeKeyword") {
|
||||
removeKeyword(request.keyword).then(() => sendResponse({ ok: true }));
|
||||
enqueue(() => removeKeyword(request.keyword)).then(() => sendResponse({ ok: true }));
|
||||
return true;
|
||||
}
|
||||
if (request.type === "removeFinding") {
|
||||
removeFinding(request.url).then(() => sendResponse({ ok: true }));
|
||||
enqueue(() => removeFinding(request.findingId)).then(() => sendResponse({ ok: true }));
|
||||
return true;
|
||||
}
|
||||
if (request.type === "clearFindings") {
|
||||
clearFindings().then(() => sendResponse({ ok: true }));
|
||||
enqueue(() => clearFindings()).then(() => sendResponse({ ok: true }));
|
||||
return true;
|
||||
}
|
||||
if (request.type === "exportFindings") {
|
||||
@@ -60,6 +134,8 @@ async function addKeyword(keyword) {
|
||||
const keywords = await getKeywords();
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) return { ok: false, error: "Keyword cannot be empty." };
|
||||
if (normalized.length > MAX_KEYWORD_LENGTH) return { ok: false, error: `Keyword must be ${MAX_KEYWORD_LENGTH} characters or fewer.` };
|
||||
if (keywords.length >= MAX_KEYWORDS) return { ok: false, error: `Maximum of ${MAX_KEYWORDS} keywords allowed.` };
|
||||
if (keywords.includes(normalized)) return { ok: false, error: "Keyword already exists." };
|
||||
keywords.push(normalized);
|
||||
await chrome.storage.local.set({ [KEYWORDS_KEY]: keywords });
|
||||
@@ -82,7 +158,15 @@ async function saveFinding(finding) {
|
||||
(f) => f.url === finding.url && f.match === finding.match
|
||||
);
|
||||
if (isDuplicate) return;
|
||||
|
||||
finding.id = crypto.randomUUID();
|
||||
findings.push(finding);
|
||||
|
||||
// Evict oldest findings when cap is exceeded
|
||||
if (findings.length > MAX_FINDINGS) {
|
||||
findings.splice(0, findings.length - MAX_FINDINGS);
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({ [FINDINGS_KEY]: findings });
|
||||
|
||||
const badgeCount = findings.length;
|
||||
@@ -90,9 +174,9 @@ async function saveFinding(finding) {
|
||||
chrome.action.setBadgeBackgroundColor({ color: "#e74c3c" });
|
||||
}
|
||||
|
||||
async function removeFinding(url) {
|
||||
async function removeFinding(findingId) {
|
||||
const findings = await getFindings();
|
||||
const updated = findings.filter((f) => f.url !== url);
|
||||
const updated = findings.filter((f) => f.id !== findingId);
|
||||
await chrome.storage.local.set({ [FINDINGS_KEY]: updated });
|
||||
chrome.action.setBadgeText({ text: updated.length > 0 ? String(updated.length) : "" });
|
||||
}
|
||||
|
||||
@@ -294,9 +294,13 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const kfNonce = document.documentElement.getAttribute("data-kf-verify") || "";
|
||||
document.documentElement.removeAttribute("data-kf-verify");
|
||||
|
||||
window.addEventListener("__kf_finding__", (e) => {
|
||||
const data = e.detail;
|
||||
if (!data) return;
|
||||
if (data.__kfNonce !== kfNonce) return;
|
||||
|
||||
if (data.rawText) {
|
||||
scanText(data.rawText, data.sourceUrl || pageUrl, data.type);
|
||||
@@ -327,6 +331,66 @@
|
||||
scanCookies();
|
||||
await scanExternalScripts();
|
||||
|
||||
// Observe DOM mutations for SPA support
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
if (node.tagName === "SCRIPT") {
|
||||
if (node.src) {
|
||||
// New external script added
|
||||
for (const kw of keywords) {
|
||||
if (node.src.toLowerCase().includes(kw)) {
|
||||
report({
|
||||
url: node.src, match: node.src, type: "script-src",
|
||||
patternName: `Script URL contains: ${kw}`,
|
||||
severity: "medium", confidence: "medium", provider: "URL Scan",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (node.textContent) {
|
||||
scanText(node.textContent, pageUrl, "inline-script");
|
||||
}
|
||||
}
|
||||
// Scan any new hidden inputs
|
||||
const hiddenInputs = node.matches && node.matches('input[type="hidden"]')
|
||||
? [node]
|
||||
: (node.querySelectorAll ? Array.from(node.querySelectorAll('input[type="hidden"]')) : []);
|
||||
for (const input of hiddenInputs) {
|
||||
const name = (input.name || input.id || "").toLowerCase();
|
||||
const value = input.value;
|
||||
if (!value || value.length < 8) continue;
|
||||
const sensitive = ["token", "csrf", "api_key", "apikey", "secret", "auth", "session", "nonce", "key", "access_token"];
|
||||
if (sensitive.some((s) => name.includes(s)) || isHighEntropy(value)) {
|
||||
report({
|
||||
url: pageUrl, match: `${name}=${value.substring(0, 100)}`,
|
||||
type: "hidden-input", patternName: "Hidden Form Field",
|
||||
severity: isHighEntropy(value) ? "high" : "medium",
|
||||
confidence: sensitive.some((s) => name.includes(s)) ? "high" : "medium",
|
||||
provider: "DOM Scan",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Scan data attributes on new elements
|
||||
const elementsToCheck = node.querySelectorAll ? [node, ...node.querySelectorAll("*")] : [node];
|
||||
for (const el of elementsToCheck) {
|
||||
if (!el.attributes) continue;
|
||||
for (const attr of el.attributes) {
|
||||
if (!/^data-.*(?:key|token|secret|auth|api|credential|password)/i.test(attr.name)) continue;
|
||||
if (!attr.value || attr.value.length < 8) continue;
|
||||
report({
|
||||
url: pageUrl, match: `${attr.name}="${attr.value.substring(0, 100)}"`,
|
||||
type: "data-attribute", patternName: "Sensitive Data Attribute",
|
||||
severity: "medium", confidence: isHighEntropy(attr.value) ? "high" : "medium",
|
||||
provider: "DOM Scan",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body || document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
if (seen.size > 0) {
|
||||
console.log(`[KeyFinder] ${seen.size} potential secret(s) found on ${pageDomain}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const nonce = crypto.randomUUID();
|
||||
|
||||
// Store nonce where both MAIN world (interceptor) and ISOLATED world (content.js) can read it.
|
||||
// The interceptor removes data-kf-nonce after reading; data-kf-verify stays for content.js.
|
||||
const el = document.documentElement;
|
||||
el.setAttribute("data-kf-nonce", nonce);
|
||||
el.setAttribute("data-kf-verify", nonce);
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = chrome.runtime.getURL("js/interceptor.js");
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
script.onload = () => script.remove();
|
||||
})();
|
||||
@@ -2,8 +2,11 @@
|
||||
"use strict";
|
||||
|
||||
const EVENT_NAME = "__kf_finding__";
|
||||
const nonce = document.documentElement.getAttribute("data-kf-nonce") || "";
|
||||
document.documentElement.removeAttribute("data-kf-nonce");
|
||||
|
||||
function emit(data) {
|
||||
data.__kfNonce = nonce;
|
||||
window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: data }));
|
||||
}
|
||||
|
||||
|
||||
+18
-11
@@ -173,8 +173,8 @@ function renderFindings() {
|
||||
delBtn.textContent = "Del";
|
||||
delBtn.title = "Remove finding";
|
||||
delBtn.addEventListener("click", async () => {
|
||||
await chrome.runtime.sendMessage({ type: "removeFinding", url: f.url });
|
||||
allFindings = allFindings.filter((x) => x !== f);
|
||||
await chrome.runtime.sendMessage({ type: "removeFinding", findingId: f.id });
|
||||
allFindings = allFindings.filter((x) => x.id !== f.id);
|
||||
renderStats();
|
||||
renderFindings();
|
||||
});
|
||||
@@ -212,19 +212,26 @@ function exportJson() {
|
||||
downloadBlob(blob, `keyfinder-findings-${Date.now()}.json`);
|
||||
}
|
||||
|
||||
function csvSafe(value) {
|
||||
let str = String(value || "");
|
||||
if (/^[=+\-@\t\r]/.test(str)) str = "'" + str;
|
||||
str = str.replace(/"/g, '""');
|
||||
return `"${str}"`;
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const filtered = getFiltered();
|
||||
const headers = ["Severity", "Provider", "Pattern", "Match", "Type", "Domain", "URL", "Page URL", "Timestamp"];
|
||||
const rows = filtered.map((f) => [
|
||||
f.severity || "",
|
||||
f.provider || "",
|
||||
f.patternName || "",
|
||||
`"${(f.match || "").replace(/"/g, '""')}"`,
|
||||
f.type || "",
|
||||
f.domain || "",
|
||||
f.url || "",
|
||||
f.pageUrl || "",
|
||||
f.timestamp ? new Date(f.timestamp).toISOString() : "",
|
||||
csvSafe(f.severity),
|
||||
csvSafe(f.provider),
|
||||
csvSafe(f.patternName),
|
||||
csvSafe(f.match),
|
||||
csvSafe(f.type),
|
||||
csvSafe(f.domain),
|
||||
csvSafe(f.url),
|
||||
csvSafe(f.pageUrl),
|
||||
csvSafe(f.timestamp ? new Date(f.timestamp).toISOString() : ""),
|
||||
]);
|
||||
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
|
||||
+10
-2
@@ -25,14 +25,22 @@
|
||||
},
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["js/interceptor.js"],
|
||||
"js": ["js/interceptor-loader.js"],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN",
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "js/background.js"
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["js/interceptor.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"permissions": ["activeTab", "storage"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user