Is Web Guardian: Phishing & Malware Protection safe?
Web Guardian tracks every site you visit and uploads session, page-view, and on-page text data to its own servers by default.
A background service worker records how long you spend on each site and each page you visit (with referrer and ad/UTM parameters), and a content script scans the visible text of every page against a remote brand-name list; both are uploaded to webguardian.st-panel-api.com, gated by a data-collection toggle that is turned on by default in the welcome screen. On Walmart, Target, and Instacart search pages, a bundled ad-finder SDK also scrapes sponsored product listings and the user's search term and uploads them to the same backend every few seconds. A separate 'Sign in with Google' flow, unrelated to phishing/malware protection, requests read access to the user's Gmail and ties the resulting session to purchases.stayfreeapps.com.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Web Guardian logs every site you visit, with collection on by default
The extension logs a session and page view, with referrer and UTM tags, for every site you visit, then uploads batches every 5 minutes to a Sensor Tower endpoint tied to your install ID.
The onboarding checkbox for this starts checked.
You browse to any website with the extension installed and leave the onboarding data-sharing checkbox as it was.
That checkbox starts checked, so no action is required to leave it enabled.
The background service worker logs a session and page-view record for that site and later uploads it to a Sensor Tower analytics endpoint tied to your install ID.
The upload also carries the page's referrer and any UTM campaign parameters.
| Field | Value | Why it matters | |
|---|---|---|---|
Install ID | 8f2e1a90-6c4d-4b3f-9a21-77e0c4d8b615 | A persistent identifier that ties every site you visit, across every session, back to the same you. | |
Website visited | www.chase.com | The hostname of each site you browse to, logged for every page you view. | |
Session duration | 182 seconds, 2026-09-18T14:02:11Z | How long you stayed on that site, with a timestamp. | |
Referrer and campaign tags | utm_source=newsletter&utm_campaign=fall_sale | Where you came from and any UTM marketing tags on the link you followed. | |
Birth year | 1991 | An optional age indicator you can give during onboarding. |
The upload function and the checkbox that gates it
// uploadSessions -- background.js, function bound as api.uploadSessions.
// Called every ~5 minutes by the upload scheduler (uploadIntervalInMs: 3e5
// == 300000ms, background.js:19063), batching whatever sessions/page views
// queued locally since the last run.
async function uploadSessions(params) {
if (!params.appId.trim()) throw new Error("Missing required parameter 'appId'");
if (!params.installId.trim()) throw new Error("Missing required parameter 'installId'");
const ua = parseUserAgent(navigator?.userAgent);
await panelApiClient.request(`/v1/web/upload`, {
method: "POST",
retry: 0,
body: {
app_id: params.appId,
install_id: params.installId, // persistent per-install identifier
time_zone: getTimeZone(),
device_name: normalizeBrowserName(ua.browser?.name),
device_type: ua.os.name,
birth_year: params.birthYear, // optional, collected during onboarding
websites: params.websites, // { hostname: { sessions: [{duration,timestamp}] } }
diff_private_websites: params.diffPrivateWebsites,
},
});
}
// baseURL for this client is set to https://webguardian.st-panel-api.com
// (background.js:19023). A sibling function uploadPageViews posts the same
// shape to /v1/page_views/upload with referrer/UTM fields added per path.// welcome/App.vue (compiled) -- the onboarding screen's data-sharing checkbox.
setup() {
// Vue ref backing the checkbox. Starts checked (true) before any user
// interaction; a stored value only overwrites it later if one already
// exists in chrome.storage.local from a previous run.
const dataCollectionEnabled = ref(true);
onMounted(async () => {
const stored = (await storage.local.get("dataCollectionEnabled"))["dataCollectionEnabled"];
if (typeof stored === "boolean") dataCollectionEnabled.value = stored;
});
// Only called when the user finishes the welcome flow (Sign in or
// Continue without signing in); writes whatever the checkbox currently
// holds, which is `true` unless the user unchecked it first.
async function persistDataCollectionConsent() {
await storage.local.set("dataCollectionEnabled", dataCollectionEnabled.value);
}
}
// Template: a plain <input type="checkbox"> two-way-bound to that ref, with
// no default `checked=false` override.
<input id="welcome-data-sharing" type="checkbox"
v-model="dataCollectionEnabled" />A scheduled alarm fires on a fixed interval and uploads everything queued since the last run.
- webguardian.st-panel-api.com
Sensor Tower's panel-api backend for this extension, shared with its StayFree/StayFocusd apps; receives session and page-view uploads keyed to your install ID.
Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.
Ad-finder SDK reads sponsored listings and your search term on 3 retailers
A bundled ad-measurement module reads sponsored product listings plus your search term on Walmart, Target, and Instacart, then uploads them to a Sensor Tower endpoint every 3 seconds while the results page stays open.
You search for a common product on Walmart, Target, or Instacart and sponsored listings render on the results page.
A bundled ad-measurement module reads each sponsored listing's title, price, and size along with your search term, then uploads them to a Sensor Tower endpoint for that retailer.
The scan reruns every 3 seconds for as long as the results page stays open.
| Field | Value | Why it matters | |
|---|---|---|---|
Ad title | Bounty Paper Towels, 12 Double Rolls | The text of the sponsored product listing shown to you. | |
Price and size | $18.97, 12 ct | The listed price and package size of that sponsored product. | |
Your search term | paper towels | What you typed into the retailer's search box to bring up those ads. | |
Install ID | 8f2e1a90-6c4d-4b3f-9a21-77e0c4d8b615 | Ties this shopping activity back to the same you across sessions. |
The Walmart ad scanner and its 3-second upload loop
// pd(container, searchUrl, onAdFound) -- content-scripts/usage-helper.js.
// Runs against document.body on www.walmart.com search-results pages.
function findWalmartSponsoredAds(container, searchUrl, onAdFound) {
const searchInput = container.querySelector('input[name="q"]');
const found = [];
const timestamp = Math.round(Date.now() / MS_PER_SEC);
container.querySelectorAll('div[data-item-id], li[data-item-id]').forEach((card) => {
if (!card?.textContent?.includes("Sponsored")) return; // only sponsored cards
const titleEl = card.querySelector('[data-automation-id="product-title"]');
const priceEl = card.querySelector('div[data-automation-id="product-price"]');
let price = Array.from(priceEl?.querySelectorAll("span") || [])
.find((s) => s.textContent?.includes("current price"))
?.textContent?.trim();
// strip "current price " / "Now " / "Was " label prefixes
for (const prefix of ["current price ", "Now ", "Was "]) {
if (price?.startsWith(prefix)) price = price.replace(prefix, "").split(",")[0].trim();
}
const brand = titleEl?.parentElement?.previousElementSibling
?.querySelector("div.mv1")?.textContent?.trim();
const title = titleEl?.textContent?.trim();
const ad = {
title: brand && title && !title.includes(brand) ? `${brand} ${title}` : title || "",
size: priceEl?.querySelector("div:last-child")?.textContent || "",
price,
timestamp,
store: "",
search_url: searchUrl,
search_term: searchInput?.value || "", // your typed search term
};
if (ad.title) { found.push(ad); onAdFound?.(ad, card); }
});
return found;
}// Vu(config) -- registers one retailer's poll loop; called once per site
// (walmart/target/instacart) when the current page URL matches config.url.
function registerAdFinderLoop(config) {
const currentUrl = window.location.href;
if (!currentUrl.includes(config.url)) return;
markUrlAsCaptured(currentUrl);
const alreadyUploaded = [];
const inFlight = new Set();
setInterval(async () => {
let ads = config.finder(document.body, currentUrl, config.onAdFound, config.instacartSelectors);
ads = ads.filter((ad) => !alreadyUploaded.includes(ad.title) && !inFlight.has(ad.title));
ads.forEach((ad) => inFlight.add(ad.title));
try {
await uploadNewAds(config, ads);
ads.forEach((ad) => alreadyUploaded.push(ad.title));
} catch {} finally {
ads.forEach((ad) => inFlight.delete(ad.title));
}
}, config.loadDelay || 3000); // default: every 3 seconds
}
// uploadNewAds -- forwards the batch to the background service worker's
// panel-api client, which POSTs to /v1/desktop/retail/{website}.
async function uploadNewAds(config, ads) {
if (ads.length === 0) return;
await config.api.uploadRetailAds({
appId: config.appId,
installId: await config.installId,
website: config.website,
ads,
});
}- webguardian.st-panel-api.com
Sensor Tower's panel-api backend for this extension; receives the scraped ad and search-term records keyed to your install ID.
- p.qljx.co
A Pathmatics/Sensor Tower crawl-upload endpoint the bundled ad-crawler module calls, enabled regardless of the data-collection setting.
Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.
Sign in with Google asks for Gmail read access, then talks to a billing API
Signing in with Google from Settings or the welcome screen requests the gmail.readonly scope, unrelated to the site-safety purpose the extension advertises.
The token is then attached to requests made to purchases.stayfreeapps.com.
You open the extension's Settings or the post-install welcome tab and click Sign in with Google.
The consent screen names the requested scope before you approve it.
The extension requests the gmail.readonly scope, unrelated to its advertised purpose, then uses the resulting session for requests to its own billing API.
The Google access token is attached as a Bearer header on requests to purchases.stayfreeapps.com.
| Field | Value | Why it matters | |
|---|---|---|---|
OAuth scope requested | https://www.googleapis.com/auth/gmail.readonly | Grants read access to your Gmail inbox, a capability the extension's stated purpose has no use for. | |
Access token | ya29.a0AfH6SMC7z9pQ1k... | The Google session credential this grant produces, kept by the extension and reused on later requests. | |
Where the token is used | Authorization: Bearer <token> to purchases.stayfreeapps.com | Requests carrying that token as a Bearer header go to the extension vendor's own billing backend, not Google's Gmail API. | |
Internal account app id | phone-guardian | Ties your Google sign-in to this extension's own account system. |
Requesting the Gmail scope, then wiring the resulting token into the billing client
// performLogin -- background.js. Opens Google's OAuth consent screen and
// pulls the authorization code back out of the redirect.
function performLogin(env) {
return async () => {
const redirectUri = env.browser.identity.getRedirectURL();
const authUrl = buildAuthUrl({
clientId: env.clientId,
redirectUri,
scopes: env.scopes, // ["https://www.googleapis.com/auth/gmail.readonly"]
});
const responseUrl = await env.browser.identity.launchWebAuthFlow({
url: authUrl,
interactive: true,
});
if (!responseUrl) throw new Error("No response from auth flow");
const params = new URL(responseUrl).searchParams;
if (params.get("error")) throw new Error(params.get("error"));
const code = params.get("code");
if (!code) throw new Error("No authorization code in response");
return { code, redirectUri };
};
}const GOOGLE_CLIENT_ID = "812707853907-9o271c7fbg18t067pdt8eodp5noed3tt.apps.googleusercontent.com";
const authSession = createAuthSession({
storage: chromeStorageBackedStore, // keys: wg_auth_session, wg_auth_tokens
installId: getInstallId(),
config: { app: "phone-guardian", platform: "web-extension", clientId: `${runtime.id}/${version}` },
performLogin: performLogin({
browser: chrome,
clientId: GOOGLE_CLIENT_ID,
scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
}),
});
// Every request through this wrapper gets the signed-in session's Google
// access token attached as a Bearer header, refreshing it once on a 401.
async function authenticatedFetch(request, init) {
await authSession.restoreSession();
const attach = async (token) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${token}`);
return fetch(request, { ...init, headers });
};
let token = authSession.getState().tokens?.accessToken
?? (await authSession.refresh())?.accessToken;
if (!token) throw new Error("not_signed_in");
const response = await attach(token);
if (response.status !== 401) return response;
const refreshed = await authSession.refresh();
if (!refreshed?.accessToken) throw new Error("session_expired");
return attach(refreshed.accessToken);
}
// Registers authenticatedFetch as the HTTP client for the purchases API.
registerApiClient({
app: "phone-guardian",
platform: "web-extension",
clientId: `${runtime.id}/${version}`,
baseUrl: "https://purchases.stayfreeapps.com",
fetch: authenticatedFetch,
});- accounts.google.com
Google's own OAuth consent endpoint; issues the authorization code for the gmail.readonly-scoped session.
- purchases.stayfreeapps.com
The extension vendor's own billing/account backend; receives Bearer-authenticated requests using the Google OAuth session from this sign-in.
Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.