diff --git a/.github/workflows/loc-update.yml b/.github/workflows/loc-update.yml index 3a29e6605..406f2bbb8 100644 --- a/.github/workflows/loc-update.yml +++ b/.github/workflows/loc-update.yml @@ -5,7 +5,7 @@ on: branches: - main paths: - - 'loc/**' + - 'loc/translations-import/**' jobs: update-localization: diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index dd1f1a767..3982a9962 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -213,6 +213,81 @@ "Please select the folder that contains your compiled output to upload your site.": "Please select the folder that contains your compiled output to upload your site.", "Upload failed. Please try again later.": "Upload failed. Please try again later.", "Missing required site information for reactivation.": "Missing required site information for reactivation.", + "The CodeQL extension is required to run this command. Do you want to install it now?": "The CodeQL extension is required to run this command. Do you want to install it now?", + "CodeQL screening started. Creating database and analyzing": "CodeQL screening started. Creating database and analyzing", + "CodeQL database created successfully. You can now run queries from the CodeQL extension.": "CodeQL database created successfully. You can now run queries from the CodeQL extension.", + "CodeQL screening failed. Please try again later.": "CodeQL screening failed. Please try again later.", + "Current site path not found. Please ensure you have a site folder open.": "Current site path not found. Please ensure you have a site folder open.", + "CodeQL database created. You can now:\n\n1. Run custom queries if you have any\n2. Use the prebuilt queries from the CodeQL extension\n\nCheck the CodeQL extension panel for available queries.": "CodeQL database created. You can now:\n\n1. Run custom queries if you have any\n2. Use the prebuilt queries from the CodeQL extension\n\nCheck the CodeQL extension panel for available queries.", + "The SARIF Viewer extension is recommended for viewing CodeQL results. Would you like to install it now?": "The SARIF Viewer extension is recommended for viewing CodeQL results. Would you like to install it now?", + "Failed to install SARIF viewer extension. Opening results as text file.": "Failed to install SARIF viewer extension. Opening results as text file.", + "Open as Text": "Open as Text", + "CodeQL Analysis": "CodeQL Analysis", + "Starting CodeQL analysis for: {0}": "Starting CodeQL analysis for: {0}", + "Database location: {0}": "Database location: {0}", + "Creating CodeQL database...": "Creating CodeQL database...", + "Running CodeQL analysis...": "Running CodeQL analysis...", + "Analysis completed using query suite: {0}": "Analysis completed using query suite: {0}", + "Primary query suite {0} failed: {1}": "Primary query suite {0} failed: {1}", + "Analysis complete! Results saved to: {0}": "Analysis complete! Results saved to: {0}", + "Error during CodeQL analysis: {0}": "Error during CodeQL analysis: {0}", + "CodeQL analysis failed: {0}": "CodeQL analysis failed: {0}", + "Found CodeQL CLI at: {0}": "Found CodeQL CLI at: {0}", + "CodeQL CLI is not installed or not in PATH. Please install the CodeQL extension or add CodeQL CLI to your PATH.": "CodeQL CLI is not installed or not in PATH. Please install the CodeQL extension or add CodeQL CLI to your PATH.", + "CodeQL version: {0}": "CodeQL version: {0}", + "CodeQL extension is not installed.": "CodeQL extension is not installed.", + "Activating CodeQL extension...": "Activating CodeQL extension...", + "Could not determine user data path.": "Could not determine user data path.", + "Looking for CodeQL CLI in: {0}": "Looking for CodeQL CLI in: {0}", + "CodeQL global storage path does not exist: {0}": "CodeQL global storage path does not exist: {0}", + "Found CodeQL CLI via extension API: {0}": "Found CodeQL CLI via extension API: {0}", + "Error getting CLI path from extension API: {0}": "Error getting CLI path from extension API: {0}", + "Could not locate CodeQL CLI in global storage.": "Could not locate CodeQL CLI in global storage.", + "Error accessing CodeQL extension: {0}": "Error accessing CodeQL extension: {0}", + "Found distribution directories: {0}": "Found distribution directories: {0}", + "Error searching for CodeQL CLI: {0}": "Error searching for CodeQL CLI: {0}", + "Executing: {0}": "Executing: {0}", + "Command completed successfully (exit code: {0})": "Command completed successfully (exit code: {0})", + "Command failed with exit code: {0}": "Command failed with exit code: {0}", + "Command failed with exit code {0}: {1}": "Command failed with exit code {0}: {1}", + "Process error: {0}": "Process error: {0}", + "Found {0} issue(s):": "Found {0} issue(s):", + "No message": "No message", + "File: {0}": "File: {0}", + "Line: {0}": "Line: {0}", + "No issues found!": "No issues found!", + "No analysis results found.": "No analysis results found.", + "CodeQL analysis completed successfully with no issues found! πŸŽ‰": "CodeQL analysis completed successfully with no issues found! πŸŽ‰", + "Error reading results: {0}": "Error reading results: {0}", + "SARIF viewer extension not found.": "SARIF viewer extension not found.", + "Installing SARIF viewer extension...": "Installing SARIF viewer extension...", + "SARIF viewer extension installed successfully. Activating...": "SARIF viewer extension installed successfully. Activating...", + "Opening results with newly installed SARIF viewer...": "Opening results with newly installed SARIF viewer...", + "Results opened in SARIF viewer successfully.": "Results opened in SARIF viewer successfully.", + "Extension installed but API not available yet. Opening as text file...": "Extension installed but API not available yet. Opening as text file...", + "Failed to install SARIF viewer extension: {0}": "Failed to install SARIF viewer extension: {0}", + "User cancelled opening results.": "User cancelled opening results.", + "Activating SARIF viewer extension...": "Activating SARIF viewer extension...", + "Opening results with SARIF viewer...": "Opening results with SARIF viewer...", + "SARIF viewer extension does not expose expected API. Falling back to text editor...": "SARIF viewer extension does not expose expected API. Falling back to text editor...", + "Error opening with SARIF viewer: {0}": "Error opening with SARIF viewer: {0}", + "CodeQL analysis completed. Would you like to open the full results file?": "CodeQL analysis completed. Would you like to open the full results file?", + "Open Results": "Open Results", + "Close": "Close", + "Error opening results file: {0}": "Error opening results file: {0}", + "Select folder for CodeQL database": "Select folder for CodeQL database", + "Use current site folder": "Use current site folder", + "CodeQL screening is not supported for this site.": "CodeQL screening is not supported for this site.", + "Found PowerPages config file: {0}": "Found PowerPages config file: {0}", + "Using custom CodeQL query suite: {0}": "Using custom CodeQL query suite: {0}", + "Added default query suite to config: {0}": "Added default query suite to config: {0}", + "Created PowerPages config file at {0} with default query suite: {1}": "Created PowerPages config file at {0} with default query suite: {1}", + "Error reading config file: {0}": "Error reading config file: {0}", + "Using default query suite: {0}": "Using default query suite: {0}", + "PowerPages config file created successfully: {0}": "PowerPages config file created successfully: {0}", + "Error creating config file: {0}": "Error creating config file: {0}", + "PowerPages config file updated successfully: {0}": "PowerPages config file updated successfully: {0}", + "Error updating config file: {0}": "Error updating config file: {0}", "Timestamp: {0}/{0} is the timestamp": { "message": "Timestamp: {0}", "comment": [ diff --git a/loc/translations-export/vscode-powerplatform.xlf b/loc/translations-export/vscode-powerplatform.xlf index 457eeba56..30854b536 100644 --- a/loc/translations-export/vscode-powerplatform.xlf +++ b/loc/translations-export/vscode-powerplatform.xlf @@ -25,6 +25,12 @@ AI-generated content can contain mistakes + + Activating CodeQL extension... + + + Activating SARIF viewer extension... + Active Sites @@ -37,10 +43,19 @@ Add content snippet name (name should be unique) + + Added default query suite to config: {0} + Adding {0} web file(s). Existing files will be skipped {0} represents the number of web files + + Analysis complete! Results saved to: {0} + + + Analysis completed using query suite: {0} + Are you sure you want to clear all the Auth Profiles? @@ -107,6 +122,9 @@ This is a markdown formatting which must persist across translations. The second line should be '[TRANSLATION HERE](https://aka.ms/pages-clear-cache).', keeping brackets and the text in the parentheses unmodified + + Close + Cloud Instance: {0} The {0} represents profile's Azure Cloud Instances @@ -126,6 +144,59 @@ The second line should be '[TRANSLATION HERE](https://aka.ms/pages-clear-cache). Code Block + + CodeQL Analysis + + + CodeQL CLI is not installed or not in PATH. Please install the CodeQL extension or add CodeQL CLI to your PATH. + + + CodeQL analysis completed successfully with no issues found! πŸŽ‰ + + + CodeQL analysis completed. Would you like to open the full results file? + + + CodeQL analysis failed: {0} + + + CodeQL database created successfully. You can now run queries from the CodeQL extension. + + + CodeQL database created. You can now: + +1. Run custom queries if you have any +2. Use the prebuilt queries from the CodeQL extension + +Check the CodeQL extension panel for available queries. + + + CodeQL extension is not installed. + + + CodeQL global storage path does not exist: {0} + + + CodeQL screening failed. Please try again later. + + + CodeQL screening is not supported for this site. + + + CodeQL screening started. Creating database and analyzing + + + CodeQL version: {0} + + + Command completed successfully (exit code: {0}) + + + Command failed with exit code {0}: {1} + + + Command failed with exit code: {0} + Confirm @@ -144,10 +215,22 @@ The second line should be '[TRANSLATION HERE](https://aka.ms/pages-clear-cache). Copy to clipboard + + Could not determine user data path. + + + Could not locate CodeQL CLI in global storage. + + + Created PowerPages config file at {0} with default query suite: {1} + Created on: {0} {0} is the created date + + Creating CodeQL database... + Creating {0}... {0} will be replaced by the entity type. @@ -162,10 +245,16 @@ The second line should be '[TRANSLATION HERE](https://aka.ms/pages-clear-cache). Current site path not found. + + Current site path not found. Please ensure you have a site folder open. + Data model version: {0} {0} is the data model version + + Database location: {0} + Default Environment: {0} The {0} represents profile's resource/environment URL @@ -209,12 +298,48 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Environment changed successfully. + + Error accessing CodeQL extension: {0} + + + Error creating config file: {0} + + + Error during CodeQL analysis: {0} + + + Error getting CLI path from extension API: {0} + + + Error opening results file: {0} + + + Error opening with SARIF viewer: {0} + + + Error reading config file: {0} + + + Error reading results: {0} + + + Error searching for CodeQL CLI: {0} + + + Error updating config file: {0} + + + Executing: {0} + Explain the following code snippet: Explain the following code {% include 'Page Copy'%} + + Extension installed but API not available yet. Opening as text file... + Failed to create a new Power Pages site. Please try again. @@ -240,6 +365,12 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Failed to get website endpoint. Please try again later + + Failed to install SARIF viewer extension. Opening results as text file. + + + Failed to install SARIF viewer extension: {0} + Feature is not enabled for this geo. @@ -256,6 +387,24 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca File(s) already exist. No new files to add + + File: {0} + + + Found CodeQL CLI at: {0} + + + Found CodeQL CLI via extension API: {0} + + + Found PowerPages config file: {0} + + + Found distribution directories: {0} + + + Found {0} issue(s): + Friendly name: {0} {0} is the website name @@ -324,6 +473,9 @@ Return to this chat and @powerpages can help you write and edit your website cod Installing Power Pages generator(v{0})... {0} represents the version number + + Installing SARIF viewer extension... + Instance url: {0} {0} is the Instance Url @@ -337,12 +489,18 @@ Return to this chat and @powerpages can help you write and edit your website cod Like something? Tell us more. + + Line: {0} + Login Login to PowerPages + + Looking for CodeQL CLI in: {0} + Make sure AI-generated content is accurate and appropriate before using. <a href="https://go.microsoft.com/fwlink/?linkid=2240145">Learn more</a> | <a href="https://go.microsoft.com/fwlink/?linkid=2189520">View terms</a> @@ -395,9 +553,18 @@ The {3} represents Dataverse Environment's Organization ID (GUID) No Website Data Found in Current Directory. Please switch to a directory that contains the PAC downloaded website data to continue. + + No analysis results found. + No environments found + + No issues found! + + + No message + No page templates found @@ -423,12 +590,24 @@ The {3} represents Dataverse Environment's Organization ID (GUID) One or more attribute names have been changed or removed. Contact your admin. + + Open Results + + + Open as Text + Open in Power Pages studio Opening preview site... + + Opening results with SARIF viewer... + + + Opening results with newly installed SARIF viewer... + Opening site preview... @@ -479,6 +658,12 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Power Pages studio URL is not available + + PowerPages config file created successfully: {0} + + + PowerPages config file updated successfully: {0} + Preparing pac CLI (v{0})... {0} represents the version number @@ -489,6 +674,12 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Preview site URL is not valid + + Primary query suite {0} failed: {1} + + + Process error: {0} + Profile Kind: {0} The {0} represents the profile type (Admin vs Dataverse) @@ -496,6 +687,21 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Response data is empty + + Results opened in SARIF viewer successfully. + + + Running CodeQL analysis... + + + SARIF viewer extension does not expose expected API. Falling back to text editor... + + + SARIF viewer extension installed successfully. Activating... + + + SARIF viewer extension not found. + Saving your file ... @@ -512,6 +718,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Select an environment + + Select folder for CodeQL database + Select the folder that contains your compiled output @@ -553,6 +762,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Something went wrong. Don’t worry, you can try again. + + Starting CodeQL analysis for: {0} + Submit @@ -563,9 +775,15 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Tenant ID: {0} {0} is the Tenant ID + + The CodeQL extension is required to run this command. Do you want to install it now? + The Power Pages generator is ready for use in your VS Code extension! + + The SARIF Viewer extension is recommended for viewing CodeQL results. Would you like to install it now? + The extension 'Microsoft Edge Tools' is required to run this command. Do you want to install it now? Do not translate 'Microsoft Edge Tools' @@ -635,10 +853,22 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Upload failed. Please try again later. + + Use current site folder + + + User cancelled opening results. + User: {0} The {0} represents auth profile's user name (email address)) + + Using custom CodeQL query suite: {0} + + + Using default query suite: {0} + We encountered an error preparing the files for edit. @@ -890,6 +1120,9 @@ The second line should be '[TRANSLATION HERE](command:powerplatform-walkthrough. Reveal in Finder + + Run CodeQL Screening + Save conflict diff --git a/package.json b/package.json index a13a4c6d3..769face26 100644 --- a/package.json +++ b/package.json @@ -479,6 +479,12 @@ { "command": "microsoft.powerplatform.pages.actionsHub.inactiveSite.reactivateSite", "title": "%microsoft.powerplatform.pages.actionsHub.inactiveSite.reactivateSite.title%" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.currentActiveSite.runCodeQLScreening", + "category": "Power Pages", + "title": "%microsoft.powerplatform.pages.actionsHub.currentActiveSite.runCodeQLScreening.title%", + "when": "microsoft.powerplatform.pages.codeQlScanEnabled" } ], "configuration": { @@ -1156,6 +1162,11 @@ "command": "microsoft.powerplatform.pages.actionsHub.inactiveSite.reactivateSite", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == inactiveSite", "group": "siteAction@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.currentActiveSite.runCodeQLScreening", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == currentActiveSite && microsoft.powerplatform.pages.codeQlScanEnabled", + "group": "siteAction@7" } ] }, diff --git a/package.nls.json b/package.nls.json index ff5693dcc..7b98ca430 100644 --- a/package.nls.json +++ b/package.nls.json @@ -115,5 +115,6 @@ "microsoft.powerplatform.pages.actionsHub.activeSite.downloadSite.title": "Download Site", "microsoft.powerplatform.pages.actionsHub.activeSite.openInStudio.title": "Open in Power Pages Studio", "microsoft.powerplatform.pages.actionsHub.inactiveSite.reactivateSite.title": "Reactivate Site", + "microsoft.powerplatform.pages.actionsHub.currentActiveSite.runCodeQLScreening.title": "Run CodeQL Screening", "microsoft.powerplatform.pages.actionsHub.configuration.downloadSite.description": "The folder where site will be downloaded when using Power Pages Actions. Leave this empty to ask for a folder every time you download a site." } diff --git a/src/client/power-pages/actions-hub/ActionsHub.ts b/src/client/power-pages/actions-hub/ActionsHub.ts index bd5b6690e..360193a37 100644 --- a/src/client/power-pages/actions-hub/ActionsHub.ts +++ b/src/client/power-pages/actions-hub/ActionsHub.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode"; import { ECSFeaturesClient } from "../../../common/ecs-features/ecsFeatureClient"; -import { EnableActionsHub } from "../../../common/ecs-features/ecsFeatureGates"; +import { EnableActionsHub, EnableCodeQlScan } from "../../../common/ecs-features/ecsFeatureGates"; import { ActionsHubTreeDataProvider } from "./ActionsHubTreeDataProvider"; import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; import { PacTerminal } from "../../lib/PacTerminal"; @@ -25,6 +25,16 @@ export class ActionsHub { return enableActionsHub; } + static isCodeQlScanEnabled(): boolean { + const enableCodeQlScan = ECSFeaturesClient.getConfig(EnableCodeQlScan).enableCodeQlScan; + + if (enableCodeQlScan === undefined) { + return false; + } + + return enableCodeQlScan; + } + static async initialize(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { if (ActionsHub._isInitialized) { return; @@ -32,6 +42,7 @@ export class ActionsHub { try { const isActionsHubEnabled = ActionsHub.isEnabled(); + const isCodeQlScanEnabled = ActionsHub.isCodeQlScanEnabled(); oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.ACTIONS_HUB_ENABLED, { isEnabled: isActionsHubEnabled.toString(), @@ -39,12 +50,13 @@ export class ActionsHub { }); vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.actionsHubEnabled", isActionsHubEnabled); + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.codeQlScanEnabled", isCodeQlScanEnabled); if (!isActionsHubEnabled) { return; } - ActionsHubTreeDataProvider.initialize(context, pacTerminal); + ActionsHubTreeDataProvider.initialize(context, pacTerminal, isCodeQlScanEnabled); ActionsHub._isInitialized = true; oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.ACTIONS_HUB_INITIALIZED, getBaseEventInfo()); } catch (exception) { diff --git a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts index fae43f039..5bd3a2b6f 100644 --- a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts +++ b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as yaml from 'yaml'; import { Constants } from './Constants'; import { PacTerminal } from '../../lib/PacTerminal'; -import { POWERPAGES_SITE_FOLDER, SUCCESS, UTF8_ENCODING, WEBSITE_YML } from '../../../common/constants'; +import { POWERPAGES_SITE_FOLDER, SUCCESS, UTF8_ENCODING, CODEQL_EXTENSION_ID } from '../../../common/constants'; import { AuthInfo, OrgListOutput } from '../../pac/PacTypes'; import { extractAuthInfo } from '../commonUtility'; import { showProgressWithNotification } from '../../../common/utilities/Utils'; @@ -24,7 +24,7 @@ import { IOtherSiteInfo, IWebsiteDetails, WebsiteYaml } from '../../../common/se import { getActiveWebsites, getAllWebsites } from '../../../common/utilities/WebsiteUtil'; import CurrentSiteContext from './CurrentSiteContext'; import path from 'path'; -import { getWebsiteRecordId } from '../../../common/utilities/WorkspaceInfoFinderUtil'; +import { getWebsiteRecordId, hasWebsiteYaml, hasPowerPagesSiteFolder, getWebsiteYamlPath } from '../../../common/utilities/WorkspaceInfoFinderUtil'; import { isEdmEnvironment } from '../../../common/copilot/dataverseMetadata'; import { IWebsiteInfo } from './models/IWebsiteInfo'; import moment from 'moment'; @@ -32,6 +32,8 @@ import { SiteVisibility } from './models/SiteVisibility'; import { getBaseEventInfo, traceError, traceInfo } from './TelemetryHelper'; import { IPowerPagesConfig, IPowerPagesConfigData } from './models/IPowerPagesConfig'; import { oneDSLoggerWrapper } from '../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper'; +import { CodeQLAction } from './actions/codeQLAction'; +import { getDefaultCodeQLDatabasePath } from './ActionsHubUtils'; const sortByCreatedOn = (item1: T, item2: T): number => { const date1 = new Date(item1.createdOn || '').valueOf(); //NaN if createdOn is null or undefined @@ -571,19 +573,18 @@ export function findOtherSites(knownSiteIds: Set, fsModule = fs, yamlMod // Check each directory for website.yml or .powerpages-site folder const otherSites: IOtherSiteInfo[] = []; for (const dir of directories) { - let websiteYamlPath = path.join(dir, WEBSITE_YML); - let hasWebsiteYaml = fsModule.existsSync(websiteYamlPath); - const powerPagesSiteFolderPath = path.join(dir, POWERPAGES_SITE_FOLDER); - const hasPowerPagesSiteFolder = fsModule.existsSync(powerPagesSiteFolderPath); + let websiteYamlPath = getWebsiteYamlPath(dir); + let hasWebsiteYamlFile = hasWebsiteYaml(dir); + const hasPowerPagesSiteFolderExists = hasPowerPagesSiteFolder(dir); let workingDir = dir; - if (hasPowerPagesSiteFolder) { + if (hasPowerPagesSiteFolderExists) { workingDir = path.join(dir, POWERPAGES_SITE_FOLDER); - websiteYamlPath = path.join(workingDir, WEBSITE_YML); - hasWebsiteYaml = fsModule.existsSync(websiteYamlPath); + websiteYamlPath = getWebsiteYamlPath(workingDir); + hasWebsiteYamlFile = hasWebsiteYaml(workingDir); } - if (hasWebsiteYaml) { + if (hasWebsiteYamlFile) { try { // Use the utility function to get website record ID const websiteId = getWebsiteRecordId(workingDir); @@ -598,7 +599,7 @@ export function findOtherSites(knownSiteIds: Set, fsModule = fs, yamlMod name: websiteData?.adx_name || path.basename(dir), // Use folder name as fallback websiteId: websiteId, folderPath: dir, - isCodeSite: hasPowerPagesSiteFolder + isCodeSite: hasPowerPagesSiteFolderExists }); } } catch (error) { @@ -939,3 +940,85 @@ export const reactivateSite = async (siteTreeItem: SiteTreeItem) => { await vscode.env.openExternal(vscode.Uri.parse(reactivateSiteUrl)); }; + +export const runCodeQLScreening = async (siteTreeItem?: SiteTreeItem) => { + traceInfo(Constants.EventNames.ACTIONS_HUB_CODEQL_SCREENING_CALLED, { methodName: runCodeQLScreening.name }); + + try { + // Get the current site path + let sitePath = ""; + if (siteTreeItem && siteTreeItem.contextValue === Constants.ContextValues.OTHER_SITE) { + sitePath = siteTreeItem.siteInfo.folderPath || ""; + } else { + sitePath = CurrentSiteContext.currentSiteFolderPath || ""; + } + + if (!sitePath) { + await vscode.window.showErrorMessage(Constants.Strings.CODEQL_CURRENT_SITE_PATH_NOT_FOUND); + return; + } + + if(!hasPowerPagesSiteFolder(sitePath)) { + traceInfo(Constants.EventNames.ACTIONS_HUB_CODEQL_SCREENING_NOT_SUPPORTED, { methodName: runCodeQLScreening.name }); + await vscode.window.showErrorMessage(Constants.Strings.CODEQL_SCREENING_NOT_SUPPORTED); + return; + } + + // Check if CodeQL extension is installed + const codeQLExtension = vscode.extensions.getExtension(CODEQL_EXTENSION_ID); + + if (!codeQLExtension) { + // Prompt user to install the CodeQL extension + const install = await vscode.window.showWarningMessage( + Constants.Strings.CODEQL_EXTENSION_NOT_INSTALLED, + Constants.Strings.INSTALL, + Constants.Strings.CANCEL + ); + + if (install === Constants.Strings.INSTALL) { + await vscode.commands.executeCommand('workbench.extensions.search', CODEQL_EXTENSION_ID); + traceInfo(Constants.EventNames.ACTIONS_HUB_CODEQL_SCREENING_EXTENSION_NOT_INSTALLED, { methodName: runCodeQLScreening.name }); + return; + } else { + return; + } + } + + // Use default database location (site folder) + const databaseLocation = getDefaultCodeQLDatabasePath(); + + traceInfo(Constants.EventNames.ACTIONS_HUB_CODEQL_SCREENING_EXTENSION_INSTALLED, { methodName: runCodeQLScreening.name }); + + const codeQLAction = new CodeQLAction(); + + try { + await showProgressWithNotification( + Constants.Strings.CODEQL_SCREENING_STARTED, + async () => { + // Use a custom method that allows specifying the database location + await codeQLAction.executeCodeQLAnalysisWithCustomPath(sitePath, databaseLocation); + } + ); + + traceInfo(Constants.EventNames.ACTIONS_HUB_CODEQL_SCREENING_COMPLETED, { methodName: runCodeQLScreening.name }); + } catch (error) { + traceError( + Constants.EventNames.ACTIONS_HUB_CODEQL_SCREENING_FAILED, + error as Error, + { methodName: runCodeQLScreening.name } + ); + await vscode.window.showErrorMessage(Constants.Strings.CODEQL_SCREENING_FAILED); + } finally { + //codeQLAction.dispose(); + } + + } catch (error) { + traceError( + Constants.EventNames.ACTIONS_HUB_CODEQL_SCREENING_FAILED, + error as Error, + { methodName: runCodeQLScreening.name } + ); + await vscode.window.showErrorMessage(Constants.Strings.CODEQL_SCREENING_FAILED); + } +}; + diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index 3cb946365..8991fcf59 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -11,7 +11,7 @@ import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLo import { EnvironmentGroupTreeItem } from "./tree-items/EnvironmentGroupTreeItem"; import { IEnvironmentInfo } from "./models/IEnvironmentInfo"; import { PacTerminal } from "../../lib/PacTerminal"; -import { fetchWebsites, openActiveSitesInStudio, openInactiveSitesInStudio, previewSite, createNewAuthProfile, refreshEnvironment, showEnvironmentDetails, switchEnvironment, revealInOS, openSiteManagement, uploadSite, showSiteDetails, downloadSite, openInStudio, reactivateSite } from "./ActionsHubCommandHandlers"; +import { fetchWebsites, openActiveSitesInStudio, openInactiveSitesInStudio, previewSite, createNewAuthProfile, refreshEnvironment, showEnvironmentDetails, switchEnvironment, revealInOS, openSiteManagement, uploadSite, showSiteDetails, downloadSite, openInStudio, reactivateSite, runCodeQLScreening } from "./ActionsHubCommandHandlers"; import PacContext from "../../pac/PacContext"; import CurrentSiteContext from "./CurrentSiteContext"; import { IOtherSiteInfo, IWebsiteDetails } from "../../../common/services/Interfaces"; @@ -29,8 +29,10 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { @@ -131,7 +133,7 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider await refreshEnvironment(pacTerminal)), @@ -166,5 +168,13 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { + // Use a temporary directory for the CodeQL database + const tempDir = os.tmpdir(); + const dbName = `codeql-database-${Date.now()}`; + return path.join(tempDir, dbName); +}; + diff --git a/src/client/power-pages/actions-hub/Constants.ts b/src/client/power-pages/actions-hub/Constants.ts index 0833fdd31..2a27ca29e 100644 --- a/src/client/power-pages/actions-hub/Constants.ts +++ b/src/client/power-pages/actions-hub/Constants.ts @@ -51,7 +51,84 @@ export const Constants = { UPLOAD_CODE_SITE_COMPILED_OUTPUT_FOLDER_NOT_FOUND: vscode.l10n.t("Please select the folder that contains your compiled output to upload your site."), UPLOAD_CODE_SITE_FAILED: vscode.l10n.t("Upload failed. Please try again later."), POWER_PAGES_CONFIG_FILE_NAME: "powerpages.config.json", - MISSING_REACTIVATION_URL_INFO: vscode.l10n.t("Missing required site information for reactivation.") + MISSING_REACTIVATION_URL_INFO: vscode.l10n.t("Missing required site information for reactivation."), + INSTALL: vscode.l10n.t("Install"), + CANCEL: vscode.l10n.t("Cancel"), + CODEQL_EXTENSION_NOT_INSTALLED: vscode.l10n.t("The CodeQL extension is required to run this command. Do you want to install it now?"), + CODEQL_SCREENING_STARTED: vscode.l10n.t("CodeQL screening started. Creating database and analyzing"), + CODEQL_DATABASE_CREATED: vscode.l10n.t("CodeQL database created successfully. You can now run queries from the CodeQL extension."), + CODEQL_SCREENING_FAILED: vscode.l10n.t("CodeQL screening failed. Please try again later."), + CODEQL_CURRENT_SITE_PATH_NOT_FOUND: vscode.l10n.t("Current site path not found. Please ensure you have a site folder open."), + CODEQL_GUIDE_MESSAGE: vscode.l10n.t("CodeQL database created. You can now:\n\n1. Run custom queries if you have any\n2. Use the prebuilt queries from the CodeQL extension\n\nCheck the CodeQL extension panel for available queries."), + SARIF_VIEWER_NOT_INSTALLED: vscode.l10n.t("The SARIF Viewer extension is recommended for viewing CodeQL results. Would you like to install it now?"), + SARIF_VIEWER_INSTALL_FAILED: vscode.l10n.t("Failed to install SARIF viewer extension. Opening results as text file."), + OPEN_AS_TEXT: vscode.l10n.t("Open as Text"), + CODEQL_ANALYSIS_CHANNEL_NAME: vscode.l10n.t("CodeQL Analysis"), + CODEQL_ANALYSIS_STARTING: vscode.l10n.t("Starting CodeQL analysis for: {0}"), + CODEQL_DATABASE_LOCATION: vscode.l10n.t("Database location: {0}"), + CODEQL_CREATING_DATABASE: vscode.l10n.t("Creating CodeQL database..."), + CODEQL_RUNNING_ANALYSIS: vscode.l10n.t("Running CodeQL analysis..."), + CODEQL_ANALYSIS_COMPLETED: vscode.l10n.t("Analysis completed using query suite: {0}"), + CODEQL_QUERY_SUITE_FAILED: vscode.l10n.t("Primary query suite {0} failed: {1}"), + CODEQL_ANALYSIS_COMPLETE: vscode.l10n.t("Analysis complete! Results saved to: {0}"), + CODEQL_ANALYSIS_ERROR: vscode.l10n.t("Error during CodeQL analysis: {0}"), + CODEQL_ANALYSIS_FAILED: vscode.l10n.t("CodeQL analysis failed: {0}"), + CODEQL_CLI_FOUND_AT: vscode.l10n.t("Found CodeQL CLI at: {0}"), + CODEQL_CLI_NOT_INSTALLED: vscode.l10n.t("CodeQL CLI is not installed or not in PATH. Please install the CodeQL extension or add CodeQL CLI to your PATH."), + CODEQL_VERSION: vscode.l10n.t("CodeQL version: {0}"), + CODEQL_EXTENSION_NOT_INSTALLED_LOG: vscode.l10n.t("CodeQL extension is not installed."), + CODEQL_ACTIVATING_EXTENSION: vscode.l10n.t("Activating CodeQL extension..."), + CODEQL_USER_DATA_PATH_ERROR: vscode.l10n.t("Could not determine user data path."), + CODEQL_LOOKING_FOR_CLI: vscode.l10n.t("Looking for CodeQL CLI in: {0}"), + CODEQL_GLOBAL_STORAGE_NOT_EXISTS: vscode.l10n.t("CodeQL global storage path does not exist: {0}"), + CODEQL_CLI_VIA_API: vscode.l10n.t("Found CodeQL CLI via extension API: {0}"), + CODEQL_API_ERROR: vscode.l10n.t("Error getting CLI path from extension API: {0}"), + CODEQL_CLI_NOT_LOCATED: vscode.l10n.t("Could not locate CodeQL CLI in global storage."), + CODEQL_EXTENSION_ACCESS_ERROR: vscode.l10n.t("Error accessing CodeQL extension: {0}"), + CODEQL_DISTRIBUTION_DIRS: vscode.l10n.t("Found distribution directories: {0}"), + CODEQL_CLI_SEARCH_ERROR: vscode.l10n.t("Error searching for CodeQL CLI: {0}"), + CODEQL_EXECUTING_COMMAND: vscode.l10n.t("Executing: {0}"), + CODEQL_COMMAND_SUCCESS: vscode.l10n.t("Command completed successfully (exit code: {0})"), + CODEQL_COMMAND_FAILED: vscode.l10n.t("Command failed with exit code: {0}"), + CODEQL_COMMAND_FAILED_ERROR: vscode.l10n.t("Command failed with exit code {0}: {1}"), + CODEQL_PROCESS_ERROR: vscode.l10n.t("Process error: {0}"), + CODEQL_ISSUES_FOUND: vscode.l10n.t("Found {0} issue(s):"), + CODEQL_NO_MESSAGE: vscode.l10n.t("No message"), + CODEQL_FILE_LABEL: vscode.l10n.t("File: {0}"), + CODEQL_LINE_LABEL: vscode.l10n.t("Line: {0}"), + CODEQL_NO_ISSUES_FOUND: vscode.l10n.t("No issues found!"), + CODEQL_NO_ANALYSIS_RESULTS: vscode.l10n.t("No analysis results found."), + CODEQL_ANALYSIS_SUCCESS_NO_ISSUES: vscode.l10n.t("CodeQL analysis completed successfully with no issues found! πŸŽ‰"), + CODEQL_ERROR_READING_RESULTS: vscode.l10n.t("Error reading results: {0}"), + CODEQL_SARIF_VIEWER_NOT_FOUND: vscode.l10n.t("SARIF viewer extension not found."), + CODEQL_INSTALLING_SARIF_VIEWER: vscode.l10n.t("Installing SARIF viewer extension..."), + CODEQL_SARIF_VIEWER_INSTALLED: vscode.l10n.t("SARIF viewer extension installed successfully. Activating..."), + CODEQL_OPENING_WITH_NEW_SARIF_VIEWER: vscode.l10n.t("Opening results with newly installed SARIF viewer..."), + CODEQL_SARIF_VIEWER_OPENED: vscode.l10n.t("Results opened in SARIF viewer successfully."), + CODEQL_SARIF_VIEWER_API_NOT_AVAILABLE: vscode.l10n.t("Extension installed but API not available yet. Opening as text file..."), + CODEQL_SARIF_VIEWER_INSTALL_ERROR: vscode.l10n.t("Failed to install SARIF viewer extension: {0}"), + CODEQL_USER_CANCELLED: vscode.l10n.t("User cancelled opening results."), + CODEQL_ACTIVATING_SARIF_VIEWER: vscode.l10n.t("Activating SARIF viewer extension..."), + CODEQL_OPENING_WITH_SARIF_VIEWER: vscode.l10n.t("Opening results with SARIF viewer..."), + CODEQL_SARIF_VIEWER_API_FALLBACK: vscode.l10n.t("SARIF viewer extension does not expose expected API. Falling back to text editor..."), + CODEQL_SARIF_VIEWER_ERROR: vscode.l10n.t("Error opening with SARIF viewer: {0}"), + CODEQL_ANALYSIS_COMPLETED_OPEN_FILE: vscode.l10n.t("CodeQL analysis completed. Would you like to open the full results file?"), + CODEQL_OPEN_RESULTS: vscode.l10n.t("Open Results"), + CODEQL_CLOSE: vscode.l10n.t("Close"), + CODEQL_ERROR_OPENING_RESULTS: vscode.l10n.t("Error opening results file: {0}"), + CODEQL_DATABASE_FOLDER_PROMPT: vscode.l10n.t("Select folder for CodeQL database"), + CODEQL_USE_CURRENT_SITE_FOLDER: vscode.l10n.t("Use current site folder"), + CODEQL_SCREENING_NOT_SUPPORTED: vscode.l10n.t("CodeQL screening is not supported for this site."), + CODEQL_CONFIG_FILE_FOUND: vscode.l10n.t("Found PowerPages config file: {0}"), + CODEQL_USING_CUSTOM_QUERY: vscode.l10n.t("Using custom CodeQL query suite: {0}"), + CODEQL_ADDED_DEFAULT_QUERY_TO_CONFIG: vscode.l10n.t("Added default query suite to config: {0}"), + CODEQL_CREATED_CONFIG_FILE: vscode.l10n.t("Created PowerPages config file at {0} with default query suite: {1}"), + CODEQL_CONFIG_ERROR: vscode.l10n.t("Error reading config file: {0}"), + CODEQL_USING_DEFAULT_QUERY: vscode.l10n.t("Using default query suite: {0}"), + CODEQL_CONFIG_FILE_CREATED_SUCCESSFULLY: vscode.l10n.t("PowerPages config file created successfully: {0}"), + CODEQL_CONFIG_FILE_CREATE_ERROR: vscode.l10n.t("Error creating config file: {0}"), + CODEQL_CONFIG_FILE_UPDATED_SUCCESSFULLY: vscode.l10n.t("PowerPages config file updated successfully: {0}"), + CODEQL_CONFIG_FILE_UPDATE_ERROR: vscode.l10n.t("Error updating config file: {0}") }, EventNames: { ACTIONS_HUB_ENABLED: "ActionsHubEnabled", @@ -109,7 +186,14 @@ export const Constants = { ACTIONS_HUB_UPLOAD_CODE_SITE_CALLED: "ActionsHubUploadCodeSiteCalled", ACTIONS_HUB_UPLOAD_CODE_SITE_FAILED: "ActionsHubUploadCodeSiteFailed", ACTIONS_HUB_UPLOAD_OTHER_CODE_SITE_PAC_TRIGGERED: "ActionsHubUploadOtherCodeSitePacTriggered", - POWER_PAGES_CONFIG_PARSE_FAILED: "PowerPagesConfigParseFailed" + POWER_PAGES_CONFIG_PARSE_FAILED: "PowerPagesConfigParseFailed", + ACTIONS_HUB_CODEQL_SCREENING_CALLED: "ActionsHubCodeQLScreeningCalled", + ACTIONS_HUB_CODEQL_SCREENING_FAILED: "ActionsHubCodeQLScreeningFailed", + ACTIONS_HUB_CODEQL_SCREENING_EXTENSION_NOT_INSTALLED: "ActionsHubCodeQLScreeningExtensionNotInstalled", + ACTIONS_HUB_CODEQL_SCREENING_EXTENSION_INSTALLED: "ActionsHubCodeQLScreeningExtensionInstalled", + ACTIONS_HUB_CODEQL_SCREENING_DATABASE_CREATED: "ActionsHubCodeQLScreeningDatabaseCreated", + ACTIONS_HUB_CODEQL_SCREENING_NOT_SUPPORTED: "ActionsHubCodeQLScreeningNotSupported", + ACTIONS_HUB_CODEQL_SCREENING_COMPLETED: "ActionsHubCodeQLScreeningCompleted" }, FeatureNames: { REFRESH_ENVIRONMENT: "RefreshEnvironment" diff --git a/src/client/power-pages/actions-hub/actions/codeQLAction.ts b/src/client/power-pages/actions-hub/actions/codeQLAction.ts new file mode 100644 index 000000000..208ea83a2 --- /dev/null +++ b/src/client/power-pages/actions-hub/actions/codeQLAction.ts @@ -0,0 +1,469 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from 'vscode'; +import { exec, spawn } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { CODEQL_EXTENSION_ID } from '../../../../common/constants'; +import { Constants } from '../Constants'; + +interface PowerPagesConfig { + codeQlQuery?: string; +} + +export class CodeQLAction { + private outputChannel: vscode.OutputChannel; + + constructor() { + this.outputChannel = vscode.window.createOutputChannel(Constants.Strings.CODEQL_ANALYSIS_CHANNEL_NAME); + } + + public async executeCodeQLAnalysisWithCustomPath(sitePath: string, databaseLocation: string): Promise { + this.outputChannel.show(); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_ANALYSIS_STARTING, path.basename(sitePath))); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_DATABASE_LOCATION, databaseLocation)); + + try { + const codeqlCliPath = await this.checkCodeQLInstallation(); + const databaseName = `codeql-db-${path.basename(sitePath)}`; + const dbPath = path.join(databaseLocation, '.codeql', databaseName); + + if (!fs.existsSync(path.dirname(dbPath))) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + } + + this.outputChannel.appendLine(Constants.Strings.CODEQL_CREATING_DATABASE); + await this.runCodeQLCommand(`"${codeqlCliPath}" database create "${dbPath}" --language=javascript-typescript --source-root="${sitePath}" --overwrite --no-run-unnecessary-builds`); + + this.outputChannel.appendLine(Constants.Strings.CODEQL_RUNNING_ANALYSIS); + const resultsPath = path.join(path.dirname(dbPath), 'results.sarif'); + + // Get the query suite from config or use default + const querySuite = await this.getCodeQLQuerySuite(sitePath); + + try { + // Use the correct syntax for JavaScript code scanning query suite + await this.runCodeQLCommand(`"${codeqlCliPath}" database analyze "${dbPath}" ${querySuite} --format=sarif-latest --output="${resultsPath}" --download`); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_ANALYSIS_COMPLETED, querySuite)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_QUERY_SUITE_FAILED, querySuite, errorMessage)); + } + + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_ANALYSIS_COMPLETE, resultsPath)); + + if (fs.existsSync(resultsPath)) { + await this.displayResults(resultsPath); + } + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_ANALYSIS_ERROR, errorMessage)); + vscode.window.showErrorMessage(vscode.l10n.t(Constants.Strings.CODEQL_ANALYSIS_FAILED, errorMessage)); + } + } + + private async checkCodeQLInstallation(): Promise { + // First try to get the CodeQL CLI path from the CodeQL extension + const codeqlCliPath = await this.getCodeQLCliPath(); + if (codeqlCliPath) { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CLI_FOUND_AT, codeqlCliPath)); + return codeqlCliPath; + } + + // Fallback to checking if CodeQL is in PATH + return new Promise((resolve, reject) => { + exec('codeql version', (error, stdout, _) => { + if (error) { + reject(new Error(Constants.Strings.CODEQL_CLI_NOT_INSTALLED)); + } else { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_VERSION, stdout.trim())); + resolve('codeql'); // Use the command as-is from PATH + } + }); + }); + } + + + private async getCodeQLCliPath(): Promise { + try { + // Get the CodeQL extension + const codeqlExtension = vscode.extensions.getExtension(CODEQL_EXTENSION_ID); + + if (!codeqlExtension) { + this.outputChannel.appendLine(Constants.Strings.CODEQL_EXTENSION_NOT_INSTALLED_LOG); + return null; + } + + if (!codeqlExtension.isActive) { + this.outputChannel.appendLine(Constants.Strings.CODEQL_ACTIVATING_EXTENSION); + await codeqlExtension.activate(); + } + + // The CodeQL extension stores the CLI in VS Code's global storage + // Path pattern: %APPDATA%\Code\User\globalStorage\github.vscode-codeql\distribution{N}\codeql\codeql.exe + const userDataPath = process.env.APPDATA || process.env.HOME; + if (!userDataPath) { + this.outputChannel.appendLine(Constants.Strings.CODEQL_USER_DATA_PATH_ERROR); + return null; + } + + const codeqlBasePath = path.join(userDataPath, 'Code', 'User', 'globalStorage', 'github.vscode-codeql'); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_LOOKING_FOR_CLI, codeqlBasePath)); + + if (!fs.existsSync(codeqlBasePath)) { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_GLOBAL_STORAGE_NOT_EXISTS, codeqlBasePath)); + return null; + } + + const foundCliPath = await this.searchCodeQLInPath(codeqlBasePath); + if (foundCliPath) { + return foundCliPath; + } + + // Try to get the CLI path from the extension's API + if (codeqlExtension.exports && typeof codeqlExtension.exports.getCliPath === 'function') { + try { + const cliPath = await codeqlExtension.exports.getCliPath(); + if (cliPath && fs.existsSync(cliPath)) { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CLI_VIA_API, cliPath)); + return cliPath; + } + } catch (apiError) { + const errorMessage = apiError instanceof Error ? apiError.message : String(apiError); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_API_ERROR, errorMessage)); + } + } + + this.outputChannel.appendLine(Constants.Strings.CODEQL_CLI_NOT_LOCATED); + return null; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_EXTENSION_ACCESS_ERROR, errorMessage)); + return null; + } + } + + private async searchCodeQLInPath(basePath: string): Promise { + try { + const distributionDirs = fs.readdirSync(basePath) + .filter(dir => dir.startsWith('distribution')) + .sort() + .reverse(); // Get the latest distribution first + + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_DISTRIBUTION_DIRS, distributionDirs.join(', '))); + + for (const distributionDir of distributionDirs) { + const codeqlPaths = [ + path.join(basePath, distributionDir, 'codeql', 'codeql.exe'), // Windows + path.join(basePath, distributionDir, 'codeql', 'codeql') // Unix/Linux/macOS + ]; + + for (const codeqlCliPath of codeqlPaths) { + if (fs.existsSync(codeqlCliPath)) { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CLI_FOUND_AT, codeqlCliPath)); + return codeqlCliPath; + } + } + } + + return null; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CLI_SEARCH_ERROR, errorMessage)); + return null; + } + } + + private async runCodeQLCommand(command: string): Promise { + return new Promise((resolve, reject) => { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_EXECUTING_COMMAND, command)); + + // Parse the command and arguments + const parts = this.parseCommand(command); + const cmd = parts[0]; + const args = parts.slice(1); + + const process = spawn(cmd, args, { + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + // Stream stdout in real-time + process.stdout?.on('data', (data: Buffer) => { + const output = data.toString(); + stdout += output; + // Show real-time output in the output channel + this.outputChannel.appendLine(`[STDOUT] ${output.trim()}`); + }); + + // Stream stderr in real-time + process.stderr?.on('data', (data: Buffer) => { + const output = data.toString(); + stderr += output; + // Show real-time stderr in the output channel + this.outputChannel.appendLine(`[STDERR] ${output.trim()}`); + }); + + process.on('close', (code: number) => { + if (code === 0) { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_COMMAND_SUCCESS, code.toString())); + resolve(stdout); + } else { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_COMMAND_FAILED, code.toString())); + reject(new Error(vscode.l10n.t(Constants.Strings.CODEQL_COMMAND_FAILED_ERROR, code.toString(), stderr || 'Unknown error'))); + } + }); + + process.on('error', (error: Error) => { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_PROCESS_ERROR, error.message)); + reject(new Error(vscode.l10n.t(Constants.Strings.CODEQL_PROCESS_ERROR, error.message))); + }); + }); + } + + private parseCommand(command: string): string[] { + // Simple command parsing that handles quoted arguments + const parts: string[] = []; + let current = ''; + let inQuotes = false; + let quoteChar = ''; + + for (let i = 0; i < command.length; i++) { + const char = command[i]; + + if ((char === '"' || char === "'") && !inQuotes) { + inQuotes = true; + quoteChar = char; + } else if (char === quoteChar && inQuotes) { + inQuotes = false; + quoteChar = ''; + } else if (char === ' ' && !inQuotes) { + if (current.trim()) { + parts.push(current.trim()); + current = ''; + } + } else { + current += char; + } + } + + if (current.trim()) { + parts.push(current.trim()); + } + + return parts; + } + + private async displayResults(resultsPath: string): Promise { + try { + const resultsContent = fs.readFileSync(resultsPath, 'utf8'); + const results = JSON.parse(resultsContent); + + let hasIssues = false; + + if (results.runs && results.runs.length > 0) { + const run = results.runs[0]; + if (run.results && run.results.length > 0) { + hasIssues = true; + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_ISSUES_FOUND, run.results.length.toString())); + + run.results.forEach((result: { message?: { text?: string }; locations?: Array<{ physicalLocation?: { artifactLocation?: { uri?: string }; region?: { startLine?: number } } }> }, index: number) => { + this.outputChannel.appendLine(`\n${index + 1}. ${result.message?.text || Constants.Strings.CODEQL_NO_MESSAGE}`); + if (result.locations && result.locations.length > 0) { + const location = result.locations[0]; + if (location.physicalLocation) { + const file = location.physicalLocation.artifactLocation?.uri; + const startLine = location.physicalLocation.region?.startLine; + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_FILE_LABEL, file || '')); + if (startLine) { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_LINE_LABEL, startLine.toString())); + } + } + } + }); + } else { + this.outputChannel.appendLine(Constants.Strings.CODEQL_NO_ISSUES_FOUND); + } + } else { + this.outputChannel.appendLine(Constants.Strings.CODEQL_NO_ANALYSIS_RESULTS); + } + + // Only open SARIF viewer if issues are found + if (hasIssues) { + await this.openWithSarifViewer(resultsPath); + } else { + // Show a simple success notification for clean results + vscode.window.showInformationMessage(Constants.Strings.CODEQL_ANALYSIS_SUCCESS_NO_ISSUES); + } + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_ERROR_READING_RESULTS, errorMessage)); + } + } + + private async openWithSarifViewer(resultsPath: string): Promise { + try { + const sarifExt = vscode.extensions.getExtension('MS-SarifVSCode.sarif-viewer'); + + if (!sarifExt) { + this.outputChannel.appendLine(Constants.Strings.CODEQL_SARIF_VIEWER_NOT_FOUND); + + const installExtension = await vscode.window.showInformationMessage( + Constants.Strings.SARIF_VIEWER_NOT_INSTALLED, + Constants.Strings.INSTALL, + Constants.Strings.OPEN_AS_TEXT, + Constants.Strings.CANCEL + ); + + if (installExtension === Constants.Strings.INSTALL) { + try { + this.outputChannel.appendLine(Constants.Strings.CODEQL_INSTALLING_SARIF_VIEWER); + await vscode.commands.executeCommand('workbench.extensions.installExtension', 'MS-SarifVSCode.sarif-viewer'); + + // Wait a moment for the extension to be available + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Try to get the extension again after installation + const newSarifExt = vscode.extensions.getExtension('MS-SarifVSCode.sarif-viewer'); + if (newSarifExt) { + this.outputChannel.appendLine(Constants.Strings.CODEQL_SARIF_VIEWER_INSTALLED); + await newSarifExt.activate(); + + if (newSarifExt.exports && typeof newSarifExt.exports.openLogs === 'function') { + this.outputChannel.appendLine(Constants.Strings.CODEQL_OPENING_WITH_NEW_SARIF_VIEWER); + await newSarifExt.exports.openLogs([vscode.Uri.file(resultsPath)]); + this.outputChannel.appendLine(Constants.Strings.CODEQL_SARIF_VIEWER_OPENED); + return; + } + } + + this.outputChannel.appendLine(Constants.Strings.CODEQL_SARIF_VIEWER_API_NOT_AVAILABLE); + await this.fallbackToTextEditor(resultsPath); + + } catch (installError) { + const errorMessage = installError instanceof Error ? installError.message : String(installError); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_SARIF_VIEWER_INSTALL_ERROR, errorMessage)); + vscode.window.showErrorMessage(Constants.Strings.SARIF_VIEWER_INSTALL_FAILED); + await this.fallbackToTextEditor(resultsPath); + } + } else if (installExtension === Constants.Strings.OPEN_AS_TEXT) { + await this.fallbackToTextEditor(resultsPath); + } else { + this.outputChannel.appendLine(Constants.Strings.CODEQL_USER_CANCELLED); + } + return; + } + + if (!sarifExt.isActive) { + this.outputChannel.appendLine(Constants.Strings.CODEQL_ACTIVATING_SARIF_VIEWER); + await sarifExt.activate(); + } + + if (sarifExt.exports && typeof sarifExt.exports.openLogs === 'function') { + this.outputChannel.appendLine(Constants.Strings.CODEQL_OPENING_WITH_SARIF_VIEWER); + await sarifExt.exports.openLogs([vscode.Uri.file(resultsPath)]); + this.outputChannel.appendLine(Constants.Strings.CODEQL_SARIF_VIEWER_OPENED); + } else { + this.outputChannel.appendLine(Constants.Strings.CODEQL_SARIF_VIEWER_API_FALLBACK); + await this.fallbackToTextEditor(resultsPath); + } + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_SARIF_VIEWER_ERROR, errorMessage)); + await this.fallbackToTextEditor(resultsPath); + } + } private async fallbackToTextEditor(resultsPath: string): Promise { + try { + // Offer to open the full SARIF file as text + const openFile = await vscode.window.showInformationMessage( + Constants.Strings.CODEQL_ANALYSIS_COMPLETED_OPEN_FILE, + Constants.Strings.CODEQL_OPEN_RESULTS, + Constants.Strings.CODEQL_CLOSE + ); + + if (openFile === Constants.Strings.CODEQL_OPEN_RESULTS) { + const document = await vscode.workspace.openTextDocument(resultsPath); + await vscode.window.showTextDocument(document); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_ERROR_OPENING_RESULTS, errorMessage)); + } + } + + private async getCodeQLQuerySuite(sitePath: string): Promise { + const configPath = path.join(sitePath, 'powerpages.config.json'); + const defaultQuerySuite = 'codeql/javascript-queries:codeql-suites/javascript-security-and-quality.qls'; + + try { + if (fs.existsSync(configPath)) { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CONFIG_FILE_FOUND, configPath)); + + const configContent = fs.readFileSync(configPath, 'utf8'); + const config: PowerPagesConfig = JSON.parse(configContent); + + if (config.codeQlQuery) { + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_USING_CUSTOM_QUERY, config.codeQlQuery)); + return config.codeQlQuery; + } else { + // Add the default query suite to the existing config + config.codeQlQuery = defaultQuerySuite; + await this.updateConfigFile(configPath, config); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_ADDED_DEFAULT_QUERY_TO_CONFIG, defaultQuerySuite)); + return defaultQuerySuite; + } + } else { + // Create new config file with default query suite + const config: PowerPagesConfig = { + codeQlQuery: defaultQuerySuite + }; + await this.createConfigFile(configPath, config); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CREATED_CONFIG_FILE, configPath, defaultQuerySuite)); + return defaultQuerySuite; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CONFIG_ERROR, errorMessage)); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_USING_DEFAULT_QUERY, defaultQuerySuite)); + return defaultQuerySuite; + } + } + + private async createConfigFile(configPath: string, config: PowerPagesConfig): Promise { + try { + const configContent = JSON.stringify(config, null, 2); + fs.writeFileSync(configPath, configContent, 'utf8'); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CONFIG_FILE_CREATED_SUCCESSFULLY, configPath)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CONFIG_FILE_CREATE_ERROR, errorMessage)); + throw error; + } + } + + private async updateConfigFile(configPath: string, config: PowerPagesConfig): Promise { + try { + const configContent = JSON.stringify(config, null, 2); + fs.writeFileSync(configPath, configContent, 'utf8'); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CONFIG_FILE_UPDATED_SUCCESSFULLY, configPath)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(vscode.l10n.t(Constants.Strings.CODEQL_CONFIG_FILE_UPDATE_ERROR, errorMessage)); + throw error; + } + } + + public dispose(): void { + this.outputChannel.dispose(); + } +} diff --git a/src/client/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts b/src/client/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts index 110e62117..da784523f 100644 --- a/src/client/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts @@ -6,7 +6,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import { showEnvironmentDetails, refreshEnvironment, switchEnvironment, openActiveSitesInStudio, openInactiveSitesInStudio, createNewAuthProfile, previewSite, fetchWebsites, revealInOS, uploadSite, createKnownSiteIdsSet, findOtherSites, showSiteDetails, openSiteManagement, downloadSite, openInStudio } from '../../../../power-pages/actions-hub/ActionsHubCommandHandlers'; +import { showEnvironmentDetails, refreshEnvironment, switchEnvironment, openActiveSitesInStudio, openInactiveSitesInStudio, createNewAuthProfile, previewSite, fetchWebsites, revealInOS, uploadSite, createKnownSiteIdsSet, findOtherSites, showSiteDetails, openSiteManagement, downloadSite, openInStudio, runCodeQLScreening } from '../../../../power-pages/actions-hub/ActionsHubCommandHandlers'; import { Constants } from '../../../../power-pages/actions-hub/Constants'; import * as CommonUtils from '../../../../power-pages/commonUtility'; import { AuthInfo, CloudInstance, EnvironmentType, OrgInfo } from '../../../../pac/PacTypes'; @@ -1496,4 +1496,86 @@ describe('ActionsHubCommandHandlers', () => { expect(mockOpenUrl.called).to.be.false; }); }); + + describe('runCodeQLScreening', () => { + let mockShowErrorMessage: sinon.SinonStub; + let mockShowWarningMessage: sinon.SinonStub; + let mockGetExtension: sinon.SinonStub; + let mockShowProgressNotification: sinon.SinonStub; + let mockSiteTreeItem: SiteTreeItem; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let mockHasPowerPagesSiteFolder: sinon.SinonStub; + + beforeEach(() => { + mockShowErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage'); + mockShowWarningMessage = sandbox.stub(vscode.window, 'showWarningMessage'); + mockGetExtension = sandbox.stub(vscode.extensions, 'getExtension'); + mockShowProgressNotification = sandbox.stub(Utils, 'showProgressWithNotification').callsFake(async (title: string, task: (progress: vscode.Progress<{ + message?: string; + increment?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }>) => Promise) => await task({} as unknown as vscode.Progress<{ message?: string; increment?: number }>)); + + mockHasPowerPagesSiteFolder = sandbox.stub(WorkspaceInfoFinderUtil, 'hasPowerPagesSiteFolder').returns(true); + + mockSiteTreeItem = new SiteTreeItem({ + name: "Test Site", + websiteId: "test-id", + dataModelVersion: 1, + status: WebsiteStatus.Active, + websiteUrl: 'https://test-site.com', + isCurrent: false, + siteVisibility: SiteVisibility.Private, + siteManagementUrl: "https://test-site-management.com", + createdOn: "2025-03-20", + creator: "Test Creator", + isCodeSite: false + }); + }); + + it('should prompt to install CodeQL extension when not installed', async () => { + mockGetExtension.returns(undefined); + mockShowWarningMessage.resolves(Constants.Strings.INSTALL); + sandbox.stub(CurrentSiteContext, 'currentSiteFolderPath').get(() => 'C:\\test\\site\\path'); + + await runCodeQLScreening(mockSiteTreeItem); + + expect(mockGetExtension.calledWith('github.vscode-codeql')).to.be.true; + expect(mockShowWarningMessage.calledWith( + Constants.Strings.CODEQL_EXTENSION_NOT_INSTALLED, + Constants.Strings.INSTALL, + Constants.Strings.CANCEL + )).to.be.true; + }); + + it('should show error when current site path not found', async () => { + mockGetExtension.returns({ id: 'github.vscode-codeql' }); + sandbox.stub(CurrentSiteContext, 'currentSiteFolderPath').get(() => null); + + await runCodeQLScreening(mockSiteTreeItem); + + expect(mockShowErrorMessage.calledWith(Constants.Strings.CODEQL_CURRENT_SITE_PATH_NOT_FOUND)).to.be.true; + }); + + it('should create CodeQL database for current site when extension is installed', async () => { + mockGetExtension.returns({ id: 'github.vscode-codeql' }); + sandbox.stub(CurrentSiteContext, 'currentSiteFolderPath').get(() => 'C:\\test\\site\\path'); + + await runCodeQLScreening(mockSiteTreeItem); + + expect(mockShowProgressNotification.calledWith(Constants.Strings.CODEQL_SCREENING_STARTED)).to.be.true; + }); + + it('should handle errors gracefully', async () => { + mockGetExtension.returns({ id: 'github.vscode-codeql' }); + sandbox.stub(CurrentSiteContext, 'currentSiteFolderPath').get(() => 'C:\\test\\site\\path'); + mockShowProgressNotification.rejects(new Error('Test error')); + + await runCodeQLScreening(mockSiteTreeItem); + + expect(mockShowErrorMessage.calledWith(Constants.Strings.CODEQL_SCREENING_FAILED)).to.be.true; + expect(traceErrorStub.calledOnce).to.be.true; + expect(traceErrorStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_CODEQL_SCREENING_FAILED); + }); + }); }); diff --git a/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts b/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts index b1925fea0..16f926082 100644 --- a/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts @@ -63,7 +63,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register refresh command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'refreshEnvironment'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.refresh")).to.be.true; @@ -75,7 +75,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register switchEnvironment command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'switchEnvironment'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.switchEnvironment")).to.be.true; @@ -87,7 +87,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register showEnvironmentDetails command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'showEnvironmentDetails'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.showEnvironmentDetails")).to.be.true; @@ -99,7 +99,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register openActiveSitesInStudio command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'openActiveSitesInStudio'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.openActiveSitesInStudio")).to.be.true; @@ -111,7 +111,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register openInactiveSitesInStudio command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'openInactiveSitesInStudio'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.openInactiveSitesInStudio")).to.be.true; @@ -123,7 +123,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register preview command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'previewSite'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.activeSite.preview")).to.be.true; @@ -135,7 +135,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register newAuthProfile command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'createNewAuthProfile'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.newAuthProfile")).to.be.true; @@ -147,7 +147,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register revealInOS commands", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'revealInOS'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.currentActiveSite.revealInOS.windows")).to.be.true; @@ -163,7 +163,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register openSiteManagement commands", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'openSiteManagement'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.inactiveSite.openSiteManagement")).to.be.true; @@ -176,7 +176,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register uploadSite command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'uploadSite'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.activeSite.uploadSite")).to.be.true; @@ -188,7 +188,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register siteDetails command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'showSiteDetails'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.siteDetails")).to.be.true; @@ -200,7 +200,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should register downloadSite command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'downloadSite'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.activeSite.downloadSite")).to.be.true; @@ -212,7 +212,7 @@ describe("ActionsHubTreeDataProvider", () => { it('should register openSiteInStudio command', async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'openInStudio'); mockCommandHandler.resolves(); - const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); actionsHubTreeDataProvider["registerPanel"](pacTerminal); expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.activeSite.openInStudio")).to.be.true; @@ -220,12 +220,24 @@ describe("ActionsHubTreeDataProvider", () => { await registerCommandStub.getCall(14).args[1](); expect(mockCommandHandler.calledOnce).to.be.true; }); + + it('should register runCodeQLScreening command', async () => { + const mockCommandHandler = sinon.stub(CommandHandlers, 'runCodeQLScreening'); + mockCommandHandler.resolves(); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, true); + actionsHubTreeDataProvider["registerPanel"](pacTerminal); + + expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.currentActiveSite.runCodeQLScreening")).to.be.true; + + await registerCommandStub.getCall(16).args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + }); }); describe('getTreeItem', () => { it("should return the element in getTreeItem", () => { const element = {} as ActionsHubTreeItem; - const result = ActionsHubTreeDataProvider.initialize(context, pacTerminal).getTreeItem(element); + const result = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false).getTreeItem(element); expect(result).to.equal(element); }); }); @@ -244,7 +256,7 @@ describe("ActionsHubTreeDataProvider", () => { sinon.stub(CommandHandlers, 'fetchWebsites').resolves({ activeSites: mockActiveSites, inactiveSites: mockInactiveSites, otherSites: otherSites }); PacContext['_authInfo'] = null; - const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); await provider.getChildren(); expect(traceInfoStub.calledOnce).to.be.true; @@ -265,7 +277,7 @@ describe("ActionsHubTreeDataProvider", () => { sinon.stub(CommandHandlers, 'fetchWebsites').resolves({ activeSites: mockActiveSites, inactiveSites: mockInactiveSites, otherSites: otherSites }); PacContext['_authInfo'] = null; - const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); const result = await provider.getChildren(); expect(result).to.not.be.null; @@ -309,7 +321,7 @@ describe("ActionsHubTreeDataProvider", () => { EnvironmentId: "test-env-id" })); - const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); const result = await provider.getChildren(); @@ -328,7 +340,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should return empty array when auth info is not available", async () => { sinon.stub(PacContext, "AuthInfo").get(() => null); - const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); provider["_loadWebsites"] = false; const result = await provider.getChildren(); @@ -339,7 +351,7 @@ describe("ActionsHubTreeDataProvider", () => { sinon.stub(PacContext, "AuthInfo").get(() => ({ OrganizationFriendlyName: 'Foo Bar' })); sinon.stub(vscode.authentication, 'getSession').resolves({ accessToken: '' } as vscode.AuthenticationSession); - const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); provider["_loadWebsites"] = false; const result = await provider.getChildren(); @@ -348,7 +360,7 @@ describe("ActionsHubTreeDataProvider", () => { it("should call element.getChildren when an element is passed", async () => { const element = new SiteTreeItem({} as IWebsiteInfo); - const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); sinon.stub(CommandHandlers, 'fetchWebsites').resolves({ activeSites: [], inactiveSites: [], otherSites: [] }); provider["_loadWebsites"] = false; const getChildrenStub = sinon.stub(element, "getChildren").resolves([]); @@ -390,7 +402,7 @@ describe("ActionsHubTreeDataProvider", () => { OrganizationId: "", OrganizationUniqueName: "" }; - const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal); + const provider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); provider["_loadWebsites"] = true; await provider.getChildren(); diff --git a/src/client/test/Integration/power-pages/actions-hub/actions/codeQLAction.test.ts b/src/client/test/Integration/power-pages/actions-hub/actions/codeQLAction.test.ts new file mode 100644 index 000000000..cafe2f28f --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/actions/codeQLAction.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { CodeQLAction } from '../../../../../power-pages/actions-hub/actions/codeQLAction'; + +describe('CodeQLAction', () => { + let sandbox: sinon.SinonSandbox; + let codeqlAction: CodeQLAction; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Mock the output channel + const mockOutputChannel = { + show: sandbox.stub(), + appendLine: sandbox.stub(), + dispose: sandbox.stub() + }; + + sandbox.stub(vscode.window, 'createOutputChannel').returns(mockOutputChannel as never); + codeqlAction = new CodeQLAction(); + }); + + afterEach(() => { + codeqlAction.dispose(); + sandbox.restore(); + }); + + describe('constructor', () => { + it('should create output channel with correct name', () => { + const action = new CodeQLAction(); + assert.ok(action); + action.dispose(); + }); + }); + + describe('dispose', () => { + it('should dispose output channel when dispose is called', () => { + const action = new CodeQLAction(); + action.dispose(); + // Should not throw when disposing + }); + }); + + describe('executeCodeQLAnalysisWithCustomPath', () => { + it('should handle missing CodeQL extension gracefully', async () => { + const sitePath = '/test/site/path'; + const databaseLocation = '/test/db/location'; + + // Mock VS Code API to simulate missing CodeQL extension + sandbox.stub(vscode.extensions, 'getExtension').returns(undefined); + const showErrorStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + + try { + await codeqlAction.executeCodeQLAnalysisWithCustomPath(sitePath, databaseLocation); + assert.ok(showErrorStub.called, 'Should show error when CodeQL extension is missing'); + } catch (error) { + // Expected error for missing extension + assert.ok(true, 'Should handle missing extension gracefully'); + } + }); + + it('should create output channel and show it during analysis', async () => { + const sitePath = '/test/site/path'; + const databaseLocation = '/test/db/location'; + + // Mock VS Code API to avoid actual extension checks + sandbox.stub(vscode.extensions, 'getExtension').returns(undefined); + sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + const createChannelStub = vscode.window.createOutputChannel as sinon.SinonStub; + + await codeqlAction.executeCodeQLAnalysisWithCustomPath(sitePath, databaseLocation); + + assert.ok(createChannelStub.called, 'Should create output channel'); + }); + + it('should handle CodeQL extension activation', async () => { + const sitePath = '/test/site/path'; + const databaseLocation = '/test/db/location'; + + const mockExtension = { + isActive: false, + activate: sandbox.stub().resolves(), + exports: null + }; + + sandbox.stub(vscode.extensions, 'getExtension').returns(mockExtension as never); + sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + + try { + await codeqlAction.executeCodeQLAnalysisWithCustomPath(sitePath, databaseLocation); + assert.ok(mockExtension.activate.called, 'Should attempt to activate CodeQL extension'); + } catch (error) { + // Handle expected errors during test execution + assert.ok(true, 'Test completed'); + } + }); + + }); + + describe('error handling', () => { + it('should handle exceptions gracefully during analysis', async () => { + // This test verifies that the method handles errors without crashing + const sitePath = '/test/site/path'; + const databaseLocation = '/test/db/location'; + + // Mock extensions.getExtension to return undefined (no CodeQL extension) + sandbox.stub(vscode.extensions, 'getExtension').returns(undefined); + sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + + // This should complete without throwing an unhandled error + await codeqlAction.executeCodeQLAnalysisWithCustomPath(sitePath, databaseLocation); + + // Test passes if we reach this point + assert.ok(true, 'Should handle missing extension gracefully'); + }); + + it('should handle null or undefined inputs gracefully', async () => { + // Test with null paths + sandbox.stub(vscode.extensions, 'getExtension').returns(undefined); + sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + + // Should not throw with null/undefined inputs + await codeqlAction.executeCodeQLAnalysisWithCustomPath('', ''); + + assert.ok(true, 'Should handle empty string inputs without throwing'); + }); + }); + + describe('integration behavior', () => { + it('should work with basic mocked VS Code environment', () => { + // Test that the class can be instantiated and used in a basic VS Code mock environment + const action = new CodeQLAction(); + + assert.ok(action, 'Should create CodeQLAction instance'); + assert.ok(typeof action.executeCodeQLAnalysisWithCustomPath === 'function', 'Should have main execution method'); + assert.ok(typeof action.dispose === 'function', 'Should have dispose method'); + + action.dispose(); + }); + + it('should handle VS Code API changes gracefully', async () => { + // Test behavior when VS Code APIs return unexpected values + const sitePath = '/test/site/path'; + const databaseLocation = '/test/db/location'; + + sandbox.stub(vscode.extensions, 'getExtension').returns(null as never); + sandbox.stub(vscode.window, 'showErrorMessage').resolves(); + + // Should handle null return from getExtension + await codeqlAction.executeCodeQLAnalysisWithCustomPath(sitePath, databaseLocation); + + assert.ok(true, 'Should handle null extension return gracefully'); + }); + + }); +}); diff --git a/src/common/constants.ts b/src/common/constants.ts index 7f78ffe46..bc7ac7e33 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -95,6 +95,7 @@ export const ADX_WEBPAGE = 'adx_webpage' export const HTML_FILE_EXTENSION = '.html'; export const UTF8_ENCODING = 'utf8'; export const EDGE_TOOLS_EXTENSION_ID = 'ms-edgedevtools.vscode-edge-devtools'; +export const CODEQL_EXTENSION_ID = 'github.vscode-codeql'; export const ADX_WEBSITE_RECORDS_API_PATH = 'api/data/v9.2/adx_websites?$select=*&$expand=owninguser($select=systemuserid,fullname)'; export const POWERPAGES_SITE_RECORDS_API_PATH = 'api/data/v9.2/powerpagesites?$select=*&$expand=owninguser($select=systemuserid,fullname)'; export const POWERPAGES_SITE_SETTINGS_API_PATH = 'api/data/v9.2/mspp_sitesettings'; diff --git a/src/common/ecs-features/ecsFeatureGates.ts b/src/common/ecs-features/ecsFeatureGates.ts index e40a4e4d9..d2a663264 100644 --- a/src/common/ecs-features/ecsFeatureGates.ts +++ b/src/common/ecs-features/ecsFeatureGates.ts @@ -89,3 +89,13 @@ export const { enableBLChanges: false, }, }) + +export const { + feature: EnableCodeQlScan +} = getFeatureConfigs({ + teamName: PowerPagesClientName, + description: 'Enable CodeQl Scan in VSCode Desktop', + fallback: { + enableCodeQlScan: false, + } +}) diff --git a/src/common/utilities/WorkspaceInfoFinderUtil.ts b/src/common/utilities/WorkspaceInfoFinderUtil.ts index 2977df9ae..ae5545da1 100644 --- a/src/common/utilities/WorkspaceInfoFinderUtil.ts +++ b/src/common/utilities/WorkspaceInfoFinderUtil.ts @@ -98,3 +98,40 @@ export function findPowerPagesSiteFolder(startPath: string): string | null { } return null; } + +/** + * Checks if a website.yml file exists in the specified directory + * @param directoryPath The directory path to check + * @returns True if website.yml exists, false otherwise + */ +export function hasWebsiteYaml(directoryPath: string): boolean { + const websiteYamlPath = path.join(directoryPath, WEBSITE_YML); + return fs.existsSync(websiteYamlPath); +} + +/** + * Checks if a .powerpages-site folder exists in the specified directory + * @param directoryPath The directory path to check + * @returns True if .powerpages-site folder exists, false otherwise + */ +export function hasPowerPagesSiteFolder(directoryPath: string): boolean { + return fs.existsSync(directoryPath); +} + +/** + * Gets the path to the website.yml file in the specified directory + * @param directoryPath The directory path + * @returns The full path to website.yml + */ +export function getWebsiteYamlPath(directoryPath: string): string { + return path.join(directoryPath, WEBSITE_YML); +} + +/** + * Gets the path to the .powerpages-site folder in the specified directory + * @param directoryPath The directory path + * @returns The full path to .powerpages-site folder + */ +export function getPowerPagesSiteFolderPath(directoryPath: string): string { + return path.join(directoryPath, POWERPAGES_SITE_FOLDER); +}