d3ro-voice/scripts/ci/check-no-hardcoded-secrets.mjs
Yun Chan 7953706142 feat(release): publish desktop updates from a tag through one feed
Desktop clients had two competing update sources: the runtime pointed at a
legacy GitLab registry while the Forgejo packages were filled in by
hardcoded, version-pinned scripts. Operators could not tell which feed was
authoritative, and no release could be reproduced from a tag.

Auto-update now reads a single canonical Forgejo registry feed, updated by
a version-agnostic publisher that runs from the tag on Forgejo, GitLab, and
GitHub CI alike. Channel, minimum supported version, forced install,
full-versus-delta thresholds, staged rollout, and a remote kill switch come
from one policy file the client fetches alongside the feed. Tag creation is
gated on a clean tree, matching version surfaces, and a changelog section.
2026-09-16 23:23:00 +09:00

140 lines
5.6 KiB
JavaScript

import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
const candidateOutput = execFileSync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
{ encoding: 'utf8' },
)
const supportedFile = /\.(?:bat|cjs|cs|gradle|html|js|json|mjs|pem|properties|ps1|sh|toml|ts|tsx|ya?ml)$/i
const excludedPath = /(?:^|\/)(?:bin|build|dist|node_modules|obj|playwright-report|scratch)(?:\/|$)|(?:^|\/)(?:__tests__|fixtures?|tests?)(?:\/|$)|\.test\.[cm]?[jt]sx?$|\.spec\.[cm]?[jt]sx?$|(?:^|\/)migrations(?:\/|$)|(?:^|\/)package-lock\.json$|(?:^|\/)deno\.lock$|\.example\.(?:txt|json|ya?ml)$/i
const rules = [
{
name: 'basic-auth-literal',
pattern: /Buffer\.from\(\s*(['"])[^\r\n'"]{1,100}:[^\r\n'"]{4,}\1/,
},
{
name: 'browser-credential-literal',
pathPattern: /^scripts\//,
pattern: /(?:page\.fill\([^\r\n,]*(?:password|user_name|email)[^\r\n,]*,|(?:password|username|email|login)\w*\.fill\()\s*(['"])(?!\$|replace|example|dummy|test|changeme|your_|android)(?:(?!\1).){2,}\1/i,
},
{
name: 'browser-operator-email-literal',
pathPattern: /^scripts\//,
pattern: /(?:email|user_name|username|login|operator)[^\r\n]{0,120}(['"])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\1/i,
},
{
name: 'keystore-password-literal',
pattern: /--(?:ks|key)-pass\s+pass:[^\s'"`]+|(['"])-(?:store|key)pass\1\s*,\s*(['"])(?:(?!\2).){4,}\2|-?(?:store|key)pass\s+[^\s$%"'`][^\s"'`]*/i,
},
{
name: 'google-oauth-client-secret',
pattern: /GOCSPX-[A-Za-z0-9_-]{20,}/,
},
{
name: 'google-oauth-client-id',
pattern: /[0-9]{6,}-[A-Za-z0-9_-]{8,}\.apps\.googleusercontent\.com/,
},
{
name: 'google-api-key',
pattern: /AIza[A-Za-z0-9_-]{20,}/,
},
{
name: 'private-key-material',
pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----\r?\n[A-Za-z0-9+/=\r\n]{40,}-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
},
{
name: 'legacy-license-prefix-fail-open',
pattern: /\.startsWith\(\s*(['"])D3RO-(?:PRO|PLUS|TEAM)-\1\s*\)\s*&&\s*[A-Za-z_$][\w$]*\.length\s*>=/,
},
{
name: 'known-live-token-prefix',
pattern: /(?:ghp_|github_pat_|glpat-|rk_live_|sk_live_)[A-Za-z0-9_-]{12,}/,
},
{
name: 'credential-in-url',
pattern: /https?:\/\/[^\s\/@:]+:[^\s\/@]+@/,
},
{
name: 'credential-assignment-literal',
pattern: /(?:password|passwd|client[_-]?secret|api[_-]?secret|service[_-]?key|jwt[_-]?(?:secret|key)|admin[_-]?(?:bootstrap[_-]?token|session[_-]?secret)|service[_-]?role[_-]?key)\s*[:=]\s*(['"])(?!\s*(?:\$|\{\{|%[A-Z_][A-Z0-9_]*%|(?:replace|example|dummy|test|ci[-_]|changeme|your_|android)\b))(?:(?!\1).){8,}\1/i,
},
]
function scanSource(path, source) {
return rules
.filter((rule) => !rule.pathPattern || rule.pathPattern.test(path))
.filter((rule) => rule.pattern.test(source))
.map((rule) => `${path}: ${rule.name}`)
}
if (process.argv.includes('--self-test')) {
const legacyLicensePrefix = ['D3RO', 'PRO', ''].join('-')
const unsafeCases = [
['scripts/login.mjs', ['await emailInput.', 'fill(', "'operator", '@', "example.com')"].join('')],
['scripts/login.mjs', `const secret = '${'GOCSPX-'}abcdefghijklmnopqrstuvwxyz'`],
['scripts/login.mjs', `const id = '1234567890-abcdefghijklmnop.${'apps.'}googleusercontent.com'`],
['scripts/release.mjs', `const token = '${'ghp_'}abcdefghijklmnopqrstuvwxyz'`],
['scripts/deploy.ps1', `$JWT_SECRET = '${'a-fixed-'}jwt-signing-secret-value'`],
['scripts/gen-keystore.js', `const args = ['-storepass', '${'fixed-keystore-password'}']`],
[
'release/evidence-private.pem',
['-----BEGIN ', 'PRIVATE KEY-----\n', 'A'.repeat(64), '\n-----END ', 'PRIVATE KEY-----'].join(''),
],
[
'packages/core/src/license.ts',
`if (trimmed.startsWith('${legacyLicensePrefix}') && trimmed.length >= 14) return { valid: true }`,
],
]
const safeCases = [
['scripts/login.mjs', `await emailInput.fill(requireEnvironment('D3RO_PORTAL_OPERATOR_EMAIL'))`],
['scripts/release.mjs', `const token = process.env.FORGEJO_TOKEN?.trim()`],
['scripts/deploy.sh', `JWT_SECRET="$JWT_SECRET"`],
['.github/workflows/ci.yml', `MOBILE_E2E_PASSWORD: \${{ secrets.MOBILE_E2E_PASSWORD }}`],
['.forgejo/workflows/release.yml', `WIN_CSC_KEY_${'PASS' + 'WORD'}: "\${{ secrets.WIN_CSC_KEY_PASSWORD }}"`],
['.env.example', 'JWT_SECRET='],
[
'release/evidence-public.pem',
['-----BEGIN ', 'PUBLIC KEY-----\n', 'A'.repeat(64), '\n-----END ', 'PUBLIC KEY-----'].join(''),
],
]
const failures = []
for (const [path, source] of unsafeCases) {
if (scanSource(path, source).length === 0) failures.push(`missed unsafe fixture: ${path}`)
}
for (const [path, source] of safeCases) {
if (scanSource(path, source).length > 0) failures.push(`flagged safe fixture: ${path}`)
}
if (failures.length > 0) {
for (const failure of failures) console.error(failure)
process.exit(1)
}
console.log('Hard-coded credential scanner self-test passed.')
process.exit(0)
}
const findings = []
for (const path of candidateOutput.split('\0').filter(Boolean)) {
const normalized = path.replaceAll('\\', '/')
if ((!supportedFile.test(normalized) && normalized !== '.env.example') || excludedPath.test(normalized)) continue
let source
try {
source = readFileSync(path, 'utf8')
} catch {
continue
}
findings.push(...scanSource(normalized, source))
}
if (findings.length > 0) {
console.error('Potential hard-coded credentials were found:')
for (const finding of findings.sort()) console.error(`- ${finding}`)
process.exit(1)
}
console.log('No hard-coded credential patterns found in source-controlled runtime files.')