diff --git a/.github/workflows/check-npm-application.yml b/.github/workflows/check-npm-application.yml new file mode 100644 index 0000000..22bd420 --- /dev/null +++ b/.github/workflows/check-npm-application.yml @@ -0,0 +1,349 @@ +name: Reusable - Check NPM App + +on: + workflow_call: + inputs: + agent: + required: false + type: string + description: "Agent where the workflow will be run" + default: ubuntu-latest + checkout_repository: + required: false + type: string + description: "Repository to checkout (owner/repo format). If empty, uses the current repository" + default: "" + checkout_ref: + required: false + type: string + description: "Git ref to checkout (branch, tag, or commit SHA). If empty, uses the default branch" + default: "" + checkout_depth: + required: false + type: number + description: "Depth of checkout. If empty, uses the default depth" + default: 0 + node_version: + required: false + type: string + default: "24" + cache: + required: false + type: string + description: "Cache type (npm, yarn, or empty string to disable). Defaults to npm if package-lock.json exists" + default: "npm" + install_dependencies: + required: false + type: boolean + default: true + build_packages: + required: false + type: boolean + default: true + build_command: + required: false + type: string + default: "" + run_checks: + required: false + type: boolean + default: true + run_lint: + required: false + type: boolean + default: true + lint_command: + required: false + type: string + default: "npm run lint" + run_audit: + required: false + type: boolean + default: true + audit_level: + required: false + type: string + description: "The value of --audit-level flag" + default: "moderate" + fail_on_critical_high: + required: false + type: boolean + description: "Whether to fail the workflow on critical or high vulnerabilities" + default: true + working_directory: + required: false + type: string + default: "." + +jobs: + check: + runs-on: ${{ inputs.agent }} + defaults: + run: + working-directory: ${{ inputs.working_directory }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: ${{ inputs.checkout_depth }} + ref: ${{ inputs.checkout_ref }} + repository: ${{ inputs.checkout_repository }} + + - uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node_version }} + cache: ${{ inputs.cache }} + cache-dependency-path: ${{ inputs.working_directory != '.' && format('{0}/package-lock.json', inputs.working_directory) || '' }} + + - name: Install dependencies + if: ${{ inputs.install_dependencies }} + run: npm ci + + - name: Build packages + if: ${{ inputs.build_packages }} + run: | + ${{ inputs.build_command }} + + - name: Run linting + if: ${{ inputs.run_lint }} + run: ${{ inputs.lint_command }} + + - name: Run typescript checks + if: ${{ inputs.run_checks }} + run: npm run types:check + + - name: Run npm audit (JSON) + if: ${{ inputs.run_audit }} + id: npm-audit-json + continue-on-error: true + run: | + npm audit --audit-level=${{ inputs.audit_level }} --json > audit.json 2>&1 || AUDIT_EXIT_CODE=$? + echo "exit_code=${AUDIT_EXIT_CODE:-0}" >> $GITHUB_OUTPUT + + - name: Convert npm audit JSON to Markdown + if: ${{ inputs.run_audit && steps.npm-audit-json.outcome != 'skipped' }} + id: npm-audit + run: | + REPORT_FILE=$(mktemp) + + # Check if audit.json exists and is valid JSON + if [ ! -f audit.json ] || ! node -e "JSON.parse(require('fs').readFileSync('audit.json','utf8'))" 2>/dev/null; then + # Fallback to text output if JSON parsing fails + TEXT_OUTPUT=$(npm audit --audit-level=${{ inputs.audit_level }} 2>&1 || true) + { + echo "## ⚠️ npm audit error" + echo "" + echo "Failed to generate audit report:" + echo "" + echo '```' + echo "$TEXT_OUTPUT" + echo '```' + } > "$REPORT_FILE" + echo "has_vulnerabilities=false" >> $GITHUB_OUTPUT + echo "report_file=$REPORT_FILE" >> $GITHUB_OUTPUT + cat "$REPORT_FILE" >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # Convert JSON to markdown + node <<'NODE' > "$REPORT_FILE" + const fs = require('node:fs'); + + const audit = JSON.parse(fs.readFileSync('audit.json','utf8')); + + // Supports npm v6 ("advisories") and npm v7+ ("vulnerabilities" + auditReportVersion) + function summarize(a) { + // npm v7+ format + if (a.metadata?.vulnerabilities) { + return { + totalDependencies: a.metadata.totalDependencies ?? null, + counts: a.metadata.vulnerabilities + }; + } + // npm v6 format + if (a.metadata && (a.advisories || a.actions)) { + // best-effort: v6 metadata sometimes differs; we'll derive counts from advisories + const adv = Object.values(a.advisories || {}); + const counts = { info:0, low:0, moderate:0, high:0, critical:0 }; + for (const x of adv) counts[x.severity] = (counts[x.severity] || 0) + 1; + return { totalDependencies: a.metadata.totalDependencies ?? null, counts }; + } + // fallback + return { totalDependencies: null, counts: {} }; + } + + const { totalDependencies, counts } = summarize(audit); + + const sevOrder = ['critical','high','moderate','low','info']; + const countOrZero = (k) => Number.isFinite(counts?.[k]) ? counts[k] : 0; + + const totalVulns = sevOrder.reduce((s,k)=>s+countOrZero(k),0); + + const md = []; + md.push(`## 📋 npm audit report`); + md.push(''); + md.push(`Generated: **${new Date().toISOString()}**`); + md.push(''); + md.push(`### Summary`); + if (totalDependencies != null) md.push(`- Total dependencies: **${totalDependencies}**`); + md.push(`- Total vulnerabilities: **${totalVulns}**`); + md.push(''); + md.push(`| Severity | Count |`); + md.push(`|---|---:|`); + for (const k of sevOrder) md.push(`| ${k} | ${countOrZero(k)} |`); + md.push(''); + + // Details (v7+) + if (audit.vulnerabilities && typeof audit.vulnerabilities === 'object') { + md.push(`### Details`); + md.push(''); + const vulns = Object.entries(audit.vulnerabilities) + .map(([name, v]) => ({ name, ...v })) + .filter(v => v.severity && v.via && v.via.length); + + // Sort by severity then name + const sevRank = { critical:5, high:4, moderate:3, low:2, info:1 }; + vulns.sort((a,b)=> (sevRank[b.severity]||0) - (sevRank[a.severity]||0) || a.name.localeCompare(b.name)); + + // Create table header + md.push(`| Package | Severity | Vulnerable Range | Advisories | Fix Available |`); + md.push(`|---------|----------|------------------|------------|---------------|`); + + for (const v of vulns) { + // Package name + const packageName = `\`${v.name}\``; + + // Severity with emoji + const severityEmoji = { critical: '🔴', high: '🟠', moderate: '🟡', low: '🟢', info: '🔵' }; + const severity = `${severityEmoji[v.severity] || ''} ${String(v.severity).toUpperCase()}`; + + // Vulnerable range + const range = v.range ? `\`${v.range}\`` : '-'; + + // Advisories + const advisories = (v.via || []).filter(x => typeof x === 'object'); + const viaStrings = (v.via || []).filter(x => typeof x === 'string'); + let advisoryLinks = []; + if (advisories.length > 0) { + advisoryLinks = advisories.slice(0, 3).map(a => { + const title = (a.title || 'Advisory').substring(0, 50); + return a.url ? `[${title}](${a.url})` : title; + }); + if (advisories.length > 3) { + advisoryLinks.push(`+${advisories.length - 3} more`); + } + } + if (viaStrings.length > 0) { + viaStrings.forEach(s => { + if (!advisoryLinks.some(a => a.includes(s))) { + advisoryLinks.push(`via \`${s}\``); + } + }); + } + const advisoryCell = advisoryLinks.length > 0 ? advisoryLinks.join('
') : '-'; + + // Fix available + let fixCell = '❌'; + if (v.fixAvailable) { + if (v.fixAvailable === true) { + fixCell = '✅ \`npm audit fix\`'; + } else if (v.fixAvailable.isSemVerMajor) { + fixCell = '✅ \`npm audit fix --force\`
⚠️ **Breaking change**'; + } else { + fixCell = '✅ \`npm audit fix\`'; + } + } + + md.push(`| ${packageName} | ${severity} | ${range} | ${advisoryCell} | ${fixCell} |`); + } + md.push(''); + } + + // Details (v6) + else if (audit.advisories && typeof audit.advisories === 'object') { + md.push(`### Details`); + md.push(''); + const adv = Object.values(audit.advisories); + const sevRank = { critical:5, high:4, moderate:3, low:2 }; + adv.sort((a,b)=> (sevRank[b.severity]||0) - (sevRank[a.severity]||0) || a.module_name.localeCompare(b.module_name)); + + // Create table header + md.push(`| Package | Severity | Vulnerable Versions | Patched Versions | Advisory |`); + md.push(`|---------|----------|---------------------|------------------|----------|`); + + for (const a of adv) { + const packageName = `\`${a.module_name}\``; + const severityEmoji = { critical: '🔴', high: '🟠', moderate: '🟡', low: '🟢', info: '🔵' }; + const severity = `${severityEmoji[a.severity] || ''} ${String(a.severity).toUpperCase()}`; + const vulnerable = `\`${a.vulnerable_versions}\``; + const patched = a.patched_versions ? `\`${a.patched_versions}\`` : 'n/a'; + const advisory = a.url ? `[View](${a.url})` : a.title || '-'; + + md.push(`| ${packageName} | ${severity} | ${vulnerable} | ${patched} | ${advisory} |`); + } + md.push(''); + } + + // Add fix instructions if vulnerabilities found + if (totalVulns > 0) { + md.push(`> 💡 To fix these issues, run: \`npm audit fix\` or \`npm audit fix --force\` (for breaking changes)`); + } else { + md.push(`> ✅ No vulnerabilities found.`); + } + + process.stdout.write(md.join('\n')); + + // Emit vulnerability count for later steps + const critical = countOrZero('critical'); + const high = countOrZero('high'); + const moderate = countOrZero('moderate'); + const low = countOrZero('low'); + const info = countOrZero('info'); + const hasVulns = totalVulns > 0; + + const outputFile = process.env.GITHUB_OUTPUT || 'ghout.txt'; + require('fs').appendFileSync(outputFile, `has_vulnerabilities=${hasVulns}\n`); + require('fs').appendFileSync(outputFile, `critical_count=${critical}\n`); + require('fs').appendFileSync(outputFile, `high_count=${high}\n`); + require('fs').appendFileSync(outputFile, `moderate_count=${moderate}\n`); + require('fs').appendFileSync(outputFile, `low_count=${low}\n`); + require('fs').appendFileSync(outputFile, `info_count=${info}\n`); + require('fs').appendFileSync(outputFile, `total_vulnerabilities=${totalVulns}\n`); + NODE + + echo "report_file=$REPORT_FILE" >> $GITHUB_OUTPUT + + # Add to workflow summary + cat "$REPORT_FILE" >> $GITHUB_STEP_SUMMARY + + # Cleanup + rm -f audit.json + + - name: Create or update PR comment with audit results + if: ${{ inputs.run_audit && github.event_name == 'pull_request' && steps.npm-audit.outcome != 'skipped' }} + uses: thollander/actions-comment-pull-request@v3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file-path: ${{ steps.npm-audit.outputs.report_file }} + comment-tag: npm-audit-report + mode: upsert + create-if-not-exists: ${{ steps.npm-audit.outputs.has_vulnerabilities || 'false' }} + + - name: Fail on critical or high vulnerabilities + if: ${{ inputs.run_audit && inputs.fail_on_critical_high && steps.npm-audit.outcome != 'skipped' }} + run: | + CRITICAL="${{ steps.npm-audit.outputs.critical_count || '0' }}" + HIGH="${{ steps.npm-audit.outputs.high_count || '0' }}" + + # Check if critical or high vulnerabilities exist + if [ "$CRITICAL" != "0" ] || [ "$HIGH" != "0" ]; then + echo "❌ Critical or high vulnerabilities found!" + echo "Critical: $CRITICAL" + echo "High: $HIGH" + echo "" + echo "Please review the audit report and fix the vulnerabilities before merging." + exit 1 + fi + + echo "✅ No critical or high vulnerabilities found."