diff --git a/src/index.ts b/src/index.ts index 00f79bc5..0d2584a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,31 +1,24 @@ import { error, getInput, info, setOutput, warning } from '@actions/core' -import { appendFileSync, existsSync } from 'fs' +import { appendFileSync, existsSync, mkdirSync } from 'fs' +import * as path from 'path' import { downloadArtifact, postCommentIfInPr, resolveExistingCommentIfFound, uploadArtifact, } from './actions' -import { compareResults } from './tool' import { callCommand, - callLaceworkCli, - debug, + codesecRun, getActionRef, getMsSinceStart, getOptionalEnvVariable, getRequiredEnvVariable, getRunUrl, + readMarkdownFile, telemetryCollector, } from './util' -import path from 'path' - -const scaSarifReport = 'scaReport/output.sarif' -const scaReport = 'sca.sarif' -const scaLWJSONReport = 'scaReport/output-lw.json' -const scaDir = 'scaReport' - async function runAnalysis() { const target = getInput('target') @@ -43,73 +36,127 @@ async function runAnalysis() { telemetryCollector.addField('tools', 'sca') const toUpload: string[] = [] - // command to print both sarif and lwjson formats - var args = ['sca', 'scan', '.', '-o', scaDir, '--formats', 'sarif,lw-json', '--deployment', 'ci'] - if (target === 'push') { - args.push('--save-results') + // Run codesec Docker scanner + // targetScan: 'new'/'old' for PR mode, 'scan' for push mode (uploads to Lacework UI) + var targetScan = target + if (target == 'push') { + targetScan = 'scan' } - if (debug()) { - args.push('--debug') + const resultsPath = await codesecRun('scan', true, true, targetScan) + + // Upload SCA SARIF from the returned results path + const scaSarifFile = path.join(resultsPath, 'sca', `sca-${targetScan}.sarif`) + if (existsSync(scaSarifFile)) { + info(`Found SCA SARIF file to upload: ${scaSarifFile}`) + toUpload.push(scaSarifFile) + } else { + info(`SCA SARIF file not found at: ${scaSarifFile}`) } - await callLaceworkCli(...args) - // make a copy of the sarif file - args = [scaSarifReport, scaReport] - await callCommand('cp', ...args) - toUpload.push(scaReport) + // Upload IAC JSON from the returned results path + const iacJsonFile = path.join(resultsPath, 'iac', `iac-${targetScan}.json`) + if (existsSync(iacJsonFile)) { + info(`Found IAC JSON file to upload: ${iacJsonFile}`) + toUpload.push(iacJsonFile) + } else { + info(`IAC JSON file not found at: ${iacJsonFile}`) + } const uploadStart = Date.now() const artifactPrefix = getInput('artifact-prefix') - if (artifactPrefix !== '') { - await uploadArtifact(artifactPrefix + '-results-' + target, ...toUpload) - } else { - await uploadArtifact('results-' + target, ...toUpload) - } + const artifactName = + artifactPrefix !== '' ? artifactPrefix + '-results-' + target : 'results-' + target + info(`Uploading artifact '${artifactName}' with ${toUpload.length} file(s)`) + await uploadArtifact(artifactName, ...toUpload) telemetryCollector.addField('duration.upload-artifacts', (Date.now() - uploadStart).toString()) setOutput(`${target}-completed`, true) } async function displayResults() { info('Displaying results') - const downloadStart = Date.now() + + // Download artifacts from previous jobs const artifactOld = await downloadArtifact('results-old') const artifactNew = await downloadArtifact('results-new') - telemetryCollector.addField( - 'duration.download-artifacts', - (Date.now() - downloadStart).toString() - ) - const sarifFileOld = path.join(artifactOld, scaReport) - const sarifFileNew = path.join(artifactNew, scaReport) - - const issuesByTool: { [tool: string]: string } = {} - if (existsSync(sarifFileOld) && existsSync(sarifFileNew)) { - issuesByTool['sca'] = await compareResults('sca', sarifFileOld, sarifFileNew) - } else { - throw new Error('SARIF file not found for SCA') + + // Create local scan-results directory for compare + mkdirSync('scan-results/sca', { recursive: true }) + mkdirSync('scan-results/iac', { recursive: true }) + + // Check and copy files for each scanner type + const scaAvailable = await prepareScannerFiles('sca', artifactOld, artifactNew) + const iacAvailable = await prepareScannerFiles('iac', artifactOld, artifactNew) + + // Need at least one scanner to compare + if (!scaAvailable && !iacAvailable) { + info('No scanner files available for comparison. Nothing to compare.') + setOutput('display-completed', true) + return } - const commentStart = Date.now() - if (Object.values(issuesByTool).some((x) => x.length > 0) && getInput('token').length > 0) { - info('Posting comment to GitHub PR as there were new issues introduced:') - let message = '' - for (const [, issues] of Object.entries(issuesByTool)) { - if (issues.length > 0) { - message += issues - } - } - if (getInput('footer') !== '') { - message += '\n\n' + getInput('footer') + // Run codesec compare mode with available scanners + await codesecRun('compare', iacAvailable, scaAvailable) + + // Read comparison output - check all possible outputs + const outputs = [ + 'scan-results/compare/merged-compare.md', + 'scan-results/compare/sca-compare.md', + 'scan-results/compare/iac-compare.md', + ] + + let message: string | null = null + for (const output of outputs) { + if (existsSync(output)) { + info(`Using comparison output: ${output}`) + message = readMarkdownFile(output) + break } - info(message) + } + + if (!message) { + info('No comparison output produced. No changes detected.') + setOutput('display-completed', true) + return + } + + // Check if there are new violations (non-zero count in "Found N new potential violations") + const hasViolations = /Found\s+[1-9]\d*\s+/.test(message) + + if (hasViolations && getInput('token').length > 0) { + info('Posting comment to GitHub PR as there were new issues introduced') const commentUrl = await postCommentIfInPr(message) if (commentUrl !== undefined) { setOutput('posted-comment', commentUrl) } } else { + // No new violations or no token - resolve existing comment if found await resolveExistingCommentIfFound() } - telemetryCollector.addField('duration.comment', (Date.now() - commentStart).toString()) - setOutput(`display-completed`, true) + + setOutput('display-completed', true) +} + +async function prepareScannerFiles( + scanner: 'sca' | 'iac', + artifactOld: string, + artifactNew: string +): Promise { + const ext = scanner === 'sca' ? 'sarif' : 'json' + const oldPath = path.join(artifactOld, 'scan-results', scanner, `${scanner}-old.${ext}`) + const newPath = path.join(artifactNew, 'scan-results', scanner, `${scanner}-new.${ext}`) + + const oldExists = existsSync(oldPath) + const newExists = existsSync(newPath) + + if (!oldExists || !newExists) { + info(`${scanner.toUpperCase()} files not found for compare. old=${oldExists}, new=${newExists}`) + return false + } + + info(`Copying ${scanner.toUpperCase()} files for compare`) + await callCommand('cp', oldPath, path.join('scan-results', scanner, `${scanner}-old.${ext}`)) + await callCommand('cp', newPath, path.join('scan-results', scanner, `${scanner}-new.${ext}`)) + return true } async function main() { diff --git a/src/tool.ts b/src/tool.ts deleted file mode 100644 index 81935c53..00000000 --- a/src/tool.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { endGroup, startGroup } from '@actions/core' -import { existsSync, readFileSync } from 'fs' -import { callLaceworkCli, debug, generateUILink } from './util' - -export async function compareResults( - tool: string, - oldReport: string, - newReport: string -): Promise { - startGroup(`Comparing ${tool} results`) - const args = [ - tool, - 'compare', - '--old', - oldReport, - '--new', - newReport, - '--markdown', - `${tool}.md`, - '--markdown-variant', - 'GitHub', - '--deployment', - 'ci', - ] - - const uiLink = generateUILink() - if (uiLink) args.push(...['--ui-link', uiLink]) - - if (debug()) args.push('--debug') - await callLaceworkCli(...args) - endGroup() - return existsSync(`${tool}.md`) ? readFileSync(`${tool}.md`, 'utf8') : '' -} diff --git a/src/util.ts b/src/util.ts index d213c790..97a42ca8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -3,6 +3,8 @@ import { context } from '@actions/github' import { spawn } from 'child_process' import { TelemetryCollector } from './telemetry' import { readFileSync } from 'fs' +import * as path from 'path' +import { mkdirSync, existsSync } from 'fs' export const telemetryCollector = new TelemetryCollector() @@ -110,3 +112,185 @@ export function generateUILink() { return url } + +// codesecRun - Docker-based scanner using codesec:latest image +// Follows the pattern from test-unified-scanner.sh for CI runner compatibility +// +// Modes: +// 1. action='scan', scanTarget='new'/'old' -> produces analysis for PR comment +// 2. action='scan', scanTarget='scan' -> full scan for scheduled events (uploads to Lacework) +// 3. action='compare' -> compares new/old results, generates diff markdown for PR comment +// +// Parameters: +// - runIac/runSca: which scanners to enable (default false - enable when ready to test) +// - scanTarget: 'new', 'old', or 'scan' depending on mode +export async function codesecRun( + action: string, + runIac: boolean = false, + runSca: boolean = false, + scanTarget?: string +): Promise { + const lwAccount = getRequiredEnvVariable('LW_ACCOUNT_NAME') + const lwApiKey = getRequiredEnvVariable('LW_API_KEY') + const lwApiSecret = getRequiredEnvVariable('LW_API_SECRET') + + // Create scan-results directory in workspace (required for artifact upload) + const reportsDir = path.join(process.cwd(), 'scan-results') + + if (action === 'scan') { + // Scan mode: mount repo as /app/src, results go to /tmp/scan-results/ in container + const containerName = `codesec-scan-${scanTarget || 'default'}` + + info(`Running codesec scan (target: ${scanTarget || 'scan'})`) + + // Run the scanner + const dockerArgs = [ + 'run', + '--name', + containerName, + '-v', + `${process.cwd()}:/app/src`, + '-e', + `WORKSPACE=src`, + '-e', + `LW_ACCOUNT=${lwAccount}`, + '-e', + `LW_API_KEY=${lwApiKey}`, + '-e', + `LW_API_SECRET=${lwApiSecret}`, + '-e', + `RUN_SCA=${runSca}`, + '-e', + `RUN_IAC=${runIac}`, + '-e', + `SCAN_TARGET=${scanTarget || 'scan'}`, + 'lacework/codesec:latest', + 'scan', + ] + + await callCommand('docker', ...dockerArgs) + + // Copy results out of container to temp dir + if (runSca) { + const scaDir = path.join(reportsDir, 'sca') + mkdirSync(scaDir, { recursive: true }) + await callCommand( + 'docker', + 'container', + 'cp', + `${containerName}:/tmp/scan-results/sca/sca-${scanTarget || 'scan'}.sarif`, + path.join(scaDir, `sca-${scanTarget || 'scan'}.sarif`) + ) + } + + if (runIac) { + const iacDir = path.join(reportsDir, 'iac') + mkdirSync(iacDir, { recursive: true }) + await callCommand( + 'docker', + 'container', + 'cp', + `${containerName}:/tmp/scan-results/iac/iac-${scanTarget || 'scan'}.json`, + path.join(iacDir, `iac-${scanTarget || 'scan'}.json`) + ) + } + + // Cleanup container + await callCommand('docker', 'rm', containerName) + } else if (action === 'compare') { + const containerName = 'codesec-compare' + + info('Running codesec compare') + + // Note: mounts both the repo and the scan-results directory separately + const dockerArgs = [ + 'run', + '--name', + containerName, + '-v', + `${process.cwd()}:/app/src`, + '-v', + `${path.join(process.cwd(), 'scan-results')}:/app/scan-results`, + '-e', + `WORKSPACE=src`, + '-e', + `LW_ACCOUNT=${lwAccount}`, + '-e', + `LW_API_KEY=${lwApiKey}`, + '-e', + `LW_API_SECRET=${lwApiSecret}`, + '-e', + `RUN_SCA=${runSca}`, + '-e', + `RUN_IAC=${runIac}`, + 'lacework/codesec:latest', + 'compare', + ] + + await callCommand('docker', ...dockerArgs) + + // Copy comparison results out + const compareDir = path.join(reportsDir, 'compare') + mkdirSync(compareDir, { recursive: true }) + + // Copy all available comparison outputs + // merged-compare.md exists when both SCA and IAC comparisons succeed + // sca-compare.md / iac-compare.md exist for individual comparisons + let copiedAny = false + + try { + await callCommand( + 'docker', + 'container', + 'cp', + `${containerName}:/tmp/scan-results/compare/merged-compare.md`, + path.join(compareDir, 'merged-compare.md') + ) + copiedAny = true + } catch { + info('Merged compare output not found (partial compare mode)') + } + + try { + await callCommand( + 'docker', + 'container', + 'cp', + `${containerName}:/tmp/scan-results/compare/sca-compare.md`, + path.join(compareDir, 'sca-compare.md') + ) + copiedAny = true + } catch { + info('SCA compare output not found (may have been skipped)') + } + + try { + await callCommand( + 'docker', + 'container', + 'cp', + `${containerName}:/tmp/scan-results/compare/iac-compare.md`, + path.join(compareDir, 'iac-compare.md') + ) + copiedAny = true + } catch { + info('IAC compare output not found (may have been skipped)') + } + + if (!copiedAny) { + throw new Error('No comparison outputs found in container') + } + + // Cleanup container + await callCommand('docker', 'rm', containerName) + } + return reportsDir +} + +export function readMarkdownFile(filePath: string): string { + try { + return readFileSync(filePath, 'utf-8') + } catch (error) { + throw new Error(`Failed to read scanner output file: ${error}`) + } +}