diff --git a/.github/workflows/full-loop.yml b/.github/workflows/full-loop.yml index 8f602713..4cdf3f83 100644 --- a/.github/workflows/full-loop.yml +++ b/.github/workflows/full-loop.yml @@ -25,6 +25,7 @@ jobs: baseline_scan_only: false gh_to_slack_user_map: ${{ secrets.GH_TO_SLACK_USER_MAP }} - run: | + set -e echo ${{ steps.action.outputs.reviewdog-findings }} if ((${{ steps.action.outputs.reviewdog-findings }} < 106)); then echo "Too few reviewdog findings" diff --git a/.github/workflows/semgrep-self-test.yml b/.github/workflows/semgrep-self-test.yml index 27094068..e818a49a 100644 --- a/.github/workflows/semgrep-self-test.yml +++ b/.github/workflows/semgrep-self-test.yml @@ -19,7 +19,7 @@ jobs: python3 -m pip --disable-pip-version-check install -r requirements.txt shell: bash - run: | - semgrep --test --disable-version-check --strict --metrics=off + cd assets/semgrep_rules/; semgrep --test --disable-version-check --strict --metrics=off shell: bash - run: | JSON=$(semgrep \ diff --git a/action.cjs b/action.cjs new file mode 100644 index 00000000..48f699d9 --- /dev/null +++ b/action.cjs @@ -0,0 +1,220 @@ +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' + +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 }) => { + const debug = inputs.debug === 'true' ? console.log : () => {} + + if (inputs.enabled !== 'true') { return } + debug('Security Action enabled') + // reviewdog-enabled-pr steps + const reviewdogEnabledPr = inputs.baseline_scan_only !== 'false' && process.env.GITHUB_EVENT_NAME === 'pull_request' && context.payload.pull_request.draft === false && context.actor !== 'dependabot[bot]' + debug(`Security Action enabled for PR: ${reviewdogEnabledPr}, baseline_scan_only: ${inputs.baseline_scan_only}, GITHUB_EVENT_NAME: ${process.env.GITHUB_EVENT_NAME}, context.actor: ${context.actor}, context.payload.pull_request.draft: ${context.payload.pull_request.draft}`) + // reviewdog-enabled-full steps + const reviewdogEnabledFull = !reviewdogEnabledPr && (inputs.baseline_scan_only === 'false' || process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') + debug(`Security Action enabled for full: ${reviewdogEnabledFull}, baseline_scan_only: ${inputs.baseline_scan_only}, GITHUB_EVENT_NAME: ${process.env.GITHUB_EVENT_NAME}`) + // reviewdog-enabled steps + if (!reviewdogEnabledPr && !reviewdogEnabledFull) { return } + debug('Security Action enabled for reviewdog') + + // Install semgrep & pip-audit + await runCommand(`pip install --disable-pip-version-check -r ${actionPath}/requirements.txt`, { shell: true }) + debug('Installed semgrep & pip-audit') + // Install xmllint for safesvg + await runCommand('sudo apt-get install -y libxml2-utils', { shell: true }) + debug('Installed xmllint') + + // debug step + if (inputs.debug === 'true') { + const env = { + ...process.env, + ASSIGNEES: inputs.assignees + } + await runCommand(`${actionPath}/assets/debug.sh`, { env }) + debug('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 }) + debug('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 }) + debug('Changed files:', changedFiles) + + // Write changed files to file + fs.writeFileSync(`${actionPath}/assets/all_changed_files.txt`, changedFiles.join('\0')) + debug('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`) + debug('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'] + }) + debug('Added unverified-commits label') + } + + // run-reviewdog-pr step + const env = { + ...process.env, + ASSIGNEES: inputs.assignees, + REVIEWDOG_GITHUB_API_TOKEN: inputs.github_token, + SEC_ACTION_DEBUG: inputs.debug, + PYPI_INDEX_URL: inputs.pip_audit_pypi_index_url, + PYPI_INSECURE_HOSTS: inputs.pip_audit_pypi_insecure_hosts + } + await runCommand(`${actionPath}/assets/reviewdog.sh`, { env }) + debug('Reviewdog PR step completed') + + // comments-after step + const commentsAfter = await commentsNumber({ context, github }) + debug('Comments after:', commentsAfter) + + // assignees-after step + const { default: assigneesAfter } = await import(`${actionPath}/src/steps/assigneesAfter.js`) + const assigneesAfterVal = await assigneesAfter({ context, github, assignees: inputs.assignees }) + debug('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 }) + debug('Assignee removed:', assigneeRemovedLabel) + + // add description-contains-hotwords step + const { default: hotwords } = await import(`${actionPath}/src/steps/hotwords.js`) + const descriptionContainsHotwords = (context.actor !== 'renovate[bot]') ? await hotwords({ context, github, hotwords: inputs.hotwords }) : false + debug('Description contains hotwords:', descriptionContainsHotwords) + + // add should-trigger label step + const shouldTrigger = reviewdogEnabledPr && !assigneeRemovedLabel && ((commentsBefore !== commentsAfter) || descriptionContainsHotwords) + debug('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'] + }) + debug('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 !== '') + }) + debug('Added assignees') + } + + const { default: sendSlackMessage } = await import(`${actionPath}/src/sendSlackMessage.js`) + + const message = `Repository: ${process.env.GITHUB_REPOSITORY}\npull-request: ${context.payload.pull_request.html_url}` + + let githubToSlack = {} + try { + githubToSlack = JSON.parse(inputs.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) + debug('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}`) + debug('Error log printed to console') + + if (inputs.slack_token) { + // reviewdog-fail-log-head step + const reviewdogFailLogHead = '\n' + fs.readFileSync('reviewdog.fail.log', 'UTF-8').split('\n').slice(0, 4).join('\n') + debug('Reviewdog fail log head:', reviewdogFailLogHead) + + // send error slack message, if there is any error + await sendSlackMessage({ + token: inputs.slack_token, + text: `[security-action] ${actor} action failed, plz take a look. /cc ${slackAssignees} ${reviewdogFailLogHead}`, + message, + channel: '#secops-hotspots', + color: 'red' + }) + debug('Sent error slack message') + } else { + // throw error if no slack token is provided, and there is an error log + debug('Error was thrown and Slack token is missing, exiting eagerly!') + throw new Error('Error was thrown and Slack token is missing, exiting eagerly!') + } + } + + if (inputs.slack_token && shouldTrigger) { + // Send slack message, if there are any findings + await sendSlackMessage({ + token: inputs.slack_token, + text: `[semgrep] ${actor} pushed commits. /cc ${slackAssignees}`, + message, + channel: '#secops-hotspots', + color: 'green' + }) + debug('Comments after:', commentsAfter) + } + } +} diff --git a/action.yml b/action.yml index 1673a4c2..2f6fc23b 100644 --- a/action.yml +++ b/action.yml @@ -62,88 +62,39 @@ inputs: outputs: reviewdog-findings: description: number of reviewdog findings - value: ${{ steps.run-reviewdog-pr.outputs.findings }}${{ steps.run-reviewdog-full.outputs.findings }} + value: ${{ steps.script.outputs.findings }} safesvg-count: description: number of safesvg findings via reviewdog - value: ${{ steps.run-reviewdog-pr.outputs.safesvg_count }}${{ steps.run-reviewdog-full.outputs.safesvg_count }} + value: ${{ steps.script.outputs.safesvg_count }} tfsec-count: description: number of tfsec findings via reviewdog - value: ${{ steps.run-reviewdog-pr.outputs.tfsec_count }}${{ steps.run-reviewdog-full.outputs.tfsec_count }} + value: ${{ steps.script.outputs.tfsec_count }} semgrep-count: description: number of semgrep findings via reviewdog - value: ${{ steps.run-reviewdog-pr.outputs.semgrep_count }}${{ steps.run-reviewdog-full.outputs.semgrep_count }} + value: ${{ steps.script.outputs.semgrep_count }} sveltegrep-count: description: number of sveltegrep findings via reviewdog - value: ${{ steps.run-reviewdog-pr.outputs.sveltegrep_count }}${{ steps.run-reviewdog-full.outputs.sveltegrep_count }} + value: ${{ steps.script.outputs.sveltegrep_count }} npm-audit-count: description: number of npm-audit findings via reviewdog - value: ${{ steps.run-reviewdog-pr.outputs.npm_audit_count }}${{ steps.run-reviewdog-full.outputs.npm_audit_count }} + value: ${{ steps.script.outputs.npm_audit_count }} pip-audit-count: description: number of pip-audit findings via reviewdog - value: ${{ steps.run-reviewdog-pr.outputs.pip_audit_count }}${{ steps.run-reviewdog-full.outputs.pip_audit_count }} + value: ${{ steps.script.outputs.pip_audit_count }} runs: using: 'composite' steps: - - name: Get changed files - if: ${{ inputs.enabled == 'true' && github.event_name == 'pull_request' }} - id: changed-files - uses: tj-actions/changed-files@0874344d6ebbaa00a27da73276ae7162fadcaf69 # v44.3.0 - with: - separator: '\0' - - name: Store reviewdog enabled for PR - if: ${{ inputs.enabled == 'true' && inputs.baseline_scan_only != 'false' && steps.changed-files.outputs.all_changed_files != '' && github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.actor != 'dependabot[bot]' }} - id: reviewdog-enabled-pr - shell: bash - run: | - set -x - echo "result=true" >> $GITHUB_OUTPUT - - name: Store reviewdog enabled for full repo manual run - if: ${{ inputs.enabled == 'true' && !(steps.reviewdog-enabled-pr.outputs.result == 'true') && (inputs.baseline_scan_only == 'false' || github.event_name == 'workflow_dispatch') }} - id: reviewdog-enabled-full - shell: bash - run: | - set -x - echo "result=true" >> $GITHUB_OUTPUT - name: Store reviewdog enabled - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' || steps.reviewdog-enabled-full.outputs.result == 'true' }} + # inputs.enabled == 'true' && ( + # (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 == 'true' && ( (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 - shell: bash - run: | - set -x - echo "result=true" >> $GITHUB_OUTPUT - # REVIEWDOG Steps - # REVIEWDOG Setup - - name: Write changed files to file - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' }} - id: write-changed-files - shell: bash - run: | - set -e - printf -- '${{ steps.changed-files.outputs.all_changed_files }}' >> ${{ github.action_path }}/assets/all_changed_files.txt - printf '${{ steps.changed-files.outputs.any_changed }}' - - id: comments-before - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' }} uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: - script: | - const actionPath = '${{ github.action_path }}' - - const {default: commentsNumber} = await import(`${actionPath}/src/commentsNumber.js`) - const {default: cleanupComments} = await import(`${actionPath}/src/cleanupComments.js`) - - const number = await commentsNumber({context, github}) - await cleanupComments({context, github}) - return number - - id: unverified-commits - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' }} - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const actionPath = '${{ github.action_path }}' - - const {default: unverifiedCommits} = await import(`${actionPath}/src/unverifiedCommits.js`) - - return await unverifiedCommits({context, github}) + script: return true - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 with: @@ -169,14 +120,9 @@ runs: ruby-version: '3.2' bundler-cache: true - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} - name: Install semgrep & pip-audit + id: npm + run: cd ${{ github.action_path }}; npm ci shell: bash - run: | - python3 -m pip install --disable-pip-version-check -r ${{ github.action_path }}/requirements.txt - - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} - name: Install xmllint for safesvg - shell: bash - run: sudo apt-get install -y libxml2-utils - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} name: Install tfsec uses: jaxxstorm/action-install-gh-release@71d17cb091aa850acb2a1a4cf87258d183eb941b # v1.11.0 @@ -184,159 +130,13 @@ runs: repo: aquasecurity/tfsec tag: v1.28.1 cache: enable - - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' && inputs.debug == 'true'}} - env: - ASSIGNEES: ${{inputs.assignees}} - run: ${{ github.action_path }}/assets/debug.sh - shell: bash - # REVIEWDOG Run - - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' }} - name: Run reviewdog - id: run-reviewdog-pr - shell: bash - env: - ASSIGNEES: ${{inputs.assignees}} - REVIEWDOG_GITHUB_API_TOKEN: ${{ inputs.github_token }} - SEC_ACTION_DEBUG: ${{ inputs.debug }} - PYPI_INDEX_URL: ${{ inputs.pip_audit_pypi_index_url }} - PYPI_INSECURE_HOSTS: ${{ inputs.pip_audit_pypi_insecure_hosts }} - run: ${{ github.action_path }}/assets/reviewdog.sh - - if: ${{ steps.reviewdog-enabled-full.outputs.result == 'true' }} - name: Run reviewdog full - id: run-reviewdog-full - shell: bash - run: unset GITHUB_BASE_REF && ${{ github.action_path }}/assets/reviewdog.sh - - id: comments-after - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' }} - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const actionPath = '${{ github.action_path }}' - const {default: commentsNumber} = await import(`${actionPath}/src/commentsNumber.js`) - return await commentsNumber({context, github}) - - id: assignees-after - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' }} - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - env: - ASSIGNEES: ${{ inputs.assignees }} + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + if: ${{ steps.reviewdog-enabled.outputs.result == 'true' }} + id: script with: - script: | + script: |- const actionPath = '${{ github.action_path }}' - const {default: assigneesAfter} = await import(`${actionPath}/src/assigneesAfter.js`) - return await assigneesAfter({context, github}) - - id: assignee-removed-label - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' }} - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - env: - ASSIGNEES: ${{ steps.assignees-after.outputs.result && fromJson(steps.assignees-after.outputs.result) }} - with: - script: | - const actionPath = '${{ github.action_path }}' - const {default: assigneeRemoved} = await import(`${actionPath}/src/assigneeRemoved.js`) - return await assigneeRemoved({context, github}) - - id: description-contains-hotwords - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' && github.actor != 'renovate[bot]' }} - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - env: - HOTWORDS: ${{ inputs.hotwords }} - with: - script: | - const actionPath = '${{ github.action_path }}' - const {default: hotwords} = await import(`${actionPath}/src/hotwords.js`) - return await hotwords({context, github}) - - id: should-trigger - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' && !(steps.assignee-removed-label.outputs.result == 'true') && ( (steps.comments-before.outputs.result != steps.comments-after.outputs.result) || steps.description-contains-hotwords.outputs.result == 'true') }} - shell: bash - run: | - set -x - echo "result=true" >> $GITHUB_OUTPUT - - uses: actions-ecosystem/action-add-labels@18f1af5e3544586314bbe15c0273249c770b2daf # v1.1.3 - if: ${{ steps.unverified-commits.outputs.result == '"UNVERIFIED-CHANGED"' }} - with: - github_token: ${{ inputs.github_token }} - labels: unverified-commits - - uses: actions-ecosystem/action-add-labels@18f1af5e3544586314bbe15c0273249c770b2daf # v1.1.3 - if: ${{ (steps.reviewdog-enabled-pr.outputs.result == 'true' && steps.should-trigger.outputs.result == 'true') }} - with: - github_token: ${{ inputs.github_token }} - labels: needs-security-review - - uses: actions-ecosystem/action-add-assignees@a5b84af721c4a621eb9c7a4a95ec20a90d0b88e9 # v1.0.1 - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' && steps.should-trigger.outputs.result == 'true' }} - with: - github_token: ${{ inputs.github_token }} - assignees: ${{ fromJson(steps.assignees-after.outputs.result) }} - - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' && hashFiles('reviewdog.fail.log') }} - shell: bash - run: | - set +x - echo -e '\033[0;31mThis action encountered an error while reporting the following findings via the Github API:' - cat reviewdog.fail.log | sed 's/^/\x1B[0;34m/' - echo -e '\033[0;31mThe 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 \033[0m' - - if: ${{ steps.reviewdog-enabled.outputs.result == 'true' && !inputs.slack_token && hashFiles('reviewdog.fail.log') }} - shell: bash - run: exit 1 - - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' && inputs.slack_token && hashFiles('reviewdog.fail.log') }} - id: reviewdog-fail-log-head - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - return '\n'+require('fs').readFileSync('reviewdog.fail.log', 'UTF-8').split('\n').slice(0, 4).join('\n') - - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' }} - id: assignees-slack - env: - ASSIGNEES: ${{steps.assignees-after.outputs.result && fromJson(steps.assignees-after.outputs.result)}} - GH_TO_SLACK_USER_MAP: ${{ inputs.gh_to_slack_user_map }} - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - let githubToSlack = {}; - try { - githubToSlack = JSON.parse(process.env.GH_TO_SLACK_USER_MAP); - } catch (e) { - console.log('GH_TO_SLACK_USER_MAP is not valid JSON'); - } + const inputs = ${{ toJson(inputs) }} - const assignees = process.env.ASSIGNEES.toLowerCase().split(/\s+/).map(e => e.trim()).filter(Boolean); - const slackAssignees = assignees.map(m => githubToSlack[m] ? githubToSlack[m] : `@${m}`).join(' '); - core.setSecret(slackAssignees); - return slackAssignees; - - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' }} - id: actor-slack - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - env: - ACTOR: ${{ github.actor }} - GH_TO_SLACK_USER_MAP: ${{ inputs.gh_to_slack_user_map }} - with: - script: | - let actor = process.env.ACTOR.toLowerCase(); - let githubToSlack = {}; - try { - githubToSlack = JSON.parse(process.env.GH_TO_SLACK_USER_MAP); - } catch (e) { - console.log('GH_TO_SLACK_USER_MAP is not valid JSON'); - } - - return githubToSlack[actor] ? githubToSlack[actor] : `@${actor}`; - - uses: actions-ecosystem/action-slack-notifier@fc778468d09c43a6f4d1b8cccaca59766656996a # v1.1.0 - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' && inputs.slack_token && hashFiles('reviewdog.fail.log') }} - with: - slack_token: ${{ inputs.slack_token }} - message: | - [semgrep] ${{ fromJson(steps.actor-slack.outputs.result) }} action failed, plz take a look. /cc ${{fromJson(steps.assignees-slack.outputs.result)}} ${{ fromJson(steps.reviewdog-fail-log-head.outputs.result) }} - channel: secops-hotspots - color: red - verbose: true - - uses: actions-ecosystem/action-slack-notifier@fc778468d09c43a6f4d1b8cccaca59766656996a # v1.1.0 - if: ${{ steps.reviewdog-enabled-pr.outputs.result == 'true' && inputs.slack_token && steps.should-trigger.outputs.result == 'true' }} - with: - slack_token: ${{ inputs.slack_token }} - message: | - [semgrep] ${{ fromJson(steps.actor-slack.outputs.result) }} pushed commits. /cc ${{fromJson(steps.assignees-slack.outputs.result)}} - channel: secops-hotspots - color: green - verbose: true -# - run: echo ${{ inputs.in-name }} -# shell: bash -# - id: output-step-id -# run: echo "output-name=antani" >> $GITHUB_OUTPUT -# shell bash + const script = require(`${actionPath}/action.cjs`) + await script({github, context, inputs, actionPath, core}) diff --git a/package-lock.json b/package-lock.json index 67d60046..f63a848c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "dependencies": { "@octokit/core": "^5.0.2", "@slack/web-api": "^7.0.0", - "@tryfabric/mack": "^1.2.1" + "@tryfabric/mack": "^1.2.1", + "markdown-to-txt": "^2.0.1" }, "devDependencies": { "standard": "17.1.0" @@ -2461,12 +2462,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.unescape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz", + "integrity": "sha512-DhhGRshNS1aX6s5YdBE3njCCouPgnG29ebyHvImlZzXZf2SHgt+J08DHgytTPnpywNbO1Y8mNUFyQuIDBq2JZg==" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2479,6 +2490,16 @@ "loose-envify": "cli.js" } }, + "node_modules/markdown-to-txt": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/markdown-to-txt/-/markdown-to-txt-2.0.1.tgz", + "integrity": "sha512-Hsj7KTN8k1gutlLum3vosHwVZGnv8/cbYKWVkUyo/D1rzOYddbDesILebRfOsaVfjIBJank/AVOySBlHAYqfZw==", + "dependencies": { + "lodash.escape": "^4.0.1", + "lodash.unescape": "^4.0.1", + "marked": "^4.0.14" + } + }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", diff --git a/package.json b/package.json index 922da913..1598e22c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "dependencies": { "@octokit/core": "^5.0.2", "@slack/web-api": "^7.0.0", - "@tryfabric/mack": "^1.2.1" + "@tryfabric/mack": "^1.2.1", + "markdown-to-txt": "^2.0.1" }, "devDependencies": { "standard": "17.1.0" diff --git a/src/pullRequestChangedFiles.js b/src/pullRequestChangedFiles.js new file mode 100644 index 00000000..367a0be2 --- /dev/null +++ b/src/pullRequestChangedFiles.js @@ -0,0 +1,55 @@ +export default async function pullRequestChangedFIles ({ github, githubToken, owner, name, prnumber }) { + if (!github && githubToken) { + const { Octokit } = await import('octokit') + + github = new Octokit({ auth: githubToken }) + } + + if (!github && !githubToken) { + throw new Error('either githubToken or github is required!') + } + + prnumber = parseInt(prnumber, 10) + + const query = `query ($owner: String!, $name: String!, $prnumber: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $prnumber) { + files(first: 100, after: $cursor) { + pageInfo { + endCursor + hasNextPage + } + nodes { + path + additions + deletions + } + } + } + } + }` + + const vars = { + owner, + name, + prnumber + } + + let hasNextPage = true + let paths = [] + while (hasNextPage) { + const { repository } = await github.graphql(query, vars) + const files = repository.pullRequest.files + + // prepare the next iteration + hasNextPage = files.pageInfo.hasNextPage + vars.cursor = files.pageInfo.endCursor + + // append new paths to paths array + // check for additions only, deletions are not relevant, in this case + paths = paths.concat( + files.nodes.filter(file => file.additions /* + file.deletions */ > 0).map(file => file.path)) + } + + return paths +} diff --git a/src/sendSlackMessage.js b/src/sendSlackMessage.js index e068d94f..20eea5be 100644 --- a/src/sendSlackMessage.js +++ b/src/sendSlackMessage.js @@ -1,9 +1,41 @@ +async function findChannelId (web, name) { + let cursor = null + + while (true) { + const r = await web.conversations.list({ cursor }) + const f = r.channels.find(c => c.name === name || c.name === name.substring(1)) + + if (f) { + return f.id + } + + if (!r.response_metadata.next_cursor) { + throw new Error('channel not found') + } + + cursor = r.response_metadata.next_cursor + } +} + +const colorCodes = { + black: '#000000', + red: '#F44336', + green: '#4CAF50', + yellow: '#FFEB3B', + blue: '#2196F3', + magenta: '#FF00FF', + cyan: '#00BCD4', + white: '#FFFFFF' +} + // send markdown message to slack channel export default async function sendSlackMessage ({ token = null, + text = null, channel = null, message = null, debug = false, + color = null, username = 'github-actions' }) { if (!token) { @@ -14,38 +46,103 @@ export default async function sendSlackMessage ({ throw new Error('channel is required!') } - if (!message) { - throw new Error('message is required!') + if (!message && !text) { + throw new Error('message || token is required!') + } + + if (colorCodes[color]) { + color = colorCodes[color] } + const colored = color?.match(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/) + debug = debug === 'true' || debug === true if (debug) { console.log(`token.length: ${token.length}, channel: ${channel}, message: ${message}`) } const { WebClient } = await import('@slack/web-api') - const { markdownToBlocks } = await import('@tryfabric/mack') const web = new WebClient(token) - let blocks = await markdownToBlocks(message) - if (debug) { console.log(blocks) } + // calculate the sha256 hash of the message + const crypto = await import('crypto') + const hash = crypto.createHash('sha256') + if (text !== null) hash.update(text) + if (message !== null) hash.update(message) + if (color !== null) hash.update(color) + const hashHex = hash.digest('hex') + + const blocks = [] + let attachments = null - if (blocks.length > 50) { - blocks = blocks.slice(0, 49) + if (text !== null) { blocks.push({ type: 'section', text: { type: 'mrkdwn', - text: '...and more' + text } }) } + if (message !== null) { + const { markdownToBlocks } = await import('@tryfabric/mack') + + let mdBlocks = await markdownToBlocks(message) + // slack blocks have a limit of 50 blocks, remove the last blocks if there are more + if (mdBlocks.length > 50) { + mdBlocks = mdBlocks.slice(0, 49) + mdBlocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: '...and more' + } + }) + } + if (colored) { + attachments = [{ + color, + blocks: mdBlocks + }] + } else { + blocks.push(...mdBlocks) + } + if (debug) { console.log(mdBlocks) } + } + + // get the channel id + const channelId = await findChannelId(web, channel) + + // get last 50 messages from the channel, in the last day + const history = await web.conversations.history({ + channel: channelId, + limit: 50, + oldest: Date.now() / 1000 - 60 * 60 * 24 // a day ago + }) + + // debounce messages if the same message was sent in the last day + if (history.messages.some(m => m.metadata?.event_type === hashHex)) { + if (debug) { + throw new Error('debounce message') + } else { + return + } + } + + const metadata = { event_type: hashHex, event_payload: { } } + + // send the message const result = await web.chat.postMessage({ username, - text: `${username} alert`, + text: text || `${username} alert`, channel, - blocks + link_names: true, + unfurl_links: true, + unfurl_media: true, + blocks, + attachments, + metadata }) if (debug) { console.log(`result: ${JSON.stringify(result)}`) } diff --git a/src/assigneeRemoved.js b/src/steps/assigneeRemoved.js similarity index 83% rename from src/assigneeRemoved.js rename to src/steps/assigneeRemoved.js index a3da6d69..144ff3cf 100644 --- a/src/assigneeRemoved.js +++ b/src/steps/assigneeRemoved.js @@ -1,11 +1,11 @@ export default async function assigneeRemoved ({ context, github, - githubToken + githubToken, + assignees }) { - const { ASSIGNEES } = process.env - console.log('assignees: %o', ASSIGNEES) - const assignees = ASSIGNEES.split(/\s+/).filter((str) => str !== '') + console.log('assignees: %o', assignees) + const assigneesOutput = assignees.split(/\s+/).filter((str) => str !== '') const query = `query ($owner: String!, $name: String!, $prnumber: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $prnumber) { @@ -35,7 +35,7 @@ export default async function assigneeRemoved ({ const removedByAssigneeEvents = timelineItems.nodes.filter( timelineItem => ( timelineItem.label.name === 'needs-security-review' && - assignees.some((a) => timelineItem.actor.login === a) + assigneesOutput.some((a) => timelineItem.actor.login === a) ) ).length console.log('RemovedByAssigneeEvents: %d', removedByAssigneeEvents) diff --git a/src/assigneesAfter.js b/src/steps/assigneesAfter.js similarity index 82% rename from src/assigneesAfter.js rename to src/steps/assigneesAfter.js index 5394d204..ed8d1114 100644 --- a/src/assigneesAfter.js +++ b/src/steps/assigneesAfter.js @@ -1,6 +1,7 @@ export default async function assigneesAfter ({ github, - context + context, + assignees }) { const query = `query($owner:String!, $name:String!, $prnumber:Int!) { repository(owner:$owner, name:$name) { @@ -29,7 +30,7 @@ export default async function assigneesAfter ({ } const result = await github.graphql(query, variables) const threads = result.repository.pullRequest.reviewThreads - const assignees = [...new Set(threads.nodes.filter( + const outputAssignees = [...new Set(threads.nodes.filter( reviewThread => ( reviewThread.comments.nodes[0].author.login === 'github-actions' && reviewThread.comments.nodes[0].body.includes('
Cc ') @@ -40,10 +41,10 @@ export default async function assigneesAfter ({ .replaceAll('@', '').trim().split(' ') ).flat())] - console.log('assignees: %o', assignees) - if (assignees.length > 0) { - return assignees.join('\n') + console.log('assignees: %o', outputAssignees) + if (outputAssignees.length > 0) { + return outputAssignees.join('\n') } else { - return process.env.ASSIGNEES.split(/\s+/).filter((str) => str !== '').join('\n') + return assignees.split(/\s+/).filter((str) => str !== '').join('\n') } } diff --git a/src/cleanupComments.js b/src/steps/cleanupComments.js similarity index 100% rename from src/cleanupComments.js rename to src/steps/cleanupComments.js diff --git a/src/commentsNumber.js b/src/steps/commentsNumber.js similarity index 100% rename from src/commentsNumber.js rename to src/steps/commentsNumber.js diff --git a/src/hotwords.js b/src/steps/hotwords.js similarity index 94% rename from src/hotwords.js rename to src/steps/hotwords.js index a7704a19..c1a51367 100644 --- a/src/hotwords.js +++ b/src/steps/hotwords.js @@ -1,10 +1,10 @@ export default async function hotwords ({ github, githubToken, - context + context, + hotwords }) { - const { HOTWORDS } = process.env - const hotwords = HOTWORDS.split('\n').map(s => s.trim()).filter((s) => s !== '') + hotwords = hotwords.split('\n').map(s => s.trim()).filter((s) => s !== '') console.log('hotwords: %s', hotwords) diff --git a/src/unverifiedCommits.js b/src/steps/unverifiedCommits.js similarity index 100% rename from src/unverifiedCommits.js rename to src/steps/unverifiedCommits.js