Is Copper CRM for Gmail and LinkedIn safe?
Copper CRM is medium risk. Copper's analytics path can send Segment POST requests after extension activity, built with your Copper user ID, event name, properties, browser context, and group traits. Network testing didn't trigger this path; no body was captured.
Who publishes itCopper CRM Inc. - no other listings under this identity, 2 shared hostnames
Copper CRM Inc. - no other listings under this identity, 2 shared hostnames
What this publisher told the store about itself, and the other listings that told it the same thing.
Shared hosts - 2 hostnames
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Segment Analytics Receives Copper Extension Events
Copper's analytics path can send Segment POST requests after extension activity, built with your Copper user ID, event name, properties, browser context, and group traits.
Network testing didn't trigger this path; no body was captured.
Copper records an analytics event while you use the extension.
Examples in the code include page-view events, user-property updates, and group-property updates.
The background worker prepares a Segment analytics request with your Copper identifiers and event context.
Network testing did not capture a live request body for this path.
| Field | Value | Why it matters | |
|---|---|---|---|
Copper user ID | 12345678 (illustrative) | Ties analytics events to your Copper user account. | |
Event name | page_view | Describes what you did in the extension or embedded Copper app. | |
Page URL | https://mail.google.com/mail/u/0/#inbox (illustrative) | Can include the page or route associated with the analytics event. | |
Browser context | Chrome/126.0.0.0; chrome_extension 2.0.478 (illustrative) | Adds your browser and extension version to the analytics record. | |
Group traits | internal_company_id: 98765 (illustrative) | Can connect your activity to a Copper company or workspace record. |
| Content-Type | application/json |
| Authorization | Basic base64(segmentWriteKey + ":") |
Shipped bundle code that maps analytics messages to Segment POSTs
define("message-listener", ["exports", "./messages/ajax-proxy-request", "./messages/analytics", "./messages/dev-reload", "./messages/host-boot-ready", "./messages/runtime", "./messages/show-extension-options", "./messages/show-notification", "./messages/register-tab", "./messages/storage", "./messages/unsupported", "./messages/window-incognito", "./messages/invoke", "./messages/linkedin-awareness", "./messages/permissions-check", "./messages/permissions-request", "./messages/register-content-script", "./messages/test/sidepanel", "./messages/test/set-credentials", "./listener-util"], function (_exports, _ajaxProxyRequest, _analytics, _devReload, _hostBootReady, _runtime, _showExtensionOptions, _showNotification, _registerTab, _storage, _unsupported, _windowIncognito, _invoke, _linkedinAwareness, _permissionsCheck, _permissionsRequest, _registerContentScript, _sidepanel, _setCredentials, _listenerUtil) {
"use strict";
Object.defineProperty(_exports, "__esModule", {
value: true
});
_exports.register = register;
/**
* Checks if the extension is running in a development environment
* by checking if host_permissions contains 'copper.cool'
* @returns {Boolean}
*/
function isDevelopment() {
const hosts = chrome.runtime.getManifest().host_permissions;
const appRootUrl = hosts[0];
return appRootUrl.includes('copper.cool');
}
/** @type {Object<string, BaseMessage>} */
const _messageListenerMap = {
'ajax.proxyRequest': new _ajaxProxyRequest.AjaxProxyRequest(),
'analytics.addGroupProperties': new _analytics.AnalyticsGroup(),
'analytics.addUserProperties': new _analytics.AnalyticsIdentify(),
'analytics.logTrackedEvent': new _analytics.AnalyticsTrack(),
'dev.reload': new _devReload.DevReload(),
'host.boot.ready': new _hostBootReady.HostBootReady(),
'invoke.contentScript': new _invoke.InvokeContentScript(),
'invoke.sidePanel': new _invoke.InvokeSidePanels(),
// single -> plural is intentional, as to content scripts it *appears* like a single side panel is messaged
'invoke.everywhere': new _invoke.InvokeEverywhere(),
'linkedin.awareness.trigger': new _linkedinAwareness.LinkedInAwareness(),
'optionspage.show': new _showExtensionOptions.ShowExtensionOptions(),
'runtime.id': new _runtime.RuntimeId(),
'runtime.getURL': new _runtime.RuntimeGetURL(),
'runtime.manifestVersion': new _runtime.RuntimeManifestVersion(),
'notification.showNotification': new _showNotification.ShowNotification(),
'permissions.check': new _permissionsCheck.PermissionsCheck(),
'permissions.request': new _permissionsRequest.PermissionsRequest(),
'scripting.registerContentScript': new _registerContentScript.RegisterContentScript(),
'storage.local.get': new _storage.StorageLocalGet(),
'storage.local.set': new _storage.StorageLocalSet(),
'storage.local.remove': new _storage.StorageLocalRemove(),
'register.tab': new _registerTab.RegisterTab(),
'window.incognito': new _windowIncognito.WindowIncognito()
};
// Register development-only message handlers for CRX Playwright tests
if (isDevelopment()) {
_messageListenerMap['test.sidepanel.open'] = new _sidepanel.SidePanelOpen();
_messageListenerMap['test.setCredentials'] = new _setCredentials.SetCredentials();
}
const _unsupportedListener = new _unsupported.Unsupported();
function register() {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
const {
action,
actionArgs
} = message;
const listener = _messageListenerMap[action] || _unsupportedListener;
listener.execute(actionArgs, sender).then(response => {
sendResponse(response);
}).catch(error => {
(0, _listenerUtil.handleListenerError)(sendResponse, error);
});
// Returning true here prevents Chrome from invalidating the `sendResponse`
// function when this listener function completes.
return true;
});
}
});define("messages/analytics", ["exports", "./-base-message"], function (_exports, _baseMessage) {
"use strict";
Object.defineProperty(_exports, "__esModule", {
value: true
});
_exports.AnalyticsTrack = _exports.AnalyticsIdentify = _exports.AnalyticsGroup = void 0;
_baseMessage = _interopRequireDefault(_baseMessage);
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
class SegmentApiHelper {
#apiPath = 'https://api.segment.io/v1/';
/**
* Sends a POST request
* @param {String} url fully qualified URL for the Segment endpoint
* @param {Object} requestData data payload that goes to Segment
* @param {String} segmentWriteKey required to log data to the correct Segment source
* @returns {Promise}
*/
async #postSegmentApiRequest(url, requestData = {}, segmentWriteKey) {
if (!segmentWriteKey) {
throw new Error('No Segment writeKey available.');
}
// see: https://segment.com/docs/connections/sources/catalog/libraries/server/http-api/
const encodedWriteKey = btoa(`${segmentWriteKey}:`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${encodedWriteKey}`
},
body: JSON.stringify(requestData)
});
return response.json();
}
/**
* Returns false if required Segment api data is not present
* @param {Object} apiData payload containing required api dependencies
* @return {Boolean}
*/
#validateApiDependencies(apiData) {
return apiData?.segmentWriteKey && apiData?.companyUserId;
}
/**
* Send an group request to Segment. Group represents the entity of which the user is a member.
* This correlates to the Company entity in Copper.
* @param {Object} groupData
* @param {Object} groupData.traits group specific data sent to Segment for logging
* @param {Object} groupData.context platform and system specific data sent to Segment for logging
* @param {Object} apiData required to make a call to Segment
* @param {String} apiData.segmentWriteKey send user data to the correct Segment source
* @param {String} apiData.companyUserId keeps userData associated to the correct Copper user
*/
async group(groupData, apiData) {
if (!this.#validateApiDependencies(apiData)) {
throw new Error('group: API data not available.');
}
const {
traits,
context
} = groupData;
if (!traits?.internal_company_id) {
throw new Error('group: internal_company_id is required.');
}
const segmentRequestBody = {
userId: `${apiData.companyUserId}`,
groupId: traits.internal_company_id,
traits,
context
};
return this.#postSegmentApiRequest(`${this.#apiPath}group`, segmentRequestBody, apiData.segmentWriteKey);
}
/**
* Send an identify request to Segment
* @param {Object} identifyData
* @param {Object} identifyData.traits group specific data sent to Segment for logging
* @param {Object} identifyData.context platform and system specific data sent to Segment for logging
* @param {Object} apiData required to make a call to Segment
* @param {String} apiData.segmentWriteKey send user data to the correct Segment source
* @param {String} apiData.companyUserId keeps userData associated to the correct Copper user
*/
async identify(identifyData, apiData) {
if (!this.#validateApiDependencies(apiData)) {
throw new Error('identify: API data not available.');
}
const {
traits,
context
} = identifyData;
const segmentRequestBody = {
userId: `${apiData.companyUserId}`,
traits,
context
};
return this.#postSegmentApiRequest(`${this.#apiPath}identify`, segmentRequestBody, apiData.segmentWriteKey);
}
/**
* Sends event data to Segment
* @param {String} event name of the event sent to Segment
* @param {Object} [properties] optional data payload sent with the event
* @param {Object} context platform and system specific data sent to Segment for logging
* @param {Object} apiData required to make a call to Segment
* @param {String} apiData.segmentWriteKey send user data to the correct Segment source
* @param {String} apiData.companyUserId keeps userData associated to the correct Copper user
*/
async trackEvent(event, properties, context, apiData) {
if (!this.#validateApiDependencies(apiData)) {
throw new Error(`trackEvent: API data not available for [${event}].`);
}
const segmentRequestBody = {
userId: `${apiData.companyUserId}`,
event,
properties,
context
};
return this.#postSegmentApiRequest(`${this.#apiPath}track`, segmentRequestBody, apiData.segmentWriteKey);
}
}
const segmentApiHelper = new SegmentApiHelper();
class AnalyticsIdentify extends _baseMessage.default {
/** @override */
async execute(actionArgs, sender) {
const {
properties,
apiData
} = actionArgs;
return segmentApiHelper.identify(properties, apiData);
}
}
_exports.AnalyticsIdentify = AnalyticsIdentify;
class AnalyticsGroup extends _baseMessage.default {
/** @override */
async execute(actionArgs, sender) {
const {
properties,
apiData
} = actionArgs;
return segmentApiHelper.group(properties, apiData);
}
}
_exports.AnalyticsGroup = AnalyticsGroup;
class AnalyticsTrack extends _baseMessage.default {
/** @override */
async execute(actionArgs, sender) {
const {
eventName,
properties,
context,
apiData
} = actionArgs;
return segmentApiHelper.trackEvent(eventName, properties, context, apiData);
}
}
_exports.AnalyticsTrack = AnalyticsTrack;
});define("chrome-ext/services/app-analytics", ["exports", "@glimmer/tracking", "@ember/service", "core/utils/object", "core/services/app-analytics", "core/constants/app-analytics"], function (_exports, _tracking, _service, _object, _appAnalytics, _appAnalytics2) {
"use strict";
Object.defineProperty(_exports, "__esModule", {
value: true
});
_exports.default = void 0;
0; //eaimeta@70e063a35619d71f0,"@glimmer/tracking",0,"@ember/service",0,"core/utils/object",0,"core/services/app-analytics",0,"core/constants/app-analytics"eaimeta@70e063a35619d71f
class ChromeExtAppAnalyticsService extends _appAnalytics.default {
static {
dt7948.g(this.prototype, "appContext", [_service.inject]);
}
#appContext = (dt7948.i(this, "appContext"), void 0);
static {
dt7948.g(this.prototype, "webExtensionTunnel", [_service.inject]);
}
#webExtensionTunnel = (dt7948.i(this, "webExtensionTunnel"), void 0);
static {
dt7948.g(this.prototype, "hostIntegration", [_service.inject]);
}
#hostIntegration = (dt7948.i(this, "hostIntegration"), void 0);
// -- Lifecycle Hooks ----------------------------------------------------------------------------
constructor(...args) {
super(...args);
this.eventProperties = {
client: 'chrome_extension',
host: this.hostIntegration.getHost()
};
this.#initCrXVersion();
}
// -- Service Properties -------------------------------------------------------------------------
/**
* Used to make calls to Segment's API
* @type {Object}
* @type {String} Object.segmentWriteKey
* @type {String} Object.companyUserId
*/
segmentApiData = null;
/** @type {String} */
static {
dt7948.g(this.prototype, "currentChromeExtensionVersion", [_tracking.tracked], function () {
return null;
});
}
#currentChromeExtensionVersion = (dt7948.i(this, "currentChromeExtensionVersion"), void 0);
// -- Public Functions ---------------------------------------------------------------------------
/** @type {Object} */
get segmentAnalyticsContextData() {
return {
userAgent: window.navigator.userAgent,
app: {
version: this.currentChromeExtensionVersion,
name: 'chrome_extension'
}
};
}
/**
* Adds properties to the user to be identified by analytics libraries.
*
* @override
* @param {Object} properties
*/
addUserProperties(properties) {
this.#addUserPropertiesSegment(properties);
}
/**
* Adds properties for the group to be identified by analytics services.
* For Copper, the group is the company associated with the currently active company user id.
*
* @override
* @param {Object} properties group properties to send to analytics services
*/
addUserGroupProperties(properties) {
this.#addUserGroupPropertiesSegment(properties);
}
/**
* Tells analytics libraries that the user is signed out.
*
* @override
*/
signOutUser() {
this.segmentApiData = null;
}
// -- Private Functions --------------------------------------------------------------------------
/**
* @typedef {Object} ViewInfo
* @property {String} routeName - The name of the current route
* @property {String} mainSection - The main section of the page. For entity pages, it is
* always 'entity', for rest of the app, it is the first
* section of the route.
* @property {String} subSection - The sub section of the page. For entity pages, it is the
* type of the view (detail, list, pipeline, etc). For the
* rest of the app, it is the second section of the route.
* @property {String} sourceEntity - The source entity of the page
*/
/**
* Using the active route, this function computes and returns the ViewInfo object defined above.
*
* @override
* @param {RouteInfo} routeInfo An object containing data about the current route
* @return {ViewInfo|Object}
* @private
*/
_getViewInfo(routeInfo) {
if (routeInfo && routeInfo.name) {
const routeName = routeInfo.name;
const pathParams = routeInfo.params && this.#getFilteredPathParams(routeInfo.params);
const routeSegments = routeName.split('.');
if (routeSegments.length) {
const baseViewInfo = {
...pathParams,
routeName
};
const mainSection = 'entity';
if (routeSegments[0] === 'profile') {
// crx detail view
return {
...baseViewInfo,
mainSection,
subSection: _appAnalytics2.ENTITY_SUB_SECTIONS.DETAIL,
sourceEntity: _appAnalytics2.SINGULAR_ENTITY_NAME_TO_REPORTING_NAME_MAP[routeSegments[1]]
};
} else if (routeSegments[0] === 'lists') {
// crx list view
return {
...baseViewInfo,
mainSection,
subSection: _appAnalytics2.ENTITY_SUB_SECTIONS.LIST,
sourceEntity: _appAnalytics2.PLURAL_ENTITY_NAME_TO_REPORTING_NAME_MAP[routeSegments[1]]
};
} else {
return {
...this._getSections(routeSegments),
...baseViewInfo
};
}
}
}
return {};
}
/**
* Filters out any :filters path params from the provided params object for the purpose
* of maintaining confidentiality with PII.
* @param {Object} pathParams The path params (Required).
* @returns {Object} The filtered params.
*/
#getFilteredPathParams(pathParams) {
// eslint-disable-next-line no-unused-vars
const {
filters,
...filteredPathParams
} = pathParams;
return filteredPathParams;
}
trackPageView(url) {
this._trackAppEvent(_appAnalytics2.TRACKING_EVENT_NAMES.PAGE_VIEW, {
properties: {
url
}
});
}
/**
* Sends tracked event logs to background js for further processing.
*
* @override
* @param eventName
* @param properties
* @private
*/
_logTrackedEvent(eventName, properties) {
this.#logTrackedEventSegment(eventName, properties);
}
#logTrackedEventSegment(eventName, properties) {
const apiData = this.segmentApiData;
if (!apiData) {
// We currently don't have enough Segment quota to track anonymous users,
// so we don't provide the API key until they log in. Events may still
// fire e.g. from the login page itself, and we have no way to get them to
// Segment so we short-circuit them here.
return;
}
const segmentParsedProperties = this.#stringifyRequiredValuesSegment((0, _object.decamelizeKeys)(properties));
const context = this.segmentAnalyticsContextData;
this.webExtensionTunnel.sendMessage({
action: 'analytics.logTrackedEvent',
eventName,
properties: segmentParsedProperties,
apiData,
context
});
}
#addUserPropertiesSegment(properties) {
const parsedAnalyticsProperties = this.#stringifyRequiredValuesSegment((0, _object.decamelizeKeys)(properties));
const analyticsPayload = {
traits: parsedAnalyticsProperties,
context: this.segmentAnalyticsContextData
};
this.webExtensionTunnel.sendMessage({
action: 'analytics.addUserProperties',
properties: analyticsPayload,
apiData: this.segmentApiData
});
}
/**
* Sends a Segment group request from Ember to the Web Extension Tunnel
* @param {Object} properties data to send to Segment
*/
#addUserGroupPropertiesSegment(properties) {
const decamelizedProperties = this.#stringifyRequiredValuesSegment((0, _object.decamelizeKeys)(properties));
const analyticsPayload = {
traits: decamelizedProperties,
context: this.segmentAnalyticsContextData
};
this.webExtensionTunnel.sendMessage({
action: 'analytics.addGroupProperties',
properties: analyticsPayload,
apiData: this.segmentApiData
});
}
/**
* Returns an object with values converted to Strings per Segment specification
* @private
* @param {Object} values traits sent to Segment
* @return {Object}
*/
#stringifyRequiredValuesSegment(values) {
const REQUIRED_STRING_FIELDS = ['internal_company_id', 'internal_company_user_id', 'internal_user_id'];
const valuesClone = {
...values
};
REQUIRED_STRING_FIELDS.forEach(requiredStringFieldKey => {
if (valuesClone.hasOwnProperty(requiredStringFieldKey)) {
valuesClone[requiredStringFieldKey] = String(valuesClone[requiredStringFieldKey]);
}
});
return valuesClone;
}
async #initCrXVersion() {
this.currentChromeExtensionVersion = await this.webExtensionTunnel.sendMessage({
action: 'runtime.manifestVersion'
});
}
}
_exports.default = ChromeExtAppAnalyticsService;
});define("chrome-ext/services/app-context", ["exports", "core/services/app-context", "@ember/service"], function (_exports, _appContext, _service) {
"use strict";
Object.defineProperty(_exports, "__esModule", {
value: true
});
_exports.default = void 0;
0; //eaimeta@70e063a35619d71f0,"core/services/app-context",0,"@ember/service"eaimeta@70e063a35619d71f
var _default = _exports.default = _appContext.default.extend({
hostIntegration: (0, _service.inject)(),
appAnalytics: (0, _service.inject)(),
update(jsonData, ignoreCompanyUser) {
this._super(jsonData, ignoreCompanyUser);
const segmentRequestRequiredData = {
segmentWriteKey: jsonData.segment.chromeExtensionApiKey,
companyUserId: this.companyUser.id
};
this.hostIntegration.api.Config.enableContentScriptEventTracking((eventName, properties, options) => {
this.appAnalytics.trackCustomEvent(eventName, {
properties,
options
});
});
this.appAnalytics.segmentApiData = segmentRequestRequiredData;
}
});
});- api.segment.io
Segment HTTP API endpoint receiving track, identify, and group analytics requests from the extension background worker.