diff --git a/package-lock.json b/package-lock.json index 1fecb96..41038a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.9.1", "dependencies": { "@monokle/parser": "^0.3.2", - "@monokle/synchronizer": "^0.13.0", + "@monokle/synchronizer": "^0.14.2", "@monokle/validation": "^0.33.0", "@segment/analytics-node": "^1.1.0", "diff": "^5.1.0", @@ -841,9 +841,9 @@ } }, "node_modules/@monokle/synchronizer": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@monokle/synchronizer/-/synchronizer-0.13.0.tgz", - "integrity": "sha512-rTfzoDC4A3mLkxYnpUeYHzLK2cIjugBarxJ3+Dyq8MgSbXcPmW5GaxbqTJRZX2KEPlj2ozJHRmXAsoxcOpwQVA==", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@monokle/synchronizer/-/synchronizer-0.14.2.tgz", + "integrity": "sha512-djMglvaokJ7LEp6EPKLd68sThEdvLZ9A5msF3E0ALp651T6lvQia8SV7CmgJXEVLIyzNIT3dheAvciyOKxvHqg==", "dependencies": { "@monokle/types": "*", "env-paths": "^2.2.1", diff --git a/package.json b/package.json index da645b0..a46eba1 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,7 @@ "test": "concurrently -s command-1 -k \"npm run test:server\" \"node ./out/test/run-test.js\"", "test:cc": "concurrently -s command-1 -k \"npm run test:server\" \"c8 node ./out/test/run-test.js\"", "test:server": "node ./out/test/run-server.js", - "test:ensure-coverage": "npx c8 check-coverage --lines 68 --functions 75 --branches 75 --statements 68" + "test:ensure-coverage": "npx c8 check-coverage --lines 68 --functions 76 --branches 82 --statements 68" }, "devDependencies": { "@graphql-tools/mock": "^9.0.0", @@ -184,7 +184,7 @@ }, "dependencies": { "@monokle/parser": "^0.3.2", - "@monokle/synchronizer": "^0.13.0", + "@monokle/synchronizer": "^0.14.2", "@monokle/validation": "^0.33.0", "@segment/analytics-node": "^1.1.0", "diff": "^5.1.0", diff --git a/src/commands/suppress.ts b/src/commands/suppress.ts new file mode 100644 index 0000000..589103e --- /dev/null +++ b/src/commands/suppress.ts @@ -0,0 +1,67 @@ +import { canRun } from '../utils/commands'; +import { COMMAND_NAMES } from '../constants'; +import { raiseWarning } from '../utils/errors'; +import { trackEvent } from '../utils/telemetry'; +import { MONOKLE_FINGERPRINT_FIELD, ValidationResultExtended } from '../core/code-actions/base-code-actions-provider'; +import { SuppressionPermissions, generateSuppression } from '../core'; +import { applySuppressions } from '../utils/validation'; +import { Folder } from '../utils/workspace'; +import globals from '../utils/globals'; +import type { RuntimeContext } from '../utils/runtime-context'; + +export function getSuppressCommand(context: RuntimeContext) { + return async (result: ValidationResultExtended, permissions: SuppressionPermissions, root: Folder) => { + if (!canRun()) { + return null; + } + + if (permissions === 'NONE') { + // Should not happen, since permissions are checked in CodeActionProvider. Still handle gracefully. + raiseWarning('You do not have permissions to suppress this misconfiguration'); + + trackEvent('command/suppress', { + status: 'failure', + error: 'Suppression command run despite no permissions.' + }); + + return null; + } + + trackEvent('command/suppress', { + status: 'started' + }); + + const user = await globals.getUser(); + + if (!user.isAuthenticated) { + raiseWarning(`You need to be logged in to use suppressions. Run ${COMMAND_NAMES.LOGIN} to authenticate first.}`); + + trackEvent('command/suppress', { + status: 'failure', + error: 'User not authenticated.' + }); + + return null; + } + + + const localSuppression = generateSuppression(result.fingerprints?.[MONOKLE_FINGERPRINT_FIELD], permissions); + + await applySuppressions(root, localSuppression); + + await context.synchronizer.toggleSuppression( + user.tokenInfo, + result.fingerprints?.[MONOKLE_FINGERPRINT_FIELD], + `${result.ruleId} - ${result.message.text}`, + result.locations.at(1).logicalLocations?.at(0)?.fullyQualifiedName, + root.uri.fsPath, + globals.project || undefined + ); + + await applySuppressions(root); + + trackEvent('command/suppress', { + status: 'success' + }); + }; +} diff --git a/src/commands/synchronize.ts b/src/commands/synchronize.ts index 746b3fa..8076d12 100644 --- a/src/commands/synchronize.ts +++ b/src/commands/synchronize.ts @@ -29,7 +29,7 @@ export function getSynchronizeCommand(context: RuntimeContext) { return null; } - await context.refreshPolicyPuller(); + await context.refreshPolicyPuller(true); trackEvent('command/synchronize', { status: 'success' diff --git a/src/constants.ts b/src/constants.ts index 73176e7..0915688 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -40,6 +40,7 @@ export const COMMANDS = { TRACK: 'monokle.track', RAISE_AUTHENTICATION_ERROR: 'monokle.raiseAuthenticationError', RUN_COMMANDS: 'monokle.runCommands', + SUPPRESS: 'monokle.suppress', }; export const COMMAND_NAMES = { diff --git a/src/core/code-actions/annotation-suppressions-code-actions-provider.ts b/src/core/code-actions/annotation-suppressions-code-actions-provider.ts index debbbdf..79e6be6 100644 --- a/src/core/code-actions/annotation-suppressions-code-actions-provider.ts +++ b/src/core/code-actions/annotation-suppressions-code-actions-provider.ts @@ -1,9 +1,19 @@ import { CodeAction, CodeActionKind, TextDocument, Range, languages } from 'vscode'; import { BaseCodeActionsProvider, CodeActionContextExtended, DiagnosticExtended, ValidationResultExtended } from './base-code-actions-provider'; import { COMMANDS } from '../../constants'; +import { getOwnerWorkspace } from '../../utils/workspace'; +import { shouldUseFingerprintSuppressions } from '../suppressions/suppressions'; class AnnotationSuppressionsCodeActionsProvider extends BaseCodeActionsProvider { public async provideCodeActions(document: TextDocument, _range: Range, context: CodeActionContextExtended) { + const workspaceRoot = getOwnerWorkspace(document); + const fingerprintSuppressionsPermissions = shouldUseFingerprintSuppressions(workspaceRoot.uri.fsPath); + + // We allow either annotation or fingerprint suppressions at the same time, but not both. + if (fingerprintSuppressionsPermissions.allowed) { + return []; + } + return this.getMonokleDiagnostics(context).map((diagnostic: DiagnosticExtended) => { return new AnnotationSuppressionsCodeAction(document, diagnostic); }); diff --git a/src/core/code-actions/base-code-actions-provider.ts b/src/core/code-actions/base-code-actions-provider.ts index 62ff17c..8359484 100644 --- a/src/core/code-actions/base-code-actions-provider.ts +++ b/src/core/code-actions/base-code-actions-provider.ts @@ -6,6 +6,7 @@ import { ValidationResult, ValidationRule } from './../../utils/validation'; export type ValidationResultExtended = ValidationResult & { _id: [string, number, number]; _rule: ValidationRule; + _uri: string; }; export type DiagnosticExtended = Diagnostic & { @@ -28,11 +29,11 @@ export abstract class BaseCodeActionsProvider imple public abstract resolveCodeAction(codeAction: T_ACTION); - protected getMonokleDiagnostics(context: CodeActionContextExtended) { + protected getMonokleDiagnostics(context: CodeActionContextExtended, groupByRule = true) { // Filter out diagnostic objects without Monokle fingerprint, because this is not Monokle related diagnostics. const monokleDiagnostics = context.diagnostics.filter(diagnostic => diagnostic?.result?.fingerprints?.[MONOKLE_FINGERPRINT_FIELD]); - return this.getUniqueDiagnosticsByRule(monokleDiagnostics); + return groupByRule ? this.getUniqueDiagnosticsByRule(monokleDiagnostics) : monokleDiagnostics; } protected getParsedDocument(document: TextDocument, result: ValidationResult) { diff --git a/src/core/code-actions/fingerprint-suppressions-code-actions-provider.ts b/src/core/code-actions/fingerprint-suppressions-code-actions-provider.ts new file mode 100644 index 0000000..e7ee025 --- /dev/null +++ b/src/core/code-actions/fingerprint-suppressions-code-actions-provider.ts @@ -0,0 +1,91 @@ +import { CodeAction, CodeActionKind, TextDocument, Range, languages, Uri } from 'vscode'; +import { BaseCodeActionsProvider, CodeActionContextExtended, DiagnosticExtended, ValidationResultExtended } from './base-code-actions-provider'; +import { COMMANDS } from '../../constants'; +import { Folder, getOwnerWorkspace } from '../../utils/workspace'; +import globals from '../../utils/globals'; +import { SuppressionPermissions, isUnderReview, shouldUseFingerprintSuppressions } from '../suppressions/suppressions'; + +class FingerprintSuppressionsCodeActionsProvider extends BaseCodeActionsProvider { + public async provideCodeActions(document: TextDocument, _range: Range, context: CodeActionContextExtended) { + const workspaceRoot = getOwnerWorkspace(document); + const fingerprintSuppressionsPermissions = shouldUseFingerprintSuppressions(workspaceRoot.uri.fsPath); + + if (!fingerprintSuppressionsPermissions.allowed) { + return []; + } + + const diagnostics = this.getMonokleDiagnostics(context, false); + + // Filter out 'under review' violations if user has only 'request suppression' rights. There is no sense in requesting again. + // However, for 'Admin' there should be still an ability to suppress such violation (so it's like accepting a request). + const roleRelevantDiagnostics = fingerprintSuppressionsPermissions.permissions === 'ADD' ? + diagnostics : + diagnostics.filter((diagnostic: DiagnosticExtended) => !isUnderReview(diagnostic.result)); + + return roleRelevantDiagnostics.map((diagnostic: DiagnosticExtended) => { + return new FingerprintSuppressionsCodeAction(diagnostic, fingerprintSuppressionsPermissions.permissions, workspaceRoot); + }); + } + + public async resolveCodeAction(codeAction: FingerprintSuppressionsCodeAction) { + const user = await globals.getUser(); + + if (!user.isAuthenticated) { + // This should not happen, since we don't show this code action if user is not authenticated. + // Still handle it if something unpredictable happens. + codeAction.command = { + command: COMMANDS.RAISE_AUTHENTICATION_ERROR, + title: 'Raise Authentication Error', + arguments: ['Suppressing a problem requires authentication.', { + event: 'code_action/fingerprint_suppression', + data: { + status: 'cancelled', + ruleId: codeAction.result.ruleId, + error: 'Unauthenticated', + } + }] + }; + } else { + codeAction.command = { + command: COMMANDS.SUPPRESS, + title: 'Suppress misconfiguration', + arguments: [codeAction.result, codeAction.permissions, codeAction.root] + }; + } + + return codeAction; + } +} + +class FingerprintSuppressionsCodeAction extends CodeAction { + private readonly _result: ValidationResultExtended; + private readonly _permissions: SuppressionPermissions; + private readonly _root: Folder; + + constructor(diagnostic: DiagnosticExtended, permissions: SuppressionPermissions, root: Folder) { + super(`${permissions === 'ADD' ? 'Suppress' : 'Request suppression of'}: ${diagnostic.result.message.text}`, CodeActionKind.QuickFix); + + this.diagnostics = [diagnostic]; + this._result = diagnostic.result; + this._permissions = permissions; + this._root = root; + } + + get result() { + return this._result; + } + + get permissions() { + return this._permissions; + } + + get root() { + return this._root; + } +} + +export function registerFingerprintSuppressionsCodeActionsProvider() { + return languages.registerCodeActionsProvider({language: 'yaml'}, new FingerprintSuppressionsCodeActionsProvider(), { + providedCodeActionKinds: FingerprintSuppressionsCodeActionsProvider.providedCodeActionKinds + }); +} diff --git a/src/core/code-actions/index.ts b/src/core/code-actions/index.ts index d62f56f..6ddfc18 100644 --- a/src/core/code-actions/index.ts +++ b/src/core/code-actions/index.ts @@ -1,3 +1,4 @@ export * from './annotation-suppressions-code-actions-provider'; export * from './fix-code-actions-provider'; export * from './show-details-code-actions-provider'; +export * from './fingerprint-suppressions-code-actions-provider'; diff --git a/src/core/code-actions/show-details-code-actions-provider.ts b/src/core/code-actions/show-details-code-actions-provider.ts index 4317a2d..7f6b376 100644 --- a/src/core/code-actions/show-details-code-actions-provider.ts +++ b/src/core/code-actions/show-details-code-actions-provider.ts @@ -39,6 +39,8 @@ class ShowDetailsCodeActionsProvider extends BaseCodeActionsProvider { + return { + guid: suppression.id, + kind: 'external', + status: toSuppressionStatus(suppression.status), + fingerprint: suppression.fingerprint, + } as FingerprintSuppression; + }); + + return { + suppressions: fingerprintSuppressions, + }; +} + +export function generateSuppression(fingerprint: string, permissions: SuppressionPermissions) { + return { + guid: `sup-${Date.now()}`, + kind: 'external', + status: toSuppressionStatus(permissions === 'ADD' ? 'accepted' : 'underReview'), + fingerprint: fingerprint, + } as FingerprintSuppression; +} + +export function shouldUseFingerprintSuppressions(repoRootPath: string): SuppressionsStatus { + const projectPermissions = globals.getProjectPermissions(repoRootPath); + + return { + permissions: projectPermissions, + allowed: projectPermissions !== 'NONE', + }; +} + +export function isUnderReview(result: ValidationResult | ValidationResultExtended) { + return result.suppressions.length > 0 && result.suppressions.every(s => s.status === 'underReview'); +} + +function toSuppressionStatus(status: string) { + switch (status) { + case 'ACCEPTED': + case 'accepted': + return 'accepted'; + case 'REJECTED': + case 'rejected': + return 'rejected'; + case 'UNDER_REVIEW': + case 'underReview': + return 'underReview'; + default: + return status; + } +} diff --git a/src/extension.ts b/src/extension.ts index 47dc305..46e6a15 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,8 +23,9 @@ import { trackEvent, initTelemetry, closeTelemetry } from './utils/telemetry'; import logger from './utils/logger'; import globals from './utils/globals'; import { raiseError } from './utils/errors'; -import { registerAnnotationSuppressionsCodeActionsProvider, registerFixCodeActionsProvider, registerShowDetailsCodeActionsProvider } from './core'; +import { registerAnnotationSuppressionsCodeActionsProvider, registerFingerprintSuppressionsCodeActionsProvider, registerFixCodeActionsProvider, registerShowDetailsCodeActionsProvider } from './core'; import type { ExtensionContext } from 'vscode'; +import { getSuppressCommand } from './commands/suppress'; let runtimeContext: RuntimeContext; @@ -107,6 +108,7 @@ async function runActivation(context: ExtensionContext) { const commandTrack = commands.registerCommand(COMMANDS.TRACK, getTrackCommand(runtimeContext)); const commandRaiseAuthenticationError = commands.registerCommand(COMMANDS.RAISE_AUTHENTICATION_ERROR, getRaiseAuthenticationErrorCommand(runtimeContext)); const commandRunCommands = commands.registerCommand(COMMANDS.RUN_COMMANDS, getRunCommandsCommand(runtimeContext)); + const commandSuppress = commands.registerCommand(COMMANDS.SUPPRESS, getSuppressCommand(runtimeContext)); context.subscriptions.push( commandLogin, @@ -119,12 +121,14 @@ async function runActivation(context: ExtensionContext) { commandDownloadPolicy, commandTrack, commandRaiseAuthenticationError, - commandRunCommands + commandRunCommands, + commandSuppress, ); context.subscriptions.push(registerAnnotationSuppressionsCodeActionsProvider()); context.subscriptions.push(registerFixCodeActionsProvider()); context.subscriptions.push(registerShowDetailsCodeActionsProvider()); + context.subscriptions.push(registerFingerprintSuppressionsCodeActionsProvider()); const configurationWatcher = workspace.onDidChangeConfiguration(async (event) => { if (event.affectsConfiguration(SETTINGS.ENABLED_PATH)) { @@ -289,8 +293,7 @@ function setupRemoteEventListeners(runtimeContext: RuntimeContext) { return; } - await runtimeContext.refreshPolicyPuller(); - await commands.executeCommand(COMMANDS.VALIDATE); + await runtimeContext.refreshPolicyPuller(true); }); runtimeContext.authenticator.on('logout', async () => { @@ -304,14 +307,13 @@ function setupRemoteEventListeners(runtimeContext: RuntimeContext) { await commands.executeCommand(COMMANDS.VALIDATE); }); - runtimeContext.synchronizer.on('synchronize', async (policy) => { + runtimeContext.synchronizer.on('synchronized', async (policy) => { logger.log('EVENT:synchronize', policy, globals.isActivated); if (!globals.isActivated || !globals.enabled) { return; } - await runtimeContext.refreshPolicyPuller(); await commands.executeCommand(COMMANDS.VALIDATE); }); } diff --git a/src/test/helpers/server.ts b/src/test/helpers/server.ts index 2bbc69f..45113e7 100644 --- a/src/test/helpers/server.ts +++ b/src/test/helpers/server.ts @@ -4,9 +4,16 @@ import { mockServer } from '@graphql-tools/mock'; import { Server } from 'http'; const schema = ` + scalar JSON + scalar DateISO + scalar ID + scalar TRUE + scalar FALSE + type Query { me: UserModel! getProject(input: GetProjectInput!): ProjectModel! + getSuppressions(input: GetSuppressionsInput!): [SuppressionsModel!]! } type UserModel { @@ -24,6 +31,8 @@ const schema = ` slug: String! name: String! repositories: [ProjectRepositoryModel!]! + repository(input: GetRepositoryInput!): ProjectRepositoryModel + permissions: ProjectPermissionsModel policy: ProjectPolicyModel } @@ -37,28 +46,100 @@ const schema = ` canEnablePrChecks: Boolean } + type ProjectPermissionsModel { + project: PermissionsModel1! + members: PermissionsModel1! + repositories: PermissionsModel2 + } + + type SuppressionsModel { + isSnapshot: Boolean!, + data: [SuppressionModel!]! + } + + type SuppressionModel { + id: String! + fingerprint: String! + description: String! + location: String! + status: String! + justification: String! + expiresAt: String! + updatedAt: String! + createdAt: String! + isUnderReview: Boolean! + isAccepted: Boolean! + isRejected: Boolean! + isExpired: Boolean! + isDeleted: Boolean! + repositoryId: String! + } + + type PermissionsModel1 { + view: Boolean! + update: Boolean! + delete: Boolean! + } + + type PermissionsModel2 { + read: TRUE! + write: FALSE! + } + enum RepositoryProviderEnum { BITBUCKET GITHUB GITLAB } - scalar JSON - type ProjectPolicyModel { id: String! json: JSON! + updatedAt: DateISO } input GetProjectInput { id: Int slug: String } + + input GetRepositoryInput { + owner: String! + name: String! + provider: String! + } + + input GetSuppressionsInput { + repositoryId: ID!, + from: String + } `; export const DEFAULT_POLICY = { plugins: { - 'open-policy-agent': true + 'pod-security-standards': false, + 'yaml-syntax': false, + 'resource-links': false, + 'kubernetes-schema': false, + 'practices': true, + }, + rules: { + 'practices/no-mounted-docker-sock': false, + 'practices/no-writable-fs': false, + 'practices/drop-capabilities': false, + 'practices/no-low-group-id': false, + 'practices/no-automount-service-account-token': false, + 'practices/no-pod-create': false, + 'practices/no-pod-execute': false, + 'practices/no-no-root-group': 'err', + 'practices/no-sys-admin': false, + 'practices/cpu-limit': false, + 'practices/no-latest-image': false, + 'practices/cpu-request': false, + 'practices/memory-request': false, + 'practices/memory-limit': false, + 'practices/no-low-user-id': 'err', + 'practices/no-root-group': false, } }; @@ -68,7 +149,11 @@ const mockData = { owner: 'kubeshop', name: 'monokle-demo', }), - JSON: () => (DEFAULT_POLICY) + JSON: () => (DEFAULT_POLICY), + DateISO: () => (new Date()).toISOString(), + ID: () => 100, + TRUE: () => true, + FALSE: () => false }; export function startMockServer(host = '0.0.0.0', port = 5000): Promise { diff --git a/src/test/run-test.ts b/src/test/run-test.ts index 46e7eea..5e96c7d 100644 --- a/src/test/run-test.ts +++ b/src/test/run-test.ts @@ -156,13 +156,11 @@ async function main() { // Run code-actions tests await runSuite('./suite-code-actions/index', [workspaces.withCodeActions], { skipResultCache: true }); + await runSuite('./suite-code-actions/index', [workspaces.withCodeActions], { skipResultCache: true, setupRemoteEnv: true }); // Run integration-like tests on multiple, different workspaces (local config). await runSuite('./suite-integration/index', [workspaces.withResources, workspaces.withoutResources, workspaces.workspace], { skipResultCache: true }); await runSuite('./suite-integration/index', [workspaces.withResources, workspaces.withoutResources, workspaces.workspace], { skipResultCache: true, validateOnSave: true }); - - // Run integration-like tests for remote config separately as it needs different setup. - await runSuite('./suite-integration/index', [workspaces.withConfig], { setupRemoteEnv: true }); } main(); diff --git a/src/test/suite-code-actions/fix.test.ts b/src/test/suite-code-actions/fix.test.ts index a3d9a46..367be93 100644 --- a/src/test/suite-code-actions/fix.test.ts +++ b/src/test/suite-code-actions/fix.test.ts @@ -9,6 +9,7 @@ import { Fix } from 'sarif'; import { removeValidationResult } from '../../utils/validation'; const RUN_ON = process.env.MONOKLE_TEST_VALIDATE_ON_SAVE === 'Y' ? 'onSave' : 'onType'; +const IS_REMOTE = process.env.MONOKLE_TEST_SERVER_URL?.length > 0; function getMisconfigurations(validationResponse) { return validationResponse.runs[0].results ?? []; @@ -26,13 +27,10 @@ async function getRange(validationResponse): Promise { return new Range(startLine - 1, startColumn, endLine - 1, endColumn); } - - suite(`CodeActions - quick fix (${RUN_ON}): ${process.env.ROOT_PATH}`, async function () { this.timeout(25000); const isDisabled = process.env.WORKSPACE_DISABLED === 'true'; - suiteSetup(async function () { await doSuiteSetup(); @@ -45,7 +43,6 @@ suite(`CodeActions - quick fix (${RUN_ON}): ${process.env.ROOT_PATH}`, async fun await doSuiteTeardown(); }); - test('Fix is available in the CodeAction quickfix list', async function () { const folders = getWorkspaceFolders(); @@ -63,7 +60,97 @@ suite(`CodeActions - quick fix (${RUN_ON}): ${process.env.ROOT_PATH}`, async fun const codeActions = await waitForCodeActionList(uri, range, 2, 5000); if (!codeActions.find(({ kind, title }) => { - return kind.value === CodeActionKind.QuickFix.value && title.includes('KBP104'); + return kind.value === CodeActionKind.QuickFix.value && title.includes('KBP104') && title.startsWith('Fix'); + })) { + fail('Quick fix missing'); + } + + } catch (error) { + fail(error.message); + } + }); + }); + + test('Show details is available in the CodeAction quickfix list', async function () { + const folders = getWorkspaceFolders(); + + await runForFolders(folders, async (folder) => { + try { + const file = resolve(folder.uri.fsPath, 'deployment.yaml'); + const uri = Uri.file(file); + + const validationResponse = await waitForValidationResults(folder); + assertValidationResults(validationResponse); + + const range = await getRange(validationResponse); + const document = await workspace.openTextDocument(uri); + await vscodeWindow.showTextDocument(document); + const codeActions = await waitForCodeActionList(uri, range, 3, 5000); + + if (!codeActions.find(({ kind, title }) => { + return kind.value === CodeActionKind.QuickFix.value && title.startsWith('Show details'); + })) { + fail('Quick fix missing'); + } + + } catch (error) { + fail(error.message); + } + }); + }); + + test('Suppress (annotation-based) is available in the CodeAction quickfix list', async function () { + if (IS_REMOTE) { + this.skip(); + } + const folders = getWorkspaceFolders(); + + await runForFolders(folders, async (folder) => { + try { + const file = resolve(folder.uri.fsPath, 'deployment.yaml'); + const uri = Uri.file(file); + + const validationResponse = await waitForValidationResults(folder); + assertValidationResults(validationResponse); + + const range = await getRange(validationResponse); + const document = await workspace.openTextDocument(uri); + await vscodeWindow.showTextDocument(document); + const codeActions = await waitForCodeActionList(uri, range, 3, 5000); + + if (!codeActions.find(({ kind, title }) => { + return kind.value === CodeActionKind.QuickFix.value && title.includes('for this resource') && title.startsWith('Suppress'); + })) { + fail('Quick fix missing'); + } + + } catch (error) { + fail(error.message); + } + }); + }); + + test('Suppress (fingerprint-based) is available in the CodeAction quickfix list', async function () { + if (!IS_REMOTE) { + this.skip(); + } + const folders = getWorkspaceFolders(); + + await runForFolders(folders, async (folder) => { + try { + const file = resolve(folder.uri.fsPath, 'deployment.yaml'); + const uri = Uri.file(file); + + const validationResponse = await waitForValidationResults(folder); + assertValidationResults(validationResponse); + + const range = await getRange(validationResponse); + const document = await workspace.openTextDocument(uri); + await vscodeWindow.showTextDocument(document); + const codeActions = await waitForCodeActionList(uri, range, 3, 5000); + + if (!codeActions.find(({ kind, title }) => { + return kind.value === CodeActionKind.QuickFix.value && title.startsWith('Request suppression of'); })) { fail('Quick fix missing'); } diff --git a/src/test/suite-policies/policies.test.ts b/src/test/suite-policies/policies.test.ts index 0be904c..871e230 100644 --- a/src/test/suite-policies/policies.test.ts +++ b/src/test/suite-policies/policies.test.ts @@ -1,4 +1,4 @@ -import { ok, deepEqual } from 'assert'; +import { ok, deepEqual, equal } from 'assert'; import { workspace, commands, ConfigurationTarget } from 'vscode'; import { join } from 'path'; import { existsSync } from 'fs'; @@ -36,12 +36,13 @@ suite(`Policies - Remote: ${process.env.ROOT_PATH}`, function () { await workspace.getConfiguration('monokle').update('enabled', true, ConfigurationTarget.Workspace); - const configRemote = await waitForValidationConfig(folder, 10000); + const configRemote = await waitForValidationConfig(folder, 15000, 'remote'); ok(configRemote); ok(configRemote.isValid); + equal(configRemote.type, 'remote'); deepEqual(configRemote.config, DEFAULT_POLICY); - }).timeout(15000); + }).timeout(25000); test('Refetches policy from remote API when authenticated and synchronize command run', async function () { const folders = getWorkspaceFolders(); @@ -69,7 +70,7 @@ suite(`Policies - Remote: ${process.env.ROOT_PATH}`, function () { }).timeout(35000); }); -async function waitForValidationConfig(workspaceFolder: Folder, timeoutMs?: number): Promise { +async function waitForValidationConfig(workspaceFolder: Folder, timeoutMs?: number, requiredType?: string): Promise { return new Promise((res) => { let timeoutId = null; let result = null; @@ -78,9 +79,11 @@ async function waitForValidationConfig(workspaceFolder: Folder, timeoutMs?: numb result = await getWorkspaceConfig(workspaceFolder); if (result && result.isValid) { - clearInterval(intervalId); - timeoutId && clearTimeout(timeoutId); - res(result); + if (requiredType && result.type === requiredType || requiredType === undefined) { + clearInterval(intervalId); + timeoutId && clearTimeout(timeoutId); + res(result); + } } }, 250); diff --git a/src/utils/globals.ts b/src/utils/globals.ts index b27d45c..5d699fd 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -5,6 +5,7 @@ import { Folder } from './workspace'; import { RuntimeContext } from './runtime-context'; import logger from './logger'; import type { Authenticator } from './authentication'; +import { SuppressionPermissions } from '../core/suppressions/suppressions'; export type FolderStatus = { valid: boolean; @@ -94,33 +95,16 @@ class Globals { return this._runtimeContext.authenticator.getUser(); } - async getRemoteProjectName(path: string) { - if (this._runtimeContext.isLocal) { + getRemoteProjectName(path: string) { + if (this._runtimeContext.isLocal || !this._runtimeContext.authenticator.user.isAuthenticated) { return ''; } - if (!this._runtimeContext?.authenticator) { - throw new Error('Authenticator not initialized for globals.'); - } - - if (!this._runtimeContext?.synchronizer) { - throw new Error('Synchronizer not initialized for globals.'); - } - - try { - const user = await this._runtimeContext.authenticator.getUser(); - const projectInfo = this.project?.length ? - await this._runtimeContext.synchronizer.getProjectInfo({slug: this.project}, user.tokenInfo) : - await this._runtimeContext.synchronizer.getProjectInfo(path, user.tokenInfo); - - return projectInfo?.name ?? ''; - } catch (err) { - return ''; - } + return (this._runtimeContext?.synchronizer.getProjectInfo(path, this.project ?? undefined) || {}).name ?? ''; } - async getRemotePolicy(path: string) { - if (this._runtimeContext.isLocal) { + getRemotePolicy(path: string) { + if (this._runtimeContext.isLocal || !this._runtimeContext.authenticator.user.isAuthenticated) { return { valid: false, path: '', @@ -128,44 +112,29 @@ class Globals { }; } - if (!this._runtimeContext?.synchronizer) { - throw new Error('Synchronizer not initialized for globals.'); - } - - try { - const policy = this.project?.length ? - await this._runtimeContext.synchronizer.getPolicy({slug: this.project}) : - await this._runtimeContext.synchronizer.getPolicy(path); - - return policy; - } catch (err) { - return { - valid: false, - path: '', - policy: {}, - }; - } + return this._runtimeContext?.synchronizer.getProjectPolicy(path, this.project ?? undefined); } - async getSuppressions(path: string, token: any) { - if (this._runtimeContext.isLocal) { + getSuppressions(path: string) { + if (this._runtimeContext.isLocal || !this._runtimeContext.authenticator.user.isAuthenticated) { return []; } - if (!this._runtimeContext?.synchronizer) { - throw new Error('Synchronizer not initialized for globals.'); + return this._runtimeContext?.synchronizer.getRepositorySuppressions(path, this.project ?? undefined); + } + + getProjectPermissions(path: string): SuppressionPermissions { + if (this._runtimeContext.isLocal || !this._runtimeContext.authenticator.user.isAuthenticated) { + return 'NONE'; } - try { - const suppressions = this.project?.length ? - await this._runtimeContext.synchronizer.getSuppressions({slug: this.project}, token) : - await this._runtimeContext.synchronizer.getSuppressions(path, token); + const permissions = this._runtimeContext?.synchronizer.getProjectPermissions(path, this.project ?? undefined); - return suppressions; - } catch (err) { - logger.error('getSuppressions', err); - return []; + if (!permissions) { + return 'NONE'; } + + return permissions.repositories.write ? 'ADD' : 'REQUEST'; } getFolderStatus(folder: Folder) { diff --git a/src/utils/policy-puller.ts b/src/utils/policy-puller.ts index c0f682a..3500d21 100644 --- a/src/utils/policy-puller.ts +++ b/src/utils/policy-puller.ts @@ -11,6 +11,7 @@ const REFETCH_POLICY_INTERVAL_MS = 1000 * 30; export class PolicyPuller { + private _force = false; private _isPulling = false; private _pullPromise: Promise | undefined; private _policyFetcherId: NodeJS.Timer | undefined; @@ -19,7 +20,9 @@ export class PolicyPuller { private _synchronizer: Synchronizer ) {} - async refresh() { + async refresh(force = false) { + this._force = force; + const user = await globals.getUser(); if (!user.isAuthenticated) { @@ -101,6 +104,7 @@ export class PolicyPuller { } } + this._force = false; this._isPulling = false; this._pullPromise = undefined; } @@ -112,9 +116,9 @@ export class PolicyPuller { } const user = await globals.getUser(); - const policy = globals.project?.length ? - await this._synchronizer.synchronize({slug: globals.project}, user.tokenInfo) : - await this._synchronizer.synchronize(root.uri.fsPath, user.tokenInfo); + const policy = this._force ? + await this._synchronizer.forceSynchronize(user.tokenInfo, root.uri.fsPath, globals.project ?? undefined) : + await this._synchronizer.synchronize(user.tokenInfo, root.uri.fsPath, globals.project ?? undefined); return policy; }, { @@ -164,9 +168,9 @@ export class PolicyPuller { private async removeOutdatedPolicy(path: string) { try { - const outdatedPolicy = await this._synchronizer.getPolicy(path); + const outdatedPolicy = this._synchronizer.getProjectPolicy(path); - if (outdatedPolicy.path) { + if (outdatedPolicy?.path) { await rm(outdatedPolicy.path, { force: true }); } } catch (err) { diff --git a/src/utils/runtime-context.ts b/src/utils/runtime-context.ts index e1a5e05..d3635c9 100644 --- a/src/utils/runtime-context.ts +++ b/src/utils/runtime-context.ts @@ -60,12 +60,12 @@ export class RuntimeContext { } } - async refreshPolicyPuller() { + async refreshPolicyPuller(force = false) { if (!this.policyPuller) { return; } - await this.policyPuller.refresh(); + await this.policyPuller.refresh(force); } async reconfigure( diff --git a/src/utils/suppressions.ts b/src/utils/suppressions.ts deleted file mode 100644 index 5b45e42..0000000 --- a/src/utils/suppressions.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { FingerprintSuppression } from '@monokle/types'; -import globals from './globals'; - -export async function getSuppressions(path: string) { - const user = await globals.getUser(); - - if (!user.isAuthenticated) { - return { suppressions: [] }; - } - - await globals.forceRefreshToken(); - - const suppressions = await globals.getSuppressions(path, user.tokenInfo); - const fingerprintSuppressions = suppressions.map((suppression) => { - return { - guid: suppression.id, - kind: 'external', - status: toSuppressionStatus(suppression.status), - fingerprint: suppression.fingerprint, - } as FingerprintSuppression; - }); - - return { - suppressions: fingerprintSuppressions, - }; -} - -function toSuppressionStatus(status: string) { - switch (status) { - case 'ACCEPTED': - case 'accepted': - return 'accepted'; - case 'REJECTED': - case 'rejected': - return 'rejected'; - case 'UNDER_REVIEW': - case 'underReview': - return 'underReview'; - default: - return status; - } -} diff --git a/src/utils/synchronization.ts b/src/utils/synchronization.ts index 481d59d..247ea47 100644 --- a/src/utils/synchronization.ts +++ b/src/utils/synchronization.ts @@ -5,7 +5,7 @@ export type Synchronizer = Awaited>; export async function getSynchronizer(origin?: string) { /* DEV_ONLY_START */ if (process.env.MONOKLE_VSC_ENV === 'TEST') { - const {Synchronizer, StorageHandlerPolicy, ApiHandler, GitHandler} = await import('@monokle/synchronizer'); + const {ProjectSynchronizer, StorageHandlerPolicy, StorageHandlerJsonCache, ApiHandler, GitHandler} = await import('@monokle/synchronizer'); const gitHandler = new GitHandler(); (gitHandler as any).getRepoRemoteData = () => { @@ -17,18 +17,19 @@ export async function getSynchronizer(origin?: string) { }; }; - return new Synchronizer( + return new ProjectSynchronizer( new StorageHandlerPolicy(process.env.MONOKLE_TEST_CONFIG_PATH), + new StorageHandlerJsonCache(process.env.MONOKLE_TEST_CONFIG_PATH), new ApiHandler(process.env.MONOKLE_TEST_SERVER_URL), gitHandler ); } /* DEV_ONLY_END */ - const {createMonokleSynchronizerFromOrigin} = await import('@monokle/synchronizer'); + const {createMonokleProjectSynchronizerFromOrigin} = await import('@monokle/synchronizer'); try { - const synchronizer = await createMonokleSynchronizerFromOrigin(getClientConfig(), origin); + const synchronizer = await createMonokleProjectSynchronizerFromOrigin(getClientConfig(), origin); return synchronizer; } catch (err: any) { // Without this entire extension can run only in local mode. Needs to be obvious to users what went wrong and how to fix. diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts index 6093c46..b144e2d 100644 --- a/src/utils/telemetry.ts +++ b/src/utils/telemetry.ts @@ -106,6 +106,8 @@ export type EventMap = { 'code_action/annotation_suppression': BaseEvent & {[key: string]: string | number | boolean}; 'code_action/fix': BaseEvent & {[key: string]: string | number | boolean}; 'code_action/show_details': BaseEvent; + // Fingerprint-based suppressions. + 'command/suppress': BaseEvent & {[key: string]: string | number | boolean}; }; function getAppVersion(): string { diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 1537627..c0c0404 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -10,10 +10,11 @@ import { getInvalidConfigError } from './errors'; import { trackEvent } from './telemetry'; import { getResultCache } from './result-cache'; import { getResourcesFromFolder } from './file-parser'; -import { getSuppressions } from './suppressions'; +import { getSuppressions } from '../core'; import logger from '../utils/logger'; import globals from './globals'; import type { Fix, Replacement, } from 'sarif'; +import type { FingerprintSuppression } from '@monokle/types'; import type { Folder } from './workspace'; import type { Resource } from './file-parser'; @@ -52,6 +53,9 @@ const VALIDATORS = new Map(); +// Store parsed resources. +const RESOURCES = new Map(); + export async function getValidator(validatorId: string, config?: any) { const validatorItem = VALIDATORS.get(validatorId); const validatorObj = validatorItem?.validator ?? await getValidatorInstance(); @@ -131,6 +135,8 @@ export async function validateResourcesFromFolder(resources: Resource[], root: F }; }); + RESOURCES.set(root.id, resourcesRelative); + logger.log(root.name, 'workspaceConfig', workspaceConfig); const validatorObj = await getValidator(root.id, workspaceConfig.config); @@ -145,7 +151,7 @@ export async function validateResourcesFromFolder(resources: Resource[], root: F }; } - const suppressions = await getSuppressions(root.uri.fsPath); + const suppressions = getSuppressions(root.uri.fsPath); let result: ValidationResponse = null; try { @@ -205,6 +211,72 @@ export async function validateResourcesFromFolder(resources: Resource[], root: F return Uri.file(resultFilePath); } +export async function applySuppressions(root: Folder, localSuppression?: FingerprintSuppression) { + let resources: Resource[] = RESOURCES.get(root.id); + + if (!resources || resources.length === 0) { + resources = await getResourcesFromFolder(root.uri.fsPath); + resources = resources.map(resource => { + return { + ...resource, + filePath: relative(root.uri.fsPath, resource.filePath), + }; + }); + } + + if (!resources.length) { + return null; + } + + const workspaceConfig = await getWorkspaceConfig(root); + + if (workspaceConfig.isValid === false) { + return null; + } + + const validatorObj = await getValidator(root.id, workspaceConfig.config); + const response = await getValidationResult(root.id); + const suppressions = getSuppressions(root.uri.fsPath); + + if (localSuppression && !suppressions.suppressions.find(sup => sup.fingerprint === localSuppression.fingerprint)) { + suppressions.suppressions.push(localSuppression); + } + + let result: ValidationResponse = null; + try { + result = await validatorObj.validator.applySuppressions(response, resources, suppressions.suppressions); + } catch(err: any) { + logger.error('Applying suppressions failed', err); + } + + if (!result) { + return null; + } + + result.runs.forEach(run => { + run.results.forEach((result: any) => { + const location = result.locations.find(location => location.physicalLocation?.artifactLocation?.uriBaseId === 'SRCROOT'); + + if (location && location.physicalLocation.artifactLocation.uri) { + location.physicalLocation.artifactLocation.uri = normalizePathForWindows(location.physicalLocation.artifactLocation.uri); + } + }); + }); + + // This causes SARIF panel to reload so we want to write new results only when they are different. + const resultUnchanged = await areValidationResultsSame(RESULTS.get(root.id), result); + if (!resultUnchanged) { + await saveValidationResults(result, root.id); + } + + RESULTS.set(root.id, result); + globals.setFolderStatus(root); + + const resultFilePath = await getValidationResultPath(root.id); + + return Uri.file(resultFilePath); +} + export async function getValidationResult(fileName: string) { const filePath = getValidationResultPath(fileName); diff --git a/src/utils/workspace.ts b/src/utils/workspace.ts index 064dad2..9a3d1b9 100644 --- a/src/utils/workspace.ts +++ b/src/utils/workspace.ts @@ -37,6 +37,10 @@ export function getWorkspaceFolders(): Folder[] { })); } +export function getOwnerWorkspace(document: TextDocument): Folder { + return getWorkspaceFolders().find(folder => isSubpath(folder.uri, document.uri.fsPath)); +} + // Config precedence: // 1. Remote policy (if user logged in). // - If there is an error fetching (any other than NO_POLICY), fallback to other options. @@ -48,8 +52,8 @@ export async function getWorkspaceConfig(workspaceFolder: Folder): Promise