diff --git a/src/commands/action/core/classes.ts b/src/commands/action/core/classes.ts index 071d6e6d..87608466 100644 --- a/src/commands/action/core/classes.ts +++ b/src/commands/action/core/classes.ts @@ -221,7 +221,7 @@ export class FullScanParams { commitMessage?: string commitHash?: string pullRequest?: number - committer?: string + committers?: string makeDefaultBranch?: boolean setAsPendingHead?: boolean diff --git a/src/commands/action/core/git_interface.ts b/src/commands/action/core/git_interface.ts new file mode 100644 index 00000000..77b5e6e3 --- /dev/null +++ b/src/commands/action/core/git_interface.ts @@ -0,0 +1,92 @@ +import simpleGit, { SimpleGit, DefaultLogFields } from 'simple-git' + +export interface GitInfo { + path: string + head: string + repoName: string + branch: string + author: string + commitSHA: string + commitMessage: string + committer: string + showFiles: string[] + changedFiles: string[] +} + +export async function gitInfo(path: string): Promise { + const repo = simpleGit(path) + + let head: string + let commit: DefaultLogFields | null = null + let repoName: string = '' + let branch: string = '' + let author: string = '' + let commitSHA: string = '' + let commitMessage: string = '' + let committer: string = '' + const showFiles: string[] = [] + const changedFiles: string[] = [] + + // Get the HEAD reference + head = await repo.revparse(['HEAD']) + + // Get the latest commit log + const logEntry = await repo.log({ n: 1 }) + commit = logEntry.latest + + // Extract the repository name from the origin remote URL + const remotes = await repo.getRemotes(true) + const originRemote = remotes.find(remote => remote.name === 'origin') + + if (originRemote) { + const url = originRemote.refs.fetch + repoName = url.split('/').pop()?.replace('.git', '') || '' + } + + // Get the current branch + try { + const branches = await repo.branchLocal() + branch = decodeURIComponent(branches.current) + } catch (error) { + console.error('Failed to get branch information:', error) + } + + // Populate commit details + if (commit) { + author = commit.author_name || '' + commitSHA = commit.hash || '' + commitMessage = commit.message || '' + committer = commit.author_email || '' + } + + // List files changed in the latest commit + if (commitSHA) { + const changedFilesOutput = await repo.raw([ + 'show', + '--name-only', + '--format=%n', + commitSHA + ]) + + changedFilesOutput + .split('\n') + .filter(item => item.trim() !== '') + .forEach(item => { + showFiles.push(item) + changedFiles.push(`${path}/${item}`) + }) + } + + return { + path, + head, + repoName, + branch, + author, + commitSHA: commitSHA, + commitMessage, + committer, + showFiles, + changedFiles + } +} diff --git a/src/commands/action/core/github.ts b/src/commands/action/core/github.ts index 3cea8b70..2d2de6d6 100644 --- a/src/commands/action/core/github.ts +++ b/src/commands/action/core/github.ts @@ -88,7 +88,7 @@ export class GitHub { } } - static checkEventType(): string | null { + checkEventType(): string | null { switch (env['GITHUB_EVENT_NAME']?.toLowerCase()) { case 'push': return env['PR_NUMBER'] ? 'diff' : 'main' @@ -112,7 +112,7 @@ export class GitHub { } } - static async addSocketComments( + async addSocketComments( securityComment: string, overviewComment: string, comments: Record, @@ -132,7 +132,7 @@ export class GitHub { ) } else { debug('Posting new Dependency Overview comment') - await GitHub.postComment(overviewComment) + await this.postComment(overviewComment) } } @@ -146,19 +146,19 @@ export class GitHub { ) } else { debug('Posting new Security Issue comment') - await GitHub.postComment(securityComment) + await this.postComment(securityComment) } } } - static async postComment(body: string): Promise { + async postComment(body: string): Promise { const repo = env['GITHUB_REPOSITORY']?.split('/')[1] const path = `repos/${env['GITHUB_REPOSITORY_OWNER']}/${repo}/issues/${env['PR_NUMBER']}/comments` const payload = JSON.stringify({ body }) await fetch(path, { body: payload, method: 'POST', headers }) } - static async updateComment(body: string, commentId: string): Promise { + async updateComment(body: string, commentId: string): Promise { const repo = env['GITHUB_REPOSITORY']?.split('/')[1] const path = `repos/${env['GITHUB_REPOSITORY_OWNER']}/${repo}/issues/comments/${commentId}` const payload = JSON.stringify({ body }) @@ -174,7 +174,7 @@ export class GitHub { file.close() } - static async getCommentsForPr( + async getCommentsForPR( repo: string, pr: string ): Promise> { @@ -196,14 +196,35 @@ export class GitHub { return Comments.checkForSocketComments(comments) } - static async postReaction(commentId: number): Promise { + removeCommentAlerts(comments: Record): void { + const securityAlert = comments['security'] + + if (securityAlert) { + const newBody = Comments.processSecurityComment(securityAlert, comments) + this.handleIgnoreReactions(comments) + this.updateComment(newBody, String(securityAlert.id)) + } + } + + handleIgnoreReactions(comments: Record): void { + if (comments['ignore']) { + for (const comment of comments['ignore']) { + if (comment.body.includes('SocketSecurity ignore')) { + if (!this.commentReactionExists(comment.id)) { + this.postReaction(comment.id) + } + } + } + } + } + async postReaction(commentId: number): Promise { const repo = env['GITHUB_REPOSITORY']?.split('/')[1] const path = `repos/${env['GITHUB_REPOSITORY_OWNER']}/${repo}/issues/comments/${commentId}/reactions` const payload = JSON.stringify({ content: '+1' }) await fetch(path, { body: payload, method: 'POST', headers }) } - static async commentReactionExists(commentId: number): Promise { + async commentReactionExists(commentId: number): Promise { const repo = env['GITHUB_REPOSITORY']?.split('/')[1] const path = `repos/${env['GITHUB_REPOSITORY_OWNER']}/${repo}/issues/comments/${commentId}/reactions` try { diff --git a/src/commands/action/core/scm_comments.ts b/src/commands/action/core/scm_comments.ts index 51ca6444..6272fe20 100644 --- a/src/commands/action/core/scm_comments.ts +++ b/src/commands/action/core/scm_comments.ts @@ -172,8 +172,8 @@ export function processSecurityComment( export function checkForSocketComments( comments: Record -): Record { - const socketComments: Record = {} +): Record { + const socketComments: Record = {} for (const [commentId, comment] of Object.entries(comments)) { if (comment.body?.includes('socket-security-comment-actions')) { diff --git a/src/commands/action/git.ts b/src/commands/action/git.ts deleted file mode 100644 index 8ca72ad6..00000000 --- a/src/commands/action/git.ts +++ /dev/null @@ -1,73 +0,0 @@ -import simpleGit, { SimpleGit, DefaultLogFields } from 'simple-git' - -export class Git { - private repo: SimpleGit - public path: string - public head: string | null = null - public commit: DefaultLogFields | null = null - public repoName: string | null = null - public branch: string | null = null - public author: string | null = null - public commitSha: string | null = null - public commitMessage: string | null = null - public committer: string | null = null - public showFiles: string[] = [] - public changedFiles: string[] = [] - - constructor(path: string) { - this.path = path - // TODO: what if there's no repo? - this.repo = simpleGit(path) - } - - async init(): Promise { - // Get the HEAD reference - this.head = await this.repo.revparse(['HEAD']) - - // Get the latest commit log - const logEntry = await this.repo.log({ n: 1 }) - this.commit = logEntry.latest - - // Extract the repository name from the origin remote URL - const remotes = await this.repo.getRemotes(true) - const originRemote = remotes.find(remote => remote.name === 'origin') - - if (originRemote) { - const url = originRemote.refs.fetch - this.repoName = url.split('.git')[0]?.split('/').pop() || null - } - - // Get the current branch - try { - const branches = await this.repo.branchLocal() - const currentBranch = branches.current - this.branch = decodeURIComponent(currentBranch) - } catch (error) { - this.branch = null - console.error('Failed to get branch information:', error) - } - - // Populate commit details - if (this.commit) { - this.author = this.commit.author_name || null - this.commitSha = this.commit.hash || null - this.commitMessage = this.commit.message || null - this.committer = this.commit.author_email || null - } - - // List files changed in the latest commit - if (this.commitSha) { - const changedFilesOutput = await this.repo.raw([ - 'show', - '--name-only', - '--format=%n', - this.commitSha - ]) - - this.showFiles = changedFilesOutput - .split('\n') - .filter(item => item.trim() !== '') - this.changedFiles = this.showFiles.map(item => `${this.path}/${item}`) - } - } -} diff --git a/src/commands/action/index.ts b/src/commands/action/index.ts index 516a9b24..b5097b05 100644 --- a/src/commands/action/index.ts +++ b/src/commands/action/index.ts @@ -2,7 +2,7 @@ import yargsParse, { Options } from 'yargs-parser' import { CliSubcommand } from '../../utils/meow-with-subcommands' import { pluralize } from '@socketsecurity/registry/lib/words' import { readFileSync, existsSync } from 'node:fs' -import { Git } from './git' +import { Git, gitInfo } from './core/git_interface.ts' import { GitError } from 'simple-git' import { GitHub } from './core/github' import { setupSdk } from '../../utils/sdk' @@ -10,6 +10,81 @@ import { handleUnsuccessfulApiResponse } from '../../utils/api-helpers' import { SocketSdkReturnType } from '@socketsecurity/sdk' import { ErrorWithCause } from 'pony-cause' import yoctoSpinner from '@socketregistry/yocto-spinner' +import * as comments from './core/scm_comments.ts' +import { createDebugLogger } from '../../utils/misc' +import { Diff, FullScanParams } from './core/classes.ts' + +const debug = createDebugLogger(false) + +function outputConsoleComments( + diffReport: Diff, + sbomFileName: string | null = null +): void { + if (diffReport.id !== 'NO_DIFF_RAN') { + const consoleSecurityComment = + Messages.createConsoleSecurityAlertTable(diffReport) + saveSbomFile(diffReport, sbomFileName) + console.log(`Socket Full Scan ID: ${diffReport.id}`) + if (diffReport.newAlerts.length > 0) { + console.log('Security issues detected by Socket Security') + const msg = `\n${consoleSecurityComment}` + console.log(msg) + if (!reportPass(diffReport) && !blockingDisabled) { + process.exit(1) + } else { + // Means only warning alerts with no blocked + if (!blockingDisabled) { + process.exit(5) + } + } + } else { + console.log('No New Security issues detected by Socket Security') + } + } +} + +function outputConsoleJson( + diffReport: Diff, + sbomFileName: string | null = null +): void { + if (diffReport.id !== 'NO_DIFF_RAN') { + const consoleSecurityComment = + Messages.createSecurityCommentJson(diffReport) + saveSbomFile(diffReport, sbomFileName) + console.log(JSON.stringify(consoleSecurityComment)) + if (!reportPass(diffReport) && !blockingDisabled) { + process.exit(1) + } else if (diffReport.newAlerts.length > 0 && !blockingDisabled) { + // Means only warning alerts with no blocked + process.exit(5) + } + } +} + +function reportPass(diffReport: Diff): boolean { + let reportPassed = true + if (diffReport.newAlerts.length > 0) { + for (const alert of diffReport.newAlerts) { + if (reportPassed && alert.error) { + reportPassed = false + break + } + } + } + return reportPassed +} + +function saveSbomFile( + diffReport: Diff, + sbomFileName: string | null = null +): void { + if (diffReport !== null && sbomFileName !== null) { + Core.saveFile( + sbomFileName, + JSON.stringify(Core.createSbomOutput(diffReport)) + ) + } +} const yargsConfig: Options = { string: [ @@ -122,9 +197,26 @@ export const action: CliSubcommand = { process.exit(1) } try { - const gitRepo = new Git(targetPath) - await gitRepo.init() - // TODO: https://github.com/SocketDev/socket-python-cli/blob/main/socketsecurity/socketcli.py#L273 + const gitRepo = await gitInfo(targetPath) + if (!repo) { + repo = gitRepo.repoName + } + if (!commitSHA || commitSHA === '') { + commitSHA = gitRepo.commit + } + if (!branch || branch === '') { + branch = gitRepo.branch + } + if (!committer || committer === '') { + committer = gitRepo.committer + } + if (!commitMessage || commitMessage === '') { + commitMessage = gitRepo.commitMessage + } + if (files.length === 0 && !ignoreCommitFiles) { + files = gitRepo.changedFiles + isRepo = true + } } catch (e) { if (e instanceof GitError) { isRepo = false @@ -179,5 +271,127 @@ export const action: CliSubcommand = { }) } // TODO: ... + let setAsPendingHead = false + if (defaultBranch) { + setAsPendingHead = true + } + const params = new FullScanParams({ + repo, + branch, + commitMessage, + commitHash: commitSHA, + pullRequest: prNumber, + committers: committer, + makeDefaultBranch: defaultBranch, + setAsPendingHead + }) + let diff: Diff = new Diff() + diff.id = 'NO_DIFF_RAN' + if (scm !== null && scm.checkEventType() === 'comment') { + console.log('Comment initiated flow') + debug( + `Getting comments for Repo ${scm.repository} for PR ${scm.prNumber}` + ) + const comments = scm.getCommentsForPr( + scm.repository, + String(scm.prNumber) + ) + debug('Removing comment alerts') + scm.removeCommentAlerts(comments) + } else if (scm !== null && scm.checkEventType() !== 'comment') { + console.log('Push initiated flow') + if (noChange) { + console.log('No manifest files changes, skipping scan') + // console.log("No dependency changes"); + } else if (scm.checkEventType() === 'diff') { + diff = core.createNewDiff(targetPath, params, targetPath, noChange) + console.log('Starting comment logic for PR/MR event') + debug( + `Getting comments for Repo ${scm.repository} for PR ${scm.prNumber}` + ) + const comments = scm.getCommentsForPr(repo, String(prNumber)) + debug('Removing comment alerts') + diff.newAlerts = Comments.removeAlerts(comments, diff.newAlerts) + debug('Creating Dependency Overview Comment') + const overviewComment = Messages.dependencyOverviewTemplate(diff) + debug('Creating Security Issues Comment') + const securityComment = Messages.securityCommentTemplate(diff) + let newSecurityComment = true + let newOverviewComment = true + const updateOldSecurityComment = + securityComment === null || + securityComment === '' || + (comments.length !== 0 && comments.get('security') !== null) + const updateOldOverviewComment = + overviewComment === null || + overviewComment === '' || + (comments.length !== 0 && comments.get('overview') !== null) + if (diff.newAlerts.length === 0 || disableSecurityIssue) { + if (!updateOldSecurityComment) { + newSecurityComment = false + debug('No new alerts or security issue comment disabled') + } else { + debug('Updated security comment with no new alerts') + } + } + if ( + (diff.newPackages.length === 0 && + diff.removedPackages.length === 0) || + disableOverview + ) { + if (!updateOldOverviewComment) { + newOverviewComment = false + debug( + 'No new/removed packages or Dependency Overview comment disabled' + ) + } else { + debug('Updated overview comment with no dependencies') + } + } + debug(`Adding comments for ${scmType}`) + scm.addSocketComments( + securityComment, + overviewComment, + comments, + newSecurityComment, + newOverviewComment + ) + } else { + console.log('Starting non-PR/MR flow') + diff = core.createNewDiff(targetPath, params, targetPath, noChange) + } + if (enableJson) { + debug('Outputting JSON Results') + outputConsoleJson(diff, sbomFile) + } else { + outputConsoleComments(diff, sbomFile) + } + } else { + console.log('API Mode') + diff = core.createNewDiff(targetPath, params, targetPath, noChange) + if (enableJson) { + outputConsoleJson(diff, sbomFile) + } else { + outputConsoleComments(diff, sbomFile) + } + } + if (diff !== null && licenseMode) { + const allPackages: { [key: string]: any } = {} + for (const packageId in diff.packages) { + const packageInfo: Package = diff.packages[packageId] + const output = { + id: packageId, + name: packageInfo.name, + version: packageInfo.version, + ecosystem: packageInfo.type, + direct: packageInfo.direct, + url: packageInfo.url, + license: packageInfo.license, + license_text: packageInfo.licenseText + } + allPackages[packageId] = output + } + core.saveFile(licenseFile, JSON.stringify(allPackages)) + } } }