Repository: GoogleChrome/lighthouse
Pull request #12530 (2021-05-20 23:36:30)

core(fr): convert inspector-issues gatherer
Ref #11313



Patch:
diff --git a/lighthouse-core/audits/csp-xss.js b/lighthouse-core/audits/csp-xss.js
index 111f3f2aa..e016fc87c 100644
--- a/lighthouse-core/audits/csp-xss.js
+++ b/lighthouse-core/audits/csp-xss.js
@@ -20,8 +20,8 @@ const UIStrings = {
   title: 'Ensure CSP is effective against XSS attacks',
   /** Description of a Lighthouse audit that evaluates the security of a page's CSP. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. "CSP" stands for "Content Security Policy". "XSS" stands for "Cross Site Scripting". "CSP" and "XSS" do not need to be translated. */
   description: 'A strong Content Security Policy (CSP) significantly ' +
-    'reduces the risk of XSS attacks. ' +
-    '[Learn more](https://csp.withgoogle.com/docs/index.html)',
+    'reduces the risk of cross-site scripting (XSS) attacks. ' +
+    '[Learn more](https://web.dev/strict-csp/)',
   /** Summary text for the results of a Lighthouse audit that evaluates the security of a page's CSP. This is displayed if no CSP is being enforced. "CSP" stands for "Content Security Policy". "CSP" does not need to be translated. */
   noCsp: 'No CSP found in enforcement mode',
   /** Message shown when one or more CSPs are defined in a <meta> tag. Shown in a table with a list of other CSP bypasses and warnings. "CSP" stands for "Content Security Policy". "CSP" and "HTTP" do not need to be translated. */
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 48f6dfea0..522ae56cf 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -237,6 +237,7 @@ const defaultConfig = {
     'unsized-images',
     'valid-source-maps',
     'preload-lcp-image',
+    'csp-xss',
     'full-page-screenshot',
     'script-treemap-data',
     'manual/pwa-cross-browser',
@@ -561,6 +562,7 @@ const defaultConfig = {
         {id: 'geolocation-on-start', weight: 1, group: 'best-practices-trust-safety'},
         {id: 'notification-on-start', weight: 1, group: 'best-practices-trust-safety'},
         {id: 'no-vulnerable-libraries', weight: 1, group: 'best-practices-trust-safety'},
+        {id: 'csp-xss', weight: 0, group: 'best-practices-trust-safety'},
         // User Experience
         {id: 'password-inputs-can-be-pasted-into', weight: 1, group: 'best-practices-ux'},
         {id: 'image-aspect-ratio', weight: 1, group: 'best-practices-ux'},
diff --git a/lighthouse-core/config/experimental-config.js b/lighthouse-core/config/experimental-config.js
index 5d716d3e2..6a480a661 100644
--- a/lighthouse-core/config/experimental-config.js
+++ b/lighthouse-core/config/experimental-config.js
@@ -16,7 +16,6 @@ const config = {
   audits: [
     'autocomplete',
     'large-javascript-libraries',
-    'csp-xss',
   ],
   categories: {
     // @ts-ignore: `title` is required in CategoryJson. setting to the same value as the default
@@ -30,7 +29,6 @@ const config = {
     // config is awkward - easier to omit the property here. Will defer to default config.
     'best-practices': {
       auditRefs: [
-        {id: 'csp-xss', weight: 0, group: 'best-practices-trust-safety'},
         {id: 'autocomplete', weight: 0, group: 'best-practices-ux'},
       ],
     },
diff --git a/lighthouse-core/fraggle-rock/config/default-config.js b/lighthouse-core/fraggle-rock/config/default-config.js
index 87c52160d..999213ef3 100644
--- a/lighthouse-core/fraggle-rock/config/default-config.js
+++ b/lighthouse-core/fraggle-rock/config/default-config.js
@@ -27,6 +27,7 @@ const artifacts = {
   IFrameElements: '',
   ImageElements: '',
   InstallabilityErrors: '',
+  InspectorIssues: '',
   JsUsage: '',
   LinkElements: '',
   MainDocumentContent: '',
@@ -74,6 +75,7 @@ const defaultConfig = {
     {id: artifacts.IFrameElements, gatherer: 'iframe-elements'},
     {id: artifacts.ImageElements, gatherer: 'image-elements'},
     {id: artifacts.InstallabilityErrors, gatherer: 'installability-errors'},
+    {id: artifacts.InspectorIssues, gatherer: 'inspector-issues'},
     {id: artifacts.JsUsage, gatherer: 'js-usage'},
     {id: artifacts.LinkElements, gatherer: 'link-elements'},
     {id: artifacts.MainDocumentContent, gatherer: 'main-document-content'},
@@ -123,6 +125,7 @@ const defaultConfig = {
         artifacts.IFrameElements,
         artifacts.ImageElements,
         artifacts.InstallabilityErrors,
+        artifacts.InspectorIssues,
         artifacts.JsUsage,
         artifacts.LinkElements,
         artifacts.MainDocumentContent,
diff --git a/lighthouse-core/gather/gatherers/inspector-issues.js b/lighthouse-core/gather/gatherers/inspector-issues.js
index c51fc7982..dfb0551ad 100644
--- a/lighthouse-core/gather/gatherers/inspector-issues.js
+++ b/lighthouse-core/gather/gatherers/inspector-issues.js
@@ -10,9 +10,17 @@
 
 'use strict';
 
-const Gatherer = require('./gatherer.js');
+const FRGatherer = require('../../fraggle-rock/gather/base-gatherer.js');
+const NetworkRecords = require('../../computed/network-records.js');
+const DevtoolsLog = require('./devtools-log.js');
+
+class InspectorIssues extends FRGatherer {
+  /** @type {LH.Gatherer.GathererMeta<'DevtoolsLog'>} */
+  meta = {
+    supportedModes: ['timespan', 'navigation'],
+    dependencies: {DevtoolsLog: DevtoolsLog.symbol},
+  }
 
-class InspectorIssues extends Gatherer {
   constructor() {
     super();
     /** @type {Array<LH.Crdp.Audits.InspectorIssue>} */
@@ -28,25 +36,28 @@ class InspectorIssues extends Gatherer {
   }
 
   /**
-   * @param {LH.Gatherer.PassContext} passContext
+   * @param {LH.Gatherer.FRTransitionalContext} context
    */
-  async beforePass(passContext) {
-    const driver = passContext.driver;
-    driver.on('Audits.issueAdded', this._onIssueAdded);
-    await driver.sendCommand('Audits.enable');
+  async startInstrumentation(context) {
+    const session = context.driver.defaultSession;
+    session.on('Audits.issueAdded', this._onIssueAdded);
+    await session.sendCommand('Audits.enable');
   }
 
   /**
-   * @param {LH.Gatherer.PassContext} passContext
-   * @param {LH.Gatherer.LoadData} loadData
-   * @return {Promise<LH.Artifacts['InspectorIssues']>}
+   * @param {LH.Gatherer.FRTransitionalContext} context
    */
-  async afterPass(passContext, loadData) {
-    const driver = passContext.driver;
-    const networkRecords = loadData.networkRecords;
+  async stopInstrumentation(context) {
+    const session = context.driver.defaultSession;
+    session.off('Audits.issueAdded', this._onIssueAdded);
+    await session.sendCommand('Audits.disable');
+  }
 
-    driver.off('Audits.issueAdded', this._onIssueAdded);
-    await driver.sendCommand('Audits.disable');
+  /**
+   * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
+   * @return {Promise<LH.Artifacts['InspectorIssues']>}
+   */
+  async _getArtifact(networkRecords) {
     const artifact = {
       /** @type {Array<LH.Crdp.Audits.MixedContentIssueDetails>} */
       mixedContent: [],
@@ -91,7 +102,6 @@ class InspectorIssues extends Gatherer {
       if (issue.details.heavyAdIssueDetails) {
         artifact.heavyAds.push(issue.details.heavyAdIssueDetails);
       }
-      // Duplicate issues can occur for the same request; only use the one with a matching networkRequest.
       if (issue.details.contentSecurityPolicyIssueDetails) {
         artifact.contentSecurityPolicy.push(issue.details.contentSecurityPolicyIssueDetails);
       }
@@ -99,6 +109,26 @@ class InspectorIssues extends Gatherer {
 
     return artifact;
   }
+
+  /**
+   * @param {LH.Gatherer.FRTransitionalContext<'DevtoolsLog'>} context
+   * @returns {Promise<LH.Artifacts['InspectorIssues']>}
+   */
+  async getArtifact(context) {
+    const devtoolsLog = context.dependencies.DevtoolsLog;
+    const networkRecords = await NetworkRecords.request(devtoolsLog, context);
+    return this._getArtifact(networkRecords);
+  }
+
+  /**
+   * @param {LH.Gatherer.PassContext} passContext
+   * @param {LH.Gatherer.LoadData} loadData
+   * @returns {Promise<LH.Artifacts['InspectorIssues']>}
+   */
+  async afterPass(passContext, loadData) {
+    await this.stopInstrumentation({...passContext, dependencies: {}});
+    return this._getArtifact(loadData.networkRecords);
+  }
 }
 
 module.exports = InspectorIssues;
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index 2adbda395..5b55f5f86 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -585,7 +585,7 @@
     "message": "Directive"
   },
   "lighthouse-core/audits/csp-xss.js | description": {
-    "message": "A strong Content Security Policy (CSP) significantly reduces the risk of XSS attacks. [Learn more](https://csp.withgoogle.com/docs/index.html)"
+    "message": "A strong Content Security Policy (CSP) significantly reduces the risk of cross-site scripting (XSS) attacks. [Learn more](https://web.dev/strict-csp/)"
   },
   "lighthouse-core/audits/csp-xss.js | metaTagMessage": {
     "message": "The page contains a CSP defined in a <meta> tag. Consider defining the CSP in an HTTP header if you can."
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 491703aeb..cd9285a3b 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -585,7 +585,7 @@
     "message": "D̂ír̂éĉt́îv́ê"
   },
   "lighthouse-core/audits/csp-xss.js | description": {
-    "message": "Â śt̂ŕôńĝ Ćôńt̂én̂t́ Ŝéĉúr̂ít̂ý P̂ól̂íĉý (ĈŚP̂) śîǵn̂íf̂íĉán̂t́l̂ý r̂éd̂úĉéŝ t́ĥé r̂íŝḱ ôf́ X̂ŚŜ át̂t́âćk̂ś. [L̂éâŕn̂ ḿôŕê](https://csp.withgoogle.com/docs/index.html)"
+    "message": "Â śt̂ŕôńĝ Ćôńt̂én̂t́ Ŝéĉúr̂ít̂ý P̂ól̂íĉý (ĈŚP̂) śîǵn̂íf̂íĉán̂t́l̂ý r̂éd̂úĉéŝ t́ĥé r̂íŝḱ ôf́ ĉŕôśŝ-śît́ê śĉŕîṕt̂ín̂ǵ (X̂ŚŜ) át̂t́âćk̂ś. [L̂éâŕn̂ ḿôŕê](https://web.dev/strict-csp/)"
   },
   "lighthouse-core/audits/csp-xss.js | metaTagMessage": {
     "message": "T̂h́ê ṕâǵê ćôńt̂áîńŝ á ĈŚP̂ d́êf́îńêd́ îń â <ḿêt́â> t́âǵ. Ĉón̂śîd́êŕ d̂éf̂ín̂ín̂ǵ t̂h́ê ĆŜṔ îń âń ĤT́T̂Ṕ ĥéâd́êŕ îf́ ŷóû ćâń."
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index 7baf0255c..cf2cd8420 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -23,7 +23,6 @@ declare global {
       | 'Fonts'
       | 'FullPageScreenshot'
       | 'HTTPRedirect'
-      | 'InspectorIssues'
       | 'Manifest'
       | 'MixedContent'
       | 'ScriptElements'


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index 5bb768893..c01a46c06 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -168,6 +168,9 @@ Object {
     Object {
       "path": "preload-lcp-image",
     },
+    Object {
+      "path": "csp-xss",
+    },
     Object {
       "path": "full-page-screenshot",
     },
@@ -764,6 +767,11 @@ Object {
           "id": "no-vulnerable-libraries",
           "weight": 1,
         },
+        Object {
+          "group": "best-practices-trust-safety",
+          "id": "csp-xss",
+          "weight": 0,
+        },
         Object {
           "group": "best-practices-ux",
           "id": "password-inputs-can-be-pasted-into",
diff --git a/lighthouse-core/test/fraggle-rock/api-test-pptr.js b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
index 4445de6d5..8bda1cd5d 100644
--- a/lighthouse-core/test/fraggle-rock/api-test-pptr.js
+++ b/lighthouse-core/test/fraggle-rock/api-test-pptr.js
@@ -121,7 +121,7 @@ describe('Fraggle Rock API', () => {
 
       const {auditResults, erroredAudits, failedAudits} = getAuditsBreakdown(lhr);
       // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached.
-      expect(auditResults.length).toMatchInlineSnapshot(`25`);
+      expect(auditResults.length).toMatchInlineSnapshot(`27`);
 
       expect(erroredAudits).toHaveLength(0);
       expect(failedAudits.map(audit => audit.id)).toContain('errors-in-console');
@@ -159,7 +159,7 @@ describe('Fraggle Rock API', () => {
       const {lhr} = result;
       const {auditResults, failedAudits, erroredAudits} = getAuditsBreakdown(lhr);
       // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached.
-      expect(auditResults.length).toMatchInlineSnapshot(`113`);
+      expect(auditResults.length).toMatchInlineSnapshot(`115`);
       expect(erroredAudits).toHaveLength(0);
 
       const failedAuditIds = failedAudits.map(audit => audit.id);
diff --git a/lighthouse-core/test/gather/gatherers/inspector-issues-test.js b/lighthouse-core/test/gather/gatherers/inspector-issues-test.js
new file mode 100644
index 000000000..734ca2cef
--- /dev/null
+++ b/lighthouse-core/test/gather/gatherers/inspector-issues-test.js
@@ -0,0 +1,330 @@
+/**
+ * @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-env jest */
+
+const InspectorIssues = require('../../../gather/gatherers/inspector-issues.js');
+const NetworkRequest = require('../../../lib/network-request.js');
+const {createMockContext} = require('../../fraggle-rock/gather/mock-driver.js');
+const {flushAllTimersAndMicrotasks} = require('../../test-utils.js');
+const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js');
+
+jest.useFakeTimers();
+
+/**
+ * @param {Partial<LH.Artifacts.NetworkRequest>=} partial
+ * @returns {LH.Artifacts.NetworkRequest}
+ */
+function mockRequest(partial) {
+  return Object.assign(new NetworkRequest(), {
+    url: 'https://example.com',
+    documentURL: 'https://example.com',
+    finished: true,
+    frameId: 'frameId',
+    isSecure: true,
+    isValid: true,
+    parsedURL: {scheme: 'https'},
+    protocol: 'http/1.1',
+    requestMethod: 'GET',
+    resourceType: 'Document',
+    ...partial,
+  });
+}
+
+/**
+ * @param {Partial<LH.Crdp.Audits.MixedContentIssueDetails>=} details
+ * @return {LH.Crdp.Audits.InspectorIssue} partial
+ */
+function mockMixedContent(details) {
+  return {
+    code: 'MixedContentIssue',
+    details: {
+      mixedContentIssueDetails: {
+        resolutionStatus: 'MixedContentBlocked',
+        insecureURL: 'https://example.com',
+        mainResourceURL: 'https://example.com',
+        ...details,
+      },
+    },
+  };
+}
+
+/**
+ * @param {Partial<LH.Crdp.Audits.SameSiteCookieIssueDetails>=} details
+ * @return {LH.Crdp.Audits.InspectorIssue} partial
+ */
+function mockSameSiteCookie(details) {
+  return {
+    code: 'SameSiteCookieIssue',
+    details: {
+      sameSiteCookieIssueDetails: {
+        cookie: {
+          name: 'name',
+          path: 'path',
+          domain: 'domain',
+        },
+        cookieWarningReasons: [],
+        cookieExclusionReasons: [],
+        operation: 'ReadCookie',
+        ...details,
+      },
+    },
+  };
+}
+
+/**
+ * @param {Partial<LH.Crdp.Audits.BlockedByResponseIssueDetails>=} details
+ * @return {LH.Crdp.Audits.InspectorIssue} partial
+ */
+function mockBlockedByResponse(details) {
+  return {
+    code: 'BlockedByResponseIssue',
+    details: {
+      blockedByResponseIssueDetails: {
+        request: {requestId: '1'},
+        reason: 'CorpNotSameOrigin',
+        ...details,
+      },
+    },
+  };
+}
+
+/**
+ * @param {Partial<LH.Crdp.Audits.HeavyAdIssueDetails>=} details
+ * @return {LH.Crdp.Audits.InspectorIssue} partial
+ */
+function mockHeavyAd(details) {
+  return {
+    code: 'HeavyAdIssue',
+    details: {
+      heavyAdIssueDetails: {
+        resolution: 'HeavyAdBlocked',
+        reason: 'CpuPeakLimit',
+        frame: {
+          frameId: 'frameId',
+        },
+        ...details,
+      },
+    },
+  };
+}
+
+/**
+ * @param {Partial<LH.Crdp.Audits.ContentSecurityPolicyIssueDetails>=} details
+ * @return {LH.Crdp.Audits.InspectorIssue} partial
+ */
+function mockCSP(details) {
+  return {
+    code: 'ContentSecurityPolicyIssue',
+    details: {
+      contentSecurityPolicyIssueDetails: {
+        violatedDirective: 'default-drc',
+        isReportOnly: false,
+        contentSecurityPolicyViolationType: 'kInlineViolation',
+        ...details,
+      },
+    },
+  };
+}
+
+describe('instrumentation', () => {
+  it('collects inspector issues', async () => {
+    const mockContext = createMockContext();
+    mockContext.driver.defaultSession.on
+      .mockEvent('Audits.issueAdded', {issue: mockMixedContent()})
+      .mockEvent('Audits.issueAdded', {issue: mockSameSiteCookie()});
+    mockContext.driver.defaultSession.sendCommand
+      .mockResponse('Audits.enable')
+      .mockResponse('Audits.disable');
+    const gatherer = new InspectorIssues();
+
+    await gatherer.startInstrumentation(mockContext.asContext());
+    await flushAllTimersAndMicrotasks();
+    await gatherer.stopInstrumentation(mockContext.asContext());
+
+    expect(gatherer._issues).toEqual([
+      mockMixedContent(),
+      mockSameSiteCookie(),
+    ]);
+  });
+});
+
+describe('_getArtifact', () => {
+  it('takes 5 types of inspector issues', async () => {
+    const gatherer = new InspectorIssues();
+    gatherer._issues = [
+      mockMixedContent({request: {requestId: '1'}}),
+      mockSameSiteCookie({request: {requestId: '2'}}),
+      mockBlockedByResponse({request: {requestId: '3'}}),
+      mockHeavyAd(),
+      mockCSP(),
+    ];
+    const networkRecords = [
+      mockRequest({requestId: '1'}),
+      mockRequest({requestId: '2'}),
+      mockRequest({requestId: '3'}),
+    ];
+
+    const artifact = await gatherer._getArtifact(networkRecords);
+
+    expect(artifact).toEqual({
+      mixedContent: [{
+        request: {requestId: '1'},
+        resolutionStatus: 'MixedContentBlocked',
+        insecureURL: 'https://example.com',
+        mainResourceURL: 'https://example.com',
+      }],
+      sameSiteCookies: [{
+        request: {requestId: '2'},
+        cookie: {
+          name: 'name',
+          path: 'path',
+          domain: 'domain',
+        },
+        cookieWarningReasons: [],
+        cookieExclusionReasons: [],
+        operation: 'ReadCookie',
+      }],
+      blockedByResponse: [{
+        request: {requestId: '3'},
+        reason: 'CorpNotSameOrigin',
+      }],
+      heavyAds: [{
+        resolution: 'HeavyAdBlocked',
+        reason: 'CpuPeakLimit',
+        frame: {
+          frameId: 'frameId',
+        },
+      }],
+      contentSecurityPolicy: [{
+        violatedDirective: 'default-drc',
+        isReportOnly: false,
+        contentSecurityPolicyViolationType: 'kInlineViolation',
+      }],
+    });
+  });
+
+  it('dedupe by request id', async () => {
+    const gatherer = new InspectorIssues();
+    gatherer._issues = [
+      mockMixedContent({request: {requestId: '1'}}),
+      mockMixedContent({request: {requestId: '2'}}),
+      mockSameSiteCookie({request: {requestId: '3'}}),
+      mockSameSiteCookie({request: {requestId: '4'}}),
+      mockBlockedByResponse({request: {requestId: '5'}}),
+      mockBlockedByResponse({request: {requestId: '6'}}),
+    ];
+    const networkRecords = [
+      mockRequest({requestId: '1'}),
+      mockRequest({requestId: '3'}),
+      mockRequest({requestId: '5'}),
+    ];
+
+    const artifact = await gatherer._getArtifact(networkRecords);
+
+    expect(artifact).toEqual({
+      mixedContent: [{
+        request: {requestId: '1'},
+        resolutionStatus: 'MixedContentBlocked',
+        insecureURL: 'https://example.com',
+        mainResourceURL: 'https://example.com',
+      }],
+      sameSiteCookies: [{
+        request: {requestId: '3'},
+        cookie: {
+          name: 'name',
+          path: 'path',
+          domain: 'domain',
+        },
+        cookieWarningReasons: [],
+        cookieExclusionReasons: [],
+        operation: 'ReadCookie',
+      }],
+      blockedByResponse: [{
+        request: {requestId: '5'},
+        reason: 'CorpNotSameOrigin',
+      }],
+      heavyAds: [],
+      contentSecurityPolicy: [],
+    });
+  });
+});
+
+describe('FR compat', () => {
+  let mockContext = createMockContext();
+  /** @type {InspectorIssues} */
+  let gatherer;
+  /** @type {LH.Artifacts.NetworkRequest[]} */
+  let networkRecords;
+  /** @type {LH.DevtoolsLog} */
+  let devtoolsLog;
+
+  beforeEach(() => {
+    gatherer = new InspectorIssues();
+    mockContext = createMockContext();
+    mockContext.driver.defaultSession.sendCommand
+      .mockResponse('Audits.enable')
+      .mockResponse('Audits.disable');
+    mockContext.driver.defaultSession.on
+      .mockEvent('Audits.issueAdded', {
+        issue: mockMixedContent({request: {requestId: '1'}}),
+      });
+    networkRecords = [
+      mockRequest({requestId: '1'}),
+    ];
+    devtoolsLog = networkRecordsToDevtoolsLog(networkRecords);
+  });
+
+  it('uses loadData in legacy mode', async () => {
+    const loadData = {
+      devtoolsLog,
+      networkRecords,
+    };
+    await gatherer.beforePass(mockContext.asLegacyContext());
+    await flushAllTimersAndMicrotasks();
+
+    const artifact = await gatherer.afterPass(mockContext.asLegacyContext(), loadData);
+
+    expect(artifact).toEqual({
+      mixedContent: [{
+        request: {requestId: '1'},
+        resolutionStatus: 'MixedContentBlocked',
+        insecureURL: 'https://example.com',
+        mainResourceURL: 'https://example.com',
+      }],
+      sameSiteCookies: [],
+      blockedByResponse: [],
+      heavyAds: [],
+      contentSecurityPolicy: [],
+    });
+  });
+
+  it('uses dependencies in FR', async () => {
+    const context = {
+      ...mockContext.asContext(),
+      dependencies: {DevtoolsLog: devtoolsLog},
+    };
+    await gatherer.startInstrumentation(context);
+    await flushAllTimersAndMicrotasks();
+    await gatherer.stopInstrumentation(context);
+
+    const artifact = await gatherer.getArtifact(context);
+
+    expect(artifact).toEqual({
+      mixedContent: [{
+        request: {requestId: '1'},
+        resolutionStatus: 'MixedContentBlocked',
+        insecureURL: 'https://example.com',
+        mainResourceURL: 'https://example.com',
+      }],
+      sameSiteCookies: [],
+      blockedByResponse: [],
+      heavyAds: [],
+      contentSecurityPolicy: [],
+    });
+  });
+});
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index b282e2788..1a2fc9b35 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -2168,6 +2168,40 @@
         "overallSavingsMs": 0
       }
     },
+    "csp-xss": {
+      "id": "csp-xss",
+      "title": "Ensure CSP is effective against XSS attacks",
+      "description": "A strong Content Security Policy (CSP) significantly reduces the risk of cross-site scripting (XSS) attacks. [Learn more](https://web.dev/strict-csp/)",
+      "score": null,
+      "scoreDisplayMode": "informative",
+      "details": {
+        "type": "table",
+        "headings": [
+          {
+            "key": "description",
+            "itemType": "text",
+            "subItemsHeading": {
+              "key": "description"
+            },
+            "text": "Description"
+          },
+          {
+            "key": "directive",
+            "itemType": "code",
+            "subItemsHeading": {
+              "key": "directive"
+            },
+            "text": "Directive"
+          }
+        ],
+        "items": [
+          {
+            "severity": "Bypass",
+            "description": "No CSP found in enforcement mode"
+          }
+        ]
+      }
+    },
     "full-page-screenshot": {
       "id": "full-page-screenshot",
       "title": "Full-page screenshot",
@@ -5542,6 +5576,11 @@
           "weight": 1,
           "group": "best-practices-trust-safety"
         },
+        {
+          "id": "csp-xss",
+          "weight": 0,
+          "group": "best-practices-trust-safety"
+        },
         {
           "id": "password-inputs-can-be-pasted-into",
           "weight": 1,
@@ -6430,6 +6469,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:audit:csp-xss",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:full-page-screenshot",
@@ -7334,7 +7379,8 @@
         "audits[font-size].details.headings[0].text"
       ],
       "lighthouse-core/lib/i18n/i18n.js | columnDescription": [
-        "audits[errors-in-console].details.headings[1].text"
+        "audits[errors-in-console].details.headings[1].text",
+        "audits[csp-xss].details.headings[0].text"
       ],
       "lighthouse-core/audits/server-response-time.js | title": [
         "audits[server-response-time].title"
@@ -7827,6 +7873,18 @@
       "lighthouse-core/audits/preload-lcp-image.js | description": [
         "audits[preload-lcp-image].description"
       ],
+      "lighthouse-core/audits/csp-xss.js | title": [
+        "audits[csp-xss].title"
+      ],
+      "lighthouse-core/audits/csp-xss.js | description": [
+        "audits[csp-xss].description"
+      ],
+      "lighthouse-core/audits/csp-xss.js | columnDirective": [
+        "audits[csp-xss].details.headings[1].text"
+      ],
+      "lighthouse-core/audits/csp-xss.js | noCsp": [
+        "audits[csp-xss].details.items[0].description"
+      ],
       "lighthouse-core/audits/manual/pwa-cross-browser.js | title": [
         "audits[pwa-cross-browser].title"
       ],
diff --git a/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-export-run-expected.txt b/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-export-run-expected.txt
index 479953621..11da6029c 100644
--- a/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-export-run-expected.txt
+++ b/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-export-run-expected.txt
@@ -2,11 +2,11 @@ Tests that exporting works.
 
 ++++++++ testExportHtml
 
-# of .lh-audit divs (original): 136
+# of .lh-audit divs (original): 137
 
-# of .lh-audit divs (exported html): 136
+# of .lh-audit divs (exported html): 137
 
 ++++++++ testExportJson
 
-# of audits (json): 155
+# of audits (json): 156
 
diff --git a/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-successful-run-expected.txt b/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-successful-run-expected.txt
index f454b7236..1daaae942 100644
--- a/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-successful-run-expected.txt
+++ b/third-party/chromium-webtests/webtests/http/tests/devtools/lighthouse/lighthouse-successful-run-expected.txt
@@ -244,6 +244,7 @@ Auditing: Page has valid source maps
 Auditing: Preload Largest Contentful Paint image
 Computing artifact: LanternLargestContentfulPaint
 Computing artifact: LanternFirstContentfulPaint
+Auditing: Ensure CSP is effective against XSS attacks
 Auditing: Full-page screenshot
 Auditing: Script Treemap Data
 Computing artifact: JSBundles
@@ -433,6 +434,7 @@ color-contrast: pass
 content-width: fail
 crawlable-anchors: pass
 critical-request-chains: informative
+csp-xss: informative
 cumulative-ad-shift: notApplicable
 cumulative-layout-shift: numeric
 custom-controls-labels: manual
@@ -571,7 +573,7 @@ viewport: fail
 viewport-ad-density: notApplicable
 visual-order-follows-dom: manual
 
-# of .lh-audit divs: 156
+# of .lh-audit divs: 157
 
 .lh-audit divs:
 accesskeys
@@ -612,6 +614,7 @@ color-contrast
 content-width
 crawlable-anchors
 critical-request-chains
+csp-xss
 cumulative-ad-shift
 custom-controls-labels
 custom-controls-roles