packages/core/src/codewhisperer/service/securityScanHandler.ts (437 lines of code) (raw):

/*! * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ import { DefaultCodeWhispererClient } from '../client/codewhisperer' import { getLogger } from '../../shared/logger/logger' import * as vscode from 'vscode' import { AggregatedCodeScanIssue, CodeScanIssue, CodeScansState, codeScanState, CodeScanStoppedError, onDemandFileScanState, RegionProfile, } from '../models/model' import { sleep } from '../../shared/utilities/timeoutUtils' import * as codewhispererClient from '../client/codewhisperer' import * as CodeWhispererConstants from '../models/constants' import { existsSync, statSync, readFileSync } from 'fs' // eslint-disable-line no-restricted-imports import { RawCodeScanIssue } from '../models/model' import * as crypto from 'crypto' import path = require('path') import { pageableToCollection } from '../../shared/utilities/collectionUtils' import { ArtifactMap, CreateUploadUrlRequest, CreateUploadUrlResponse } from '../client/codewhispereruserclient' import { TelemetryHelper } from '../util/telemetryHelper' import request, { RequestError } from '../../shared/request' import { ZipMetadata } from '../util/zipUtil' import { getNullLogger } from '../../shared/logger/logger' import { CreateCodeScanError, CreateUploadUrlError, InvalidSourceZipError, SecurityScanTimedOutError, UploadArtifactToS3Error, } from '../models/errors' import { getTelemetryReasonDesc, isAwsError } from '../../shared/errors' import { CodeWhispererSettings } from '../util/codewhispererSettings' import { detectCommentAboveLine } from '../../shared/utilities/commentUtils' import { runtimeLanguageContext } from '../util/runtimeLanguageContext' import { FeatureUseCase } from '../models/constants' import { UploadTestArtifactToS3Error } from '../../amazonqTest/error' import { ChatSessionManager } from '../../amazonqTest/chat/storages/chatSession' import { AmazonqCreateUpload, Span, telemetry } from '../../shared/telemetry/telemetry' import { AuthUtil } from '../util/authUtil' export async function listScanResults( client: DefaultCodeWhispererClient, jobId: string, codeScanFindingsSchema: string, projectPaths: string[], scope: CodeWhispererConstants.CodeAnalysisScope, editor: vscode.TextEditor | undefined, profile?: RegionProfile ) { const logger = getLoggerForScope(scope) const codeScanIssueMap: Map<string, RawCodeScanIssue[]> = new Map() const aggregatedCodeScanIssueList: AggregatedCodeScanIssue[] = [] const requester = (request: codewhispererClient.ListCodeScanFindingsRequest) => client.listCodeScanFindings(request, profile?.arn) const request: codewhispererClient.ListCodeScanFindingsRequest = { jobId, codeAnalysisFindingsSchema: codeScanFindingsSchema, profileArn: profile?.arn, } const collection = pageableToCollection(requester, request, 'nextToken') const issues = await collection .flatten() .map((resp) => { logger.verbose(`ListCodeScanFindingsRequest requestId: ${resp.$response.requestId}`) if ('codeScanFindings' in resp) { return resp.codeScanFindings } return resp.codeAnalysisFindings }) .promise() for (const issue of issues) { mapToAggregatedList(codeScanIssueMap, issue, editor, scope) } for (const [key, issues] of codeScanIssueMap.entries()) { // Project path example: /Users/username/project // Key example: project/src/main/java/com/example/App.java const mappedProjectPaths: Set<string> = new Set() for (const projectPath of projectPaths) { // There could be multiple projectPaths with the same parent dir // In that case, make sure to break out of this loop after a filePath is found // or else it might result in duplicate findings. const filePath = path.join(projectPath, '..', key) if (existsSync(filePath) && statSync(filePath).isFile()) { mappedProjectPaths.add(filePath) const document = await vscode.workspace.openTextDocument(filePath) const aggregatedCodeScanIssue: AggregatedCodeScanIssue = { filePath: filePath, issues: issues.map((issue) => mapRawToCodeScanIssue(issue, document, jobId, scope)), } aggregatedCodeScanIssueList.push(aggregatedCodeScanIssue) break } } const maybeAbsolutePath = `/${key}` if ( !mappedProjectPaths.has(maybeAbsolutePath) && existsSync(maybeAbsolutePath) && statSync(maybeAbsolutePath).isFile() ) { const document = await vscode.workspace.openTextDocument(maybeAbsolutePath) const aggregatedCodeScanIssue: AggregatedCodeScanIssue = { filePath: maybeAbsolutePath, issues: issues.map((issue) => mapRawToCodeScanIssue(issue, document, jobId, scope)), } aggregatedCodeScanIssueList.push(aggregatedCodeScanIssue) } } return aggregatedCodeScanIssueList } function mapRawToCodeScanIssue( issue: RawCodeScanIssue, document: vscode.TextDocument, jobId: string, scope: CodeWhispererConstants.CodeAnalysisScope ): CodeScanIssue { const isIssueTitleIgnored = CodeWhispererSettings.instance.getIgnoredSecurityIssues().includes(issue.title) const isSingleIssueIgnored = detectCommentAboveLine( document, issue.startLine - 1, CodeWhispererConstants.amazonqIgnoreNextLine ) const language = runtimeLanguageContext.getLanguageContext( document.languageId, path.extname(document.fileName) ).language return { startLine: issue.startLine - 1 >= 0 ? issue.startLine - 1 : 0, endLine: issue.endLine, comment: `${issue.title.trim()}: ${issue.description.text.trim()}`, title: issue.title, description: issue.description, detectorId: issue.detectorId, detectorName: issue.detectorName, findingId: issue.findingId, ruleId: issue.ruleId, relatedVulnerabilities: issue.relatedVulnerabilities, severity: issue.severity, recommendation: issue.remediation.recommendation, suggestedFixes: issue.remediation.suggestedFixes, visible: !isIssueTitleIgnored && !isSingleIssueIgnored, scanJobId: jobId, language, autoDetected: scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO, } } export function mapToAggregatedList( codeScanIssueMap: Map<string, RawCodeScanIssue[]>, json: string, editor: vscode.TextEditor | undefined, scope: CodeWhispererConstants.CodeAnalysisScope ) { const codeScanIssues: RawCodeScanIssue[] = JSON.parse(json) const filteredIssues = codeScanIssues.filter((issue) => { if ( (scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND) && editor ) { for (let lineNumber = issue.startLine; lineNumber <= issue.endLine; lineNumber++) { const line = editor.document.lineAt(lineNumber - 1)?.text const codeContent = issue.codeSnippet.find((codeIssue) => codeIssue.number === lineNumber)?.content if (codeContent?.includes('***')) { // CodeSnippet contains redacted code so we can't do a direct comparison return line.length === codeContent.length } else { return line === codeContent } } } return true }) for (const issue of filteredIssues) { const filePath = issue.filePath if (codeScanIssueMap.has(filePath)) { if (!isExistingIssue(issue, codeScanIssueMap)) { codeScanIssueMap.get(filePath)?.push(issue) } else { getLogger().warn('Found duplicate issue %O, ignoring...', issue) } } else { codeScanIssueMap.set(filePath, [issue]) } } } function isDuplicateIssue(issueA: RawCodeScanIssue, issueB: RawCodeScanIssue) { return ( issueA.filePath === issueB.filePath && issueA.title === issueB.title && issueA.startLine === issueB.startLine && issueA.endLine === issueB.endLine ) } function isExistingIssue(issue: RawCodeScanIssue, codeScanIssueMap: Map<string, RawCodeScanIssue[]>) { return codeScanIssueMap.get(issue.filePath)?.some((existingIssue) => isDuplicateIssue(issue, existingIssue)) } export async function pollScanJobStatus( client: DefaultCodeWhispererClient, jobId: string, scope: CodeWhispererConstants.CodeAnalysisScope, codeScanStartTime: number, profile?: RegionProfile ) { const pollingStartTime = performance.now() // We don't expect to get results immediately, so sleep for some time initially to not make unnecessary calls await sleep(getPollingDelayMsForScope(scope)) const logger = getLoggerForScope(scope) logger.verbose(`Polling scan job status...`) let status: string = 'Pending' while (true) { throwIfCancelled(scope, codeScanStartTime) const req: codewhispererClient.GetCodeScanRequest = { jobId: jobId, profileArn: profile?.arn, } const resp = await client.getCodeScan(req) logger.verbose(`GetCodeScanRequest requestId: ${resp.$response.requestId}`) if (resp.status !== 'Pending') { status = resp.status logger.verbose(`Scan job status: ${status}`) logger.verbose(`Complete Polling scan job status.`) break } throwIfCancelled(scope, codeScanStartTime) await sleep(CodeWhispererConstants.codeScanJobPollingIntervalSeconds * 1000) const elapsedTime = performance.now() - pollingStartTime if (elapsedTime > getPollingTimeoutMsForScope(scope)) { logger.verbose(`Scan job status: ${status}`) logger.verbose(`Security Scan failed. Amazon Q timed out.`) throw new SecurityScanTimedOutError() } } return status } export async function createScanJob( client: DefaultCodeWhispererClient, artifactMap: codewhispererClient.ArtifactMap, languageId: string, scope: CodeWhispererConstants.CodeAnalysisScope, scanName: string, profile?: RegionProfile ) { const logger = getLoggerForScope(scope) logger.verbose(`Creating scan job...`) const codeAnalysisScope = scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO ? 'FILE' : 'PROJECT' const req: codewhispererClient.CreateCodeScanRequest = { artifacts: artifactMap, programmingLanguage: { languageName: languageId, }, scope: codeAnalysisScope, codeScanName: scanName, profileArn: profile?.arn, } const resp = await client.createCodeScan(req).catch((err) => { getLogger().error(`Failed creating scan job. Request id: ${err.requestId}`) if ( err.message === CodeWhispererConstants.scansLimitReachedErrorMessage && err.code === 'ThrottlingException' ) { throw err } throw new CreateCodeScanError(err) }) getLogger().info( `Amazon Q Code Review requestId: ${resp.$response.requestId} and Amazon Q Code Review jobId: ${resp.jobId}` ) TelemetryHelper.instance.sendCodeScanEvent(languageId, resp.$response.requestId) return resp } export async function getPresignedUrlAndUpload( client: DefaultCodeWhispererClient, zipMetadata: ZipMetadata, scope: CodeWhispererConstants.CodeAnalysisScope, scanName: string, profile?: RegionProfile ) { const artifactMap = await telemetry.amazonq_createUpload.run(async (span) => { const logger = getLoggerForScope(scope) if (zipMetadata.zipFilePath === '') { getLogger().error('Failed to create valid source zip') throw new InvalidSourceZipError() } const uploadIntent = getUploadIntent(scope) span.record({ amazonqUploadIntent: uploadIntent, amazonqRepositorySize: zipMetadata.srcPayloadSizeInBytes, credentialStartUrl: AuthUtil.instance.startUrl, }) const srcReq: CreateUploadUrlRequest = { contentMd5: getMd5(zipMetadata.zipFilePath), artifactType: 'SourceCode', uploadIntent: uploadIntent, uploadContext: { codeAnalysisUploadContext: { codeScanName: scanName, }, }, profileArn: profile?.arn, } logger.verbose(`Prepare for uploading src context...`) const srcResp = await client.createUploadUrl(srcReq).catch((err) => { getLogger().error(`Failed getting presigned url for uploading src context. Request id: ${err.requestId}`) span.record({ requestId: err.requestId }) throw new CreateUploadUrlError(err.message) }) logger.verbose(`CreateUploadUrlRequest request id: ${srcResp.$response.requestId}`) logger.verbose(`Complete Getting presigned Url for uploading src context.`) logger.verbose(`Uploading src context...`) await uploadArtifactToS3(zipMetadata.zipFilePath, srcResp, FeatureUseCase.CODE_SCAN, scope, span) logger.verbose(`Complete uploading src context.`) const artifactMap: ArtifactMap = { SourceCode: srcResp.uploadId, } return artifactMap }) return artifactMap } function getUploadIntent(scope: CodeWhispererConstants.CodeAnalysisScope) { if ( scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT || scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND ) { return CodeWhispererConstants.projectScanUploadIntent } else { return CodeWhispererConstants.fileScanUploadIntent } } export function getMd5(fileName: string) { const hasher = crypto.createHash('md5') hasher.update(readFileSync(fileName)) return hasher.digest('base64') } export function throwIfCancelled(scope: CodeWhispererConstants.CodeAnalysisScope, codeScanStartTime: number) { switch (scope) { case CodeWhispererConstants.CodeAnalysisScope.PROJECT: if (codeScanState.isCancelling()) { throw new CodeScanStoppedError() } break case CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND: if (onDemandFileScanState.isCancelling()) { throw new CodeScanStoppedError() } break case CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO: { const latestCodeScanStartTime = CodeScansState.instance.getLatestScanTime() if ( !CodeScansState.instance.isScansEnabled() || (latestCodeScanStartTime && latestCodeScanStartTime > codeScanStartTime) ) { throw new CodeScanStoppedError() } break } default: getLogger().warn(`Unknown code analysis scope: ${scope}`) break } } // TODO: Refactor this export async function uploadArtifactToS3( fileName: string, resp: CreateUploadUrlResponse, featureUseCase: FeatureUseCase, scope?: CodeWhispererConstants.CodeAnalysisScope, span?: Span<AmazonqCreateUpload> ) { const logger = getLoggerForScope(scope) const encryptionContext = `{"uploadId":"${resp.uploadId}"}` const headersObj: Record<string, string> = { 'Content-MD5': getMd5(fileName), 'x-amz-server-side-encryption': 'aws:kms', 'Content-Type': 'application/zip', 'x-amz-server-side-encryption-context': Buffer.from(encryptionContext, 'utf8').toString('base64'), } if (resp.kmsKeyArn !== '' && resp.kmsKeyArn !== undefined) { headersObj['x-amz-server-side-encryption-aws-kms-key-id'] = resp.kmsKeyArn } let requestId: string | undefined = undefined let id2: string | undefined = undefined let responseCode: string = '' try { const response = await request.fetch('PUT', resp.uploadUrl, { body: readFileSync(fileName), headers: resp?.requestHeaders ?? headersObj, }).response logger.debug(`StatusCode: ${response.status}, Text: ${response.statusText}`) requestId = response.headers?.get('x-amz-request-id') ?? undefined id2 = response.headers?.get('x-amz-id-2') ?? undefined responseCode = response.status.toString() } catch (error) { if (span && error instanceof RequestError) { requestId = error.response.headers.get('x-amz-request-id') ?? undefined id2 = error.response.headers.get('x-amz-id-2') ?? undefined responseCode = error.code.toString() } let errorMessage = '' const isCodeScan = featureUseCase === FeatureUseCase.CODE_SCAN const featureType = isCodeScan ? 'security scans' : 'unit test generation' const defaultMessage = isCodeScan ? 'Security scan failed.' : 'Test generation failed.' getLogger().error( `Amazon Q is unable to upload workspace artifacts to Amazon S3 for ${featureType}. ` + 'For more information, see the Amazon Q documentation or contact your network or organization administrator.' ) const errorDesc = getTelemetryReasonDesc(error) if (errorDesc?.includes('"PUT" request failed with code "403"')) { errorMessage = '"PUT" request failed with code "403"' } else if (errorDesc?.includes('"PUT" request failed with code "503"')) { errorMessage = '"PUT" request failed with code "503"' } else { errorMessage = errorDesc ?? defaultMessage } if (isAwsError(error) && featureUseCase === FeatureUseCase.TEST_GENERATION) { ChatSessionManager.Instance.getSession().startTestGenerationRequestId = error.requestId } throw isCodeScan ? new UploadArtifactToS3Error(errorMessage) : new UploadTestArtifactToS3Error(errorMessage) } finally { getLogger().debug(`Upload to S3 response details: x-amz-request-id: ${requestId}, x-amz-id-2: ${id2}`) if (span) { span.record({ requestId: requestId, requestId2: id2, requestServiceType: 's3', httpStatusCode: responseCode, }) } } } // TODO: Refactor this export function getLoggerForScope(scope?: CodeWhispererConstants.CodeAnalysisScope) { return scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO ? getNullLogger() : getLogger() } function getPollingDelayMsForScope(scope: CodeWhispererConstants.CodeAnalysisScope) { return ( (scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND ? CodeWhispererConstants.fileScanPollingDelaySeconds : CodeWhispererConstants.projectScanPollingDelaySeconds) * 1000 ) } function getPollingTimeoutMsForScope(scope: CodeWhispererConstants.CodeAnalysisScope) { return scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO ? CodeWhispererConstants.expressScanTimeoutMs : CodeWhispererConstants.standardScanTimeoutMs }