From 923eddf16f858b1293deecb9798b8cdcf33aefdb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 15 Oct 2026 11:57:58 +0700 Subject: [PATCH] security: finish sanitizing public source without reverting concurrent credential cleanup Retain the latest sanitized example values, remove unsafe public token fields and add contributor security policies and regression checks. --- .env.example | 11 ++---- .github/dependabot.yml | 19 +++++++++- .github/workflows/deploy-github-pages.yml | 4 +- CONTRIBUTING.md | 32 ++++++++++++++++ README.md | 18 ++++++--- SECURITY.md | 46 +++++++++++++++++++++++ docs/CI_BUILD_COST.md | 2 +- docs/SEO.md | 2 +- nuxt.config.ts | 2 - tests/public-repository-security.test.mjs | 37 ++++++++++++++++++ 10 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 tests/public-repository-security.test.mjs diff --git a/.env.example b/.env.example index 512b5a8d..762fed4c 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ +# Only browser-safe settings belong in NUXT_PUBLIC_*; these are exposed to visitors. +# Public-safe example. Keep all tokens, write/admin keys, and provider secrets out of Git. #SERVER_PRESET=digital-oceans WEBSITE_NAME="ePlus OnAir — Listen Beyond Borders." DESCRIPTION="ePlus OnAir — Listen Beyond Borders. Discover and listen to live radio stations worldwide." @@ -17,21 +19,16 @@ CORS_SERVER_4="https://e-cors.netlify.app/" NUXT_PUBLIC_API_FRESHPING= # Algolia ALGOLIA_APPLICATION_ID= +# Optional: use only an index-restricted Algolia SEARCH key, never an Admin/Write key. ALGOLIA_API_KEY= -ALGOLIA_WRITE_API_KEY= # ALGOLIA_APPLICATION_ID= # ALGOLIA_API_KEY= -# ALGOLIA_WRITE_API_KEY= # Zing Server NUXT_PUBLIC_API_ZING_LIVE_STREAM_PROGRAM_DETAIL= NUXT_PUBLIC_API_ZING_GET_LYRIC= # Cloudinary CLOUDINARY_NAME="eplus-dev" -CLOUDINARY_API_KEY= -CLOUDINARY_API_SECRET= +# Cloudinary upload/API credentials belong in ignored local .env or environment secrets. NUXT_PUBLIC_CLOUDINARY_BASE_URL="http://localhost:3000" -# DigitalOcean -NUXT_PUBLIC_DIGITALOCEAN_ENDPOINT="https://api.digitalocean.com/v2/apps/7cebe9c4-324f-4d47-a41f-ebfe3baedaf6/deployments?page=1&per_page=1" -NUXT_PUBLIC_DIGITALOCEAN_API_KEY= # Sitemap NUXT_PUBLIC_SITE_URL="http://localhost:3000" \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 30c8a493..16460ece 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,4 +9,21 @@ updates: schedule: interval: "weekly" labels: - - "dependencies" \ No newline at end of file + - "dependencies" + + # Keep runtime/frontend dependencies visible to maintainers without + # opening dozens of near-identical public dependency PRs. + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + groups: + minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + labels: + - "dependencies" diff --git a/.github/workflows/deploy-github-pages.yml b/.github/workflows/deploy-github-pages.yml index d8a110cb..2b3cbfaa 100644 --- a/.github/workflows/deploy-github-pages.yml +++ b/.github/workflows/deploy-github-pages.yml @@ -108,7 +108,7 @@ jobs: exit 1 fi if [ -z "$PAGES_DEPLOY_TOKEN" ]; then - echo "::error::Missing PAGES_DEPLOY_TOKEN in the private source repository." + echo "::error::Missing PAGES_DEPLOY_TOKEN in the source repository's environment secrets." exit 1 fi auth="$(printf 'x-access-token:%s' "$PAGES_DEPLOY_TOKEN" | base64 -w0)" @@ -143,7 +143,7 @@ jobs: fi echo "Validated target $TARGET_REPOSITORY and read/write PAT permissions." - - name: Check out private source + - name: Check out trusted source if: steps.current.outputs.run == 'true' uses: actions/checkout@v7 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..9bb375cb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to ePlus OnAir + +Thanks for contributing improvements to the open development of the ePlus OnAir +website. The source is public; please read the [security policy](./SECURITY.md) +and [coding guide](./AGENTS.md) before proposing changes. + +## Before opening a PR + +1. For normal fixes/features, branch from `develop` and open a PR against + `develop`; `main` is production. For production hotfixes, coordinate + with maintainers so they are also applied to `develop`. +2. Run `npm ci` using the Node version in `.node-version`, then `npm test`. + Run `npm run test:ui` where browser behavior is changed; Playwright + is optional in the default CI. For substantial rendering/SEO changes, + run `npm run generate:site`. +3. Describe the issue, user impact, changes, test results and (if UI-related) + screenshots at desktop and mobile sizes. Avoid unrelated generated files. +4. Keep UI text translated into all supported locales; check keyboard + navigation, touch targets and light/dark contrast. +5. Never include credentials, private .env files, tokens, personal user data, + third-party copyrighted source code, or unlicensed artwork in a PR. + The ignored `.env` file is only for local development; `.env.example` + must remain safe to publish. +6. If contributing a new station, verify its stream source, name, country, + artwork permission and official site; do not embed short-lived access + tokens or upload private/copyrighted files. +7. If a security issue is discovered, report it **privately** as described + in [SECURITY.md](./SECURITY.md), not in a public issue. + +Source code publication does not itself grant permission to redistribute +radio station logos, audio streams, third-party media or proprietary metadata. +A repository source license will need to be selected and published separately. diff --git a/README.md b/README.md index dd7516e3..bbb71cbf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **Website:** https://radio.eplus.dev · **Staging:** https://new-radio.eplus.dev > [!IMPORTANT] -> This **private repository is the canonical source** for ePlus OnAir: Nuxt application, player, station catalog, tests, and release automation. The older `ePlus-DEV/get-radio` crawler is legacy history, not an active deployment source. Public Pages repositories hold generated artifacts, not application source. +> This **public repository is the canonical source** for ePlus OnAir: Nuxt application, player, station catalog, tests, and release automation. The older `ePlus-DEV/get-radio` crawler is legacy history, not an active deployment source. Public Pages repositories hold generated artifacts, not application source. ## Repository and environment map @@ -17,7 +17,7 @@ - `develop` is the integration and staging branch. Open feature/bugfix PRs against it. - `main` is the released source. Promote reviewed changes from `develop` through a release PR. Hotfixes start at `main` and must be brought back to `develop`. - `master` is a legacy backup with separate history. Never reset, force-push, or deploy it as the current application. -- The destination repositories use their own `main` branches for the **static website output**; these are not the private source branch. +- The destination repositories use their own `main` branches for the **static website output**; these are not the application source branch. - The custom hostnames and DNS must be verified separately. A successful GitHub push alone does not prove the live DNS is served by Pages. **SEO isolation:** `develop` staging is always `noindex`; `main` production builds are indexable (`index, follow`) with canonical URLs and a sitemap. Production-only GA4 (`G-ETDY41KBBJ`) is enabled on the canonical production hostname. Verify DNS and HTTPS separately from GitHub Pages deployment. @@ -80,7 +80,7 @@ npm run test:ui # Optional Playwright browser tests 5. The deployment updates `main` in the matching public Pages repository, re-creates `CNAME`, and **preserves its root `README.md`** during the destructive generated-site sync. 6. Confirm the Actions job succeeded and inspect the destination commit, deep-linked pages, images, and the configured custom hostname. Keep DNS/SEO cutover as an independent, reviewed task. -Required setup in the private source repository: GitHub Environments `development` / `production`, with `NUXT_PUBLIC_SITE_URL` set respectively to `https://new-radio.eplus.dev` / `https://radio.eplus.dev`; `PAGES_DEPLOY_TOKEN` must have scoped write access to both public Pages repositories. Do not expose this token to untrusted PR workflows. +Required setup in the source repository (store all credentials as GitHub **Secrets**, never in code): GitHub Environments `development` / `production`, with `NUXT_PUBLIC_SITE_URL` set respectively to `https://new-radio.eplus.dev` / `https://radio.eplus.dev`; `PAGES_DEPLOY_TOKEN` must have scoped write access to both public Pages repositories. Do not expose this token to untrusted PR workflows. All four workflows share a repository-wide concurrency queue. The workflows are: @@ -110,8 +110,16 @@ Smaller pushes do **not** lift GitHub Pages' published-site 1 GB limit; keep the See [SEO architecture](./docs/SEO.md). +## Security when working in this public repository + +- **Never commit real values** to `.env.example`, code, tests, documentation or issue/PR comments. See [security policy](./SECURITY.md). +- Keep only browser-safe keys under `NUXT_PUBLIC_*`. Never include provider admin/write keys, Cloudinary secrets, DigitalOcean tokens or GitHub publishing tokens. +- Place deployment PATs in GitHub **Environment Secrets**, restrict them to the exact Pages output repositories and Contents write access, and require approval for the `production` environment where practical. +- Use read-only CI for untrusted fork pull requests. Do not make `pull_request_target` workflows checkout untrusted PR code and run it with secrets. +- A credential in Git history must be **revoked/rotated** even if removed from the latest commit; deleting a line does not undo exposure. + ## Contribution and maintainership -Follow [AGENTS.md](./AGENTS.md), [project context](./docs/PROJECT_CONTEXT.md), and [radio synchronization notes](./docs/instant-audio-sync.md). Keep UI text translated, validate station streams and artwork paths, test changes, and avoid committing secrets. +Follow [CONTRIBUTING.md](./CONTRIBUTING.md), [AGENTS.md](./AGENTS.md), [project context](./docs/PROJECT_CONTEXT.md), and [radio synchronization notes](./docs/instant-audio-sync.md). Keep UI text translated, validate station streams and artwork paths, test changes, and avoid committing secrets. -**License:** Maintained by ePlus.DEV; consult repository licensing information before redistribution. +**License:** No open-source license has been selected yet. Public visibility alone does not grant reuse or redistribution rights. Before accepting code contributions or advertising this as open source, choose and add a LICENSE (for example MIT or Apache-2.0 after checking third-party assets and dependencies). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..d29463ba --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,46 @@ +# Security policy + +## Reporting a vulnerability + +Please **do not publish vulnerabilities, passwords, private keys, provider API tokens, +or full exploit details in a public GitHub issue or pull request**. + +Report security concerns privately to **admin@eplus.dev** (subject: +`ePlus OnAir security report`), or use GitHub's **Report a vulnerability** +option on the repository's **Security** tab if private vulnerability reporting +is enabled. Include a concise description, affected file or endpoint, +impact, and reproducible steps without publishing live credentials. + +This is a community-maintained site. A specific response or fix SLA is not guaranteed. + +## Secrets and public deployment + +- `main` and `develop` are **public source** branches. Public forks, + PR diffs and commit history can permanently expose any committed value. +- Never store live credentials in `.env.example`, `NUXT_PUBLIC_*`, + source code, tests, screenshots, CI logs, discussions or PR comments. +- `NUXT_PUBLIC_*` values are included in client bundles/static pages. + Only put information there that is safe for every visitor to see. +- The search-only Algolia key (if configured) must be limited to **search**, + restricted to the appropriate indexes. **Never** use an Admin/Write key. +- Publishing PATs belong in GitHub **Environment Secrets**, ideally with + access only to the specific destination repositories. Review and rotate + those tokens regularly, use minimal permissions, and protect releases. +- Do not let untrusted fork PR code access write tokens. Prefer read-only + PR checks and separate trusted deployments. + +If a secret was previously committed, **revoke or rotate it immediately**. +Removing it from `main` is not enough: earlier commits, tags, forks, +clones, caches and pull-request diffs may still contain it. After revocation, +consider GitHub's documented sensitive-data history-removal process, +coordinate with collaborators and recheck the full public history. + +## Vulnerability classes + +Particularly useful reports include credential leakage, malicious +station artwork or catalog payloads, dependency vulnerabilities, +supply-chain / GitHub Actions permission issues, stored XSS, unsafe +cross-origin requests and data/privacy leaks. + +Third-party radio streams, station logos and music programming belong to +their respective operators and may be subject to separate rights or terms. diff --git a/docs/CI_BUILD_COST.md b/docs/CI_BUILD_COST.md index 4c2a8ad3..3e769571 100644 --- a/docs/CI_BUILD_COST.md +++ b/docs/CI_BUILD_COST.md @@ -1,6 +1,6 @@ # ePlus OnAir CI build-cost policy -The private source uses **`develop` for staging** and **`main` for production**. +The public source repository uses **`develop` for staging** and **`main` for production**. This document is about PR validation only, not publishing or DNS. ## Two CI paths diff --git a/docs/SEO.md b/docs/SEO.md index 62b5482a..c6175a13 100644 --- a/docs/SEO.md +++ b/docs/SEO.md @@ -6,7 +6,7 @@ This file is the durable SEO checklist for ePlus OnAir. It records what is imple ## GitHub Pages and production indexing policy -GitHub Pages publishes generated static output from the private source into +GitHub Pages publishes generated static output from the public source repository into two public repositories with **custom hostnames** and `NUXT_APP_BASE_URL=/`: - `develop` → `ePlus-DEV/onair-staging-pages` → `https://new-radio.eplus.dev`: diff --git a/nuxt.config.ts b/nuxt.config.ts index 522c5a53..f250a75e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -86,8 +86,6 @@ export default defineNuxtConfig({ apiZingLiveStreamProgramDetail: process.env.NUXT_PUBLIC_API_ZING_LIVE_STREAM_PROGRAM_DETAIL, apiZingGetLyric: process.env.NUXT_PUBLIC_API_ZING_GET_LYRIC, - digitaloceanEndpoint: process.env.NUXT_PUBLIC_DIGITALOCEAN_ENDPOINT, - digitaloceanApiKey: process.env.NUXT_PUBLIC_DIGITALOCEAN_API_KEY, x: process.env.NUXT_PUBLIC_X, facebook: process.env.NUXT_PUBLIC_FACEBOOK, github: process.env.NUXT_PUBLIC_GITHUB, diff --git a/tests/public-repository-security.test.mjs b/tests/public-repository-security.test.mjs new file mode 100644 index 00000000..e8744cd3 --- /dev/null +++ b/tests/public-repository-security.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const read = path => readFile(new URL('../' + path, import.meta.url), 'utf8'); + +test('the public .env.example does not contain credential values', async () => { + const content = await read('.env.example'); + assert.doesNotMatch(content, /dop_v1_[a-z0-9]+/i); + assert.doesNotMatch(content, /(?:ghp_|gho_|github_pat_)[a-zA-Z0-9_]+/); + assert.doesNotMatch(content, /(?:sk_live_|sk_test_)[a-zA-Z0-9]+/); + assert.doesNotMatch(content, /^\s*(?:CLOUDINARY_API_SECRET|CLOUDINARY_API_KEY|ALGOLIA_WRITE_API_KEY|NUXT_PUBLIC_DIGITALOCEAN_API_KEY)\s*=/m); + for (const line of content.split('\n')) { + if (line.startsWith('#')) continue; + const match = line.match(/^\s*([\w]+(?:API_KEY|API_SECRET|TOKEN|PASSWORD))\s*=\s*(.*)$/i); + if (match) assert.ok(/^(?:""|'')?$/.test(match[2].trim()), + `Credential placeholder for ${match[1]} must remain empty`); + } +}); + +test('Nuxt public runtime config must never export administrative provider credentials', async () => { + const config = await read('nuxt.config.ts'); + assert.doesNotMatch(config, /digitaloceanApiKey\s*:/); + assert.doesNotMatch(config, /NUXT_PUBLIC_DIGITALOCEAN_API_KEY/); + assert.doesNotMatch(config, /CLOUDINARY_API_SECRET/); + const readme = await read('README.md'); + assert.doesNotMatch(readme, /This \*\*private repository\*\*/); + assert.match(readme, /SECURITY\.md/); +}); + +test('public security and contribution policy are documented', async () => { + const [policy, guide] = await Promise.all([read('SECURITY.md'), read('CONTRIBUTING.md')]); + assert.match(policy, /revoke or rotate/i); + assert.match(policy, /NUXT_PUBLIC_\*/); + assert.match(guide, /develop/); + assert.match(guide, /npm test/); +});