diff --git a/Package.swift b/Package.swift index 07359ccb..3215d734 100644 --- a/Package.swift +++ b/Package.swift @@ -30,10 +30,5 @@ let package = Package( .copy("app/img"), .copy ("app/public"), .copy ("app/index.html")]), - - .testTarget( - name: "PrivacyDashboardTests", - dependencies: ["PrivacyDashboardResources"], - path: "swift-package/Tests"), ] ) diff --git a/integration-tests/DashboardPage.js b/integration-tests/DashboardPage.js index d6811f85..056019dd 100644 --- a/integration-tests/DashboardPage.js +++ b/integration-tests/DashboardPage.js @@ -189,6 +189,28 @@ export class DashboardPage { await expect(page.locator('#main-nav div')).toContainText('Site May Be Deceptive'); } + async hasMalwareIcon() { + const { page } = this; + await expect(page.locator('#key-insight div').nth(1)).toHaveClass(/hero-icon--phishing/); + } + + async hasMalwareHeadingText() { + const { page } = this; + await expect(page.getByRole('heading', { name: 'privacy-test-pages.site' })).toBeVisible(); + } + + async hasMalwareWarningText() { + const { page } = this; + await expect(page.locator('#popup-container')).toContainText( + 'This site has been flagged for distributing malware designed to compromise your device or steal your personal information.' + ); + } + + async hasMalwareStatusText() { + const { page } = this; + await expect(page.locator('#main-nav div')).toContainText('Site May Be Deceptive'); + } + async connectionLinkDoesntShow() { await expect(this.connectInfoLink()).not.toBeVisible(); } diff --git a/integration-tests/macos.spec-int.js b/integration-tests/macos.spec-int.js index e8bec2e9..db74489a 100644 --- a/integration-tests/macos.spec-int.js +++ b/integration-tests/macos.spec-int.js @@ -46,6 +46,18 @@ test('phishing warning', { tag: '@screenshots' }, async ({ page }) => { await dash.connectionLinkDoesntShow(); }); +test('malware warning', { tag: '@screenshots' }, async ({ page }) => { + /** @type {DashboardPage} */ + const dash = await DashboardPage.webkit(page, { platform: 'macos' }); + await dash.addState([testDataStates.malware]); + await dash.screenshot('malware-warning.png'); + await dash.hasMalwareIcon(); + await dash.hasMalwareHeadingText(); + await dash.hasMalwareWarningText(); + await dash.hasMalwareStatusText(); + await dash.connectionLinkDoesntShow(); +}); + test('insecure certificate', async ({ page }) => { /** @type {DashboardPage} */ const dash = await DashboardPage.webkit(page, { platform: 'macos' }); diff --git a/integration-tests/macos.spec-int.js-snapshots/malware-warning-macos-darwin.png b/integration-tests/macos.spec-int.js-snapshots/malware-warning-macos-darwin.png new file mode 100644 index 00000000..2733858d Binary files /dev/null and b/integration-tests/macos.spec-int.js-snapshots/malware-warning-macos-darwin.png differ diff --git a/integration-tests/macos.spec-int.js-snapshots/phishing-warning-macos-darwin.png b/integration-tests/macos.spec-int.js-snapshots/phishing-warning-macos-darwin.png index 40fd746e..09c55baf 100644 Binary files a/integration-tests/macos.spec-int.js-snapshots/phishing-warning-macos-darwin.png and b/integration-tests/macos.spec-int.js-snapshots/phishing-warning-macos-darwin.png differ diff --git a/package-lock.json b/package-lock.json index 75aeedfb..ac3805a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "zod": "^3.22.4" }, "engines": { - "node": ">=18.0.0", + "node": ">=22.0.0", "npm": ">=9.0.0" } }, diff --git a/schema/__generated__/schema.parsers.mjs b/schema/__generated__/schema.parsers.mjs index 51b9f28a..da438db9 100644 --- a/schema/__generated__/schema.parsers.mjs +++ b/schema/__generated__/schema.parsers.mjs @@ -87,8 +87,8 @@ export const localeSettingsSchema = z.object({ locale: z.string() }); -export const phishingStatusSchema = z.object({ - phishingStatus: z.boolean() +export const maliciousSiteStatusSchema = z.object({ + kind: z.union([z.literal("phishing"), z.literal("malware")]).nullable() }); export const parentEntitySchema = z.object({ @@ -243,7 +243,7 @@ export const tabSchema = z.object({ upgradedHttps: z.boolean(), protections: protectionsStatusSchema, localeSettings: localeSettingsSchema.optional(), - phishingStatus: phishingStatusSchema.optional(), + maliciousSiteStatus: maliciousSiteStatusSchema.optional(), parentEntity: parentEntitySchema.optional(), specialDomainName: z.string().optional() }); @@ -299,7 +299,8 @@ export const windowsViewModelSchema = z.object({ certificates: z.array(z.unknown()).optional(), cookiePromptManagementStatus: cookiePromptManagementStatusSchema.optional(), isInvalidCert: z.boolean().optional(), - localeSettings: localeSettingsSchema.optional() + localeSettings: localeSettingsSchema.optional(), + maliciousSiteStatus: maliciousSiteStatusSchema.optional() }); export const toggleReportScreenSchema = z.object({ diff --git a/schema/__generated__/schema.types.ts b/schema/__generated__/schema.types.ts index 471dff25..00681c49 100644 --- a/schema/__generated__/schema.types.ts +++ b/schema/__generated__/schema.types.ts @@ -347,7 +347,7 @@ export interface Tab { upgradedHttps: boolean; protections: ProtectionsStatus; localeSettings?: LocaleSettings; - phishingStatus?: PhishingStatus; + maliciousSiteStatus?: MaliciousSiteStatus; parentEntity?: ParentEntity; /** * Provide this if the current tab is a domain that we cannot provide regular dashboard features for (like new tab, about://blank etc) @@ -385,13 +385,13 @@ export interface LocaleSettings { locale: string; } /** - * This describes the payload required to set the phishing status + * This describes the payload required to set the phishing & malware status */ -export interface PhishingStatus { +export interface MaliciousSiteStatus { /** - * Set to true if page is potentially malicious + * Kind of threat detected */ - phishingStatus: boolean; + kind: "phishing" | "malware" | null; } /** * This fields required to describe a 'parent entity' @@ -465,6 +465,7 @@ export interface WindowsViewModel { cookiePromptManagementStatus?: CookiePromptManagementStatus; isInvalidCert?: boolean; localeSettings?: LocaleSettings; + maliciousSiteStatus?: MaliciousSiteStatus; } /** * This describes the fields needed for the dashboard to display the status of CPM (Cookie Prompt Management) diff --git a/schema/get-privacy-dashboard-data.json b/schema/get-privacy-dashboard-data.json index fbbd07da..a5c42ada 100644 --- a/schema/get-privacy-dashboard-data.json +++ b/schema/get-privacy-dashboard-data.json @@ -51,8 +51,8 @@ "localeSettings": { "$ref": "./locale.json" }, - "phishingStatus": { - "$ref": "./phishing.json" + "maliciousSiteStatus": { + "$ref": "./malicious-site.json" }, "parentEntity": { "$ref": "./parent-entity.json" }, "specialDomainName": { diff --git a/schema/malicious-site.json b/schema/malicious-site.json new file mode 100644 index 00000000..c13b86bd --- /dev/null +++ b/schema/malicious-site.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MaliciousSiteStatus", + "type": "object", + "description": "This describes the payload required to set the phishing & malware status", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { + "description": "Kind of threat detected", + "enum": ["phishing", "malware", null] + } + } +} diff --git a/schema/phishing.json b/schema/phishing.json deleted file mode 100644 index c2467993..00000000 --- a/schema/phishing.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "PhishingStatus", - "type": "object", - "description": "This describes the payload required to set the phishing status", - "additionalProperties": false, - "required": ["phishingStatus"], - "properties": { - "phishingStatus": { - "description": "Set to true if page is potentially malicious", - "type": "boolean" - } - } -} diff --git a/schema/windows-view-model.json b/schema/windows-view-model.json index f15e29e8..39efa245 100644 --- a/schema/windows-view-model.json +++ b/schema/windows-view-model.json @@ -31,6 +31,9 @@ }, "localeSettings": { "$ref": "locale.json" + }, + "maliciousSiteStatus": { + "$ref": "malicious-site.json" } } } diff --git a/shared/data/constants.js b/shared/data/constants.js index 367843fe..c6a66a59 100644 --- a/shared/data/constants.js +++ b/shared/data/constants.js @@ -11,4 +11,10 @@ export const httpsMessages = { none: 'site:connectionNotSecure.title', invalid: 'site:connectionNotSecureInvalidCertificate.title', phishing: 'site:phishingWebsite.title', + malware: 'site:malwareWebsite.title', +}; + +export const duckDuckGoURLs = { + phishingAndMalwareHelpPage: 'https://dub.duckduckgo.com/pages/duckduckgo/mgurgel-help-pages/privacy/phishing-and-malware-protection/', + reportSiteAsSafeForm: 'https://use-devtesting12.duckduckgo.com/malicious-site-protection/report-error?url=', }; diff --git a/shared/js/browser/android-communication.js b/shared/js/browser/android-communication.js index 69ecf68b..d33b8ca2 100644 --- a/shared/js/browser/android-communication.js +++ b/shared/js/browser/android-communication.js @@ -13,6 +13,7 @@ import { protectionsStatusSchema, remoteFeatureSettingsSchema, requestDataSchema, + maliciousSiteStatusSchema, } from '../../../schema/__generated__/schema.parsers.mjs'; import { setupBlurOnLongPress, setupGlobalOpenerListener } from '../ui/views/utils/utils'; import { @@ -52,10 +53,14 @@ let locale; /** @type {import('../../../schema/__generated__/schema.types').RemoteFeatureSettings | undefined} */ let featureSettings; +/** @type {import('../../../schema/__generated__/schema.types').MaliciousSiteStatus} */ +let maliciousSiteStatus; + const combineSources = () => ({ tab: Object.assign( {}, trackerBlockingData || {}, + { maliciousSiteStatus: maliciousSiteStatus ?? false }, { isPendingUpdates, parentEntity, @@ -73,7 +78,8 @@ const resolveInitialRender = function () { const isIsProtectedSet = typeof protections !== 'undefined'; const isTrackerBlockingDataSet = typeof trackerBlockingData === 'object'; const isLocaleSet = typeof locale === 'string'; - if (!isLocaleSet || !isUpgradedHttpsSet || !isIsProtectedSet || !isTrackerBlockingDataSet) { + const isMaliciousSiteSet = maliciousSiteStatus && maliciousSiteStatus.kind !== undefined; + if (!isLocaleSet || !isUpgradedHttpsSet || !isIsProtectedSet || !isTrackerBlockingDataSet || !isMaliciousSiteSet) { return; } @@ -177,6 +183,28 @@ export function onChangeLocale(payload) { channel?.send('updateTabData', { via: 'onChangeLocale' }); } +/** + * {@inheritDoc common.onChangeMaliciousSiteStatus} + * @type {import("./common.js").onChangeMaliciousSiteStatus} + * @group macOS -> JavaScript Interface + * @example + * + * ```swift + * // swift + * evaluate(js: "window.onChangeMaliciousSiteStatus(\(maliciousSiteStatusJsonString))", in: webView) + * ``` + */ +export function onChangeMaliciousSiteStatus(payload) { + const parsed = maliciousSiteStatusSchema.safeParse(payload); + if (!parsed.success) { + console.error('could not parse incoming data from onChangeMaliciousSiteStatus'); + console.error(parsed.error); + return; + } + maliciousSiteStatus = parsed.data; + resolveInitialRender(); +} + /** * {@inheritDoc common.onChangeFeatureSettings} * @type {import("./common.js").onChangeFeatureSettings} @@ -477,6 +505,7 @@ export function setup(debug) { }; window.onChangeProtectionStatus = onChangeProtectionStatus; window.onChangeLocale = onChangeLocale; + window.onChangeMaliciousSiteStatus = onChangeMaliciousSiteStatus; window.onChangeRequestData = onChangeRequestData; window.onChangeConsentManaged = onChangeConsentManaged; window.onChangeFeatureSettings = onChangeFeatureSettings; diff --git a/shared/js/browser/common.js b/shared/js/browser/common.js index 86d2b953..fb270230 100644 --- a/shared/js/browser/common.js +++ b/shared/js/browser/common.js @@ -160,19 +160,19 @@ export function assert(condition, message = '') { export function onChangeLocale(payload) {} /** - * Sets the phishing status for a page. This is a required call. + * Sets the phishing & malware status for a page. This is a required call. * - * Example Payload: see {@link "Generated Schema Definitions".PhishingStatus} + * Example Payload: see {@link "Generated Schema Definitions".MaliciousSiteStatus} * * ```json * { - * "phishingStatus": true + * "kind": "phishing" * } * ``` * - * @param {import('../../../schema/__generated__/schema.types').PhishingStatus} payload + * @param {import('../../../schema/__generated__/schema.types').MaliciousSiteStatus} payload */ -export function onChangePhishingStatus(payload) {} +export function onChangeMaliciousSiteStatus(payload) {} /** * Sets the Feature Settings diff --git a/shared/js/browser/macos-communication.js b/shared/js/browser/macos-communication.js index 50066b6d..2f459f44 100644 --- a/shared/js/browser/macos-communication.js +++ b/shared/js/browser/macos-communication.js @@ -16,7 +16,7 @@ import invariant from 'tiny-invariant'; import { cookiePromptManagementStatusSchema, localeSettingsSchema, - phishingStatusSchema, + maliciousSiteStatusSchema, protectionsStatusSchema, requestDataSchema, toggleReportScreenSchema, @@ -61,8 +61,8 @@ let isPendingUpdates; let parentEntity; const cookiePromptManagementStatus = {}; -/** @type {boolean | undefined} */ -let phishingStatus; +/** @type {import('../../../schema/__generated__/schema.types').MaliciousSiteStatus} */ +let maliciousSiteStatus; /** @type {string | undefined} */ let locale; @@ -71,7 +71,7 @@ const combineSources = () => ({ tab: Object.assign( {}, trackerBlockingData || {}, - { phishingStatus: phishingStatus ?? false }, + { maliciousSiteStatus: maliciousSiteStatus ?? false }, { isPendingUpdates, parentEntity, @@ -89,8 +89,8 @@ const resolveInitialRender = function () { const isIsProtectedSet = typeof protections !== 'undefined'; const isTrackerBlockingDataSet = typeof trackerBlockingData === 'object'; const isLocaleSet = typeof locale === 'string'; - const isPhishingSet = typeof phishingStatus === 'boolean'; - if (!isLocaleSet || !isUpgradedHttpsSet || !isIsProtectedSet || !isTrackerBlockingDataSet || !isPhishingSet) { + const isMaliciousSiteSet = maliciousSiteStatus && maliciousSiteStatus.kind !== undefined; + if (!isLocaleSet || !isUpgradedHttpsSet || !isIsProtectedSet || !isTrackerBlockingDataSet || !isMaliciousSiteSet) { return; } getBackgroundTabDataPromises.forEach((resolve) => resolve(combineSources())); @@ -178,24 +178,24 @@ export function onChangeLocale(payload) { } /** - * {@inheritDoc common.onChangePhishingStatus} - * @type {import("./common.js").onChangePhishingStatus} + * {@inheritDoc common.onChangeMaliciousSiteStatus} + * @type {import("./common.js").onChangeMaliciousSiteStatus} * @group macOS -> JavaScript Interface * @example * * ```swift * // swift - * evaluate(js: "window.onChangePhishingStatus(\(phishingStatusJsonString))", in: webView) + * evaluate(js: "window.onChangeMaliciousSiteStatus(\(maliciousSiteStatusJsonString))", in: webView) * ``` */ -export function onChangePhishingStatus(payload) { - const parsed = phishingStatusSchema.safeParse(payload); +export function onChangeMaliciousSiteStatus(payload) { + const parsed = maliciousSiteStatusSchema.safeParse(payload); if (!parsed.success) { - console.error('could not parse incoming data from onChangePhishingStatus'); + console.error('could not parse incoming data from onChangeMaliciousSiteStatus'); console.error(parsed.error); return; } - phishingStatus = parsed.data.phishingStatus; + maliciousSiteStatus = parsed.data; resolveInitialRender(); } @@ -537,7 +537,7 @@ export function setupShared() { if (trackerBlockingData) trackerBlockingData.upgradedHttps = upgradedHttps; resolveInitialRender(); }; - window.onChangePhishingStatus = onChangePhishingStatus; + window.onChangeMaliciousSiteStatus = onChangeMaliciousSiteStatus; window.onChangeProtectionStatus = onChangeProtectionStatus; window.onChangeLocale = onChangeLocale; window.onChangeCertificateData = function (data) { diff --git a/shared/js/browser/utils/communication-mocks.mjs b/shared/js/browser/utils/communication-mocks.mjs index f33623d4..842eb1fd 100644 --- a/shared/js/browser/utils/communication-mocks.mjs +++ b/shared/js/browser/utils/communication-mocks.mjs @@ -49,7 +49,7 @@ export async function mockDataProvider(params) { } window.onChangeLocale?.(state.localeSettings); window.onChangeRequestData(state.url, { requests: state.requests || [] }); - window.onChangePhishingStatus?.(state.phishing); + window.onChangeMaliciousSiteStatus?.(state.maliciousSiteStatus); } export function windowsMockApis() { diff --git a/shared/js/browser/utils/request-details.mjs b/shared/js/browser/utils/request-details.mjs index bcc1ddc5..181a3e0a 100644 --- a/shared/js/browser/utils/request-details.mjs +++ b/shared/js/browser/utils/request-details.mjs @@ -33,7 +33,7 @@ export class TabData { * @param {Record | null | undefined} ctaScreens * @param {Record | null | undefined} search * @param {Record | null | undefined} emailProtection - * @param {boolean | undefined} phishingStatus + * @param {import("../../../../schema/__generated__/schema.types").MaliciousSiteStatus | undefined} maliciousSiteStatus * @param {{prevalence: number, displayName: string} | null | undefined} parentEntity * @param {string | null | undefined} error * @param {boolean | null | undefined} isInvalidCert @@ -53,7 +53,7 @@ export class TabData { ctaScreens, search, emailProtection, - phishingStatus, + maliciousSiteStatus, parentEntity, error, isInvalidCert @@ -71,7 +71,7 @@ export class TabData { this.ctaScreens = ctaScreens; this.search = search; this.emailProtection = emailProtection; - this.phishingStatus = phishingStatus; + this.maliciousSiteStatus = maliciousSiteStatus; this.parentEntity = parentEntity; this.error = error; this.isInvalidCert = isInvalidCert; @@ -108,7 +108,7 @@ export const createTabData = (tabUrl, upgradedHttps, protections, rawRequestData ctaScreens: undefined, search: undefined, emailProtection: undefined, - phishingStatus: undefined, + maliciousSiteStatus: undefined, isPendingUpdates: undefined, certificate: undefined, platformLimitations: undefined, diff --git a/shared/js/browser/utils/request-details.test.mjs b/shared/js/browser/utils/request-details.test.mjs index c395e4be..d015d2f4 100644 --- a/shared/js/browser/utils/request-details.test.mjs +++ b/shared/js/browser/utils/request-details.test.mjs @@ -260,7 +260,7 @@ describe('createTabData', () => { specialDomainName: undefined, status: 'complete', upgradedHttps: true, - phishingStatus: undefined, + maliciousSiteStatus: undefined, url: 'https://www.example.com/', }; deepEqual(tabData, expected); diff --git a/shared/js/browser/windows-communication.js b/shared/js/browser/windows-communication.js index 8d3960d3..a950fc81 100644 --- a/shared/js/browser/windows-communication.js +++ b/shared/js/browser/windows-communication.js @@ -67,6 +67,9 @@ let protections; let isPendingUpdates; let parentEntity; +/** @type {import('../../../schema/__generated__/schema.types').MaliciousSiteStatus} */ +let maliciousSiteStatus; + /** @type {string | undefined} */ let locale; @@ -74,6 +77,7 @@ const combineSources = () => ({ tab: Object.assign( {}, trackerBlockingData || {}, + { maliciousSiteStatus: maliciousSiteStatus ?? null }, { isPendingUpdates, parentEntity, @@ -88,7 +92,8 @@ const resolveInitialRender = function () { const isUpgradedHttpsSet = typeof upgradedHttps === 'boolean'; const isIsProtectedSet = typeof protections !== 'undefined'; const isTrackerBlockingDataSet = typeof trackerBlockingData === 'object'; - if (!isUpgradedHttpsSet || !isIsProtectedSet || !isTrackerBlockingDataSet) { + const isMalwareSet = maliciousSiteStatus.kind && typeof maliciousSiteStatus.kind === 'string'; + if (!isUpgradedHttpsSet || !isIsProtectedSet || !isTrackerBlockingDataSet || !isMalwareSet) { return; } @@ -117,10 +122,12 @@ function handleViewModelUpdate(viewModel) { certificateData = viewModel.certificates || []; protections = viewModel.protections; locale = viewModel.localeSettings?.locale; + maliciousSiteStatus = viewModel.maliciousSiteStatus || { kind: null }; trackerBlockingData = createTabData(viewModel.tabUrl, upgradedHttps, viewModel.protections, viewModel.rawRequestData); trackerBlockingData.cookiePromptManagementStatus = viewModel.cookiePromptManagementStatus; trackerBlockingData.isInvalidCert = viewModel.isInvalidCert; + trackerBlockingData.maliciousSiteStatus = maliciousSiteStatus; if (trackerBlockingData) trackerBlockingData.upgradedHttps = upgradedHttps; diff --git a/shared/js/ui/components/_button.scss b/shared/js/ui/components/_button.scss index 8a2a61d6..31cd351e 100644 --- a/shared/js/ui/components/_button.scss +++ b/shared/js/ui/components/_button.scss @@ -110,6 +110,47 @@ color: var(--color-accent-blue-active); } } + + &[data-variant='standard'] { + text-align: center; + padding: 10px 12px; + cursor: pointer; + display: block; + text-decoration: none; + width: 100%; + + box-shadow: var(--btn-accent-shadow); + border-radius: var(--btn-accent-border-radius); + border: var(--btn-accent-border); + color: var(--btn-accent-color); + background: var(--btn-accent-bg); + + &:hover { + background: var(--btn-accent-bg-hover); + } + &:active { + background: var(--btn-accent-bg-active); + } + + .environment--windows & { + /* Windows/Label */ + box-shadow: none; + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 19px; + border-radius: 4px; + + &:focus { + /* todo(Shane): where does this live? */ + box-shadow: 0px 0px 0px 1px #ffffff, 0px 0px 0px 3px #3969ef; + } + + &:active { + box-shadow: none; + } + } + } } .button-bar { diff --git a/shared/js/ui/components/button.jsx b/shared/js/ui/components/button.jsx index b15ddc7c..57e4af7a 100644 --- a/shared/js/ui/components/button.jsx +++ b/shared/js/ui/components/button.jsx @@ -3,7 +3,7 @@ import { h } from 'preact'; /** * @typedef {object} ComponentProps - * @property {"desktop-vibrancy" | "desktop-standard" | "ios-secondary" | "macos-standard"} [variant] + * @property {"standard" | "desktop-vibrancy" | "desktop-standard" | "ios-secondary" | "macos-standard"} [variant] * @property {"big" | "desktop-large" | "small"} [btnSize] * @param {import("preact").ComponentProps<'button'> & ComponentProps} props */ diff --git a/shared/js/ui/models/site.js b/shared/js/ui/models/site.js index 49481c29..c1c84afd 100644 --- a/shared/js/ui/models/site.js +++ b/shared/js/ui/models/site.js @@ -1,7 +1,7 @@ /** * @typedef PublicSiteModel * @property {boolean} protectionsEnabled - * @property {'secure' | 'upgraded' | 'none' | 'invalid' | 'phishing'} httpsState + * @property {'secure' | 'upgraded' | 'none' | 'invalid' | 'phishing' | 'malware'} httpsState * @property {boolean} isBroken * @property {boolean} isAllowlisted * @property {boolean} isDenylisted diff --git a/shared/js/ui/platform-features.mjs b/shared/js/ui/platform-features.mjs index b374773e..d00f47e7 100644 --- a/shared/js/ui/platform-features.mjs +++ b/shared/js/ui/platform-features.mjs @@ -82,7 +82,7 @@ export function createPlatformFeatures(platform) { initialScreen: screen, opener, supportsInvalidCertsImplicitly: platform.name !== 'browser' && platform.name !== 'windows', - supportsPhishingWarning: platform.name === 'macos', + supportsMaliciousSiteWarning: platform.name !== 'browser', includeToggleOnBreakageForm, breakageScreen, randomisedCategories, @@ -104,7 +104,7 @@ export class PlatformFeatures { * @param {boolean} params.supportsInvalidCertsImplicitly * @param {boolean} params.includeToggleOnBreakageForm * @param {InitialScreen} params.breakageScreen - * @param {boolean} params.supportsPhishingWarning + * @param {boolean} params.supportsMaliciousSiteWarning * @param {boolean} params.randomisedCategories * @param {"default" | "material-web-dialog"} params.breakageFormCategorySelect */ @@ -140,10 +140,10 @@ export class PlatformFeatures { */ this.includeToggleOnBreakageForm = params.includeToggleOnBreakageForm; /** - * Does the current platform support phishing warnings? + * Does the current platform support phishing and malware warnings? * @type {boolean} */ - this.supportsPhishingWarning = params.supportsPhishingWarning; + this.supportsMaliciousSiteWarning = params.supportsMaliciousSiteWarning; /** * @type {import("../../../schema/__generated__/schema.types").EventOrigin['screen']} */ diff --git a/shared/js/ui/templates/key-insights.js b/shared/js/ui/templates/key-insights.js index fa6ce5c5..410b4e04 100644 --- a/shared/js/ui/templates/key-insights.js +++ b/shared/js/ui/templates/key-insights.js @@ -3,6 +3,7 @@ import raw from 'nanohtml/raw'; import { i18n } from '../base/localize.js'; import { normalizeCompanyName } from '../models/mixins/normalize-company-name.mjs'; import { getColorId } from './shared/utils.js'; +import { duckDuckGoURLs } from '../../../data/constants.js'; const keyInsightsState = /** @type {const} */ ({ /* 01 */ insecure: 'insecure', @@ -16,6 +17,7 @@ const keyInsightsState = /** @type {const} */ ({ /* 09 */ invalid: 'invalid', /* 10 */ noneBlocked_firstPartyAllowed: 'noneBlocked_firstPartyAllowed', /* 11 */ phishing: 'phishing', + /* 12 */ malware: 'malware', }); /** @@ -29,6 +31,7 @@ export function renderKeyInsight(modelOverride) { /** @type {keyInsightsState[keyof keyInsightsState]} */ const state = (() => { if (model.httpsState === 'phishing') return keyInsightsState.phishing; + if (model.httpsState === 'malware') return keyInsightsState.malware; if (model.httpsState === 'none') return keyInsightsState.insecure; if (model.httpsState === 'invalid') return keyInsightsState.invalid; if (model.isBroken) return keyInsightsState.broken; @@ -152,6 +155,25 @@ export function renderKeyInsight(modelOverride) {
${title(model.tab.domain)} ${description(raw(text))} + +
+ `; + }, + malware: () => { + const text = i18n.t('site:malwareWebsiteDesc.title', { domain: model.tab.domain }); + return html` +
+
+ ${title(model.tab.domain)} ${description(raw(text))} +
`; }, diff --git a/shared/js/ui/templates/page-connection.js b/shared/js/ui/templates/page-connection.js index 1cda6291..3e5ab117 100644 --- a/shared/js/ui/templates/page-connection.js +++ b/shared/js/ui/templates/page-connection.js @@ -55,7 +55,14 @@ function getKeyUsage(key) { * @param {import("../../browser/utils/request-details.mjs").TabData} tab */ export function renderCertificateDetails(site, tab) { - if (site.httpsState === 'none' || site.httpsState === 'phishing' || !tab.certificate || tab.certificate.length === 0) return null; + if ( + site.httpsState === 'none' || + site.httpsState === 'phishing' || + site.httpsState === 'malware' || + !tab.certificate || + tab.certificate.length === 0 + ) + return null; const certificate = tab.certificate[0]; return html` @@ -162,6 +169,9 @@ export function renderConnectionDescription(site, tab) { if (site.httpsState === 'phishing') { return i18n.t('connection:phishingWebsiteDesc.title', { domain: tab.domain }); } + if (site.httpsState === 'malware') { + return i18n.t('connection:malwareWebsiteDesc.title', { domain: tab.domain }); + } if (site.httpsState === 'invalid') { return i18n.t('connection:invalidConnectionDesc.title', { domain: tab.domain }); } diff --git a/shared/js/ui/templates/shared/thirdparty-text.js b/shared/js/ui/templates/shared/thirdparty-text.js index 1ef80f66..04c4df7e 100644 --- a/shared/js/ui/templates/shared/thirdparty-text.js +++ b/shared/js/ui/templates/shared/thirdparty-text.js @@ -37,11 +37,11 @@ export function thirdpartyTitle(requestDetails, protectionsEnabled) { /** * @param {import("../../../browser/utils/request-details.mjs").RequestDetails} requestDetails * @param {boolean} protectionsEnabled - * @param {boolean} [phishingDetected] + * @param {import("../../../../../schema/__generated__/schema.types").MaliciousSiteStatus['kind']} [threatDetected] * @returns {'info'|'blocked'} */ -export function thirdpartyIcon(requestDetails, protectionsEnabled, phishingDetected) { - if (phishingDetected) { +export function thirdpartyIcon(requestDetails, protectionsEnabled, threatDetected) { + if (threatDetected === 'phishing' || threatDetected === 'malware') { return 'info'; } diff --git a/shared/js/ui/templates/shared/tracker-networks-text.js b/shared/js/ui/templates/shared/tracker-networks-text.js index 4c022cfa..59b5c79e 100644 --- a/shared/js/ui/templates/shared/tracker-networks-text.js +++ b/shared/js/ui/templates/shared/tracker-networks-text.js @@ -38,11 +38,11 @@ export function trackerNetworksTitle(requestDetails, protectionsEnabled) { /** * @param {import("../../../browser/utils/request-details.mjs").RequestDetails} requestDetails * @param {any} protectionsEnabled - * @param {boolean} [phishingDetected] + * @param {import("../../../../../schema/__generated__/schema.types").MaliciousSiteStatus['kind']} [threatDetected] * @returns {'info'|'blocked'|'warning'} */ -export function trackerNetworksIcon(requestDetails, protectionsEnabled, phishingDetected) { - if (phishingDetected) { +export function trackerNetworksIcon(requestDetails, protectionsEnabled, threatDetected) { + if (threatDetected === 'phishing' || threatDetected === 'malware') { return 'info'; } diff --git a/shared/js/ui/views/main-nav.js b/shared/js/ui/views/main-nav.js index 365954be..198012b0 100644 --- a/shared/js/ui/views/main-nav.js +++ b/shared/js/ui/views/main-nav.js @@ -15,7 +15,7 @@ export function template(model, nav) { const networkTrackersLink = shouldRenderTrackerNetworksLink(model) ? html`` : ''; - const renderConnectionAsText = model.httpsState === 'phishing'; + const renderConnectionAsText = model.httpsState === 'phishing' || model.httpsState === 'malware'; const connectionRow = renderConnectionAsText ? html`` : html``; @@ -115,7 +115,7 @@ function renderConnectionText(model) { */ function renderTrackerNetworksNew(model, cb) { const title = trackerNetworksTitle(model.tab.requestDetails, model.protectionsEnabled); - const icon = trackerNetworksIcon(model.tab.requestDetails, model.protectionsEnabled, model.tab.phishingStatus); + const icon = trackerNetworksIcon(model.tab.requestDetails, model.protectionsEnabled, model.tab.maliciousSiteStatus?.kind || null); return html` { requests: [], permissions, }), + malware: new MockData({ + url: 'https://privacy-test-pages.site/security/badware/malware.html?">someQueryParam=false', + requests: [], + maliciousSiteStatus: { + kind: 'malware', + }, + certificate: defaultCertificates, + }), phishing: new MockData({ - url: 'https://privacy-test-pages.site/security/badware/phishing.html', + url: 'https://privacy-test-pages.site/security/badware/phishing.html?query=param&and=another', requests: [], - phishing: { - phishingStatus: true, + maliciousSiteStatus: { + kind: 'phishing', }, certificate: defaultCertificates, }), diff --git a/shared/locales/en/site.json b/shared/locales/en/site.json index dd549829..bee841ca 100644 --- a/shared/locales/en/site.json +++ b/shared/locales/en/site.json @@ -71,6 +71,14 @@ "title": "Copied to your clipboard!", "note": "Note to inform that the email address was copied" }, + "reportWebsiteAsSafeCTA": { + "title": "Report a site as safe", + "note": "button label text for a trigger that shows a feedback form in which the user can report the current website as safe" + }, + "aboutPhishingMalwareLink": { + "title": "About our phishing and malware protection", + "note": "Label for a link that takes the user to a help page about Phishing & Malware protection" + }, "websiteNotWorkingQ": { "title": "Website not working as expected?", "note": "Call to action for user to click if they are having issues with this web page" @@ -143,6 +151,14 @@ "title": "This website may be impersonating a legitimate site in order to trick you into providing personal information, such as passwords or credit card numbers.", "note": "Shown as the description text when the URL is reported as a phishing website." }, + "malwareWebsite": { + "title": "Site May Be Deceptive", + "note": "Shown as the button text when the URL is reported as a phishing website." + }, + "malwareWebsiteDesc": { + "title": "This site has been flagged for distributing malware designed to compromise your device or steal your personal information.", + "note": "Shown as the description text when the URL is reported as a malware website." + }, "trackerNetworksDesc": { "title": "Requests Blocked from Loading", "note": "This indicates that 1 or more trackers were blocked." diff --git a/swift-package/Tests/PrivacyDashboardTests.swift b/swift-package/Tests/PrivacyDashboardTests.swift deleted file mode 100644 index 1d6d0e72..00000000 --- a/swift-package/Tests/PrivacyDashboardTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import PrivacyDashboardResources - -final class PrivacyDashboardTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertTrue(true) - } -} diff --git a/types.ts b/types.ts index 511ef073..0a84f783 100644 --- a/types.ts +++ b/types.ts @@ -43,7 +43,7 @@ interface Window { onChangeAllowedPermissions: any; onChangeUpgradedHttps: any; onChangeProtectionStatus: (protections: import('./shared/js/browser/utils/protections.mjs').Protections) => void; - onChangePhishingStatus?: any; + onChangeMaliciousSiteStatus?: any; onChangeCertificateData: any; onIsPendingUpdates: any; diff --git a/v2/components/protection-header.jsx b/v2/components/protection-header.jsx index 1d1d255b..55b02a51 100644 --- a/v2/components/protection-header.jsx +++ b/v2/components/protection-header.jsx @@ -6,35 +6,76 @@ import { TextLink } from '../../shared/js/ui/components/text-link'; import { useNav } from '../navigation'; import { ns } from '../../shared/js/ui/base/localize'; import { CheckBrokenSiteReportHandledMessage } from '../../shared/js/browser/common'; +import { duckDuckGoURLs } from '../../shared/data/constants'; -export function ProtectionHeader() { +/** + * @param {string} urlString + * @returns {string} + */ +const sanitizeAndEncodeURL = (urlString) => { + if (!urlString) return ''; + + try { + const url = new URL(urlString); + + return encodeURIComponent(`${url.origin}${url.pathname}`); + } catch (error) { + return ''; + } +}; + +export function BreakageFormLink() { const { push } = useNav(); - const data = useData(); - const onToggle = useToggle(); const fetcher = useFetcher(); - const { breakageScreen } = useFeatures(); const featureSettings = useFeatureSettings(); + const { breakageScreen } = useFeatures(); + + return ( + { + // this is a workaround for ios, to ensure we follow the old implementation + fetcher(new CheckBrokenSiteReportHandledMessage()) + .then(() => { + if (featureSettings.webBreakageForm.state === 'enabled') { + push(breakageScreen); + } + }) + .catch(console.error); + }} + rounded={true} + > + {ns.site('websiteNotWorkingPrompt.title')} + + ); +} + +export function PhishingMalwareLink() { + const { + tab: { url }, + } = useData(); + + const sanitizedURL = sanitizeAndEncodeURL(url); + const reportPageURL = `${duckDuckGoURLs.reportSiteAsSafeForm}${sanitizedURL}`; + + return ( + + {ns.site('reportWebsiteAsSafeCTA.title')} + + ); +} + +export function ProtectionHeader() { + const data = useData(); + const onToggle = useToggle(); + + const isMaliciousSite = + data.tab?.maliciousSiteStatus && + (data.tab.maliciousSiteStatus.kind === 'phishing' || data.tab.maliciousSiteStatus.kind === 'malware'); return (
-
- { - // this is a workaround for ios, to ensure we follow the old implementation - fetcher(new CheckBrokenSiteReportHandledMessage()) - .then(() => { - if (featureSettings.webBreakageForm.state === 'enabled') { - push(breakageScreen); - } - }) - .catch(console.error); - }} - rounded={true} - > - {ns.site('websiteNotWorkingPrompt.title')} - -
+
{isMaliciousSite ? : }
); diff --git a/v2/data-provider.js b/v2/data-provider.js index 4527f442..73a9f244 100644 --- a/v2/data-provider.js +++ b/v2/data-provider.js @@ -18,8 +18,7 @@ import { useNav } from './navigation'; /** * @typedef {Object} DataChannelPublicData * @property {boolean} protectionsEnabled - * @property {'secure' | 'upgraded' | 'none' | 'invalid' | 'phishing'} httpsState - * @property {boolean} isBroken + * @property {'secure' | 'upgraded' | 'none' | 'invalid' | 'phishing' | 'malware'} httpsState * @property {boolean} isBroken * @property {boolean} isAllowlisted * @property {boolean} isDenylisted * @property {boolean} displayBrokenUI @@ -37,7 +36,7 @@ import { useNav } from './navigation'; export class DataChannel extends EventTarget { protectionsEnabled = false; - /** @type {'secure' | 'upgraded' | 'none' | 'invalid' | 'phishing'} */ + /** @type {'secure' | 'upgraded' | 'none' | 'invalid' | 'phishing' | 'malware'} */ httpsState = 'none'; isBroken = false; isAllowlisted = false; @@ -177,9 +176,13 @@ export class DataChannel extends EventTarget { /** @type {import('../shared/js/ui/models/site.js').PublicSiteModel['httpsState']} */ const nextState = (() => { - if (this.features.supportsPhishingWarning) { - if (this.tab.phishingStatus) { - return 'phishing'; + if (this.features.supportsMaliciousSiteWarning && this.tab.maliciousSiteStatus) { + const { kind } = this.tab.maliciousSiteStatus; + switch (kind) { + case 'phishing': + case 'malware': + return kind; + default: } }