From f7bff933ed515e9f16a4e5d67fde7cad9123d1c6 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 5 Aug 2026 12:45:49 +0700 Subject: [PATCH 1/3] harden package publishing --- .github/dependabot.yml | 25 +++ .github/workflows/ci.yml | 8 + .github/workflows/dependency-audit.yml | 21 ++ .github/workflows/dependency-review.yml | 22 +++ .github/workflows/release.yml | 244 ++++++++++-------------- CODEOWNERS | 8 + package.json | 4 + pnpm-workspace.yaml | 3 +- 8 files changed, 187 insertions(+), 148 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependency-audit.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 CODEOWNERS diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..12cd404 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + commit-message: + prefix: "ci" + labels: + - "dependencies" + - "github-actions" + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + commit-message: + prefix: "deps" + labels: + - "dependencies" + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef0632a..81dcbc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -40,6 +42,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -65,6 +69,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -96,6 +102,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 0000000..ef0ab10 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,21 @@ +name: Scheduled Dependency Audit + +on: + workflow_dispatch: + schedule: + - cron: '37 9 * * 1' + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - name: Audit production dependencies + run: pnpm audit --prod --audit-level=high diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..135474a --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,22 @@ +name: Dependency Review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + review: + name: Dependency Review + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Reject newly introduced vulnerable dependencies + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14ea331..4e0f441 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,48 +8,37 @@ on: permissions: {} jobs: - publish: - # npm provenance publishing only supports GitHub-hosted Actions runners. - # Keep this job on ubuntu-latest unless npm adds self-hosted provenance support. + build: + name: Build and inspect package runs-on: ubuntu-latest permissions: - contents: write # Required to create the GitHub release - id-token: write # Required for OIDC trusted publishing - # Workflow-context values are bound to env here and referenced as - # shell variables ($TAG/$REPO/$COMMIT_SHA) in run: blocks instead of - # `${{ }}` interpolation, so an attacker-controlled tag name cannot be - # injected into a script body. Tag pushes are attacker-controllable: - # anyone able to push a v* tag triggers this workflow. + contents: read + outputs: + version: ${{ steps.version.outputs.version }} env: TAG: ${{ github.ref_name }} - REPO: ${{ github.repository }} COMMIT_SHA: ${{ github.sha }} steps: - name: Validate tag format - # Fail closed before any other step runs. A strict semver gate - # rejects a tag containing shell metacharacters ($(), backticks, - # ;, |) so it never reaches a later run: block. run: | if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then - echo "❌ Error: Tag '$TAG' is not a valid vMAJOR.MINOR.PATCH semver tag" - echo "Releases must be triggered by a strict semver tag, e.g. v1.2.3 or v1.2.3-rc.1" + echo "Error: '$TAG' is not a strict semver release tag" exit 1 fi - echo "✅ Tag format validated: $TAG" - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - - name: Verify tag is on main branch + - name: Verify tag is on main run: | git fetch origin main - if ! git merge-base --is-ancestor "$COMMIT_SHA" origin/main; then - echo "❌ Tag is not on the main branch — aborting release" + git merge-base --is-ancestor "$COMMIT_SHA" origin/main || { + echo "Error: release tags must point to a commit on main" exit 1 - fi - echo "✅ Tag is on main branch" + } - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -57,147 +46,108 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: "24.15.0" + node-version: '24.15.0' cache: 'pnpm' - # No registry-url - using OIDC trusted publishing instead - - - name: Update npm for trusted publishing - run: npm install -g npm@latest - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Extract version from tag + - name: Verify package version id: version run: | - # $GITHUB_REF is a runner-provided env var (safe shell - # expansion, not template interpolation); the tag was strictly - # validated above, so VERSION is a clean semver string. - VERSION="${GITHUB_REF#refs/tags/v}" + VERSION="${TAG#v}" + PACKAGE_VERSION=$(node -p "require('./package.json').version") + test "$VERSION" = "$PACKAGE_VERSION" || { + echo "Error: tag $TAG does not match package.json $PACKAGE_VERSION" + exit 1 + } echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Publishing version: $VERSION" - - name: Verify tag matches package.json version - env: - VERSION: ${{ steps.version.outputs.version }} + - name: Build and test run: | - PKG_VERSION=$(node -p "require('./package.json').version") - if [ "$VERSION" != "$PKG_VERSION" ]; then - echo "❌ Tag v$VERSION does not match package.json version $PKG_VERSION" - exit 1 - fi - echo "✅ Version confirmed: $VERSION" - - - name: Build - run: pnpm build - - - name: Test - run: pnpm test + pnpm build + pnpm test env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} - - name: Publish to npm - run: npm publish --provenance - # Explicitly use --provenance flag for clarity - # OIDC trusted publishing (id-token: write) enables automatic provenance generation - - - name: Generate release notes - id: release_notes - env: - VERSION: ${{ steps.version.outputs.version }} + - name: Pack and inspect publish contents run: | - PREV_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${TAG}$" | head -1) - RELEASE_DATE=$(date +%Y-%m-%d) - - if [ -n "$PREV_TAG" ]; then - # Put the hash first and separate it from the subject with a real - # tab. The loop below validates the hash before making a link, so - # malformed log output cannot silently publish an empty URL. - COMMITS=$(git log "${PREV_TAG}..HEAD" --pretty=format:'%h%x09%s' --no-merges) - else - COMMITS=$(git log --pretty=format:'%h%x09%s' --no-merges) - fi - - FEATURES="" - FIXES="" - OTHER="" - - while IFS=$'\t' read -r hash message; do - [ -z "$message" ] && continue - if [[ ! $hash =~ ^[0-9a-f]+$ ]]; then - echo "❌ Could not parse commit hash for release-note entry: $message" >&2 - exit 1 - fi - if [[ $message =~ \(#([0-9]+)\) ]]; then - PR_NUM="${BASH_REMATCH[1]}" - CLEAN_MESSAGE=$(echo "$message" | sed -E 's/ ?\(#[0-9]+\)//') - PR_LINK="[#$PR_NUM](https://github.com/${REPO}/pull/$PR_NUM)" - COMMIT_LINK="[$hash](https://github.com/${REPO}/commit/$hash)" - ITEM="$CLEAN_MESSAGE ($PR_LINK) ($COMMIT_LINK)" - else - COMMIT_LINK="[$hash](https://github.com/${REPO}/commit/$hash)" - ITEM="$message ($COMMIT_LINK)" - fi - - if [[ $message =~ ^feat(\([^\)]+\))?: ]]; then - STRIPPED=$(echo "$ITEM" | sed -E 's/^feat(\([^)]+\))?: //') - FEATURES="${FEATURES}- ${STRIPPED} - " - elif [[ $message =~ ^fix(\([^\)]+\))?: ]]; then - STRIPPED=$(echo "$ITEM" | sed -E 's/^fix(\([^)]+\))?: //') - FIXES="${FIXES}- ${STRIPPED} - " - else - OTHER="${OTHER}- ${ITEM} - " - fi - done <<< "$COMMITS" - - cat > release_notes.md <> release_notes.md <> release_notes.md <> release_notes.md < "$RUNNER_TEMP/pack.json" + PACK_JSON="$RUNNER_TEMP/pack.json" node <<'NODE' + const fs = require('node:fs') + const reports = JSON.parse(fs.readFileSync(process.env.PACK_JSON, 'utf8')) + if (reports.length !== 1) throw new Error(`Expected one tarball, got ${reports.length}`) + const forbidden = reports[0].files + .map(({ path }) => path) + .filter((path) => /(^|\/)(\.env|.*\.(pem|key|p12|pfx))$/i.test(path)) + if (forbidden.length) throw new Error(`Sensitive files in package: ${forbidden.join(', ')}`) + console.log(reports[0].files.map(({ path }) => path).join('\n')) + NODE + + - name: Upload immutable package candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-package + path: ${{ runner.temp }}/package/*.tgz + if-no-files-found: error + retention-days: 1 - cat >> release_notes.md <> "$GITHUB_ENV" + + - name: Publish with OIDC provenance + run: npm publish "$tarball" --provenance --access public + + release: + name: Create GitHub release + needs: [build, publish] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create release notes + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: - body_path: release_notes.md - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + generate_release_notes: true diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..941c60a --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,8 @@ +* @yosriady + +# Publishing identity and dependency resolution are security boundaries. +/.github/workflows/ @yosriady +/package.json @yosriady +/pnpm-lock.yaml @yosriady +/pnpm-workspace.yaml @yosriady +/scripts/ @yosriady diff --git a/package.json b/package.json index a6749b9..f0341f3 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "dist", "README.md" ], + "publishConfig": { + "access": "public", + "provenance": true + }, "scripts": { "build": "tsc", "prepublishOnly": "pnpm build", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7884d19..487935d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ -minimumReleaseAge: 2880 +minimumReleaseAge: 10080 blockExoticSubdeps: true +trustPolicy: no-downgrade overrides: serialize-javascript: '>=7.0.5' From 270cd2472c96eefe3da7491548ed436912ad4fcf Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 5 Aug 2026 13:38:34 +0700 Subject: [PATCH 2/3] Fix hardened release workflow --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e0f441..28ea9fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,7 +91,7 @@ jobs: name: npm-package path: ${{ runner.temp }}/package/*.tgz if-no-files-found: error - retention-days: 1 + retention-days: 7 publish: name: Publish approved tarball @@ -122,7 +122,7 @@ jobs: - name: Verify tarball identity and lifecycle hooks run: | - mapfile -t TARBALLS < <(find package -maxdepth 1 -name '*.tgz' -type f) + mapfile -t TARBALLS < <(find "$PWD/package" -maxdepth 1 -name '*.tgz' -type f) test "${#TARBALLS[@]}" -eq 1 || { echo "Expected exactly one tarball"; exit 1; } mkdir inspect tar -xzf "${TARBALLS[0]}" -C inspect @@ -135,6 +135,7 @@ jobs: if (pkg.scripts?.[hook]) throw new Error(`Forbidden lifecycle hook: ${hook}`) } NODE + test ! -f inspect/package/binding.gyp || { echo "Forbidden implicit install hook: binding.gyp"; exit 1; } echo "tarball=${TARBALLS[0]}" >> "$GITHUB_ENV" - name: Publish with OIDC provenance From 3c09bcb91fc1c754aac62b7068d8101c952bc4d4 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 5 Aug 2026 13:52:53 +0700 Subject: [PATCH 3/3] Remove redundant dependency automation --- .github/dependabot.yml | 25 ------------------------- .github/workflows/dependency-audit.yml | 21 --------------------- .github/workflows/dependency-review.yml | 22 ---------------------- 3 files changed, 68 deletions(-) delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/dependency-audit.yml delete mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 12cd404..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,25 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - cooldown: - default-days: 7 - commit-message: - prefix: "ci" - labels: - - "dependencies" - - "github-actions" - - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - cooldown: - default-days: 7 - commit-message: - prefix: "deps" - labels: - - "dependencies" - open-pull-requests-limit: 10 diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml deleted file mode 100644 index ef0ab10..0000000 --- a/.github/workflows/dependency-audit.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Scheduled Dependency Audit - -on: - workflow_dispatch: - schedule: - - cron: '37 9 * * 1' - -permissions: - contents: read - -jobs: - audit: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - - name: Audit production dependencies - run: pnpm audit --prod --audit-level=high diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 135474a..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Dependency Review - -on: - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - review: - name: Dependency Review - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Reject newly introduced vulnerable dependencies - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 - with: - fail-on-severity: high