diff --git a/.github/workflows/full-loop.yml b/.github/workflows/full-loop.yml index ba48a7e2..d81a99a7 100644 --- a/.github/workflows/full-loop.yml +++ b/.github/workflows/full-loop.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 with: fetch-depth: 0 - - uses: ./ + - uses: ./actions/main name: Run action on full security-action repo id: action with: diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index 4b660f38..6dea1446 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -25,7 +25,7 @@ jobs: pwd tree -a shell: bash - - uses: ./ + - uses: ./actions/main with: debug: true github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/actions/main/action.cjs b/actions/main/action.cjs new file mode 100644 index 00000000..0ce2f644 --- /dev/null +++ b/actions/main/action.cjs @@ -0,0 +1,268 @@ +const fs = require('fs') +const { spawn } = require('child_process') + +const CONSOLE_BLUE = '\x1B[0;34m' +const CONSOLE_RED = '\x1b[0;31m' +const RESET_CONSOLE_COLOR = '\x1b[0m' + +const ASSIGNEES = `thypon +bcaller` +const HOTWORDS = `password +cryptography +login +policy +authentication +authorization +authn +authz +oauth +secure +insecure +safebrowsing +safe browsing +csp +url parse +urlparse +:disableDigestUpdates +pinDigest` + +function runCommand () { + const args = Array.prototype.slice.call(arguments) + return new Promise((resolve, reject) => { + const childProcess = spawn.apply(null, args) + + childProcess.stdout.on('data', (data) => { + console.log(`stdout: ${data}`) + }) + + childProcess.stderr.on('data', (data) => { + console.error(`stderr: ${data}`) + }) + + childProcess.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Command exited with code ${code}`)) + } else { + resolve() + } + }) + }) +} + +module.exports = async ({ github, context, inputs, actionPath, core, debug = false }) => { + const { default: getConfig } = await import(`${actionPath}/src/getConfig.js`) + const { default: getProperties } = await import(`${actionPath}/src/getProperties.js`) + + // delete if empty string in inputs value + Object.keys(inputs).forEach(key => inputs[key] === '' && delete inputs[key]) + + const config = await getConfig({ owner: context.repo.owner, repo: context.repo.repo, path: '.github/security-action.json', debug, github }) + const properties = await getProperties({ owner: context.repo.owner, repo: context.repo.repo, debug, github, prefix: 'security_action_' }) + + const options = Object.assign({ + enabled: 'true', + baseline_scan_only: 'true', + assignees: ASSIGNEES, + hotwords: HOTWORDS, + hotwords_enabled: 'true' + }, config, properties, inputs) + + options.enabled = options.enabled === 'true' + options.hotwords_enabled = options.hotwords_enabled === 'true' + options.baseline_scan_only = options.baseline_scan_only === 'true' + options.debug = options.debug ? (options.debug === 'true') : debug + + const debugLog = options.debug ? console.log : () => {} + + debugLog('Options: ', options) + + if (!options.enabled) { return } + + debugLog('Security Action enabled') + // reviewdog-enabled-pr steps + const reviewdogEnabledPr = options.baseline_scan_only && process.env.GITHUB_EVENT_NAME === 'pull_request' && context.actor !== 'dependabot[bot]' + debugLog(`Security Action enabled for PR: ${reviewdogEnabledPr}, baseline_scan_only: ${options.baseline_scan_only}, GITHUB_EVENT_NAME: ${process.env.GITHUB_EVENT_NAME}, context.actor: ${context.actor}`) + // reviewdog-enabled-full steps + const reviewdogEnabledFull = !reviewdogEnabledPr && (!options.baseline_scan_only || process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') + debugLog(`Security Action enabled for full: ${reviewdogEnabledFull}, baseline_scan_only: ${options.baseline_scan_only}, GITHUB_EVENT_NAME: ${process.env.GITHUB_EVENT_NAME}`) + // reviewdog-enabled steps + if (!reviewdogEnabledPr && !reviewdogEnabledFull) { return } + debugLog('Security Action enabled for reviewdog') + + // Install semgrep & pip-audit + await runCommand(`pip install --disable-pip-version-check -r ${actionPath}/requirements.txt`, { shell: true }) + debugLog('Installed semgrep & pip-audit') + // Install xmllint for safesvg + await runCommand('sudo apt-get install -y libxml2-utils', { shell: true }) + debugLog('Installed xmllint') + + // debug step + if (options.debug) { + const env = { + ...process.env, + ASSIGNEES: options.assignees + } + await runCommand(`${actionPath}/assets/debug.sh`, { env }) + debugLog('Debug step completed') + } + + // run-reviewdog-full step + if (reviewdogEnabledFull) { + const env = { ...process.env } + delete env.GITHUB_BASE_REF + await runCommand(`${actionPath}/assets/reviewdog.sh`, { env }) + debugLog('Reviewdog full step completed') + } + + if (reviewdogEnabledPr) { + // changed-files steps + const { default: pullRequestChangedFiles } = await import(`${actionPath}/src/pullRequestChangedFiles.js`) + const changedFiles = await pullRequestChangedFiles({ github, owner: context.repo.owner, name: context.repo.repo, prnumber: context.payload.pull_request.number }) + debugLog('Changed files:', changedFiles) + + // Write changed files to file + fs.writeFileSync(`${actionPath}/assets/all_changed_files.txt`, changedFiles.join('\0')) + debugLog('Wrote changed files to file') + + // comments-before steps + const { default: commentsNumber } = await import(`${actionPath}/src/steps/commentsNumber.js`) + const { default: cleanupComments } = await import(`${actionPath}/src/steps/cleanupComments.js`) + debugLog('Comments before:', await commentsNumber({ context, github })) + + const commentsBefore = await commentsNumber({ context, github }) + await cleanupComments({ context, github }) + + // unverified-commits steps + const { default: unverifiedCommits } = await import(`${actionPath}/src/steps/unverifiedCommits.js`) + + // add unverified-commits label step + const unverifiedCommitsSteps = await unverifiedCommits({ context, github }) + if (unverifiedCommitsSteps === '"UNVERIFIED-CHANGED"') { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['unverified-commits'] + }) + debugLog('Added unverified-commits label') + } + + // run-reviewdog-pr step + const env = { + ...process.env, + ASSIGNEES: options.assignees, + REVIEWDOG_GITHUB_API_TOKEN: options.github_token, + SEC_ACTION_DEBUG: options.debug, + PYPI_INDEX_URL: options.pip_audit_pypi_index_url, + PYPI_INSECURE_HOSTS: options.pip_audit_pypi_insecure_hosts + } + await runCommand(`${actionPath}/assets/reviewdog.sh`, { env }) + debugLog('Reviewdog PR step completed') + + // comments-after step + const commentsAfter = await commentsNumber({ context, github }) + debugLog('Comments after:', commentsAfter) + + // assignees-after step + const { default: assigneesAfter } = await import(`${actionPath}/src/steps/assigneesAfter.js`) + const assigneesAfterVal = await assigneesAfter({ context, github, assignees: options.assignees }) + debugLog('Assignees after:', assigneesAfterVal) + + // assignee-removed-label step + const { default: assigneeRemoved } = await import(`${actionPath}/src/steps/assigneeRemoved.js`) + const assigneeRemovedLabel = await assigneeRemoved({ context, github, assignees: assigneesAfterVal }) + debugLog('Assignee removed:', assigneeRemovedLabel) + + // add description-contains-hotwords step + const { default: hotwords } = await import(`${actionPath}/src/steps/hotwords.js`) + const descriptionContainsHotwords = (context.actor !== 'renovate[bot]' && options.hotwords_enabled) ? await hotwords({ context, github, hotwords: options.hotwords }) : false + debugLog('Description contains hotwords:', descriptionContainsHotwords) + + // add should-trigger label step + const shouldTrigger = reviewdogEnabledPr && context.payload.pull_request.draft === false && !assigneeRemovedLabel && ((commentsBefore < commentsAfter) || descriptionContainsHotwords) + debugLog('Should trigger:', shouldTrigger) + + if (shouldTrigger) { + // add label step + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['needs-security-review'] + }) + debugLog('Added needs-security-review label') + // add assignees step + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + assignees: assigneesAfterVal.split(/\s+/).filter((str) => str !== '') + }) + debugLog('Added assignees') + } + + const { default: sendSlackMessage } = await import(`${actionPath}/src/sendSlackMessage.js`) + + const message = `Repository: [${process.env.GITHUB_REPOSITORY}](https://github.com/${process.env.GITHUB_REPOSITORY})\npull-request: ${context.payload.pull_request.html_url}\nFindings: ${commentsAfter}` + + let githubToSlack = {} + try { + githubToSlack = JSON.parse(options.gh_to_slack_user_map) + } catch (e) { + console.log('GH_TO_SLACK_USER_MAP is not valid JSON') + } + + // assignees-slack step + const assignees = assigneesAfterVal.toLowerCase().split(/\s+/).map(e => e.trim()).filter(Boolean) + const slackAssignees = assignees.map(m => githubToSlack[m] ? githubToSlack[m] : `@${m}`).join(' ') + core.setSecret(slackAssignees) + debugLog('Slack assignees:', slackAssignees) + + // actor-slack step + const actor = githubToSlack[context.actor] ? githubToSlack[context.actor] : `@${context.actor}` + core.setSecret(actor) + + if (fs.existsSync('reviewdog.fail.log')) { + // print reviewdog.fail.log to the console + const log = fs.readFileSync('reviewdog.fail.log', 'UTF-8').replaceAll(/^/g, CONSOLE_BLUE) + console.log(`${CONSOLE_RED}This action encountered an error while reporting the following findings via the Github API:`) + console.log(log) + console.log(`${CONSOLE_RED}The failure of this action should not prevent you from merging your PR. Please report this failure to the maintainers of https://github.com/brave/security-action ${RESET_CONSOLE_COLOR}`) + debugLog('Error log printed to console') + + if (options.slack_token) { + // reviewdog-fail-log-head step + const reviewdogFailLogHead = '\n' + fs.readFileSync('reviewdog.fail.log', 'UTF-8').split('\n').slice(0, 4).join('\n') + debugLog('Reviewdog fail log head:', reviewdogFailLogHead) + + // send error slack message, if there is any error + await sendSlackMessage({ + token: options.slack_token, + text: `[error] ${actor} action failed, plz take a look. /cc ${slackAssignees} ${reviewdogFailLogHead}`, + message, + channel: '#secops-hotspots', + color: 'red', + username: 'security-action' + }) + debugLog('Sent error slack message') + } else { + // throw error if no slack token is provided, and there is an error log + debugLog('Error was thrown and Slack token is missing, exiting eagerly!') + throw new Error('Error was thrown and Slack token is missing, exiting eagerly!') + } + } + + if (options.slack_token && shouldTrigger) { + // Send slack message, if there are any findings + await sendSlackMessage({ + token: options.slack_token, + text: `[security-action] ${actor} pushed commits. /cc ${slackAssignees}`, + message, + channel: '#secops-hotspots', + color: 'green', + username: 'security-action' + }) + debugLog('Comments after:', commentsAfter) + } + } +} diff --git a/actions/main/action.yml b/actions/main/action.yml new file mode 100644 index 00000000..d0f3dc80 --- /dev/null +++ b/actions/main/action.yml @@ -0,0 +1,127 @@ +name: 'Security Action' +description: 'Collect and Generalize multiple CI Security checks' +inputs: + # in-name: + # description: yadda yadda + # required: true + # default: 0 + github_token: + description: | + Secret token to push review comments, and + interact with the repository systematically + required: true + slack_token: + description: | + Secret token to forward findings to slack + required: false + assignees: + description: assign PR to the people linked + required: false + hotwords: + description: body hotwords which should trigger the action + required: false + hotwords_enabled: + description: control if the hotwords should trigger the action + required: false + debug: + description: enables debug output for this action + required: false + enabled: + description: may disable the whole action, big red button for emergency cases + required: false + baseline_scan_only: + description: compare changed files with the base ref, do not scan the entire repo with reviewdog + required: false + pip_audit_pypi_index_url: + description: Pypi index for pip-audit to use in case you have a private index + required: false + pip_audit_pypi_insecure_hosts: + description: Hosts for --trusted-host in pip-audit in case you have an untrusted private index, comma separated + required: false + gh_to_slack_user_map: + description: JSON map of github usernames to slack usernames + required: false +outputs: + reviewdog-findings: + description: number of reviewdog findings + value: ${{ steps.script.outputs.findings }} + safesvg-count: + description: number of safesvg findings via reviewdog + value: ${{ steps.script.outputs.safesvg_count }} + tfsec-count: + description: number of tfsec findings via reviewdog + value: ${{ steps.script.outputs.tfsec_count }} + semgrep-count: + description: number of semgrep findings via reviewdog + value: ${{ steps.script.outputs.semgrep_count }} + sveltegrep-count: + description: number of sveltegrep findings via reviewdog + value: ${{ steps.script.outputs.sveltegrep_count }} + npm-audit-count: + description: number of npm-audit findings via reviewdog + value: ${{ steps.script.outputs.npm_audit_count }} + pip-audit-count: + description: number of pip-audit findings via reviewdog + value: ${{ steps.script.outputs.pip_audit_count }} +runs: + using: 'composite' + steps: + - name: Store reviewdog enabled + # inputs.enabled != 'false' && ( + # (inputs.baseline_scan_only != 'false' && github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.actor != 'dependabot[bot]') # reviewdog-enabled-pr + # || + # (inputs.baseline_scan_only == 'false' || github.event_name == 'workflow_dispatch') # reviewdog-enabled-full + # ) + if: ${{ inputs.enabled != 'false' && ( (inputs.baseline_scan_only != 'false' && github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.actor != 'dependabot[bot]') || (inputs.baseline_scan_only == 'false' || github.event_name == 'workflow_dispatch') )}} + id: reviewdog-enabled + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: return true + - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} + uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + with: + python-version: '3.12' + - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} + name: Cache pip cache + id: cache-pip + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: ~/.cache/pip/ + key: ${{ runner.os }}-pip + - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} + uses: reviewdog/action-setup@3f401fe1d58fe77e10d665ab713057375e39b887 # v1.3.0 + with: + reviewdog_version: latest # Optional. [latest,nightly,v.X.Y.Z] + - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} + name: Setup Ruby + id: ruby + uses: ruby/setup-ruby@v1 + env: + BUNDLE_GEMFILE: ${{ github.action_path }}/../../Gemfile + with: + ruby-version: '3.2' + bundler-cache: true + - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} + id: npm + run: cd ${{ github.action_path }}/../..; npm ci + shell: bash + - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} + name: Install tfsec + uses: jaxxstorm/action-install-gh-release@71d17cb091aa850acb2a1a4cf87258d183eb941b # v1.11.0 + with: # Grab a specific tag with caching + repo: aquasecurity/tfsec + tag: v1.28.1 + cache: enable + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} + id: script + env: + DEBUG: ${{ (inputs.debug == 'true' || runner.debug) && 'true' || 'false'}} + with: + script: |- + const actionPath = '${{ github.action_path }}/../../' + const inputs = ${{ toJson(inputs) }} + + const script = require(`${actionPath}/action.cjs`) + await script({github, context, inputs, actionPath, core, + debug: process.env.DEBUG === 'true'})