--- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Security Suite on: schedule: - cron: '55 23 * * 0' # Every Sunday at 23:55 workflow_dispatch: pull_request: paths: - '**/package.json' - '**/package-lock.json' - '**/yarn.lock' - '**/pnpm-lock.yaml' - '**/requirements.txt' - '**/Dockerfile' - '**/*.py' - '**/*.js' - '**/*.ts' - '**/workflows/*.yml' merge_group: push: branches: [main] permissions: read-all concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: check-secrets: name: Check Required Secrets runs-on: ubuntu-latest outputs: run_snyk: ${{ steps.check.outputs.run_snyk }} run_slack: ${{ steps.check.outputs.run_slack }} run_sonarcloud: ${{ steps.check.outputs.run_sonarcloud }} steps: - name: Check Required Secrets id: check shell: bash run: | { echo "run_snyk=false" echo "run_slack=false" echo "run_sonarcloud=false" } >> "$GITHUB_OUTPUT" if [ -n "${{ secrets.SNYK_TOKEN }}" ]; then echo "run_snyk=true" >> "$GITHUB_OUTPUT" else echo "::warning::SNYK_TOKEN not set - Snyk scans will be skipped" fi if [ -n "${{ secrets.SLACK_WEBHOOK }}" ]; then echo "run_slack=true" >> "$GITHUB_OUTPUT" else echo "::warning::SLACK_WEBHOOK not set - Slack notifications will be skipped" fi if [ -n "${{ secrets.SONAR_TOKEN }}" ]; then echo "run_sonarcloud=true" >> "$GITHUB_OUTPUT" else echo "::warning::SONAR_TOKEN not set - SonarCloud analysis will be skipped" fi owasp: name: OWASP Dependency Check runs-on: ubuntu-latest needs: check-secrets permissions: security-events: write steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Run OWASP Dependency Check uses: dependency-check/Dependency-Check_Action@3102a65fd5f36d0000297576acc56a475b0de98d # main with: project: 'GitHub Actions' path: '.' format: 'SARIF' out: 'reports' args: > --enableRetired --enableExperimental --failOnCVSS 7 - name: Upload OWASP Results uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 with: sarif_file: reports/dependency-check-report.sarif category: owasp-dependency-check - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: owasp-results path: reports/dependency-check-report.sarif snyk: name: Snyk Security Scan runs-on: ubuntu-latest needs: check-secrets if: needs.check-secrets.outputs.run_snyk == 'true' permissions: security-events: write steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 'lts/*' cache: 'npm' - name: Run Snyk Scan uses: snyk/actions/node@cdb760004ba9ea4d525f2e043745dfe85bb9077e # master continue-on-error: true env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: args: --all-projects --sarif-file-output=snyk-results.sarif - name: Upload Snyk Results uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 with: sarif_file: snyk-results.sarif category: snyk - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: snyk-results path: snyk-results.sarif scorecard: name: OSSF Scorecard runs-on: ubuntu-latest needs: check-secrets permissions: security-events: write id-token: write steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Run Scorecard uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 with: results_file: scorecard-results.sarif results_format: sarif publish_results: true - name: Upload Scorecard Results uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 with: sarif_file: scorecard-results.sarif category: scorecard - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: scorecard-results path: scorecard-results.sarif analyze: name: Analyze Results runs-on: ubuntu-latest needs: [check-secrets, owasp, scorecard, snyk] if: always() permissions: issues: write steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download scan results uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: ./results - name: Analyze Results id: analysis uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const fs = require('fs'); async function analyzeResults() { const metrics = { timestamp: new Date().toISOString(), vulnerabilities: { critical: 0, high: 0, medium: 0, low: 0 }, scorecard: null, trends: {}, tools: {} }; function analyzeSarif(file, tool) { if (!fs.existsSync(file)) return null; try { const data = JSON.parse(fs.readFileSync(file, 'utf8')); const results = { total: 0, bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, details: [] }; data.runs.forEach(run => { if (!run.results) return; run.results.forEach(result => { results.total++; const severity = result.level === 'error' ? 'high' : result.level === 'warning' ? 'medium' : 'low'; results.bySeverity[severity]++; metrics.vulnerabilities[severity]++; results.details.push({ title: result.message?.text || 'Unnamed issue', severity, location: result.locations?.[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown', description: result.message?.text || '', ruleId: result.ruleId || '' }); }); }); return results; } catch (error) { console.error(`Error analyzing ${tool} results:`, error); return null; } } // Analyze all SARIF files metrics.tools = { owasp: analyzeSarif('./results/owasp-results/dependency-check-report.sarif', 'OWASP'), snyk: ${{ needs.check-secrets.outputs.run_snyk == 'true' }} ? analyzeSarif('./results/snyk-results/snyk-results.sarif', 'Snyk') : null, scorecard: analyzeSarif('./results/scorecard-results/scorecard-results.sarif', 'Scorecard') }; // Save results fs.writeFileSync('security-results.json', JSON.stringify(metrics, null, 2)); // Set outputs core.setOutput('total_critical', metrics.vulnerabilities.critical); core.setOutput('total_high', metrics.vulnerabilities.high); return metrics; } return await analyzeResults(); - name: Generate Reports if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const fs = require('fs'); const metrics = JSON.parse(fs.readFileSync('security-results.json', 'utf8')); // Find existing security report issue const issues = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', labels: ['security-report'], per_page: 1 }); const severityEmoji = { critical: '🚨', high: '⚠️', medium: '⚡', low: '📝' }; // Generate report body const report = `## Security Scan Report ${new Date().toISOString()} ### Summary ${Object.entries(metrics.vulnerabilities) .map(([sev, count]) => `${severityEmoji[sev]} ${sev}: ${count}`) .join('\n')} ### Tool Results ${Object.entries(metrics.tools) .filter(([_, results]) => results) .map(([tool, results]) => ` #### ${tool.toUpperCase()} - Total issues: ${results.total} ${Object.entries(results.bySeverity) .filter(([_, count]) => count > 0) .map(([sev, count]) => `- ${sev}: ${count}`) .join('\n')} ${results.details .filter(issue => ['critical', 'high'].includes(issue.severity)) .map(issue => `- ${severityEmoji[issue.severity]} ${issue.title} (${issue.severity}) - Location: \`${issue.location}\` - Rule: \`${issue.ruleId}\``) .join('\n')} `).join('\n')} ### Action Items ${metrics.vulnerabilities.critical + metrics.vulnerabilities.high > 0 ? `- [ ] Address ${metrics.vulnerabilities.critical} critical and ${metrics.vulnerabilities.high} high severity issues - [ ] Review automated fix PRs - [ ] Update dependencies with known vulnerabilities` : '✅ No critical or high severity issues found'} ### Links - [Workflow Run](${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) - [Security Overview](${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/security) > Last updated: ${new Date().toISOString()}`; // Update or create issue if (issues.data.length > 0) { await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issues.data[0].number, body: report, state: 'open' }); } else { await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: '🔒 Security Scan Report', body: report, labels: ['security-report', 'automated'], assignees: ['ivuorinen'] }); } // Add summary to workflow await core.summary .addRaw(report) .write(); - name: Archive Results if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: security-results path: | reports/ *.sarif security-results.json retention-days: 30 - name: Notify on Failure if: failure() && needs.check-secrets.outputs.run_slack == 'true' run: | curl -X POST -H 'Content-type: application/json' \ --data '{"text":"❌ Security checks failed! Check the logs for details."}' \ ${{ secrets.SLACK_WEBHOOK }}