mirror of
https://github.com/Heretek-AI/awesome-opencode.git
synced 2026-11-07 02:35:58 -10:00
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:
1 parent
c2d1dc4abf
commit
fdef3018ba
14 files changed
+76
-28
No files matched your search
@@ -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: |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Re-check held PRs
|
||||
env:
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Find candidates
|
||||
env:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Label and comment
|
||||
env:
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -45,6 +45,34 @@ function escapeHtml(text) {
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">
|
||||
|
||||
@@ -73,6 +73,6 @@ test('README entries escape contributor text and show compat badges', () => {
|
||||
assert.match(html, /<prompt>/);
|
||||
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>/);
|
||||
});
|
||||
Reference in new issue
Block a user