Is Web Guardian: Phishing & Malware Protection safe?

Medium risk

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.

ST Advancedv0.5.1Chrome Web Store
45Risk

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI FOUND

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

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 extension did this

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.

02EvidenceFIELD TABLE
Fields included in each upload
FieldValueWhy it matters
Install ID
8f2e1a90-6c4d-4b3f-9a21-77e0c4d8b615A persistent identifier that ties every site you visit, across every session, back to the same you.
Website visited
www.chase.comThe hostname of each site you browse to, logged for every page you view.
Session duration
182 seconds, 2026-09-18T14:02:11ZHow long you stayed on that site, with a timestamp.
Referrer and campaign tags
utm_source=newsletter&utm_campaign=fall_saleWhere you came from and any UTM marketing tags on the link you followed.
Birth year
1991An optional age indicator you can give during onboarding.
03EvidenceCODE COMPARE
The code that does this

The upload function and the checkbox that gates it

What it actually does
uploadSessions, annotatedbackground.js
// 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.
The onboarding checkbox, annotatedchunks/welcome-Bn2nJPUT.js
// 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" />
04EvidenceTEMPORAL PATTERN
When this fires
Every 5 minutes

A scheduled alarm fires on a fixed interval and uploads everything queued since the last run.

05EvidenceTHIRD PARTY LIST
Where the browsing records go
  • 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.

06EvidencePLAIN NOTE
Observation

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.

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI FOUND

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You search for a common product on Walmart, Target, or Instacart and sponsored listings render on the results page.

The extension did this

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.

02EvidenceFIELD TABLE
Fields captured per sponsored ad
FieldValueWhy it matters
Ad title
Bounty Paper Towels, 12 Double RollsThe text of the sponsored product listing shown to you.
Price and size
$18.97, 12 ctThe listed price and package size of that sponsored product.
Your search term
paper towelsWhat you typed into the retailer's search box to bring up those ads.
Install ID
8f2e1a90-6c4d-4b3f-9a21-77e0c4d8b615Ties this shopping activity back to the same you across sessions.
03EvidenceCODE COMPARE
The code that does this

The Walmart ad scanner and its 3-second upload loop

What it actually does
pd(), annotatedcontent-scripts/usage-helper.js
// 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()/Hu(), annotatedcontent-scripts/usage-helper.js
// 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,
  });
}
04EvidenceTHIRD PARTY LIST
Where scraped ad and search data go
  • 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.

05EvidencePLAIN NOTE
Observation

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.

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-522
SourceAI FOUND

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

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 did this

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.

02EvidenceFIELD TABLE
What the sign-in flow requests and where it lands
FieldValueWhy it matters
OAuth scope requested
https://www.googleapis.com/auth/gmail.readonlyGrants 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.comRequests 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-guardianTies your Google sign-in to this extension's own account system.
03EvidenceCODE COMPARE
The code that does this

Requesting the Gmail scope, then wiring the resulting token into the billing client

What it actually does
performLogin, annotatedbackground.js
// 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 };
  };
}
Session config and Bearer-token fetch wrapper, annotatedbackground.js
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,
});
04EvidenceTHIRD PARTY LIST
Where the sign-in flow sends requests
  • 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.

05EvidencePLAIN NOTE
Observation

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.

Data recipients

webguardian.st-panel-api.compurchases.stayfreeapps.comp.qljx.coGoogle (gmail.readonly OAuth scope)
Updated 20 September 2026aajmomfdmgpnkhoekokehfgablakcofi