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.
140 lines
5.6 KiB
JavaScript
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.')
|