diff --git a/.github/scripts/security-checker.mjs b/.github/scripts/security-checker.mjs new file mode 100644 index 0000000..841c82e --- /dev/null +++ b/.github/scripts/security-checker.mjs @@ -0,0 +1,177 @@ +const STATES = { + open: 'open', + closed: 'closed', +}; + +const LABELS = { + dependabot: 'dependabot', + codeq: 'codeql', + security: 'security notification', +}; + +class SecurityChecker { + constructor (github, context, issueRepo) { + this.github = github; + this.issueRepo = issueRepo; + this.context = { + owner: context.repo.owner, + repo: context.repo.repo, + }; + } + + async check () { + const dependabotAlerts = await this.getDependabotAlerts(); + const codeqlAlerts = await this.getCodeqlAlerts(); + const existedIssues = await this.getExistedIssues(); + + this.alertDictionary = this.createAlertDictionary(existedIssues); + + await this.closeSpoiledIssues(); + this.createDependabotlIssues(dependabotAlerts); + this.createCodeqlIssues(codeqlAlerts); + } + + async getDependabotAlerts () { + const { data } = await this.github.rest.dependabot.listAlertsForRepo({ state: STATES.open, ...this.context }); + + return data; + } + + async getCodeqlAlerts () { + try { + const { data } = await this.github.rest.codeScanning.listAlertsForRepo({ state: STATES.open, ...this.context }); + + return data; + } + catch (e) { + if (e.message.includes('no analysis found')) + return []; + + throw e; + } + } + + async getExistedIssues () { + const { data: existedIssues } = await this.github.rest.issues.listForRepo({ + owner: this.context.owner, + repo: this.issueRepo, + labels: [LABELS.security], + state: STATES.open, + }); + + return existedIssues; + } + + createAlertDictionary (existedIssues) { + return existedIssues.reduce((res, issue) => { + const [, url, number] = issue.body.match(/Link:\s*(https.*?(\d+)$)/); + + if (!url) + return res; + + res[url] = { + issue, number, + isDependabot: url.includes('dependabot'), + }; + + return res; + }, {}); + } + + async closeSpoiledIssues () { + for (const key in this.alertDictionary) { + const alert = this.alertDictionary[key]; + + if (alert.isDependabot) { + const isAlertOpened = await this.isDependabotAlertOpened(alert.number); + + if (isAlertOpened) + continue; + + await this.closeIssue(alert.issue.number); + } + } + } + + async isDependabotAlertOpened (alertNumber) { + const alert = await this.getDependabotAlertInfo(alertNumber); + + return alert.state === STATES.open; + } + + async getDependabotAlertInfo (alertNumber) { + try { + const { data } = await this.github.rest.dependabot.getAlert({ alert_number: alertNumber, ...this.context }); + + return data; + } + catch (e) { + if (e.message.includes('No alert found for alert number')) + return {}; + + throw e; + } + } + + async closeIssue (issueNumber) { + return this.github.rest.issues.update({ + owner: this.context.owner, + repo: this.issueRepo, + issue_number: issueNumber, + state: STATES.closed, + }); + } + + async createDependabotlIssues (dependabotAlerts) { + dependabotAlerts.forEach(alert => { + if (!this.needCreateIssue(alert)) + return; + + this.createIssue({ + labels: [LABELS.dependabot, LABELS.security, alert.dependency.scope], + originRepo: this.context.repo, + summary: alert.security_advisory.summary, + description: alert.security_advisory.description, + link: alert.html_url, + issuePackage: alert.dependency.package.name, + }); + }); + } + + async createCodeqlIssues (codeqlAlerts) { + codeqlAlerts.forEach(alert => { + if (!this.needCreateIssue(alert)) + return; + + this.createIssue({ + labels: [LABELS.codeql, LABELS.security], + originRepo: this.context.repo, + summary: alert.rule.description, + description: alert.most_recent_instance.message.text, + link: alert.html_url, + }); + }); + } + + needCreateIssue (alert) { + return !this.alertDictionary[alert.html_url]; + } + + async createIssue ({ labels, originRepo, summary, description, link, issuePackage = '' }) { + const title = `[${originRepo}] ${summary}`; + const body = '' + + `#### Repository: \`${originRepo}\`\n` + + (issuePackage ? `#### Package: \`${issuePackage}\`\n` : '') + + `#### Description:\n` + + `${description}\n` + + `#### Link: ${link}`; + + return this.github.rest.issues.create({ + title, body, labels, + owner: this.context.owner, + repo: this.issueRepo, + }); + } +} + +export default SecurityChecker; diff --git a/.github/workflows/check-security-alerts.yml b/.github/workflows/check-security-alerts.yml index 2c0c022..a699fbf 100644 --- a/.github/workflows/check-security-alerts.yml +++ b/.github/workflows/check-security-alerts.yml @@ -9,101 +9,16 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v6 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: latest + - uses: actions/github-script@v7 with: github-token: ${{ secrets.ACTIVE_TOKEN }} script: | - if (!'${{secrets.SECURITY_ISSUE_REPO}}') - return; + const {default: SecurityChecker} = await import('${{ github.workspace }}/.github/scripts/security-checker.mjs') - const { owner, repo } = context.repo; - const state = 'open'; - const dependabotLabel = 'dependabot'; - const codeqlLabel = 'codeql'; - const securityLabel = 'security notification'; + const securityChecker = new SecurityChecker(github, context, '${{secrets.SECURITY_ISSUE_REPO}}'); - async function getDependabotAlerts () { - const dependabotListAlertsUrl = `https://api.github.com/repos/${ owner }/${ repo }/dependabot/alerts?state=${ state }`; - const dependabotRequestOptions = { - headers: { 'Authorization': 'Bearer ${{ secrets.ACTIVE_TOKEN }}' } - } - - const response = await fetch(dependabotListAlertsUrl, dependabotRequestOptions); - const data = await response.json(); - - // If data isn't arry somethig goes wrong - if (Array.isArray(data)) - return data; - - return []; - } - - async function getCodeqlAlerts () { - // When CodeQL is turned of it throws error - try { - const { data } = await github.rest.codeScanning.listAlertsForRepo({ owner, repo, state }); - - return data; - } catch (_) { - return []; - } - } - - async function createIssue ({owner, repo, labels, originRepo, summary, description, link, package = ''}) { - const title = `[${originRepo}] ${summary}`; - const body = '' - + `#### Repository: \`${ originRepo }\`\n` - + (!!package ? `#### Package: \`${ package }\`\n` : '') - + `#### Description:\n` - + `${ description }\n` - + `#### Link: ${ link }` - - return github.rest.issues.create({ owner, repo, title, body, labels }); - } - - function needCreateIssue (alert) { - return !issueDictionary[alert.html_url] - && Date.now() - new Date(alert.created_at) <= 1000 * 60 * 60 * 24; - } - - const dependabotAlerts = await getDependabotAlerts(); - const codeqlAlerts = await getCodeqlAlerts(); - const {data: existedIssues} = await github.rest.issues.listForRepo({ owner, repo, labels: [securityLabel], state }); - - const issueDictionary = existedIssues.reduce((res, issue) => { - const alertUrl = issue.body.match(/Link:\s*(https.*\d*)/)?.[1]; - - if (alertUrl) - res[alertUrl] = issue; - - return res; - }, {}) - - dependabotAlerts.forEach(alert => { - if (!needCreateIssue(alert)) - return; - - createIssue({ owner, - repo: '${{ secrets.SECURITY_ISSUE_REPO }}', - labels: [dependabotLabel, securityLabel], - originRepo: repo, - summary: alert.security_advisory.summary, - description: alert.security_advisory.description, - link: alert.html_url, - package: alert.dependency.package.name - }) - }); - - codeqlAlerts.forEach(alert => { - if (!needCreateIssue(alert)) - return; - - createIssue({ owner, - repo: '${{ secrets.SECURITY_ISSUE_REPO }}', - labels: [codeqlLabel, securityLabel], - originRepo: repo, - summary: alert.rule.description, - description: alert.most_recent_instance.message.text, - link: alert.html_url, - }) - }); \ No newline at end of file + await securityChecker.check();