From ce3d614317759ca732d879140355f376a5b6fb1f Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 19 Sep 2025 16:28:10 +0100 Subject: [PATCH] feat: support & require RH OIDC auth --- .eslintignore | 1 + package-lock.json | 40 +- package.json | 23 +- src/caStatusBarProvider.ts | 43 ++ src/config.ts | 6 + src/dependencyAnalysis/analysis.ts | 11 +- src/dependencyAnalysis/diagnostics.ts | 5 +- src/exhortServices.ts | 2 +- src/extension.ts | 950 ++++++++++++++------------ src/fileHandler.ts | 19 +- src/imageAnalysis.ts | 11 +- src/imageAnalysis/diagnostics.ts | 6 +- src/oidcAuthentication.ts | 247 +++++++ src/redhatTelemetry.ts | 4 +- src/rhda.ts | 7 +- src/stackAnalysis.ts | 6 +- src/tokenProvider.ts | 26 + src/tokenValidation.ts | 9 +- test/fileHandler.test.ts | 5 +- test/imageAnalysis.test.ts | 9 +- test/imageAnalysis/collector.test.ts | 1 + test/redhatTelemetry.test.ts | 2 +- test/rhda.test.ts | 17 +- test/stackAnalysis.test.ts | 7 +- test/tokenValidation.test.ts | 13 +- transformToDynamicImport.sh | 3 +- 26 files changed, 968 insertions(+), 505 deletions(-) create mode 100644 .eslintignore create mode 100644 src/oidcAuthentication.ts create mode 100644 src/tokenProvider.ts diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..53c37a16 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 35e931f8..9a782387 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@redhat-developer/vscode-redhat-telemetry": "^0.8.0", "@trustification/exhort-api-spec": "^1.0.18", - "@trustification/exhort-javascript-api": "^0.2.4-ea.9", + "@trustification/exhort-javascript-api": "^0.2.4-ea.12", "@xml-tools/ast": "^5.0.5", "@xml-tools/parser": "^1.0.11", "cli-table3": "^0.6.5", @@ -19,6 +19,7 @@ "json-to-ast": "^2.1.0", "minimatch": "^10.0.3", "mustache": "^4.2.0", + "openid-client": "^6.8.0", "path": "^0.12.7", "tree-sitter-python": "^0.23.6", "web-tree-sitter": "^0.25.6" @@ -930,9 +931,9 @@ "license": "Apache-2.0" }, "node_modules/@trustification/exhort-javascript-api": { - "version": "0.2.4-ea.9", - "resolved": "https://npm.pkg.github.com/download/@trustification/exhort-javascript-api/0.2.4-ea.9/58977b4d88d1b0721adb12b226151531d021ce7b", - "integrity": "sha512-si80L+SdETzNgJA1NtVdt12wJ5A/iTTHYPjhIHiIYNINRC0JUcWMBxxl2ahCuSu+B1SrnxUpD7JJMiV+DTN45w==", + "version": "0.2.4-ea.12", + "resolved": "https://npm.pkg.github.com/download/@trustification/exhort-javascript-api/0.2.4-ea.12/49dbe65726828ea0d79841f339929c30a56cc9ae", + "integrity": "sha512-W34rpfr0IT92aowj2IxFpx6L/N0DD3q19g3aJky8kI/DyzacrrCoo01I5uJUWdXMge4ZRQGSCbheqTuQbHIFJQ==", "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.23.2", @@ -3726,6 +3727,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -4505,6 +4515,15 @@ "node": ">=6" } }, + "node_modules/oauth4webapi": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.1.tgz", + "integrity": "sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-hash": { "version": "3.0.0", "license": "MIT", @@ -4532,6 +4551,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.0.tgz", + "integrity": "sha512-oG1d1nAVhIIE+JSjLS+7E9wY1QOJpZltkzlJdbZ7kEn7Hp3hqur2TEeQ8gLOHoHkhbRAGZJKoOnEQcLOQJuIyg==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.0", + "oauth4webapi": "^3.8.1" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, diff --git a/package.json b/package.json index 322fcb4f..e9c6c12b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,11 @@ "command": "rhda.stackLogs", "title": "Debug logs", "category": "Red Hat Dependency Analytics" + }, + { + "command": "rhda.authenticate", + "title": "Authenticate", + "category": "Red Hat Dependency Analytics" } ], "menus": { @@ -374,6 +379,21 @@ "type": "string" }, "description": "List of path globs for manifests to ignore for analysis. Only forward slash is support as a path separator." + }, + "redHatDependencyAnalytics.oidc.endpoint": { + "type": "string", + "default": "https://sso.redhat.com/auth/realms/redhat-external", + "description": "URL used for OIDC auth server metadata discovery." + }, + "redHatDependencyAnalytics.oidc.clientId": { + "type": "string", + "default": "rhda-vscode", + "description": "Specifies the OIDC client ID." + }, + "redHatDependencyAnalytics.oidc.allowInsecure": { + "type": "boolean", + "default": false, + "description": "Enables specifying HTTP-only endpoints." } } } @@ -428,7 +448,7 @@ "dependencies": { "@redhat-developer/vscode-redhat-telemetry": "^0.8.0", "@trustification/exhort-api-spec": "^1.0.18", - "@trustification/exhort-javascript-api": "^0.2.4-ea.9", + "@trustification/exhort-javascript-api": "^0.2.4-ea.12", "@xml-tools/ast": "^5.0.5", "@xml-tools/parser": "^1.0.11", "cli-table3": "^0.6.5", @@ -436,6 +456,7 @@ "json-to-ast": "^2.1.0", "minimatch": "^10.0.3", "mustache": "^4.2.0", + "openid-client": "^6.8.0", "path": "^0.12.7", "tree-sitter-python": "^0.23.6", "web-tree-sitter": "^0.25.6" diff --git a/src/caStatusBarProvider.ts b/src/caStatusBarProvider.ts index 7b893827..af854506 100644 --- a/src/caStatusBarProvider.ts +++ b/src/caStatusBarProvider.ts @@ -47,6 +47,49 @@ class CAStatusBarProvider implements Disposable { this.statusBarItem.tooltip = PromptText.LSP_FAILURE_TEXT; } + /** + * Shows authentication required status in the status bar. + */ + public showAuthRequired(): void { + this.statusBarItem.text = `$(account) RHDA: Not Signed In`; + this.statusBarItem.command = { + title: 'Authenticate with RHDA', + command: 'rhda.authenticate', + }; + this.statusBarItem.tooltip = 'Click to sign in for enhanced RHDA features (optional)'; + this.statusBarItem.show(); + } + + /** + * Shows authenticated status in the status bar. + */ + public showAuthenticated(): void { + this.statusBarItem.text = `$(verified) RHDA: Authenticated`; + this.statusBarItem.command = undefined; // No command needed when authenticated + this.statusBarItem.tooltip = 'RHDA is authenticated and ready for dependency analysis'; + this.statusBarItem.show(); + } + + /** + * Shows session expired status in the status bar. + */ + public showSessionExpired(): void { + this.statusBarItem.text = `$(warning) RHDA: Session Expired`; + this.statusBarItem.command = { + title: 'Re-authenticate with RHDA', + command: 'rhda.authenticate', + }; + this.statusBarItem.tooltip = 'Your RHDA session has expired. Click to re-authenticate and restore functionality.'; + this.statusBarItem.show(); + } + + /** + * Hides the status bar item. + */ + public hide(): void { + this.statusBarItem.hide(); + } + /** * Disposes of the status bar item. */ diff --git a/src/config.ts b/src/config.ts index 38cebc30..a8368eed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,9 @@ class Config { enablePythonBestEffortsInstallation!: string; usePipDepTree!: string; vulnerabilityAlertSeverity!: string; + oidcRealmUrl!: string; + oidcClientId!: string; + oidcAllowInsecure!: boolean; exhortMvnPath!: string; exhortPreferMvnw!: string; exhortMvnArgs!: string; @@ -132,6 +135,9 @@ class Config { this.exhortPodmanPath = rhdaConfig.podman.executable.path || this.DEFAULT_PODMAN_EXECUTABLE; this.exhortImagePlatform = rhdaConfig.imagePlatform; this.excludePatterns = (rhdaConfig.exclude as string[]).map(pattern => new Minimatch(pattern)); + this.oidcRealmUrl = rhdaConfig.oidc.endpoint; + this.oidcClientId = rhdaConfig.oidc.clientId; + this.oidcAllowInsecure = rhdaConfig.oidc.allowInsecure; } private getEffectiveHttpProxyUrl(): string { diff --git a/src/dependencyAnalysis/analysis.ts b/src/dependencyAnalysis/analysis.ts index fac2962b..14aa2e0d 100644 --- a/src/dependencyAnalysis/analysis.ts +++ b/src/dependencyAnalysis/analysis.ts @@ -4,7 +4,8 @@ * ------------------------------------------------------------------------------------------ */ 'use strict'; -import exhort from '@trustification/exhort-javascript-api'; + +import exhort, { Options } from '@trustification/exhort-javascript-api'; import { AnalysisReport } from '@trustification/exhort-api-spec/model/v4/AnalysisReport'; import { globalConfig } from '../config'; @@ -15,6 +16,7 @@ import { notifications, outputChannelDep } from '../extension'; import { Source } from '@trustification/exhort-api-spec/model/v4/Source'; import { DependencyReport } from '@trustification/exhort-api-spec/model/v4/DependencyReport'; import { Issue } from '@trustification/exhort-api-spec/model/v4/Issue'; +import { TokenProvider } from '../tokenProvider'; /** * Represents a source object with an ID and dependencies array. @@ -146,11 +148,12 @@ class AnalysisResponse implements IAnalysisResponse { * @param provider - The dependency provider of the corresponding ecosystem. * @returns A Promise resolving to an AnalysisResponse object. */ -async function executeComponentAnalysis(diagnosticFilePath: Uri, provider: IDependencyProvider): Promise { +async function executeComponentAnalysis(tokenProvider: TokenProvider, diagnosticFilePath: Uri, provider: IDependencyProvider): Promise { // Define configuration options for the component analysis request - const options = { - 'RHDA_TOKEN': globalConfig.telemetryId, + const options: Options = { + 'RHDA_TOKEN': await tokenProvider.getToken() ?? '', + 'RHDA_TELEMETRY_ID': globalConfig.telemetryId, 'RHDA_SOURCE': globalConfig.utmSource, 'MATCH_MANIFEST_VERSIONS': globalConfig.matchManifestVersions, 'EXHORT_PROXY_URL': globalConfig.exhortProxyUrl, diff --git a/src/dependencyAnalysis/diagnostics.ts b/src/dependencyAnalysis/diagnostics.ts index 4a113c1e..8427d65d 100644 --- a/src/dependencyAnalysis/diagnostics.ts +++ b/src/dependencyAnalysis/diagnostics.ts @@ -15,6 +15,7 @@ import { AbstractDiagnosticsPipeline } from '../diagnosticsPipeline'; import { Diagnostic, DiagnosticSeverity, Uri } from 'vscode'; import { notifications, outputChannelDep } from '../extension'; import { globalConfig } from '../config'; +import { TokenProvider } from '../tokenProvider'; /** * Implementation of DiagnosticsPipeline interface. @@ -95,7 +96,7 @@ class DiagnosticsPipeline extends AbstractDiagnosticsPipeline { * @param provider - The dependency provider of the corresponding ecosystem. * @returns A Promise that resolves when diagnostics are completed. */ -async function performDiagnostics(diagnosticFilePath: Uri, contents: string, provider: IDependencyProvider) { +async function performDiagnostics(tokenProvider: TokenProvider, diagnosticFilePath: Uri, contents: string, provider: IDependencyProvider) { try { const dependencies = provider.collect(contents); const ecosystem = provider.getEcosystem(); @@ -104,7 +105,7 @@ async function performDiagnostics(diagnosticFilePath: Uri, contents: string, pro const diagnosticsPipeline = new DiagnosticsPipeline(dependencyMap, diagnosticFilePath); diagnosticsPipeline.clearDiagnostics(); - const response = await executeComponentAnalysis(diagnosticFilePath, provider); + const response = await executeComponentAnalysis(tokenProvider, diagnosticFilePath, provider); clearCodeActionsMap(diagnosticFilePath); diff --git a/src/exhortServices.ts b/src/exhortServices.ts index 2677ae61..d27b5ade 100644 --- a/src/exhortServices.ts +++ b/src/exhortServices.ts @@ -50,7 +50,7 @@ function parseImageReference(image: IImageRef, options: IOptions): ImageRef { * @param source The source for which the token is being validated. * @returns A promise resolving after validating the token. */ -async function tokenValidationService(options: { [key: string]: string }, source: string): Promise { +async function tokenValidationService(options: Options, source: string): Promise { try { // Get token validation status code const response = await exhort.validateToken(options); diff --git a/src/extension.ts b/src/extension.ts index 0fbd7555..eddc1954 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,452 +1,508 @@ -'use strict'; - -import * as path from 'path'; -import * as vscode from 'vscode'; - -import * as commands from './commands'; -import { GlobalState, EXTENSION_QUALIFIED_ID, REDHAT_MAVEN_REPOSITORY, REDHAT_MAVEN_REPOSITORY_DOCUMENTATION_URL, REDHAT_CATALOG } from './constants'; -import { generateRHDAReport } from './rhda'; -import { globalConfig } from './config'; -import { StatusMessages, PromptText } from './constants'; -import { caStatusBarProvider } from './caStatusBarProvider'; -import { CANotification, CANotificationData } from './caNotification'; -import { DepOutputChannel } from './depOutputChannel'; -import { record, startUp, TelemetryActions } from './redhatTelemetry'; -import { applySettingNameMappings, buildLogErrorMessage } from './utils'; -import { clearCodeActionsMap, getDiagnosticsCodeActions } from './codeActionHandler'; -import { AnalysisMatcher } from './fileHandler'; -import { EventEmitter } from 'node:events'; -import { ListModelCardResponse, llmAnalysis } from './llmAnalysis'; -import { LLMAnalysisReportPanel } from './llmAnalysisReportPanel'; -// eslint-disable-next-line @typescript-eslint/no-require-imports -import CliTable3 = require('cli-table3'); -import { Language, Parser, Query } from 'web-tree-sitter'; - -export let outputChannelDep: DepOutputChannel; - -export const notifications = new EventEmitter(); - -/** - * Activates the extension upon launch. - * @param context - The extension context. - */ -export async function activate(context: vscode.ExtensionContext) { - outputChannelDep = new DepOutputChannel(); - outputChannelDep.info(`starting RHDA extension ${context.extension.packageJSON['version']}`); - - globalConfig.linkToSecretStorage(context); - - startUp(context); - - context.subscriptions.push(vscode.languages.registerCodeActionsProvider('*', new class implements vscode.CodeActionProvider { - provideCodeActions(document: vscode.TextDocument, range: vscode.Range | vscode.Selection, ctx: vscode.CodeActionContext): vscode.ProviderResult { - return getDiagnosticsCodeActions(ctx.diagnostics, document.uri); - } - }())); - - const fileHandler = new AnalysisMatcher(); - context.subscriptions.push(vscode.workspace.onDidSaveTextDocument((doc) => fileHandler.handle(doc, outputChannelDep))); - // Anecdotaly, some extension(s) may cause did-open events for files that aren't actually open in the editor, - // so this will trigger CA for files not actually open. - context.subscriptions.push(vscode.workspace.onDidOpenTextDocument((doc) => fileHandler.handle(doc, outputChannelDep))); - context.subscriptions.push(vscode.workspace.onDidCloseTextDocument(doc => clearCodeActionsMap(doc.uri))); - // Iterate all open docs, as there is (in general) no did-open event for these. - for (const doc of vscode.workspace.textDocuments) { - fileHandler.handle(doc, outputChannelDep); - } - - // show welcome message after first install or upgrade - showUpdateNotification(context); - - const llmAnalysisDiagnosticsCollection = vscode.languages.createDiagnosticCollection('rhdaLLM'); - context.subscriptions.push(llmAnalysisDiagnosticsCollection); - const modelsInDocs = new Map>(); - - context.subscriptions.push(vscode.languages.registerCodeActionsProvider('*', - new class implements vscode.CodeActionProvider { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - provideCodeActions(document: vscode.TextDocument, range: vscode.Range | vscode.Selection, actionContext: vscode.CodeActionContext, token: vscode.CancellationToken): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> { - if (!actionContext.diagnostics.some(diagnostic => diagnostic.code?.toString() === 'rhdallm')) { - return; - } - - const llmModel = document.getText(actionContext.diagnostics[0].range); - - record(context, TelemetryActions.llmAnalysisDiagnosticsHovered, { modelName: llmModel }); - - return [{ - title: 'Open LLM Evaluation Report', - command: { - command: commands.LLM_MODELS_ANALYSIS_REPORT, - title: 'Show LLM Analysis Report', - arguments: [llmModel, document.uri, actionContext.diagnostics[0].range], - }, - kind: vscode.CodeActionKind.QuickFix - }]; - } - }(), { - providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] - })); - - let wasmPath: string; - if (context.extensionMode === vscode.ExtensionMode.Production) { - wasmPath = path.resolve(context.extensionPath, 'dist'); - } else { - wasmPath = path.resolve(context.extensionPath, 'node_modules'); - } - - let python: Language; - try { - await Parser.init({ - locateFile() { - return path.resolve(wasmPath, 'web-tree-sitter', 'tree-sitter.wasm'); - }, - }); - const pypath = path.resolve(wasmPath, 'tree-sitter-python', 'tree-sitter-python.wasm'); - python = await Language.load(pypath); - } catch (e) { - outputChannelDep.error(`Error when initializing tree-sitter: ${e}`); - } - - const doLLMAnalysis = async (doc: vscode.TextDocument) => { - if (doc.languageId !== 'python' || python == null) { - return; - } - - const diagnostics: vscode.Diagnostic[] = []; - - const parser = new Parser(); - parser.setLanguage(python); - const tree = parser.parse(doc.getText()); - - const modelWithLoc: Map = new Map(); - - const query = new Query(python, ` - ( - expression_statement - (string (string_content) @comment) - (#match? @comment "@rhda[ \\n\\t]+model=") - ) - ( - (comment) @rhdamarker - (comment) @modelmarker - (#match? @rhdamarker "# @rhda") - (#match? @modelmarker "# model=") - )` - ); - const queryMatches = query.matches(tree!.rootNode); - - for (const match of queryMatches) { - if (match.captures[0].name === 'rhdamarker') { - const commentStr = match.captures[1].node.text; - const modelNameIndex = commentStr.indexOf('model=') + 'model='.length; - const modelEndIndex = /\s/.exec(commentStr.substring(modelNameIndex))?.index ?? commentStr.length; - const model = commentStr.substring(modelNameIndex, modelEndIndex); - - const startPos = doc.positionAt(match.captures[1].node.startIndex + modelNameIndex); - const endPos = new vscode.Position(startPos.line, modelNameIndex + modelEndIndex); - const range = new vscode.Range(startPos, endPos); - - const ranges = modelWithLoc.get(model) ?? []; - ranges.push(range); - modelWithLoc.set(model, ranges); - } else { - const commentStr = match.captures[0].node.text; - const markerIndex = commentStr.indexOf('@rhda'); - - let currentModelOffset = 0; - // eslint-disable-next-line no-cond-assign - while ((currentModelOffset = commentStr.indexOf('model=', markerIndex + currentModelOffset + '@rhda\n'.length)) > 0) { - const modelStrIndex = /\s/.exec(commentStr.substring(currentModelOffset + 'model='.length))!.index; - const model = commentStr.substring(currentModelOffset + 'model='.length, currentModelOffset + 'model='.length + modelStrIndex).trim(); - - const startPos = doc.positionAt(match.captures[0].node.startIndex + currentModelOffset + 'model='.length); - const endPos = new vscode.Position(startPos.line, startPos.character + model.length); - const range = new vscode.Range(startPos, endPos); - - const ranges = modelWithLoc.get(model) ?? []; - ranges.push(range); - modelWithLoc.set(model, ranges); - } - } - } +'use strict'; + +import * as path from 'path'; +import * as vscode from 'vscode'; + +import * as commands from './commands'; +import { GlobalState, EXTENSION_QUALIFIED_ID, REDHAT_MAVEN_REPOSITORY, REDHAT_MAVEN_REPOSITORY_DOCUMENTATION_URL, REDHAT_CATALOG } from './constants'; +import { generateRHDAReport } from './rhda'; +import { globalConfig } from './config'; +import { StatusMessages, PromptText } from './constants'; +import { caStatusBarProvider } from './caStatusBarProvider'; +import { CANotification, CANotificationData } from './caNotification'; +import { DepOutputChannel } from './depOutputChannel'; +import { initTelemetry, record, TelemetryActions } from './redhatTelemetry'; +import { applySettingNameMappings, buildLogErrorMessage, buildNotificationErrorMessage } from './utils'; +import { clearCodeActionsMap, getDiagnosticsCodeActions } from './codeActionHandler'; +import { AnalysisMatcher } from './fileHandler'; +import { EventEmitter } from 'node:events'; +import { ListModelCardResponse, llmAnalysis } from './llmAnalysis'; +import { LLMAnalysisReportPanel } from './llmAnalysisReportPanel'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import CliTable3 = require('cli-table3'); +import { Language, Parser, Query } from 'web-tree-sitter'; +import { getValidAccessToken, performOIDCAuthorizationFlow } from './oidcAuthentication'; +import { TokenProvider, VSCodeTokenProvider } from './tokenProvider'; + +export let outputChannelDep: DepOutputChannel; + +export const notifications = new EventEmitter(); + +async function enableExtensionFeatures(context: vscode.ExtensionContext, tokenProvider: TokenProvider): Promise { + outputChannelDep.info('Initializing RHDA analysis features'); + + context.subscriptions.push(vscode.languages.registerCodeActionsProvider('*', new class implements vscode.CodeActionProvider { + provideCodeActions(document: vscode.TextDocument, range: vscode.Range | vscode.Selection, ctx: vscode.CodeActionContext): vscode.ProviderResult { + return getDiagnosticsCodeActions(ctx.diagnostics, document.uri); + } + }())); + + const fileHandler = new AnalysisMatcher(tokenProvider); + context.subscriptions.push(vscode.workspace.onDidSaveTextDocument((doc) => fileHandler.handle(doc, outputChannelDep))); + // Anecdotaly, some extension(s) may cause did-open events for files that aren't actually open in the editor, + // so this will trigger CA for files not actually open. + context.subscriptions.push(vscode.workspace.onDidOpenTextDocument((doc) => fileHandler.handle(doc, outputChannelDep))); + context.subscriptions.push(vscode.workspace.onDidCloseTextDocument(doc => clearCodeActionsMap(doc.uri))); + // Iterate all open docs, as there is (in general) no did-open event for these. + for (const doc of vscode.workspace.textDocuments) { + fileHandler.handle(doc, outputChannelDep); + } + + // show welcome message after first install or upgrade + showUpdateNotification(context); + + const llmAnalysisDiagnosticsCollection = vscode.languages.createDiagnosticCollection('rhdaLLM'); + context.subscriptions.push(llmAnalysisDiagnosticsCollection); + const modelsInDocs = new Map>(); + + context.subscriptions.push(vscode.languages.registerCodeActionsProvider('*', + new class implements vscode.CodeActionProvider { + provideCodeActions(document: vscode.TextDocument, range: vscode.Range | vscode.Selection, actionContext: vscode.CodeActionContext): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> { + if (!actionContext.diagnostics.some(diagnostic => diagnostic.code?.toString() === 'rhdallm')) { + return; + } + + const llmModel = document.getText(actionContext.diagnostics[0].range); + + record(context, TelemetryActions.llmAnalysisDiagnosticsHovered, { modelName: llmModel }); + + return [{ + title: 'Open LLM Evaluation Report', + command: { + command: commands.LLM_MODELS_ANALYSIS_REPORT, + title: 'Show LLM Analysis Report', + arguments: [llmModel, document.uri, actionContext.diagnostics[0].range], + }, + kind: vscode.CodeActionKind.QuickFix + }]; + } + }(), { + providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] + })); + + let wasmPath: string; + if (context.extensionMode === vscode.ExtensionMode.Production) { + wasmPath = path.resolve(context.extensionPath, 'dist'); + } else { + wasmPath = path.resolve(context.extensionPath, 'node_modules'); + } + + let python: Language; + try { + await Parser.init({ + locateFile() { + return path.resolve(wasmPath, 'web-tree-sitter', 'tree-sitter.wasm'); + }, + }); + const pypath = path.resolve(wasmPath, 'tree-sitter-python', 'tree-sitter-python.wasm'); + python = await Language.load(pypath); + } catch (e) { + outputChannelDep.error(`Error when initializing tree-sitter: ${e}`); + } + + const doLLMAnalysis = async (doc: vscode.TextDocument) => { + if (doc.languageId !== 'python' || python == null) { + return; + } + + const diagnostics: vscode.Diagnostic[] = []; + + const parser = new Parser(); + parser.setLanguage(python); + const tree = parser.parse(doc.getText()); + + const modelWithLoc: Map = new Map(); + + const query = new Query(python, ` + ( + expression_statement + (string (string_content) @comment) + (#match? @comment "@rhda[ \\n\\t]+model=") + ) + ( + (comment) @rhdamarker + (comment) @modelmarker + (#match? @rhdamarker "# @rhda") + (#match? @modelmarker "# model=") + )` + ); + const queryMatches = query.matches(tree!.rootNode); + + for (const match of queryMatches) { + if (match.captures[0].name === 'rhdamarker') { + const commentStr = match.captures[1].node.text; + const modelNameIndex = commentStr.indexOf('model=') + 'model='.length; + const modelEndIndex = /\s/.exec(commentStr.substring(modelNameIndex))?.index ?? commentStr.length; + const model = commentStr.substring(modelNameIndex, modelEndIndex); + + const startPos = doc.positionAt(match.captures[1].node.startIndex + modelNameIndex); + const endPos = new vscode.Position(startPos.line, modelNameIndex + modelEndIndex); + const range = new vscode.Range(startPos, endPos); + + const ranges = modelWithLoc.get(model) ?? []; + ranges.push(range); + modelWithLoc.set(model, ranges); + } else { + const commentStr = match.captures[0].node.text; + const markerIndex = commentStr.indexOf('@rhda'); + + let currentModelOffset = 0; + // eslint-disable-next-line no-cond-assign + while ((currentModelOffset = commentStr.indexOf('model=', markerIndex + currentModelOffset + '@rhda\n'.length)) > 0) { + const modelStrIndex = /\s/.exec(commentStr.substring(currentModelOffset + 'model='.length))!.index; + const model = commentStr.substring(currentModelOffset + 'model='.length, currentModelOffset + 'model='.length + modelStrIndex).trim(); + + const startPos = doc.positionAt(match.captures[0].node.startIndex + currentModelOffset + 'model='.length); + const endPos = new vscode.Position(startPos.line, startPos.character + model.length); + const range = new vscode.Range(startPos, endPos); + + const ranges = modelWithLoc.get(model) ?? []; + ranges.push(range); + modelWithLoc.set(model, ranges); + } + } + } if (modelWithLoc.size === 0) { return; } - - const modelCardsInfo = await llmAnalysis(Array.from(modelWithLoc.keys())); - if (!modelCardsInfo) { - return; - } - - const rangeToModel = new Map(); - modelsInDocs.set(doc.uri, rangeToModel); - for (const [model, ranges] of modelWithLoc) { - for (const range of ranges) { - const modelInfo = modelCardsInfo.find(modelResponse => modelResponse.model_name === model); - if (!modelInfo) { - // log something here? - continue; - } - rangeToModel.set(range, modelInfo); - } - } - - // TODO: handle model with no data from API - for (const modelInfo of modelCardsInfo) { - const table = new CliTable3({ - head: ['Safety Metric', 'Score', 'Assessment'], - chars: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'top': '', 'top-mid': '', 'top-left': '', 'top-right': '', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'bottom': '', 'bottom-mid': '', 'bottom-left': '', 'bottom-right': '', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'left': '', 'left-mid': '', 'mid': '─', 'mid-mid': '', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'right': '', 'right-mid': '', 'middle': ' ' - }, - style: { compact: true, border: [], head: [] }, - }); - - for (const warning of modelInfo.metrics.filter(metric => metric.metric.startsWith('pct_') || metric.metric === 'acc')) { - table.push([`${warning.task}: ${warning.metric}`, warning.score.toFixed(3), warning.assessment]); - } - - for (const range of modelWithLoc.get(modelInfo.model_name)!) { - diagnostics.push({ - range: range, - message: table.toString() + `\n\nOpen the detailed report via Code Actions for further details & recommended guardrails.\n`, - severity: vscode.DiagnosticSeverity.Information, - source: 'Red Hat LLM Dependency Analytics', - code: `rhdallm` - }); - } - } - - llmAnalysisDiagnosticsCollection.set(doc.uri, diagnostics); - - { - const compareArrays = (a: string[], b: string[]) => a.length === b.length && a.every((element, index) => element === b[index]); - const knownModels = modelCardsInfo.map(model => model.model_name).sort(); - const previouslyReferencedModels = context.globalState.get(`rhda-llm-annotation-models:${doc.uri.fsPath}`, [] as string[]); - if (!compareArrays(previouslyReferencedModels, knownModels)) { - context.globalState.update(`rhda-llm-annotation-models:${doc.uri.fsPath}`, knownModels); - if (knownModels.length > 0) { - record(context, TelemetryActions.llmAnalysisModelAnnotationsDiscovered, { knownModelsReferenced: knownModels }); - } - } - } - }; - - vscode.workspace.textDocuments.forEach(doLLMAnalysis); - vscode.workspace.onDidOpenTextDocument(doLLMAnalysis); - vscode.workspace.onDidChangeTextDocument((event) => doLLMAnalysis(event.document)); - - const disposableLLMAnalysisReportCommand = vscode.commands.registerCommand( - commands.LLM_MODELS_ANALYSIS_REPORT, - async (model: string, uri: vscode.Uri, range: vscode.Range) => { - LLMAnalysisReportPanel.createOrShowPanel(); - // remove null check, better missing handling - await LLMAnalysisReportPanel.currentPanel?.updatePanel(modelsInDocs.get(uri)!.get(range)!.id); - record(context, TelemetryActions.llmAnalysisReportDone, { modelName: model }); - } - ); - - const disposableStackAnalysisCommand = vscode.commands.registerCommand( - commands.STACK_ANALYSIS_COMMAND, - // filePath must be string as this can be invoked from the editor - async (filePath: string, isFromCA: boolean = false) => { - // TODO: vscode.window.activeTextEditor may be null - const fspath = filePath ? filePath : vscode.window.activeTextEditor!.document.uri.fsPath; - const fileName = path.basename(fspath); - if (isFromCA) { - record(context, TelemetryActions.componentAnalysisVulnerabilityReportQuickfixOption, { manifest: fileName, fileName: fileName }); - } - try { - await generateRHDAReport(context, fspath, outputChannelDep); - record(context, TelemetryActions.vulnerabilityReportDone, { manifest: fileName, fileName: fileName }); - } catch (error) { - // TODO: dont show raw message - const message = applySettingNameMappings((error as Error).message); - vscode.window.showErrorMessage(`RHDA error while analyzing ${filePath}: ${message}`); - outputChannelDep.error(buildLogErrorMessage((error as Error))); - record(context, TelemetryActions.vulnerabilityReportFailed, { manifest: fileName, fileName: fileName, error: message }); - } - } - ); - - const disposableStackLogsCommand = vscode.commands.registerCommand( - commands.STACK_LOGS_COMMAND, - () => { - if (outputChannelDep) { - outputChannelDep.show(); - } else { - vscode.window.showInformationMessage(StatusMessages.WIN_SHOW_LOGS); - } - } - ); - - const disposableTrackRecommendationAcceptance = vscode.commands.registerCommand( - commands.TRACK_RECOMMENDATION_ACCEPTANCE_COMMAND, - (dependency, fileName) => { - record(context, TelemetryActions.componentAnalysisRecommendationAccepted, { manifest: fileName, fileName: fileName, package: dependency.split('@')[0], version: dependency.split('@')[1] }); - - if (fileName === 'Dockerfile' || fileName === 'Containerfile') { - redirectToRedHatCatalog(); - } - if (fileName === 'pom.xml') { - showRHRepositoryRecommendationNotification(); - } - } - ); - - registerStackAnalysisCommands(context); - - const showVulnerabilityFoundPrompt = async (msg: string, filePath: vscode.Uri) => { - const fileName = path.basename(filePath.fsPath); - const selection = await vscode.window.showWarningMessage(`${msg}`, PromptText.FULL_STACK_PROMPT_TEXT); - if (selection === PromptText.FULL_STACK_PROMPT_TEXT) { - record(context, TelemetryActions.vulnerabilityReportPopupOpened, { manifest: fileName, fileName: fileName }); - vscode.commands.executeCommand(commands.STACK_ANALYSIS_COMMAND, filePath.fsPath); - } else { - record(context, TelemetryActions.vulnerabilityReportPopupIgnored, { manifest: fileName, fileName: fileName }); - } - }; - - notifications.on('caNotification', (respData: CANotificationData) => { - const notification = new CANotification(respData); - caStatusBarProvider.showSummary(notification.statusText(), notification.origin()); - if (notification.hasWarning()) { - showVulnerabilityFoundPrompt(notification.popupText(), notification.origin()); - record(context, TelemetryActions.componentAnalysisDone, { manifest: path.basename(notification.origin().fsPath), fileName: path.basename(notification.origin().fsPath) }); - } - }); - - notifications.on('caError', (errorData: CANotificationData) => { - const notification = new CANotification(errorData); - caStatusBarProvider.setError(); - - // Since CA is an automated feature, only warning message will be shown on failure - vscode.window.showWarningMessage(`RHDA error while analyzing ${errorData.uri.fsPath}: ${notification.errorMsg()}`); - - // Record telemetry event - record(context, TelemetryActions.componentAnalysisFailed, { manifest: path.basename(notification.origin().fsPath), fileName: path.basename(notification.origin().fsPath), error: notification.errorMsg() }); - }); - - try { - await globalConfig.authorizeRHDA(context); - } catch (err) { - vscode.window.showErrorMessage(`Failed to Authorize Red Hat Dependency Analytics extension: ${(err as Error).message}`); - throw err; - } - - context.subscriptions.push( - disposableLLMAnalysisReportCommand, - disposableStackAnalysisCommand, - disposableStackLogsCommand, - disposableTrackRecommendationAcceptance, - // disposableSetSnykToken, - caStatusBarProvider, - ); - - vscode.workspace.onDidChangeConfiguration(() => { - globalConfig.loadData(); - }); -} - -/** - * Deactivates the extension. - */ -export function deactivate(): Thenable { return new Promise(resolve => resolve()); } - -/** - * Shows an update notification if the extension has been updated to a new version. - * @param context - The extension context. - * @returns A Promise that resolves once the notification has been displayed if needed. - */ -async function showUpdateNotification(context: vscode.ExtensionContext) { - const packageJSON = vscode.extensions.getExtension(EXTENSION_QUALIFIED_ID)!.packageJSON; - const version = packageJSON.version; - const previousVersion = context.globalState.get(GlobalState.VERSION); - - if (version === previousVersion) { - return; - } - - context.globalState.update(GlobalState.VERSION, version); - - const result = await vscode.window.showInformationMessage( - `${packageJSON.displayName} has been updated to v${version} — check out what's new!`, - 'README', - 'Release Notes' - ); - - if (result !== undefined) { - if (result === 'README') { - await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(packageJSON.homepage)); - } else if (result === 'Release Notes') { - await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(`${packageJSON.repository.url}/releases/tag/v${version}`)); - } - } -} - -/** - * Redirects the user to the Red Hat certified image catalog website. - */ -function redirectToRedHatCatalog() { - vscode.env.openExternal(vscode.Uri.parse(REDHAT_CATALOG)); -} - -/** - * Shows a notification regarding Red Hat Dependency Analytics recommendations. - */ -function showRHRepositoryRecommendationNotification() { - const msg = 'Important: If you apply Red Hat Dependency Analytics recommendations, ' + - `make sure the Red Hat GA Repository (${REDHAT_MAVEN_REPOSITORY}) has been added to your project configuration. ` + - 'This ensures that the applied dependencies work correctly. ' + - `Learn how to add the repository: [Click here](${REDHAT_MAVEN_REPOSITORY_DOCUMENTATION_URL})`; - vscode.window.showWarningMessage(msg); -} - -/** - * Registers stack analysis commands to track RHDA report generations. - * @param context - The extension context. - */ -function registerStackAnalysisCommands(context: vscode.ExtensionContext) { - - const invokeFullStackReport = async (filePath: string) => { - const fileName = path.basename(filePath); - try { - await generateRHDAReport(context, filePath, outputChannelDep); - record(context, TelemetryActions.vulnerabilityReportDone, { manifest: fileName, fileName: fileName }); - } catch (error) { - const message = applySettingNameMappings((error as Error).message); - vscode.window.showErrorMessage(`RHDA error while analyzing ${filePath}: ${message}`); - outputChannelDep.error(buildLogErrorMessage((error as Error))); - record(context, TelemetryActions.vulnerabilityReportFailed, { manifest: fileName, fileName: fileName, error: message }); - } - }; - - const recordAndInvoke = (origin: string, uri: vscode.Uri) => { - // TODO: vscode.window.activeTextEditor may be null - const fileUri = uri || vscode.window.activeTextEditor!.document.uri; - const filePath = fileUri.fsPath; - record(context, origin, { manifest: path.basename(filePath), fileName: path.basename(filePath) }); - invokeFullStackReport(filePath); - }; - - const registerCommand = (cmd: string, action: TelemetryActions) => { - return vscode.commands.registerCommand(cmd, recordAndInvoke.bind(null, action)); - }; - - const stackAnalysisCommands = [ - registerCommand(commands.STACK_ANALYSIS_FROM_EDITOR_COMMAND, TelemetryActions.vulnerabilityReportEditor), - registerCommand(commands.STACK_ANALYSIS_FROM_EXPLORER_COMMAND, TelemetryActions.vulnerabilityReportExplorer), - registerCommand(commands.STACK_ANALYSIS_FROM_PIE_BTN_COMMAND, TelemetryActions.vulnerabilityReportPieBtn), - registerCommand(commands.STACK_ANALYSIS_FROM_STATUS_BAR_COMMAND, TelemetryActions.vulnerabilityReportStatusBar), - ]; - - context.subscriptions.push(...stackAnalysisCommands); + + const modelCardsInfo = await llmAnalysis(Array.from(modelWithLoc.keys())); + if (!modelCardsInfo) { + return; + } + + const rangeToModel = new Map(); + modelsInDocs.set(doc.uri, rangeToModel); + for (const [model, ranges] of modelWithLoc) { + for (const range of ranges) { + const modelInfo = modelCardsInfo.find(modelResponse => modelResponse.model_name === model); + if (!modelInfo) { + // log something here? + continue; + } + rangeToModel.set(range, modelInfo); + } + } + + // TODO: handle model with no data from API + for (const modelInfo of modelCardsInfo) { + const table = new CliTable3({ + head: ['Safety Metric', 'Score', 'Assessment'], + chars: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'top': '', 'top-mid': '', 'top-left': '', 'top-right': '', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'bottom': '', 'bottom-mid': '', 'bottom-left': '', 'bottom-right': '', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'left': '', 'left-mid': '', 'mid': '─', 'mid-mid': '', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'right': '', 'right-mid': '', 'middle': ' ' + }, + style: { compact: true, border: [], head: [] }, + }); + + for (const warning of modelInfo.metrics.filter(metric => metric.metric.startsWith('pct_') || metric.metric === 'acc')) { + table.push([`${warning.task}: ${warning.metric}`, warning.score.toFixed(3), warning.assessment]); + } + + for (const range of modelWithLoc.get(modelInfo.model_name)!) { + diagnostics.push({ + range: range, + message: table.toString() + `\n\nOpen the detailed report via Code Actions for further details & recommended guardrails.\n`, + severity: vscode.DiagnosticSeverity.Information, + source: 'Red Hat LLM Dependency Analytics', + code: `rhdallm` + }); + } + } + + llmAnalysisDiagnosticsCollection.set(doc.uri, diagnostics); + + { + const compareArrays = (a: string[], b: string[]) => a.length === b.length && a.every((element, index) => element === b[index]); + const knownModels = modelCardsInfo.map(model => model.model_name).sort(); + const previouslyReferencedModels = context.globalState.get(`rhda-llm-annotation-models:${doc.uri.fsPath}`, [] as string[]); + if (!compareArrays(previouslyReferencedModels, knownModels)) { + context.globalState.update(`rhda-llm-annotation-models:${doc.uri.fsPath}`, knownModels); + if (knownModels.length > 0) { + record(context, TelemetryActions.llmAnalysisModelAnnotationsDiscovered, { knownModelsReferenced: knownModels }); + } + } + } + }; + + vscode.workspace.textDocuments.forEach(doLLMAnalysis); + vscode.workspace.onDidOpenTextDocument(doLLMAnalysis); + vscode.workspace.onDidChangeTextDocument((event) => doLLMAnalysis(event.document)); + + const disposableLLMAnalysisReportCommand = vscode.commands.registerCommand( + commands.LLM_MODELS_ANALYSIS_REPORT, + async (model: string, uri: vscode.Uri, range: vscode.Range) => { + LLMAnalysisReportPanel.createOrShowPanel(); + // remove null check, better missing handling + await LLMAnalysisReportPanel.currentPanel?.updatePanel(modelsInDocs.get(uri)!.get(range)!.id); + record(context, TelemetryActions.llmAnalysisReportDone, { modelName: model }); + } + ); + + const disposableStackAnalysisCommand = vscode.commands.registerCommand( + commands.STACK_ANALYSIS_COMMAND, + // filePath must be string as this can be invoked from the editor + async (filePath: string, isFromCA: boolean = false) => { + // TODO: vscode.window.activeTextEditor may be null + const fspath = filePath ? filePath : vscode.window.activeTextEditor!.document.uri.fsPath; + const fileName = path.basename(fspath); + if (isFromCA) { + record(context, TelemetryActions.componentAnalysisVulnerabilityReportQuickfixOption, { manifest: fileName, fileName: fileName }); + } + try { + await generateRHDAReport(context, tokenProvider, fspath, outputChannelDep); + record(context, TelemetryActions.vulnerabilityReportDone, { manifest: fileName, fileName: fileName }); + } catch (error) { + // TODO: dont show raw message + const message = applySettingNameMappings((error as Error).message); + vscode.window.showErrorMessage(`RHDA error while analyzing ${filePath}: ${message}`); + outputChannelDep.error(buildLogErrorMessage((error as Error))); + record(context, TelemetryActions.vulnerabilityReportFailed, { manifest: fileName, fileName: fileName, error: message }); + } + } + ); + + const disposableStackLogsCommand = vscode.commands.registerCommand( + commands.STACK_LOGS_COMMAND, + () => { + if (outputChannelDep) { + outputChannelDep.show(); + } else { + vscode.window.showInformationMessage(StatusMessages.WIN_SHOW_LOGS); + } + } + ); + + const disposableTrackRecommendationAcceptance = vscode.commands.registerCommand( + commands.TRACK_RECOMMENDATION_ACCEPTANCE_COMMAND, + (dependency, fileName) => { + record(context, TelemetryActions.componentAnalysisRecommendationAccepted, { manifest: fileName, fileName: fileName, package: dependency.split('@')[0], version: dependency.split('@')[1] }); + + if (fileName === 'Dockerfile' || fileName === 'Containerfile') { + redirectToRedHatCatalog(); + } + if (fileName === 'pom.xml') { + showRHRepositoryRecommendationNotification(); + } + } + ); + + registerStackAnalysisCommands(context, tokenProvider); + + const showVulnerabilityFoundPrompt = async (msg: string, filePath: vscode.Uri) => { + const fileName = path.basename(filePath.fsPath); + const selection = await vscode.window.showWarningMessage(`${msg}`, PromptText.FULL_STACK_PROMPT_TEXT); + if (selection === PromptText.FULL_STACK_PROMPT_TEXT) { + record(context, TelemetryActions.vulnerabilityReportPopupOpened, { manifest: fileName, fileName: fileName }); + vscode.commands.executeCommand(commands.STACK_ANALYSIS_COMMAND, filePath.fsPath); + } else { + record(context, TelemetryActions.vulnerabilityReportPopupIgnored, { manifest: fileName, fileName: fileName }); + } + }; + + notifications.on('caNotification', (respData: CANotificationData) => { + const notification = new CANotification(respData); + caStatusBarProvider.showSummary(notification.statusText(), notification.origin()); + if (notification.hasWarning()) { + showVulnerabilityFoundPrompt(notification.popupText(), notification.origin()); + record(context, TelemetryActions.componentAnalysisDone, { manifest: path.basename(notification.origin().fsPath), fileName: path.basename(notification.origin().fsPath) }); + } + }); + + notifications.on('caError', (errorData: CANotificationData) => { + const notification = new CANotification(errorData); + caStatusBarProvider.setError(); + + // Since CA is an automated feature, only warning message will be shown on failure + vscode.window.showWarningMessage(`RHDA error while analyzing ${errorData.uri.fsPath}: ${notification.errorMsg()}`); + + // Record telemetry event + record(context, TelemetryActions.componentAnalysisFailed, { manifest: path.basename(notification.origin().fsPath), fileName: path.basename(notification.origin().fsPath), error: notification.errorMsg() }); + }); + + try { + await globalConfig.authorizeRHDA(context); + } catch (err) { + vscode.window.showErrorMessage(`Failed to Authorize Red Hat Dependency Analytics extension: ${(err as Error).message}`); + throw err; + } + + context.subscriptions.push( + disposableLLMAnalysisReportCommand, + disposableStackAnalysisCommand, + disposableStackLogsCommand, + disposableTrackRecommendationAcceptance, + // disposableSetSnykToken, + caStatusBarProvider, + ); + + vscode.workspace.onDidChangeConfiguration(() => { + globalConfig.loadData(); + }); +} + +export async function activate(context: vscode.ExtensionContext): Promise { + outputChannelDep = new DepOutputChannel(); + outputChannelDep.info(`starting RHDA extension ${context.extension.packageJSON['version']}`); + + globalConfig.linkToSecretStorage(context); + + initTelemetry(context); + + const tokenProvider = new VSCodeTokenProvider(context); + + // Register authentication command (always available) + const authenticateCommand = vscode.commands.registerCommand('rhda.authenticate', async () => { + const existingToken = await getValidAccessToken(context); + if (existingToken) { + vscode.window.showInformationMessage('RHDA: Already authenticated!'); + caStatusBarProvider.showAuthenticated(); + return; + } + + try { + outputChannelDep.info('🔄 Starting authentication flow...'); + await performOIDCAuthorizationFlow(context); + } catch (error) { + outputChannelDep.error(`Authentication failed: ${buildLogErrorMessage(error as Error)}`); + vscode.window.showErrorMessage(`RHDA: Authentication failed. ${buildNotificationErrorMessage(error as Error)}`); + caStatusBarProvider.showAuthRequired(); // Keep showing auth required if failed + } + }); + context.subscriptions.push(authenticateCommand); + + // Check if user is already authenticated (optional) + const existingToken = await getValidAccessToken(context); + if (existingToken) { + outputChannelDep.info('User authenticated'); + caStatusBarProvider.showAuthenticated(); + } else { + outputChannelDep.info('User not authenticated (authentication is optional)'); + caStatusBarProvider.showAuthRequired(); + + const neverShowAuthPrompt = context.globalState.get('rhda-dont-show-auth-prompt', false); + if (!neverShowAuthPrompt) { + vscode.window.showInformationMessage( + 'RHDA: You can optionally authenticate for enhanced features.', + 'Authenticate', + 'Do not show again' + ).then(selection => { + if (selection === 'Authenticate') { + performOIDCAuthorizationFlow(context).catch(error => { + outputChannelDep.error(`Authentication failed: ${buildLogErrorMessage(error)}`); + vscode.window.showErrorMessage(`RHDA: Authentication failed. ${buildNotificationErrorMessage(error as Error)}`); + }); + } else if (selection === 'Do not show again') { + context.globalState.update('rhda-dont-show-auth-prompt', true); + outputChannelDep.info('User chose to never show authentication prompt again'); + } + }); + } + } + + await enableExtensionFeatures(context, tokenProvider); + + return context; +} + +/** + * Deactivates the extension. + */ +export function deactivate(): Thenable { return new Promise(resolve => resolve()); } + +/** + * Shows an update notification if the extension has been updated to a new version. + * @param context - The extension context. + * @returns A Promise that resolves once the notification has been displayed if needed. + */ +async function showUpdateNotification(context: vscode.ExtensionContext) { + const packageJSON = vscode.extensions.getExtension(EXTENSION_QUALIFIED_ID)!.packageJSON; + const version = packageJSON.version; + const previousVersion = context.globalState.get(GlobalState.VERSION); + + if (version === previousVersion) { + return; + } + + context.globalState.update(GlobalState.VERSION, version); + + const result = await vscode.window.showInformationMessage( + `${packageJSON.displayName} has been updated to v${version} — check out what's new!`, + 'README', + 'Release Notes' + ); + + if (result !== undefined) { + if (result === 'README') { + await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(packageJSON.homepage)); + } else if (result === 'Release Notes') { + await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(`${packageJSON.repository.url}/releases/tag/v${version}`)); + } + } +} + +/** + * Redirects the user to the Red Hat certified image catalog website. + */ +function redirectToRedHatCatalog() { + vscode.env.openExternal(vscode.Uri.parse(REDHAT_CATALOG)); +} + +/** + * Shows a notification regarding Red Hat Dependency Analytics recommendations. + */ +function showRHRepositoryRecommendationNotification() { + const msg = 'Important: If you apply Red Hat Dependency Analytics recommendations, ' + + `make sure the Red Hat GA Repository (${REDHAT_MAVEN_REPOSITORY}) has been added to your project configuration. ` + + 'This ensures that the applied dependencies work correctly. ' + + `Learn how to add the repository: [Click here](${REDHAT_MAVEN_REPOSITORY_DOCUMENTATION_URL})`; + vscode.window.showWarningMessage(msg); +} + +/** + * Registers stack analysis commands to track RHDA report generations. + * @param context - The extension context. + */ +function registerStackAnalysisCommands(context: vscode.ExtensionContext, tokenProvider: TokenProvider) { + + const invokeFullStackReport = async (filePath: string) => { + const fileName = path.basename(filePath); + try { + await generateRHDAReport(context, tokenProvider, filePath, outputChannelDep); + record(context, TelemetryActions.vulnerabilityReportDone, { manifest: fileName, fileName: fileName }); + } catch (error) { + const message = applySettingNameMappings((error as Error).message); + vscode.window.showErrorMessage(`RHDA error while analyzing ${filePath}: ${message}`); + outputChannelDep.error(buildLogErrorMessage((error as Error))); + record(context, TelemetryActions.vulnerabilityReportFailed, { manifest: fileName, fileName: fileName, error: message }); + } + }; + + const recordAndInvoke = (origin: string, uri: vscode.Uri) => { + // TODO: vscode.window.activeTextEditor may be null + const fileUri = uri || vscode.window.activeTextEditor!.document.uri; + const filePath = fileUri.fsPath; + record(context, origin, { manifest: path.basename(filePath), fileName: path.basename(filePath) }); + invokeFullStackReport(filePath); + }; + + const registerCommand = (cmd: string, action: TelemetryActions) => { + return vscode.commands.registerCommand(cmd, recordAndInvoke.bind(null, action)); + }; + + const stackAnalysisCommands = [ + registerCommand(commands.STACK_ANALYSIS_FROM_EDITOR_COMMAND, TelemetryActions.vulnerabilityReportEditor), + registerCommand(commands.STACK_ANALYSIS_FROM_EXPLORER_COMMAND, TelemetryActions.vulnerabilityReportExplorer), + registerCommand(commands.STACK_ANALYSIS_FROM_PIE_BTN_COMMAND, TelemetryActions.vulnerabilityReportPieBtn), + registerCommand(commands.STACK_ANALYSIS_FROM_STATUS_BAR_COMMAND, TelemetryActions.vulnerabilityReportStatusBar), + ]; + + context.subscriptions.push(...stackAnalysisCommands); } \ No newline at end of file diff --git a/src/fileHandler.ts b/src/fileHandler.ts index 3e8c5b3f..98d53a45 100644 --- a/src/fileHandler.ts +++ b/src/fileHandler.ts @@ -10,37 +10,44 @@ import { DependencyProvider as BuildGradle } from './providers/build.gradle'; import { ImageProvider as Docker } from './providers/docker'; import { globalConfig } from './config'; import { DepOutputChannel } from './depOutputChannel'; +import { TokenProvider } from './tokenProvider'; export class AnalysisMatcher { + tokenProvider: TokenProvider; + + constructor(tokenProvider: TokenProvider) { + this.tokenProvider = tokenProvider; + } + matchers: Array<{ scheme: string, pattern: RegExp, callback: (path: Uri, contents: string) => Promise }> = [ { scheme: 'file', pattern: new RegExp('^package\\.json$'), callback: (path: Uri, contents: string) => { - return dependencyDiagnostics.performDiagnostics(path, contents, new PackageJson()); + return dependencyDiagnostics.performDiagnostics(this.tokenProvider, path, contents, new PackageJson()); } }, { scheme: 'file', pattern: new RegExp('^pom\\.xml$'), callback: (path: Uri, contents: string) => { - return dependencyDiagnostics.performDiagnostics(path, contents, new PomXml()); + return dependencyDiagnostics.performDiagnostics(this.tokenProvider, path, contents, new PomXml()); } }, { scheme: 'file', pattern: new RegExp('^go\\.mod$'), callback: (path: Uri, contents: string) => { - return dependencyDiagnostics.performDiagnostics(path, contents, new GoMod()); + return dependencyDiagnostics.performDiagnostics(this.tokenProvider, path, contents, new GoMod()); } }, { scheme: 'file', pattern: new RegExp('^requirements\\.txt$'), callback: (path: Uri, contents: string) => { - return dependencyDiagnostics.performDiagnostics(path, contents, new RequirementsTxt()); + return dependencyDiagnostics.performDiagnostics(this.tokenProvider, path, contents, new RequirementsTxt()); } }, { scheme: 'file', pattern: new RegExp('^build\\.gradle$'), callback: (path: Uri, contents: string) => { - return dependencyDiagnostics.performDiagnostics(path, contents, new BuildGradle()); + return dependencyDiagnostics.performDiagnostics(this.tokenProvider, path, contents, new BuildGradle()); } }, { scheme: 'file', pattern: new RegExp('^(Dockerfile|Containerfile)$'), callback: (path: Uri, contents: string) => { - return imageDiagnostics.performDiagnostics(path, contents, new Docker()); + return imageDiagnostics.performDiagnostics(this.tokenProvider, path, contents, new Docker()); } } ]; diff --git a/src/imageAnalysis.ts b/src/imageAnalysis.ts index f3e65ea9..5f63317d 100644 --- a/src/imageAnalysis.ts +++ b/src/imageAnalysis.ts @@ -11,12 +11,14 @@ import { Options } from '@trustification/exhort-javascript-api'; import { updateCurrentWebviewPanel } from './rhda'; import { buildLogErrorMessage } from './utils'; import { DepOutputChannel } from './depOutputChannel'; +import { TokenProvider } from './tokenProvider'; /** * Represents options for image analysis. */ interface IOptions extends Options { RHDA_TOKEN: string; + RHDA_TELEMETRY_ID: string; RHDA_SOURCE: string; EXHORT_SYFT_PATH: string; EXHORT_SYFT_CONFIG_PATH: string; @@ -156,7 +158,7 @@ class DockerImageAnalysis { /** * Runs the image analysis process. */ - public async runImageAnalysis() { + public async runImageAnalysis(tokenProvider: TokenProvider) { return await vscode.window.withProgress({ location: vscode.ProgressLocation.Window, title: Titles.EXT_TITLE }, async p => { p.report({ message: StatusMessages.WIN_ANALYZING_DEPENDENCIES }); @@ -173,7 +175,8 @@ class DockerImageAnalysis { } const options: IOptions = { - 'RHDA_TOKEN': globalConfig.telemetryId ?? '', + 'RHDA_TOKEN': await tokenProvider.getToken() ?? '', + 'RHDA_TELEMETRY_ID': globalConfig.telemetryId ?? '', 'RHDA_SOURCE': globalConfig.utmSource, 'EXHORT_SYFT_PATH': globalConfig.exhortSyftPath, 'EXHORT_SYFT_CONFIG_PATH': globalConfig.exhortSyftConfigPath, @@ -214,9 +217,9 @@ class DockerImageAnalysis { * @param filePath - The path to the image file to analyze. * @returns A Promise resolving to an Analysis Report HTML. */ -async function executeDockerImageAnalysis(filePath: string, outputChannel: DepOutputChannel): Promise { +async function executeDockerImageAnalysis(tokenProvider: TokenProvider, filePath: string, outputChannel: DepOutputChannel): Promise { const dockerImageAnalysis = new DockerImageAnalysis(filePath, outputChannel); - await dockerImageAnalysis.runImageAnalysis(); + await dockerImageAnalysis.runImageAnalysis(tokenProvider); return dockerImageAnalysis.imageAnalysisReportHtml; } diff --git a/src/imageAnalysis/diagnostics.ts b/src/imageAnalysis/diagnostics.ts index d6f2246c..2d7c384f 100644 --- a/src/imageAnalysis/diagnostics.ts +++ b/src/imageAnalysis/diagnostics.ts @@ -14,6 +14,7 @@ import { Diagnostic, DiagnosticSeverity, Uri } from 'vscode'; import { notifications, outputChannelDep } from '../extension'; import { globalConfig } from '../config'; import { type IOptions } from '../imageAnalysis'; +import { TokenProvider } from '../tokenProvider'; /** * Implementation of DiagnosticsPipeline interface. @@ -88,10 +89,11 @@ class DiagnosticsPipeline extends AbstractDiagnosticsPipeline { * @param provider - The image provider of the corresponding ecosystem. * @returns A Promise that resolves when diagnostics are completed. */ -async function performDiagnostics(diagnosticFilePath: Uri, contents: string, provider: IImageProvider) { +async function performDiagnostics(tokenProvider: TokenProvider, diagnosticFilePath: Uri, contents: string, provider: IImageProvider) { try { const options: IOptions = { - 'RHDA_TOKEN': globalConfig.telemetryId ?? '', + 'RHDA_TOKEN': await tokenProvider.getToken() ?? '', + 'RHDA_TELEMETRY_ID': globalConfig.telemetryId ?? '', 'RHDA_SOURCE': globalConfig.utmSource, 'EXHORT_SYFT_PATH': globalConfig.exhortSyftPath, 'EXHORT_SYFT_CONFIG_PATH': globalConfig.exhortSyftConfigPath, diff --git a/src/oidcAuthentication.ts b/src/oidcAuthentication.ts new file mode 100644 index 00000000..e8c3ac8c --- /dev/null +++ b/src/oidcAuthentication.ts @@ -0,0 +1,247 @@ +import * as vscode from 'vscode'; + +import * as client from 'openid-client'; +import { outputChannelDep } from './extension'; +import { record, TelemetryActions } from './redhatTelemetry'; +import { caStatusBarProvider } from './caStatusBarProvider'; +import { globalConfig } from './config'; + +// OIDC flow state storage +interface OIDCFlowState { + codeVerifier: string; + state?: string; + nonce: string; +} + +const callbackUrl = 'vscode://redhat.fabric8-analytics/auth-callback'; + +/** + * Performs OIDC Authorization Code Flow with VSCode URL handler + */ +export async function performOIDCAuthorizationFlow(context: vscode.ExtensionContext): Promise { + const clientId = globalConfig.oidcClientId; + const realmUrl = globalConfig.oidcRealmUrl; + + try { + let config: client.Configuration | undefined; + + try { + config = await client.discovery( + new URL(realmUrl), + clientId, + undefined, + undefined, + { + execute: globalConfig.oidcAllowInsecure ? [client.allowInsecureRequests] : [], + } + ); + outputChannelDep.info(`Discovery successful for: ${realmUrl}`); + } catch (error) { + outputChannelDep.info(`Discovery failed for ${realmUrl}: ${error}`); + } + + if (!config) { + throw new Error(`Could not discover OIDC configuration from ${realmUrl}`); + } + + outputChannelDep.debug(`Using issuer: ${realmUrl}`); + outputChannelDep.debug(`Authorization endpoint: ${config.serverMetadata().authorization_endpoint}`); + + // Generate PKCE parameters + const codeVerifier = client.randomPKCECodeVerifier(); + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + const nonce = client.randomNonce(); + + let state: string | undefined; + const parameters: Record = { + // eslint-disable-next-line @typescript-eslint/naming-convention + redirect_uri: callbackUrl, + scope: 'openid profile email', + // eslint-disable-next-line @typescript-eslint/naming-convention + code_challenge: codeChallenge, + // eslint-disable-next-line @typescript-eslint/naming-convention + code_challenge_method: 'S256', + nonce: nonce, + }; + + // Use state if PKCE is not supported + if (!config.serverMetadata().supportsPKCE()) { + state = client.randomState(); + parameters.state = state; + } + + // Store flow state for callback verification + const flowState: OIDCFlowState = { + codeVerifier, + state, + nonce, + }; + await context.globalState.update('rhda-oidc-flow-state', flowState); + + // Register URL handler for the callback + const disposable = vscode.window.registerUriHandler({ + handleUri: async (uri: vscode.Uri) => { + try { + await handleOIDCCallback(context, config, uri, flowState); + } catch (error) { + outputChannelDep.error(`OIDC callback error: ${error}`); + vscode.window.showErrorMessage(`RHDA authentication failed: ${(error as Error).message}`); + } finally { + disposable.dispose(); + await context.globalState.update('rhda-oidc-flow-state', undefined); + } + }, + }); + + // Build authorization URL and redirect user + outputChannelDep.debug(`Building authorization URL with parameters: ${JSON.stringify(parameters)}`); + const authUrl = client.buildAuthorizationUrl(config, parameters); + outputChannelDep.debug(`Complete authorization URL: ${authUrl.href}`); + + // Open authorization URL in browser + await vscode.env.openExternal(vscode.Uri.parse(authUrl.href)); + + vscode.window.showInformationMessage('Please complete RHDA authentication in your browser.'); + } catch (error) { + outputChannelDep.error(`OIDC flow initialization error: ${error}`); + vscode.window.showErrorMessage(`RHDA authentication initialization failed: ${(error as Error).message}`); + } +} + +/** + * Handles the OIDC callback from VSCode URL handler + */ +async function handleOIDCCallback(context: vscode.ExtensionContext, config: client.Configuration, callbackUri: vscode.Uri, flowState: OIDCFlowState): Promise { + try { + const url = new URL(callbackUri.toString()); + // Handle double-encoded query parameters from VSCode + const searchParams = new URLSearchParams(url.searchParams.keys().next().value!); + + // Create a simple URL with just the base and our clean parameters + const cleanCallbackUrl = new URL(`${callbackUrl}?${searchParams.toString()}`); + outputChannelDep.debug(`Clean callback URL: ${cleanCallbackUrl.toString()}`); + + // First try the standard approach + const tokens = await client.authorizationCodeGrant( + config, + cleanCallbackUrl, + { + pkceCodeVerifier: flowState.codeVerifier, + expectedState: flowState.state, + expectedNonce: flowState.nonce, + } + ); + + outputChannelDep.info('Successfully obtained tokens from OIDC authorization server'); + + // Store tokens securely + if (tokens.access_token) { + await context.secrets.store('rhda-oidc-access-token', tokens.access_token); + } + if (tokens.refresh_token) { + await context.secrets.store('rhda-oidc-refresh-token', tokens.refresh_token); + } + if (tokens.id_token) { + await context.secrets.store('rhda-oidc-id-token', tokens.id_token); + } + + // Store token expiration time + if (tokens.expires_in) { + const expirationTime = Date.now() + tokens.expires_in * 1000; + await context.globalState.update('rhda-oidc-token-expiration', expirationTime); + } + + vscode.window.showInformationMessage('RHDA authentication successful!'); + + // Record successful authentication telemetry + record(context, TelemetryActions.vulnerabilityReportDone, { + action: 'oidc-authentication-success', + }); + + // Update status bar to show authenticated state + outputChannelDep.info('Authentication complete'); + caStatusBarProvider.showAuthenticated(); + } catch (error) { + outputChannelDep.error(`Token exchange failed: ${error}`); + throw new Error(`Failed to complete authentication: ${(error as Error).message}`); + } +} + +/** + * Retrieves a valid access token, refreshing if necessary + */ +export async function getValidAccessToken(context: vscode.ExtensionContext): Promise { + try { + const accessToken = await context.secrets.get('rhda-oidc-access-token'); + const expirationTime = context.globalState.get('rhda-oidc-token-expiration'); + + if (!accessToken) { + // No token means user hasn't authenticated yet + return null; + } + + // Check if token is expired (with 5 minute buffer) + if (expirationTime && Date.now() > expirationTime - 300000) { + const refreshToken = await context.secrets.get('rhda-oidc-refresh-token'); + if (refreshToken) { + return await refreshAccessToken(context, refreshToken); + } + return null; + } + + return accessToken; + } catch (error) { + outputChannelDep.error(`Failed to get access token: ${error}`); + return null; + } +} + +/** + * Refreshes the access token using the refresh token + */ +async function refreshAccessToken(context: vscode.ExtensionContext, refreshToken: string): Promise { + const clientId = globalConfig.oidcClientId; + const realmUrl = globalConfig.oidcRealmUrl; + + try { + const config = await client.discovery( + new URL(realmUrl), + clientId, + undefined, + undefined, + { + execute: globalConfig.oidcAllowInsecure ? [client.allowInsecureRequests] : [], + } + ); + + const tokens = await client.refreshTokenGrant(config, refreshToken); + + // Update stored tokens + if (tokens.access_token) { + await context.secrets.store('rhda-oidc-access-token', tokens.access_token); + } + if (tokens.refresh_token) { + await context.secrets.store('rhda-oidc-refresh-token', tokens.refresh_token); + } + + // Update token expiration time + if (tokens.expires_in) { + const expirationTime = Date.now() + tokens.expires_in * 1000; + await context.globalState.update('rhda-oidc-token-expiration', expirationTime); + } + + outputChannelDep.info('Successfully refreshed access token'); + return tokens.access_token || null; + } catch (error) { + outputChannelDep.error(`Token refresh failed: ${error}`); + // Clear invalid tokens + await context.secrets.delete('rhda-oidc-access-token'); + await context.secrets.delete('rhda-oidc-refresh-token'); + await context.secrets.delete('rhda-oidc-id-token'); + await context.globalState.update('rhda-oidc-token-expiration', undefined); + + // Update status bar to show session expired + caStatusBarProvider.showSessionExpired(); + return null; + } +} \ No newline at end of file diff --git a/src/redhatTelemetry.ts b/src/redhatTelemetry.ts index 5952cb3e..49987de6 100644 --- a/src/redhatTelemetry.ts +++ b/src/redhatTelemetry.ts @@ -59,7 +59,7 @@ async function record(context: vscode.ExtensionContext, eventName: string, prope * @param context The extension context. * @returns A promise that resolves once the even has been sent. */ -async function startUp(context: vscode.ExtensionContext) { +async function initTelemetry(context: vscode.ExtensionContext) { telemetryServiceObj = await telemetryService(context); await telemetryServiceObj?.sendStartupEvent(); } @@ -76,4 +76,4 @@ async function getTelemetryId(context: vscode.ExtensionContext) { return telemetryId; } -export { TelemetryActions, record, startUp, getTelemetryId }; \ No newline at end of file +export { TelemetryActions, record, initTelemetry, getTelemetryId }; \ No newline at end of file diff --git a/src/rhda.ts b/src/rhda.ts index 3c36e709..d5c860f9 100644 --- a/src/rhda.ts +++ b/src/rhda.ts @@ -9,6 +9,7 @@ import { DependencyReportPanel } from './dependencyReportPanel'; import { globalConfig } from './config'; import { executeDockerImageAnalysis } from './imageAnalysis'; import { DepOutputChannel } from './depOutputChannel'; +import { TokenProvider } from './tokenProvider'; /** * Represents supported file types for analysis. @@ -97,15 +98,15 @@ async function writeReportToFile(data: string) { * @param filePath The path of the file for analysis. * @returns A promise that resolves once the report generation is complete. */ -async function generateRHDAReport(context: vscode.ExtensionContext, filePath: string, outputChannel: DepOutputChannel) { +async function generateRHDAReport(context: vscode.ExtensionContext, tokenProvider: TokenProvider, filePath: string, outputChannel: DepOutputChannel) { const fileType = getFileType(filePath); if (fileType) { await triggerWebviewPanel(context); let resp: string; if (fileType === 'docker') { - resp = await executeDockerImageAnalysis(filePath, outputChannel); + resp = await executeDockerImageAnalysis(tokenProvider, filePath, outputChannel); } else { - resp = await executeStackAnalysis(filePath, outputChannel); + resp = await executeStackAnalysis(tokenProvider, filePath, outputChannel); } /* istanbul ignore else */ if (DependencyReportPanel.currentPanel) { diff --git a/src/stackAnalysis.ts b/src/stackAnalysis.ts index e84b9f37..1b222cd8 100644 --- a/src/stackAnalysis.ts +++ b/src/stackAnalysis.ts @@ -9,19 +9,21 @@ import { updateCurrentWebviewPanel } from './rhda'; import { buildLogErrorMessage } from './utils'; import { DepOutputChannel } from './depOutputChannel'; import { Options } from '@trustification/exhort-javascript-api'; +import { TokenProvider } from './tokenProvider'; /** * Executes the RHDA stack analysis process. * @param manifestFilePath The file path to the manifest file for analysis. * @returns The stack analysis response string. */ -export async function executeStackAnalysis(manifestFilePath: string, outputChannel: DepOutputChannel): Promise { +export async function executeStackAnalysis(tokenProvider: TokenProvider, manifestFilePath: string, outputChannel: DepOutputChannel): Promise { return await vscode.window.withProgress({ location: vscode.ProgressLocation.Window, title: Titles.EXT_TITLE }, async p => { p.report({ message: StatusMessages.WIN_ANALYZING_DEPENDENCIES }); // set up configuration options for the stack analysis request const options: Options = { - 'RHDA_TOKEN': globalConfig.telemetryId, + 'RHDA_TOKEN': await tokenProvider.getToken() ?? '', + 'RHDA_TELEMETRY_ID': globalConfig.telemetryId, 'RHDA_SOURCE': globalConfig.utmSource, 'MATCH_MANIFEST_VERSIONS': globalConfig.matchManifestVersions, 'EXHORT_PYTHON_VIRTUAL_ENV': globalConfig.usePythonVirtualEnvironment, diff --git a/src/tokenProvider.ts b/src/tokenProvider.ts new file mode 100644 index 00000000..703ea00f --- /dev/null +++ b/src/tokenProvider.ts @@ -0,0 +1,26 @@ +import * as vscode from 'vscode'; +import { getValidAccessToken } from './oidcAuthentication'; + +export interface TokenProvider { + getToken(): Promise; +} + +export class VSCodeTokenProvider implements TokenProvider { + constructor(private context: vscode.ExtensionContext) { } + + async getToken(): Promise { + return await getValidAccessToken(this.context); + } +} + +export class MockTokenProvider implements TokenProvider { + constructor(private token: string | null = null) { } + + async getToken(): Promise { + return this.token; + } + + setToken(token: string | null): void { + this.token = token; + } +} diff --git a/src/tokenValidation.ts b/src/tokenValidation.ts index c44d6916..2f7f145e 100644 --- a/src/tokenValidation.ts +++ b/src/tokenValidation.ts @@ -5,16 +5,19 @@ import * as vscode from 'vscode'; import { globalConfig } from './config'; import { SNYK_URL } from './constants'; import { tokenValidationService } from './exhortServices'; +import { Options } from '@trustification/exhort-javascript-api'; +import { TokenProvider } from './tokenProvider'; /** * Validates the Snyk token using the Exhort token validation service. * @returns A Promise that resolves when token has been validated. */ -async function validateSnykToken(token: string): Promise { +async function validateSnykToken(tokenProvider: TokenProvider, token: string): Promise { if (token !== '') { // set up configuration options for the token validation request - const options = { - 'RHDA_TOKEN': globalConfig.telemetryId ?? '', + const options: Options = { + 'RHDA_TOKEN': await tokenProvider.getToken() ?? '', + 'RHDA_TELEMETRY_ID': globalConfig.telemetryId ?? '', 'RHDA_SOURCE': globalConfig.utmSource, 'EXHORT_SNYK_TOKEN': token }; diff --git a/test/fileHandler.test.ts b/test/fileHandler.test.ts index 0a172b6d..d6b80ca3 100644 --- a/test/fileHandler.test.ts +++ b/test/fileHandler.test.ts @@ -7,11 +7,12 @@ import * as sinonChai from 'sinon-chai'; import { AnalysisMatcher } from '../src/fileHandler'; import { DepOutputChannel } from '../src/depOutputChannel'; import * as path from 'path'; +import { MockTokenProvider } from '../src/tokenProvider'; const expect = chai.expect; chai.use(sinonChai); -suite('File Handler', () => { +suite('File Handler', async () => { let sandbox: sinon.SinonSandbox; setup(() => { @@ -22,7 +23,7 @@ suite('File Handler', () => { sandbox.restore(); }); test('test file handler exclusion', async () => { - const fileHandler = new AnalysisMatcher(); + const fileHandler = new AnalysisMatcher(new MockTokenProvider()); vscode.workspace.getConfiguration('redHatDependencyAnalytics').update('exclude', ['**/requirements.txt']); diff --git a/test/imageAnalysis.test.ts b/test/imageAnalysis.test.ts index eb9c3d40..ae6665db 100644 --- a/test/imageAnalysis.test.ts +++ b/test/imageAnalysis.test.ts @@ -9,12 +9,13 @@ import { globalConfig } from '../src/config'; import { executeDockerImageAnalysis } from '../src/imageAnalysis'; import * as rhda from '../src/rhda'; import { DepOutputChannel } from '../src/depOutputChannel'; +import { MockTokenProvider } from '../src/tokenProvider'; const expect = chai.expect; chai.use(sinonChai); const outputChannel = new DepOutputChannel('test'); -suite('ImageAnalysis module', () => { +suite('ImageAnalysis module', async () => { let sandbox: sinon.SinonSandbox; const mockPath = '/mock/path/to/file'; const mockReponse = ' mockResponse '; @@ -49,7 +50,7 @@ FROM scratch sandbox.stub(fs, 'readFileSync').returns(encodedMockFileContent); const updateCurrentWebviewPanelSpy = sandbox.spy(rhda, 'updateCurrentWebviewPanel'); - const response = await executeDockerImageAnalysis(mockPath, outputChannel); + const response = await executeDockerImageAnalysis(new MockTokenProvider(), mockPath, outputChannel); expect(imageAnalysisServiceStub.calledOnce).to.be.true; expect(response).to.eq(mockReponse); @@ -61,7 +62,7 @@ FROM scratch sandbox.stub(fs, 'readFileSync').returns(encodedMockFileContent); const updateCurrentWebviewPanelSpy = sandbox.spy(rhda, 'updateCurrentWebviewPanel'); - await executeDockerImageAnalysis(mockPath, outputChannel) + await executeDockerImageAnalysis(new MockTokenProvider(), mockPath, outputChannel) .then(() => { throw (new Error('should have thrown error')); }) @@ -76,7 +77,7 @@ FROM scratch sandbox.stub(fs, 'readFileSync').throws(new Error('Mock Error')); const updateCurrentWebviewPanelSpy = sandbox.spy(rhda, 'updateCurrentWebviewPanel'); - await executeDockerImageAnalysis(mockPath, outputChannel) + await executeDockerImageAnalysis(new MockTokenProvider(), mockPath, outputChannel) .then(() => { throw (new Error('should have thrown error')); }) diff --git a/test/imageAnalysis/collector.test.ts b/test/imageAnalysis/collector.test.ts index a7c09cb2..96573c75 100644 --- a/test/imageAnalysis/collector.test.ts +++ b/test/imageAnalysis/collector.test.ts @@ -51,6 +51,7 @@ suite('Image Analysis Collector tests', () => { reqImages.forEach(image => image.platform = 'linux/amd64'); const options: IOptions = { + 'RHDA_TELEMETRY_ID': globalConfig.telemetryId ?? '', 'RHDA_TOKEN': globalConfig.telemetryId ?? '', 'RHDA_SOURCE': globalConfig.utmSource, 'EXHORT_SYFT_PATH': globalConfig.exhortSyftPath, diff --git a/test/redhatTelemetry.test.ts b/test/redhatTelemetry.test.ts index 4a4ab1ef..4732f04a 100644 --- a/test/redhatTelemetry.test.ts +++ b/test/redhatTelemetry.test.ts @@ -49,7 +49,7 @@ suite('RedhatTelemetry module', async () => { }); test('should send statup telemetry event', async () => { - await redhatTelemetryRewire.startUp({}); + await redhatTelemetryRewire.initTelemetry({}); expect(sendEventMock.sendStartupEvent).to.have.been.calledOnce; }); diff --git a/test/rhda.test.ts b/test/rhda.test.ts index ebedf953..526b6d52 100644 --- a/test/rhda.test.ts +++ b/test/rhda.test.ts @@ -12,6 +12,7 @@ import * as rhda from '../src/rhda'; import { context } from './vscontext.mock'; import { DependencyReportPanel } from '../src/dependencyReportPanel'; import { DepOutputChannel } from '../src/depOutputChannel'; +import { MockTokenProvider } from '../src/tokenProvider'; const expect = chai.expect; chai.use(sinonChai); @@ -54,7 +55,7 @@ suite('RHDA module', () => { const imageAnalysisServiceStub = sandbox.stub(imageAnalysis, 'executeDockerImageAnalysis').resolves(mockReponse); const showInformationMessageSpy = sandbox.spy(vscode.window, 'showInformationMessage'); - await rhda.generateRHDAReport(context, unsupportedFilePath, outputChannel); + await rhda.generateRHDAReport(context, new MockTokenProvider(), unsupportedFilePath, outputChannel); expect(authorizeRHDAStub.calledOnce).to.be.false; expect(stackAnalysisServiceStub.calledOnce).to.be.false; @@ -67,7 +68,7 @@ suite('RHDA module', () => { const stackAnalysisServiceStub = sandbox.stub(stackAnalysis, 'executeStackAnalysis').resolves(mockReponse); const writeFileStub = sandbox.stub(fs.promises, 'writeFile').resolves(undefined); - await rhda.generateRHDAReport(context, mockGoPath, outputChannel); + await rhda.generateRHDAReport(context, new MockTokenProvider(), mockGoPath, outputChannel); expect(authorizeRHDAStub.calledOnce).to.be.true; expect(stackAnalysisServiceStub.calledOnce).to.be.true; @@ -79,7 +80,7 @@ suite('RHDA module', () => { const imageAnalysisServiceStub = sandbox.stub(imageAnalysis, 'executeDockerImageAnalysis').resolves(mockReponse); const writeFileStub = sandbox.stub(fs.promises, 'writeFile').resolves(undefined); - await rhda.generateRHDAReport(context, mockDockerfilePath, outputChannel); + await rhda.generateRHDAReport(context, new MockTokenProvider(), mockDockerfilePath, outputChannel); expect(authorizeRHDAStub.calledOnce).to.be.true; expect(imageAnalysisServiceStub.calledOnce).to.be.true; @@ -90,7 +91,7 @@ suite('RHDA module', () => { const authorizeRHDAStub = sandbox.stub(globalConfig, 'authorizeRHDA').resolves(); const stackAnalysisServiceStub = sandbox.stub(stackAnalysis, 'executeStackAnalysis').rejects(new Error('Mock Error')); - await rhda.generateRHDAReport(context, mockMavenPath, outputChannel) + await rhda.generateRHDAReport(context, new MockTokenProvider(), mockMavenPath, outputChannel) .then(() => { throw (new Error('should have thrown error')); }) @@ -105,7 +106,7 @@ suite('RHDA module', () => { const authorizeRHDAStub = sandbox.stub(globalConfig, 'authorizeRHDA').resolves(); const imageAnalysisServiceStub = sandbox.stub(imageAnalysis, 'executeDockerImageAnalysis').rejects(new Error('Mock Error')); - await rhda.generateRHDAReport(context, mockContainerfilePath, outputChannel) + await rhda.generateRHDAReport(context, new MockTokenProvider(), mockContainerfilePath, outputChannel) .then(() => { throw (new Error('should have thrown error')); }) @@ -121,7 +122,7 @@ suite('RHDA module', () => { const stackAnalysisServiceStub = sandbox.stub(stackAnalysis, 'executeStackAnalysis').resolves(mockReponse); const writeFileStub = sandbox.stub(fs.promises, 'writeFile').throws(new Error('Mock Error')); - await rhda.generateRHDAReport(context, mockNpmPath, outputChannel) + await rhda.generateRHDAReport(context, new MockTokenProvider(), mockNpmPath, outputChannel) .then(() => { throw (new Error('should have thrown error')); }) @@ -139,7 +140,7 @@ suite('RHDA module', () => { sandbox.stub(fs, 'existsSync').returns(false); const mkdirSyncStub = sandbox.stub(fs, 'mkdirSync').throws(new Error('Mock Error')); - await rhda.generateRHDAReport(context, mockPythonPath, outputChannel) + await rhda.generateRHDAReport(context, new MockTokenProvider(), mockPythonPath, outputChannel) .then(() => { throw (new Error('should have thrown error')); @@ -157,7 +158,7 @@ suite('RHDA module', () => { const stackAnalysisServiceStub = sandbox.stub(stackAnalysis, 'executeStackAnalysis').resolves(mockReponse); const writeFileStub = sandbox.stub(fs.promises, 'writeFile').resolves(undefined); - await rhda.generateRHDAReport(context, mockGradlePath, outputChannel); + await rhda.generateRHDAReport(context, new MockTokenProvider(), mockGradlePath, outputChannel); expect(authorizeRHDAStub.calledOnce).to.be.true; expect(stackAnalysisServiceStub.calledOnce).to.be.true; diff --git a/test/stackAnalysis.test.ts b/test/stackAnalysis.test.ts index ac7f25fc..1025bbcd 100644 --- a/test/stackAnalysis.test.ts +++ b/test/stackAnalysis.test.ts @@ -9,12 +9,13 @@ import { DependencyReportPanel } from '../src/dependencyReportPanel'; import { executeStackAnalysis } from '../src/stackAnalysis'; import * as templates from '../src/template'; import { DepOutputChannel } from '../src/depOutputChannel'; +import { MockTokenProvider } from '../src/tokenProvider'; const expect = chai.expect; chai.use(sinonChai); const outputChannel = new DepOutputChannel('test'); -suite('StackAnalysis module', () => { +suite('StackAnalysis module', async () => { let sandbox: sinon.SinonSandbox; const mockPath = '/mock/path/pom.xml'; const mockReponse = ' mockResponse '; @@ -41,7 +42,7 @@ suite('StackAnalysis module', () => { test('should generate RHDA report for supported file', async () => { const stackAnalysisServiceStub = sandbox.stub(exhortServices, 'stackAnalysisService').resolves(mockReponse); - await executeStackAnalysis(mockPath, outputChannel); + await executeStackAnalysis(new MockTokenProvider(), mockPath, outputChannel); expect(stackAnalysisServiceStub.calledOnce).to.be.true; expect(DependencyReportPanel.data).to.eq(mockReponse); @@ -50,7 +51,7 @@ suite('StackAnalysis module', () => { test('should fail to generate RHDA report for supported file', async () => { const stackAnalysisServiceStub = sandbox.stub(exhortServices, 'stackAnalysisService').rejects(new Error('Mock Error')); - await executeStackAnalysis(mockPath, outputChannel) + await executeStackAnalysis(new MockTokenProvider(), mockPath, outputChannel) .then(() => { throw (new Error('should have thrown error')); }) diff --git a/test/tokenValidation.test.ts b/test/tokenValidation.test.ts index f5ae29d5..23e7e8e9 100644 --- a/test/tokenValidation.test.ts +++ b/test/tokenValidation.test.ts @@ -8,11 +8,12 @@ import { globalConfig } from '../src/config'; import { validateSnykToken } from '../src/tokenValidation'; import * as exhortServices from '../src/exhortServices'; import { SNYK_URL } from '../src/constants'; +import { MockTokenProvider } from '../src/tokenProvider'; const expect = chai.expect; chai.use(sinonChai); -suite('TokenValidation module', () => { +suite('TokenValidation module', async () => { let sandbox: sinon.SinonSandbox; setup(() => { @@ -26,16 +27,18 @@ suite('TokenValidation module', () => { test('should validate non-empty Snyk token', async () => { globalConfig.telemetryId = 'mockId'; const options = { - 'RHDA_TOKEN': 'mockId', + 'RHDA_TOKEN': '', + 'RHDA_TELEMETRY_ID': 'mockId', 'RHDA_SOURCE': 'vscode', 'EXHORT_SNYK_TOKEN': 'mockToken' }; const exhortServicesStub = sandbox.stub(exhortServices, 'tokenValidationService'); - await validateSnykToken('mockToken'); + await validateSnykToken(new MockTokenProvider(), 'mockToken'); - expect(exhortServicesStub.calledOnceWithExactly(options, 'Snyk')).to.be.true; + expect(exhortServicesStub.getCall(0).args[0]).to.eql(options); + expect(exhortServicesStub.getCall(0).args[1]).to.equal('Snyk'); }); test('should validate empty Snyk token', async () => { @@ -43,7 +46,7 @@ suite('TokenValidation module', () => { const showInformationMessageStub = sandbox.stub(vscode.window, 'showInformationMessage'); - await validateSnykToken(''); + await validateSnykToken(new MockTokenProvider(), ''); const showInformationMessageCall = showInformationMessageStub.getCall(0); const showInformationMessageMsg = showInformationMessageCall.args[0]; diff --git a/transformToDynamicImport.sh b/transformToDynamicImport.sh index ec85308e..9048def2 100755 --- a/transformToDynamicImport.sh +++ b/transformToDynamicImport.sh @@ -1 +1,2 @@ -find out/ | grep -E 'js$' | xargs -I {} sed -i.bak 's|require("@trustification/exhort-javascript-api")|import("@trustification/exhort-javascript-api")|g' {} && find out/ -name "*.bak" -delete \ No newline at end of file +find out/ | grep -E 'js$' | xargs -I {} sed -i.bak 's|require("@trustification/exhort-javascript-api")|import("@trustification/exhort-javascript-api")|g' {} && find out/ -name "*.bak" -delete +find out/ | grep -E 'js$' | xargs -I {} sed -i.bak 's|require("openid-client")|import("openid-client")|g' {} && find out/ -name "*.bak" -delete \ No newline at end of file