fix(security): address CodeQL and SonarCloud findings

- Escape backslashes as well as pipes in markdown table cells
- Decide gist/discussion/npm-page links by parsed hostname instead of substring or unanchored regex checks (README, site)
- Encode every '/' in scoped npm names; validate npm names before calling the registry
- The REST helper only calls https://api.github.com and rejects dot segments
- npm ci --ignore-scripts in every workflow (no dependency needs install scripts)
- Move write permissions to job level in issues.yml and site.yml

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
John SmithandClaude Opus 5.5 committed 2026-10-17 13:10:04 -04:00
1 parent c2d1dc4abf
commit fdef3018ba
14 files changed
+76 -28

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Generate and push README/registry
run: |
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Check health and open bot PRs
env:
+13 -6
View File
@@ -11,10 +11,7 @@ on:
types: [opened, edited, reopened, labeled]
permissions:
contents: write
pull-requests: write
issues: write
actions: write
contents: read
concurrency:
group: issue-${{ github.event.issue.number }}
@@ -23,6 +20,11 @@ concurrency:
jobs:
submission:
if: contains(github.event.issue.labels.*.name, 'submission') && github.event.issue.state == 'open'
permissions:
contents: write
pull-requests: write
issues: write
actions: write
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -37,7 +39,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Build entry and open PR
env:
@@ -47,6 +49,11 @@ jobs:
scout-accept:
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'scout')
permissions:
contents: write
pull-requests: write
issues: write
actions: write
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -61,7 +68,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Open PRs for ticked candidates
env:
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Re-check held PRs
env:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Find candidates
env:
+4 -3
View File
@@ -15,8 +15,6 @@ on:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
@@ -38,7 +36,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Build site
env:
@@ -58,6 +56,9 @@ jobs:
deploy:
needs: build
permissions:
pages: write
id-token: write
runs-on: ubuntu-latest
environment:
name: github-pages
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Label and comment
env:
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Unit tests
run: npm test
+2 -2
View File
@@ -9,7 +9,7 @@
const { CATEGORIES, KINDS, KIND_LABELS, TOPICS, TOPIC_LABELS, V1_SUNSET, checkCatalog, formatProblems, loadCatalog } = require('./utils/catalog');
const { addedDates } = require('./utils/history');
const { escapeHtml, readTemplate, replacePlaceholder, generateEntryHtml, writeReadme } = require('./utils/template');
const { escapeCell, escapeHtml, readTemplate, replacePlaceholder, generateEntryHtml, writeReadme } = require('./utils/template');
const RECENT_COUNT = 15;
const README_WARN_KB = 450;
@@ -72,7 +72,7 @@ function generateRecent(entries) {
.filter(e => dates.has(e.file))
.sort((a, b) => dates.get(b.file).localeCompare(dates.get(a.file)) || byName(a, b))
.slice(0, RECENT_COUNT);
const rows = recent.map(e => `| **[${escapeHtml(e.data.name).replace(/\|/g, '\\|')}](${e.data.repo})** | ${e.category} | ${escapeHtml(e.data.tagline).replace(/\|/g, '\\|')} |`);
const rows = recent.map(e => `| **[${escapeCell(escapeHtml(e.data.name))}](${e.data.repo})** | ${e.category} | ${escapeCell(escapeHtml(e.data.tagline))} |`);
return ['| Name | Category | Tagline |', '| :--- | :--- | :--- |', ...rows].join('\n');
}
+8 -1
View File
@@ -79,6 +79,9 @@ async function graphql(query, variables = {}) {
*/
async function rest(route, { method = 'GET', body } = {}) {
const url = route.startsWith('http') ? route : `https://api.github.com${route}`;
if (!url.startsWith('https://api.github.com/') || /\/\.{1,2}(\/|\?|$)/.test(url.split('?')[0])) {
throw new Error(`refusing to call ${url}`);
}
const res = await fetchWithRetry(url, {
method,
headers: headers(body ? { 'Content-Type': 'application/json' } : {}),
@@ -142,8 +145,11 @@ async function getFiles(requests, { batchSize = 60 } = {}) {
* @param {string} pkg
* @returns {Promise<object|null>} null when the package does not exist
*/
const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
async function getNpmLatest(pkg) {
const res = await fetchWithRetry(`https://registry.npmjs.org/${pkg.replace('/', '%2F')}/latest`, {
if (!NPM_NAME_RE.test(pkg)) return null;
const res = await fetchWithRetry(`https://registry.npmjs.org/${pkg.replace(/\//g, '%2F')}/latest`, {
headers: { 'User-Agent': 'awesome-opencode-bot', Accept: 'application/json' }
});
if (res.status === 404) return null;
@@ -157,6 +163,7 @@ async function getNpmLatest(pkg) {
* @returns {Promise<number>}
*/
async function getNpmWeeklyDownloads(pkg) {
if (!NPM_NAME_RE.test(pkg)) return 0;
const res = await fetchWithRetry(`https://api.npmjs.org/downloads/point/last-week/${pkg}`, {
headers: { 'User-Agent': 'awesome-opencode-bot' }
});
+2 -1
View File
@@ -10,6 +10,7 @@ const yaml = require('js-yaml');
const { CATEGORIES, canonicalKey, checkCatalog, parseRepoUrl } = require('./catalog');
const { probeCompat } = require('./compat');
const { getRepos } = require('./github');
const { escapeCell } = require('./template');
// Generated files a PR must never edit by hand
const GENERATED_FILES = ['README.md', 'dist/registry.json'];
@@ -158,7 +159,7 @@ function formatReport(result) {
const verified = result.entries.filter(e => e.probe);
if (verified.length) {
lines.push('', '**V2 compatibility**', '', '| Entry | Detected | Evidence |', '| :--- | :--- | :--- |');
for (const e of verified) lines.push(`| \`${path.basename(e.file)}\` | ${e.probe.compat} | ${e.probe.evidence.join('; ').replace(/\|/g, '\\|') || '—'} |`);
for (const e of verified) lines.push(`| \`${path.basename(e.file)}\` | ${e.probe.compat} | ${escapeCell(e.probe.evidence.join('; ')) || '—'} |`);
}
lines.push('', '<sub>Entry format: [contributing.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/contributing.md) · Catalog policy: [docs/policy.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md)</sub>');
return lines.join('\n');
+38 -7
View File
@@ -45,6 +45,34 @@ function escapeHtml(text) {
.replace(/"/g, '&quot;');
}
/**
* Escape text for a markdown table cell.
* @param {string} text
* @returns {string}
*/
function escapeCell(text) {
return String(text).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
}
/**
* Parse a URL, or null when it is not one.
* @param {string} url
* @returns {URL|null}
*/
function parseUrl(url) {
try { return new URL(url); } catch { return null; }
}
/**
* True for npm package pages (they duplicate the npm link, so they are not shown as docs).
* @param {string} url
* @returns {boolean}
*/
function isNpmPage(url) {
const u = parseUrl(url);
return Boolean(u && (u.hostname === 'www.npmjs.com' || u.hostname === 'npmjs.com') && u.pathname.startsWith('/package/'));
}
/**
* URL of the entry's code, including the monorepo subfolder when set.
* @param {object} entry - Parsed YAML data
@@ -61,19 +89,20 @@ function sourceUrl(entry) {
* @returns {string} HTML string for the details element
*/
function generateEntryHtml(entry, category) {
const url = parseUrl(entry.repo);
const isGist = Boolean(url && url.hostname === 'gist.github.com');
const isDiscussion = Boolean(url && url.hostname === 'github.com' && /^\/[^/]+\/[^/]+\/discussions\//.test(url.pathname));
// Determine link text based on URL type first
let linkText = '🔗 <b>View Repository</b>';
if (entry.repo.includes('gist.github.com')) {
if (isGist) {
linkText = '🔗 <b>View Gist</b>';
} else if (entry.repo.includes('/discussions/')) {
} else if (isDiscussion) {
linkText = '🔗 <b>View Discussion</b>';
}
// Extract owner/repo from URL for star badge (only for non-gist, non-discussion GitHub repos)
// Use negative lookahead to exclude gist.github.com
const isGist = entry.repo.includes('gist.github.com');
const isDiscussion = entry.repo.includes('/discussions/');
const repoMatch = entry.repo.match(/github\.com\/(?!gist\.)([^\/]+)\/([^\/]+)/);
const repoMatch = url && url.hostname === 'github.com' ? url.pathname.match(/^\/([^/]+)\/([^/]+)/) : null;
let summaryContent = `<b>${escapeHtml(entry.name)}</b>`;
@@ -100,7 +129,7 @@ function generateEntryHtml(entry, category) {
if (entry.npm) {
links.push(`<a href="https://www.npmjs.com/package/${escapeHtml(entry.npm)}">📦 <b>npm</b></a>`);
}
if (entry.homepage && !/npmjs\.com\/package\//.test(entry.homepage)) {
if (entry.homepage && !isNpmPage(entry.homepage)) {
links.push(`<a href="${escapeHtml(entry.homepage)}">📖 <b>Docs</b></a>`);
}
@@ -124,7 +153,9 @@ function writeReadme(content) {
}
module.exports = {
escapeCell,
escapeHtml,
isNpmPage,
readTemplate,
replacePlaceholder,
generateEntryHtml,
+2 -1
View File
@@ -106,6 +106,7 @@ const TOPIC_LABELS = { memory: 'Memory', context: 'Context & tokens', orchestrat
const STATUS_LABELS = { quiet: 'quiet', stale: 'no push in 1y', archived: 'archived', missing: 'repo missing', deprecated: 'deprecated' };
const DEAD = ['archived', 'missing', 'deprecated'];
const $ = id => document.getElementById(id);
const isNpmPage = url => { try { const u = new URL(url); return (u.hostname === 'www.npmjs.com' || u.hostname === 'npmjs.com') && u.pathname.startsWith('/package/'); } catch { return false; } };
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const state = { q: '', cat: '', kind: '', topic: '', compat: '', status: '', sort: 'name' };
let entries = [];
@@ -155,7 +156,7 @@ function card(e) {
const src = e.repoPath ? `${e.repoUrl.replace(/\/+$/, '')}/tree/HEAD/${e.repoPath}` : e.repoUrl;
const links = [`<a href="${esc(src)}" rel="noopener">Repository</a>`];
if (e.npmPackage) links.push(`<a href="https://www.npmjs.com/package/${esc(e.npmPackage)}" rel="noopener">npm</a>`);
if (e.homepageUrl && !/npmjs\.com\/package\//.test(e.homepageUrl)) links.push(`<a href="${esc(e.homepageUrl)}" rel="noopener">Docs</a>`);
if (e.homepageUrl && !isNpmPage(e.homepageUrl)) links.push(`<a href="${esc(e.homepageUrl)}" rel="noopener">Docs</a>`);
if (e.pushedAt) links.push(`<span class="stars">updated ${esc(e.pushedAt.slice(0, 10))}</span>`);
const long = (e.description || '').length > 180;
return `<article class="card">
+1 -1
View File
@@ -73,6 +73,6 @@ test('README entries escape contributor text and show compat badges', () => {
assert.match(html, /&lt;prompt&gt;/);
assert.match(html, /<code>V1 only<\/code>/);
assert.match(html, /tree\/HEAD\/pkg\/x/);
assert.match(html, /npmjs\.com\/package\/thing/);
assert.ok(html.includes('href="https://www.npmjs.com/package/thing"'));
assert.doesNotMatch(generateEntryHtml({ ...base, compat: 'n/a' }, 'projects'), /<code>/);
});