diff --git a/.github/workflows/required-secret-check.yml b/.github/workflows/required-secret-check.yml new file mode 100644 index 00000000000..c21a9f76b4e --- /dev/null +++ b/.github/workflows/required-secret-check.yml @@ -0,0 +1,134 @@ +name: Secret Fields Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + secret-fields-check: + runs-on: ubuntu-22.04 + timeout-minutes: 5 + strategy: + matrix: + node-version: [22.x] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Check PR description + id: pr_desc + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const body = (pr?.body || "").toLowerCase(); + + const patterns = [ + /-\s*\[x\]\s*required secret checkout/i, + /required secret checkout\s*:\s*true/i, + // The new checkbox you provided (checked) + /-\s*\[x\]\s*\*\*\s*Reviewed all field definitions\s*\*\*\s*for sensitive data\s*\(API keys, tokens, passwords, client secrets\)\s*and confirmed they use\s*`type:\s*'password'`/i + ]; + + const skip = patterns.some(rx => rx.test(body)); + core.setOutput("skip", skip ? "true" : "false"); + console.log("Skip secret check:", skip); + + - name: Run validate-secret-fields + id: list + if: steps.pr_desc.outputs.skip != 'true' + uses: actions/github-script@v7 + with: + script: | + const { execSync } = require("child_process"); + + let raw = execSync("./bin/run validate-secret-fields", { + encoding: "utf8" + }); + + const parsed = JSON.parse(raw); + + const findings = []; + for (const [destination, obj] of Object.entries(parsed)) { + if (Array.isArray(obj.settings) && obj.settings.length > 0) { + findings.push({ + destination, + settings: obj.settings + }); + } + } + + core.setOutput("findings", JSON.stringify(findings)); + console.log("Findings:", findings); + + - name: Comment if secret fields found + if: steps.pr_desc.outputs.skip != 'true' + uses: actions/github-script@v7 + with: + findings: ${{ steps.list.outputs.findings }} + script: | + const findings = JSON.parse(core.getInput("findings") || "[]"); + + // If NO secrets found → exit quietly and succeed + if (findings.length === 0) { + console.log("No secret fields found — skipping comment."); + return; + } + + // Build comment markdown + const lines = findings + .map(f => `- **Destination**: ${f.destination}\n - Settings: ${f.settings.join(", ")}`) + .join("\n"); + + const body = ` + ### ⚠️ Secret Fields Detected + + The following destinations require secret \`settings\` values that require review: + + ${lines} + + This PR cannot be merged until these are removed or converted to secure secrets. + `; + + // Find existing comment + const comments = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo + }); + + const existing = comments.data.find(c => + c.body?.includes("") + ); + + if (!existing) { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); + } else { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); + } + + core.setFailed( + `Secret-like settings found in ${findings.length} destination(s).` + ); diff --git a/packages/cli/src/commands/validate-secret-fields.ts b/packages/cli/src/commands/validate-secret-fields.ts new file mode 100644 index 00000000000..24cdd645f84 --- /dev/null +++ b/packages/cli/src/commands/validate-secret-fields.ts @@ -0,0 +1,98 @@ +import { Command, flags } from '@oclif/command' +import { loadDestination } from '../lib/destinations' +import globby from 'globby' +import type { DestinationDefinition as CloudDestinationDefinition } from '@segment/actions-core' +import type { BrowserDestinationDefinition } from '@segment/destinations-manifest' + +export default class ValidateSecretFields extends Command { + public static enableJsonFlag = true + + static description = `Returns a list of secret fields for each action in the destination.` + + static examples = [ + `$ ./bin/run validate-secret-fields`, + `$ ./bin/run validate-secret-fields -p ./packages/destination-actions/src/destinations/hubspot/index.ts` + ] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static flags: flags.Input = { + help: flags.help({ char: 'h' }), + path: flags.string({ + char: 'p', + description: 'file path for the integration(s). Accepts glob patterns.', + multiple: true + }) + } + + static args = [] + + async run() { + const { flags } = this.parse(ValidateSecretFields) + + const globs = flags.path || [ + './packages/*/src/destinations/*/index.ts', + './packages/browser-destinations/destinations/*/src/index.ts' + ] + + const files = await globby(globs, { + expandDirectories: false, + gitignore: true, + ignore: ['node_modules'] + }) + + const destinationMap: Record> = {} + const actionToSecretFields: Record = {} + + const cloudDestinationRegex = new RegExp(/packages\/destination-actions\/src\/destinations\/([^/]+)\/*/i) + const browserDestinationRegex = new RegExp(/packages\/browser-destinations\/destinations\/([^/]+)\/*/i) + + for (const file of files) { + let filePath = file + // extract the destination folder name from the file path + const cloudMatch = cloudDestinationRegex.exec(file) + const browserMatch = browserDestinationRegex.exec(file) + if (cloudMatch) { + const destination = cloudMatch[1] + if (destinationMap[destination]) { + continue + } + filePath = `packages/destination-actions/src/destinations/${cloudMatch[1]}/index.ts` + } else if (browserMatch) { + const destination = browserMatch[1] + if (destinationMap[destination]) { + continue + } + filePath = `packages/browser-destinations/destinations/${browserMatch[1]}/src/index.ts` + } + + const destination = await loadDestination(filePath).catch((error) => { + this.debug(`Couldn't load ${filePath}: ${error.message}`) + return null + }) + + if (!destination) { + continue + } + + const settings = { + ...(destination as BrowserDestinationDefinition).settings, + ...(destination as CloudDestinationDefinition).authentication?.fields + } + + destinationMap[destination.name] = { settings: [] } + const secretFieldPattern = /(^|[^A-Za-z0-9])(key|token|secret|password|code)($|[^A-Za-z0-9])/i + for (const [key, field] of Object.entries(settings)) { + if (secretFieldPattern.test(key) && field.type !== 'password') { + destinationMap[destination.name].settings.push(key) + } + } + + destinationMap[destination.name] = { + ...destinationMap[destination.name], + ...actionToSecretFields + } + } + + this.log(JSON.stringify(destinationMap, null, 2)) + } +}