|
16 | 16 | workflow_dispatch: |
17 | 17 |
|
18 | 18 | jobs: |
| 19 | + vulnerability-scan: |
| 20 | + runs-on: ubuntu-latest |
| 21 | + environment: security-scan |
| 22 | + permissions: |
| 23 | + contents: read |
| 24 | + steps: |
| 25 | + - uses: actions/checkout@v4 |
| 26 | + |
| 27 | + - name: Setup Node.js |
| 28 | + uses: actions/setup-node@v4 |
| 29 | + with: |
| 30 | + node-version-file: 'openmetadata-ui/src/main/resources/ui/.nvmrc' |
| 31 | + |
| 32 | + - name: Enable yarn |
| 33 | + run: corepack enable |
| 34 | + |
| 35 | + - name: Install UI dependencies |
| 36 | + working-directory: openmetadata-ui/src/main/resources/ui |
| 37 | + run: yarn install --frozen-lockfile --ignore-scripts |
| 38 | + |
| 39 | + - name: Run Retire.js scan |
| 40 | + id: retire-scan |
| 41 | + continue-on-error: true |
| 42 | + working-directory: openmetadata-ui/src/main/resources/ui |
| 43 | + run: | |
| 44 | + npx retire@5 \ |
| 45 | + --path node_modules/ \ |
| 46 | + --severity medium \ |
| 47 | + --outputformat json \ |
| 48 | + --outputpath retire-report.json |
| 49 | +
|
| 50 | + - name: Verify report was generated |
| 51 | + working-directory: openmetadata-ui/src/main/resources/ui |
| 52 | + run: | |
| 53 | + if [ ! -f retire-report.json ]; then |
| 54 | + echo '::error::retire-report.json was not generated — retire scan may have crashed' |
| 55 | + exit 1 |
| 56 | + fi |
| 57 | +
|
| 58 | + - name: Upload Retire.js Report |
| 59 | + if: success() |
| 60 | + uses: actions/upload-artifact@v4 |
| 61 | + with: |
| 62 | + name: retire-js-report |
| 63 | + path: openmetadata-ui/src/main/resources/ui/retire-report.json |
| 64 | + retention-days: 30 |
| 65 | + |
| 66 | + - name: Publish Retire.js Summary |
| 67 | + if: success() |
| 68 | + working-directory: openmetadata-ui/src/main/resources/ui |
| 69 | + run: | |
| 70 | + python3 - << 'EOF' >> $GITHUB_STEP_SUMMARY |
| 71 | + import json |
| 72 | +
|
| 73 | + SEVERITY_ICON = {"critical": "🚨", "high": "🔴", "medium": "🟠", "low": "🟡"} |
| 74 | + SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3} |
| 75 | + NM = "node_modules/" |
| 76 | +
|
| 77 | + def escape(text): |
| 78 | + return str(text).replace('|', '\\|').replace('`', "'") |
| 79 | +
|
| 80 | + try: |
| 81 | + with open("retire-report.json") as f: |
| 82 | + data = json.load(f) |
| 83 | + except FileNotFoundError: |
| 84 | + print("## Retire.js Scan Results\n\n> Report file not found — scan may not have run.") |
| 85 | + raise SystemExit(0) |
| 86 | +
|
| 87 | + findings = data.get("data", []) |
| 88 | + libs = {} |
| 89 | + for item in findings: |
| 90 | + filepath = item.get("file", "") |
| 91 | + short = filepath[filepath.find(NM) + len(NM):] if NM in filepath else filepath |
| 92 | + for result in item.get("results", []): |
| 93 | + key = (result.get("component", ""), result.get("version", "")) |
| 94 | + if key not in libs: |
| 95 | + libs[key] = {"files": [], "vulns": result.get("vulnerabilities", [])} |
| 96 | + if short not in libs[key]["files"]: |
| 97 | + libs[key]["files"].append(short) |
| 98 | +
|
| 99 | + print("## Retire.js Scan Results\n") |
| 100 | +
|
| 101 | + if not libs: |
| 102 | + print("✅ No vulnerable libraries found.") |
| 103 | + else: |
| 104 | + total_vulns = sum(len(v["vulns"]) for v in libs.values()) |
| 105 | + print(f"> **{len(libs)} vulnerable librar{'y' if len(libs) == 1 else 'ies'} · {total_vulns} CVE{'s' if total_vulns != 1 else ''} found**\n") |
| 106 | +
|
| 107 | + for (component, version), info in sorted(libs.items(), key=lambda x: min( |
| 108 | + (SEVERITY_ORDER.get(v.get("severity", "low"), 3) for v in x[1]["vulns"]), default=3)): |
| 109 | + top_sev = min(info["vulns"], key=lambda v: SEVERITY_ORDER.get(v.get("severity", "low"), 3)) |
| 110 | + icon = SEVERITY_ICON.get(top_sev.get("severity", "low"), "⚪") |
| 111 | + print(f"### {icon} {component} {version}\n") |
| 112 | + print("| Severity | CVE | Summary |") |
| 113 | + print("|---|---|---|") |
| 114 | + for vuln in sorted(info["vulns"], key=lambda v: SEVERITY_ORDER.get(v.get("severity", "low"), 3)): |
| 115 | + sev = vuln.get("severity", "") |
| 116 | + ids = vuln.get("identifiers", {}) |
| 117 | + cves = ids.get("CVE", []) |
| 118 | + summary = ids.get("summary", "").split("\n")[0][:120] |
| 119 | + cve_str = ", ".join(f"[{c}](https://nvd.nist.gov/vuln/detail/{c})" for c in cves) if cves else ids.get("githubID", "—") |
| 120 | + print(f"| {SEVERITY_ICON.get(sev, '')} {sev} | {escape(cve_str)} | {escape(summary)} |") |
| 121 | + print("\n**Bundled in:**") |
| 122 | + for f in info["files"]: |
| 123 | + print(f"- `{f}`") |
| 124 | + print() |
| 125 | + EOF |
| 126 | +
|
| 127 | + - name: Slack on Failure |
| 128 | + if: steps.retire-scan.outcome == 'failure' |
| 129 | + uses: slackapi/slack-github-action@v1.23.0 |
| 130 | + with: |
| 131 | + channel-id: ${{ secrets.SLACK_CHANNEL_IDS }} |
| 132 | + payload: | |
| 133 | + { |
| 134 | + "text": "🚨 Vulnerability scan failed, please check it <https://github.com/open-metadata/OpenMetadata/actions/runs/${{ github.run_id }}|here>. 🚨" |
| 135 | + } |
| 136 | + env: |
| 137 | + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} |
| 138 | + |
| 139 | + - name: Slack on Success |
| 140 | + if: steps.retire-scan.outcome == 'success' |
| 141 | + uses: slackapi/slack-github-action@v1.23.0 |
| 142 | + with: |
| 143 | + channel-id: ${{ secrets.SLACK_CHANNEL_IDS }} |
| 144 | + payload: | |
| 145 | + { |
| 146 | + "text": "🟢 Vulnerability scan passed for OpenMetadata Repo, please check it <https://github.com/open-metadata/OpenMetadata/actions/runs/${{ github.run_id }}|here>." |
| 147 | + } |
| 148 | + env: |
| 149 | + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} |
| 150 | + |
| 151 | + - name: Force failure on vulnerabilities found |
| 152 | + if: steps.retire-scan.outcome == 'failure' |
| 153 | + run: exit 1 |
| 154 | + |
19 | 155 | security-scan: |
20 | 156 | runs-on: ubuntu-latest |
21 | 157 | environment: security-scan |
|
0 commit comments