diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 5d89c59..3f2dded 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 with: node-version: 18 @@ -36,10 +36,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 with: node-version: 18 @@ -56,10 +56,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 with: node-version: 18 diff --git a/README.md b/README.md index 7d0d366..294da78 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This project contains: - **SEQICO.sol**: The main ICO contract allowing token purchases with ETH, USDT, and USDC - **SEQToken.sol**: The ERC20 token contract - **Deployment scripts**: Two deployment scripts with different configurations +- **GitHub Actions Automation**: Script to pin GitHub Actions to full-length commit SHAs for improved security ## Features @@ -23,6 +24,15 @@ This project contains: - Initial distribution: 10% to owner, 90% to ICO contract - 500,000 total supply +### GitHub Actions Security Automation +Automated tool to pin GitHub Actions to full-length commit SHAs for improved security: + +- **Automatic Detection**: Scans all workflow files for unpinned GitHub Actions +- **Safe Updates**: Fetches the latest commit SHAs and safely replaces version tags +- **Validation**: Ensures YAML remains valid after updates +- **Dry Run Mode**: Preview changes before applying them +- **Comprehensive Testing**: Full test suite with edge case handling + ## Setup 1. Install dependencies: @@ -44,6 +54,65 @@ npx hardhat run scripts/deploy.js npx hardhat run scripts/deploy-DE.js ``` +## GitHub Actions Security + +### Pinning Actions to Commit SHAs + +For improved security, this project includes automation to pin GitHub Actions to full-length commit SHAs instead of using version tags. This prevents potential supply chain attacks where malicious code could be injected into new versions of actions. + +#### Usage + +Preview what would be changed (recommended first): +```bash +npm run pin-actions -- --dry-run +``` + +Pin all GitHub Actions in workflow files: +```bash +npm run pin-actions +``` + +With GitHub token for better rate limits: +```bash +GITHUB_TOKEN=your_token_here npm run pin-actions +``` + +#### Features + +- **Smart Detection**: Automatically finds all `.yml` and `.yaml` files in `.github/workflows/` +- **Version Resolution**: Resolves version tags (like `@v3`) to the latest patch version commit SHA +- **Selective Updates**: Only updates unpinned actions, skips already pinned ones +- **Error Handling**: Gracefully handles non-existent repositories and network issues +- **YAML Validation**: Ensures workflow files remain valid after updates +- **Caching**: Caches API responses to minimize GitHub API calls + +#### Supported Action Formats + +✅ **Will be pinned:** +- `actions/checkout@v3` → `actions/checkout@f43a0e5ff2bd294f1e76c1b0c63c18e4bd` +- `actions/setup-node@v3.8.1` → `actions/setup-node@60edb5d...` +- `company/action@main` → `company/action@abc123...` + +❌ **Will be skipped (already secure):** +- `actions/checkout@f43a0e5ff2bd294f1e76c1b0c63c18e4bd` (already pinned) + +#### Testing + +Run the automation tests: +```bash +npm test +# or specifically +npm run test:pin-actions +``` + +The test suite covers: +- Workflow file discovery +- Action parsing and identification +- SHA resolution and replacement +- YAML validation +- Dry run functionality +- Error handling for edge cases + ## Contract Functions ### SEQICO Contract diff --git a/package-lock.json b/package-lock.json index 34ecc06..56310f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "ISC", "devDependencies": { "@nomicfoundation/hardhat-toolbox": "^6.1.0", + "@octokit/rest": "^20.0.2", "@openzeppelin/contracts": "^5.4.0", - "hardhat": "^3.0.3" + "hardhat": "^3.0.3", + "js-yaml": "^4.1.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -741,6 +743,173 @@ "node": ">= 12" } }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", + "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.4.4-cjs.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.4-cjs.2.tgz", + "integrity": "sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.7.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-4.0.1.tgz", + "integrity": "sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.3.2-cjs.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.2-cjs.1.tgz", + "integrity": "sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.8.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.1.2.tgz", + "integrity": "sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^5.0.2", + "@octokit/plugin-paginate-rest": "11.4.4-cjs.2", + "@octokit/plugin-request-log": "^4.0.0", + "@octokit/plugin-rest-endpoint-methods": "13.3.2-cjs.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, "node_modules/@openzeppelin/contracts": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.4.0.tgz", @@ -844,6 +1013,20 @@ "node": ">=8" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/chalk": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", @@ -875,6 +1058,13 @@ } } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true, + "license": "ISC" + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -1022,6 +1212,19 @@ "hardhat": "dist/src/cli.js" } }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-stream-stringify": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/json-stream-stringify/-/json-stream-stringify-3.1.6.tgz", @@ -1103,6 +1306,16 @@ "dev": true, "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/p-map": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", @@ -1199,6 +1412,20 @@ "node": ">=18.17" } }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/package.json b/package.json index d426f1a..952e104 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,19 @@ "description": "Deployment", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node test/pin-github-actions.test.js", + "test:pin-actions": "node test/pin-github-actions.test.js", + "pin-actions": "node scripts/pin-github-actions.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@nomicfoundation/hardhat-toolbox": "^6.1.0", + "@octokit/rest": "^20.0.2", "@openzeppelin/contracts": "^5.4.0", - "hardhat": "^3.0.3" + "hardhat": "^3.0.3", + "js-yaml": "^4.1.0" }, "type": "module" } diff --git a/scripts/pin-github-actions.js b/scripts/pin-github-actions.js new file mode 100644 index 0000000..1be1699 --- /dev/null +++ b/scripts/pin-github-actions.js @@ -0,0 +1,331 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; +import { Octokit } from '@octokit/rest'; + +class GitHubActionsPinner { + constructor(options = {}) { + this.octokit = new Octokit({ + auth: options.token || process.env.GITHUB_TOKEN, + userAgent: 'github-actions-pinner/1.0.0' + }); + this.workflowsDir = options.workflowsDir || '.github/workflows'; + this.dryRun = options.dryRun || false; + this.cache = new Map(); // Cache for commit SHAs to avoid duplicate API calls + } + + /** + * Main function to pin all GitHub Actions in workflow files + */ + async pinActions() { + console.log('🔍 Scanning for workflow files...'); + + const workflowFiles = this.findWorkflowFiles(); + if (workflowFiles.length === 0) { + console.log('❌ No workflow files found in', this.workflowsDir); + return false; + } + + console.log(`📁 Found ${workflowFiles.length} workflow file(s):`); + workflowFiles.forEach(file => console.log(` - ${file}`)); + + let totalUpdates = 0; + const results = []; + + for (const filePath of workflowFiles) { + console.log(`\n🔧 Processing ${filePath}...`); + const result = await this.processWorkflowFile(filePath); + results.push(result); + totalUpdates += result.updates; + } + + console.log(`\n✅ Processing complete!`); + console.log(`📊 Summary: ${totalUpdates} action(s) pinned across ${workflowFiles.length} file(s)`); + + if (this.dryRun) { + console.log('🔍 Dry run mode - no files were modified'); + } + + return totalUpdates > 0; + } + + /** + * Find all workflow YAML files + */ + findWorkflowFiles() { + if (!fs.existsSync(this.workflowsDir)) { + return []; + } + + return fs.readdirSync(this.workflowsDir) + .filter(file => file.endsWith('.yml') || file.endsWith('.yaml')) + .map(file => path.join(this.workflowsDir, file)); + } + + /** + * Process a single workflow file + */ + async processWorkflowFile(filePath) { + const result = { + file: filePath, + updates: 0, + errors: [], + actions: [] + }; + + try { + const content = fs.readFileSync(filePath, 'utf8'); + const workflow = yaml.load(content); + + if (!workflow || !workflow.jobs) { + result.errors.push('Invalid workflow structure - no jobs found'); + return result; + } + + const unpinnedActions = this.findUnpinnedActions(workflow); + console.log(` Found ${unpinnedActions.length} action(s) to process`); + + if (unpinnedActions.length === 0) { + console.log(' ✅ All actions are already pinned or no actions found'); + return result; + } + + // Get commit SHAs for all unpinned actions + const actionSHAs = await this.fetchCommitSHAs(unpinnedActions); + + // Update the workflow content + let updatedContent = content; + for (const action of unpinnedActions) { + const sha = actionSHAs.get(action.full); + if (sha) { + const oldUses = `uses: ${action.full}`; + const newUses = `uses: ${action.repo}@${sha}`; + + // Use regex to replace the specific line to avoid partial matches + const regex = new RegExp(`(\\s+uses:\\s+)${action.full.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'); + updatedContent = updatedContent.replace(regex, `$1${action.repo}@${sha}`); + + result.actions.push({ + action: action.full, + sha: sha, + updated: true + }); + result.updates++; + + console.log(` ✅ ${action.full} → ${action.repo}@${sha.substring(0, 7)}...`); + } else { + result.errors.push(`Failed to get commit SHA for ${action.full}`); + result.actions.push({ + action: action.full, + sha: null, + updated: false + }); + console.log(` ❌ Failed to pin ${action.full}`); + } + } + + // Validate the updated YAML + if (result.updates > 0) { + try { + yaml.load(updatedContent); + + if (!this.dryRun) { + fs.writeFileSync(filePath, updatedContent, 'utf8'); + console.log(` 💾 File updated successfully`); + } else { + console.log(` 🔍 Would update file (dry run mode)`); + } + } catch (yamlError) { + result.errors.push(`YAML validation failed after updates: ${yamlError.message}`); + console.log(` ❌ YAML validation failed - changes not saved`); + } + } + + } catch (error) { + result.errors.push(`Error processing file: ${error.message}`); + console.log(` ❌ Error: ${error.message}`); + } + + return result; + } + + /** + * Find all unpinned GitHub Actions in a workflow + */ + findUnpinnedActions(workflow) { + const actions = []; + const actionPattern = /^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@(v?\d+(?:\.\d+)*(?:\.\d+)?|main|master|latest)$/; + + const processSteps = (steps) => { + if (!Array.isArray(steps)) return; + + for (const step of steps) { + if (step.uses && typeof step.uses === 'string') { + const match = step.uses.match(actionPattern); + if (match) { + const [, repo, tag] = match; + actions.push({ + full: step.uses, + repo: repo, + tag: tag + }); + } + } + } + }; + + // Process all jobs and their steps + for (const [jobName, job] of Object.entries(workflow.jobs)) { + if (job.steps) { + processSteps(job.steps); + } + } + + return actions; + } + + /** + * Fetch commit SHAs for multiple actions + */ + async fetchCommitSHAs(actions) { + const shaMap = new Map(); + const uniqueActions = [...new Set(actions.map(a => a.full))]; + + console.log(` 🌐 Fetching commit SHAs for ${uniqueActions.length} unique action(s)...`); + + for (const actionFull of uniqueActions) { + try { + const sha = await this.getLatestCommitSHA(actionFull); + shaMap.set(actionFull, sha); + } catch (error) { + console.log(` ❌ Failed to fetch SHA for ${actionFull}: ${error.message}`); + shaMap.set(actionFull, null); + } + } + + return shaMap; + } + + /** + * Get the latest commit SHA for a GitHub Action + */ + async getLatestCommitSHA(actionSpec) { + // Check cache first + if (this.cache.has(actionSpec)) { + return this.cache.get(actionSpec); + } + + const [repo, tag] = actionSpec.split('@'); + const [owner, repoName] = repo.split('/'); + + try { + // For version tags, get the specific tag + if (tag.match(/^v?\d+(\.\d+)*$/)) { + const { data } = await this.octokit.rest.repos.listTags({ + owner, + repo: repoName, + per_page: 100 + }); + + // Find exact match first, then try major version match + let targetTag = data.find(t => t.name === tag); + + if (!targetTag && tag.match(/^v?\d+$/)) { + // For major version tags like 'v3', find the latest patch version + const majorVersion = tag.replace('v', ''); + targetTag = data.find(t => t.name.startsWith(`v${majorVersion}.`) || t.name.startsWith(`${majorVersion}.`)); + } + + if (targetTag) { + this.cache.set(actionSpec, targetTag.commit.sha); + return targetTag.commit.sha; + } + } + + // Fallback to latest commit on the tag/branch + const { data } = await this.octokit.rest.repos.getBranch({ + owner, + repo: repoName, + branch: tag + }); + + const sha = data.commit.sha; + this.cache.set(actionSpec, sha); + return sha; + + } catch (error) { + if (error.status === 404) { + throw new Error(`Repository ${repo} or tag/branch ${tag} not found`); + } + throw new Error(`GitHub API error: ${error.message}`); + } + } +} + +/** + * Main CLI function + */ +async function main() { + const args = process.argv.slice(2); + const options = { + dryRun: args.includes('--dry-run') || args.includes('-d'), + token: process.env.GITHUB_TOKEN + }; + + if (args.includes('--help') || args.includes('-h')) { + console.log(` +GitHub Actions Pinner + +Usage: node scripts/pin-github-actions.js [options] + +Options: + --dry-run, -d Show what would be changed without modifying files + --help, -h Show this help message + +Environment Variables: + GITHUB_TOKEN GitHub personal access token (optional, but recommended for rate limiting) + +Examples: + npm run pin-actions + npm run pin-actions -- --dry-run + GITHUB_TOKEN=ghp_xxx npm run pin-actions +`); + return; + } + + console.log('🚀 GitHub Actions Pinner'); + console.log('=======================\n'); + + if (options.dryRun) { + console.log('🔍 Running in dry-run mode\n'); + } + + if (!options.token) { + console.log('⚠️ No GITHUB_TOKEN provided. Rate limiting may apply.\n'); + } + + try { + const pinner = new GitHubActionsPinner(options); + const hasUpdates = await pinner.pinActions(); + + if (hasUpdates && !options.dryRun) { + console.log('\n💡 Don\'t forget to commit these changes:'); + console.log(' git add .github/workflows/'); + console.log(' git commit -m "Pin GitHub Actions to full-length commit SHAs for improved security"'); + } + + process.exit(0); + } catch (error) { + console.error('\n❌ Error:', error.message); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} + +export { GitHubActionsPinner }; \ No newline at end of file diff --git a/test/pin-github-actions.test.js b/test/pin-github-actions.test.js new file mode 100644 index 0000000..905e4a9 --- /dev/null +++ b/test/pin-github-actions.test.js @@ -0,0 +1,358 @@ +import fs from 'fs'; +import path from 'path'; +import { GitHubActionsPinner } from '../scripts/pin-github-actions.js'; + +// Mock test data +const testWorkflowContent = `name: Test Workflow + +on: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Already pinned action + uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 + + - name: Custom action + uses: company/custom-action@v1.2.3 +`; + +const testWorkflowAlreadyPinned = `name: Already Pinned + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@704facf57e6136b1bc63b828d79edcd491f0ee84 +`; + +const testWorkflowNoActions = `name: No Actions + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Run script + run: echo "Hello World" +`; + +/** + * Simple test runner + */ +class TestRunner { + constructor() { + this.tests = []; + this.passed = 0; + this.failed = 0; + } + + addTest(name, testFn) { + this.tests.push({ name, testFn }); + } + + async runAll() { + console.log(`🧪 Running ${this.tests.length} test(s)...\n`); + + for (const test of this.tests) { + try { + await test.testFn(); + console.log(`✅ ${test.name}`); + this.passed++; + } catch (error) { + console.log(`❌ ${test.name}`); + console.log(` Error: ${error.message}`); + this.failed++; + } + } + + console.log(`\n📊 Test Results: ${this.passed} passed, ${this.failed} failed`); + return this.failed === 0; + } +} + +/** + * Test helper functions + */ +function assert(condition, message) { + if (!condition) { + throw new Error(message || 'Assertion failed'); + } +} + +function createTempDir() { + const tempDir = `/tmp/github-actions-test-${Date.now()}`; + fs.mkdirSync(tempDir, { recursive: true }); + return tempDir; +} + +function createTestWorkflow(dir, filename, content) { + const workflowsDir = path.join(dir, '.github', 'workflows'); + fs.mkdirSync(workflowsDir, { recursive: true }); + const filePath = path.join(workflowsDir, filename); + fs.writeFileSync(filePath, content); + return filePath; +} + +function cleanup(dir) { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Mock GitHub API for testing + */ +class MockOctokit { + constructor() { + this.rest = { + repos: { + listTags: this.mockListTags.bind(this), + getBranch: this.mockGetBranch.bind(this) + } + }; + } + + async mockListTags({ owner, repo }) { + if (owner === 'actions' && repo === 'checkout') { + return { + data: [ + { name: 'v4.1.7', commit: { sha: 'a1b2c3d4e5f6789012345678901234567890abcd' } }, + { name: 'v3.6.0', commit: { sha: 'b2c3d4e5f6789012345678901234567890abcdef' } } + ] + }; + } + if (owner === 'actions' && repo === 'setup-node') { + return { + data: [ + { name: 'v4.0.2', commit: { sha: 'c3d4e5f6789012345678901234567890abcdef12' } }, + { name: 'v3.8.1', commit: { sha: 'd4e5f6789012345678901234567890abcdef1234' } } + ] + }; + } + throw new Error('Repository not found'); + } + + async mockGetBranch({ owner, repo, branch }) { + if (owner === 'company' && repo === 'custom-action' && branch === 'v1.2.3') { + return { + data: { + commit: { sha: 'e5f6789012345678901234567890abcdef123456' } + } + }; + } + throw new Error('Branch not found'); + } +} + +/** + * Tests + */ +async function runTests() { + const runner = new TestRunner(); + + // Test 1: Find workflow files + runner.addTest('Should find workflow files', async () => { + const tempDir = createTempDir(); + try { + createTestWorkflow(tempDir, 'test.yml', testWorkflowContent); + createTestWorkflow(tempDir, 'test2.yaml', testWorkflowContent); + + const pinner = new GitHubActionsPinner({ + workflowsDir: path.join(tempDir, '.github', 'workflows'), + dryRun: true + }); + + const files = pinner.findWorkflowFiles(); + assert(files.length === 2, `Expected 2 files, got ${files.length}`); + assert(files.some(f => f.endsWith('test.yml')), 'Should find test.yml'); + assert(files.some(f => f.endsWith('test2.yaml')), 'Should find test2.yaml'); + } finally { + cleanup(tempDir); + } + }); + + // Test 2: Parse unpinned actions + runner.addTest('Should identify unpinned actions', async () => { + const tempDir = createTempDir(); + try { + const filePath = createTestWorkflow(tempDir, 'test.yml', testWorkflowContent); + + const pinner = new GitHubActionsPinner({ + workflowsDir: path.join(tempDir, '.github', 'workflows'), + dryRun: true + }); + + const content = fs.readFileSync(filePath, 'utf8'); + const yaml = await import('js-yaml'); + const workflow = yaml.load(content); + const actions = pinner.findUnpinnedActions(workflow); + + assert(actions.length === 3, `Expected 3 unpinned actions, got ${actions.length}`); + + const actionNames = actions.map(a => a.full); + assert(actionNames.includes('actions/checkout@v3'), 'Should find checkout@v3'); + assert(actionNames.includes('actions/setup-node@v3'), 'Should find setup-node@v3'); + assert(actionNames.includes('company/custom-action@v1.2.3'), 'Should find custom-action@v1.2.3'); + } finally { + cleanup(tempDir); + } + }); + + // Test 3: Skip already pinned actions + runner.addTest('Should skip already pinned actions', async () => { + const tempDir = createTempDir(); + try { + const filePath = createTestWorkflow(tempDir, 'pinned.yml', testWorkflowAlreadyPinned); + + const pinner = new GitHubActionsPinner({ + workflowsDir: path.join(tempDir, '.github', 'workflows'), + dryRun: true + }); + + const content = fs.readFileSync(filePath, 'utf8'); + const yaml = await import('js-yaml'); + const workflow = yaml.load(content); + const actions = pinner.findUnpinnedActions(workflow); + + assert(actions.length === 0, `Expected 0 unpinned actions, got ${actions.length}`); + } finally { + cleanup(tempDir); + } + }); + + // Test 4: Handle workflows with no actions + runner.addTest('Should handle workflows with no actions', async () => { + const tempDir = createTempDir(); + try { + const filePath = createTestWorkflow(tempDir, 'no-actions.yml', testWorkflowNoActions); + + const pinner = new GitHubActionsPinner({ + workflowsDir: path.join(tempDir, '.github', 'workflows'), + dryRun: true + }); + + const result = await pinner.processWorkflowFile(filePath); + assert(result.updates === 0, `Expected 0 updates, got ${result.updates}`); + assert(result.errors.length === 0, `Expected 0 errors, got ${result.errors.length}`); + } finally { + cleanup(tempDir); + } + }); + + // Test 5: Dry run mode + runner.addTest('Should not modify files in dry run mode', async () => { + const tempDir = createTempDir(); + try { + const filePath = createTestWorkflow(tempDir, 'test.yml', testWorkflowContent); + const originalContent = fs.readFileSync(filePath, 'utf8'); + + const pinner = new GitHubActionsPinner({ + workflowsDir: path.join(tempDir, '.github', 'workflows'), + dryRun: true + }); + + // Mock the GitHub API + pinner.octokit = new MockOctokit(); + + await pinner.processWorkflowFile(filePath); + + const afterContent = fs.readFileSync(filePath, 'utf8'); + assert(originalContent === afterContent, 'File should not be modified in dry run mode'); + } finally { + cleanup(tempDir); + } + }); + + // Test 6: File modification + runner.addTest('Should modify files when not in dry run mode', async () => { + const tempDir = createTempDir(); + try { + const filePath = createTestWorkflow(tempDir, 'test.yml', testWorkflowContent); + const originalContent = fs.readFileSync(filePath, 'utf8'); + + const pinner = new GitHubActionsPinner({ + workflowsDir: path.join(tempDir, '.github', 'workflows'), + dryRun: false + }); + + // Mock the GitHub API + pinner.octokit = new MockOctokit(); + + const result = await pinner.processWorkflowFile(filePath); + + const afterContent = fs.readFileSync(filePath, 'utf8'); + assert(originalContent !== afterContent, 'File should be modified when not in dry run mode'); + assert(result.updates > 0, 'Should have updates'); + assert(!afterContent.includes('@v3'), 'Should not contain @v3 references'); + } finally { + cleanup(tempDir); + } + }); + + // Test 7: YAML validation + runner.addTest('Should validate YAML after modifications', async () => { + const tempDir = createTempDir(); + try { + const filePath = createTestWorkflow(tempDir, 'test.yml', testWorkflowContent); + + const pinner = new GitHubActionsPinner({ + workflowsDir: path.join(tempDir, '.github', 'workflows'), + dryRun: false + }); + + // Mock the GitHub API + pinner.octokit = new MockOctokit(); + + const result = await pinner.processWorkflowFile(filePath); + + // Verify the updated file is valid YAML + const updatedContent = fs.readFileSync(filePath, 'utf8'); + const yaml = await import('js-yaml'); + + let parsedYaml; + try { + parsedYaml = yaml.load(updatedContent); + } catch (error) { + throw new Error(`Updated YAML is invalid: ${error.message}`); + } + + assert(parsedYaml !== null, 'YAML should parse successfully'); + assert(parsedYaml.jobs !== undefined, 'Jobs should be present'); + } finally { + cleanup(tempDir); + } + }); + + return await runner.runAll(); +} + +// Run tests if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + console.log('🧪 GitHub Actions Pinner Tests'); + console.log('==============================\n'); + + runTests().then(success => { + process.exit(success ? 0 : 1); + }).catch(error => { + console.error('❌ Test runner error:', error.message); + process.exit(1); + }); +} + +export { runTests }; \ No newline at end of file