diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 5dea80b983..7293efeba0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -69,6 +69,7 @@ jobs: persist-credentials: false - name: Resolve PR context + id: pr-context shell: bash env: GH_TOKEN: ${{ github.token }} @@ -193,6 +194,7 @@ jobs: persist-credentials: false - name: Resolve PR context + id: public-scan-context shell: bash env: GH_TOKEN: ${{ github.token }} @@ -211,6 +213,14 @@ jobs: BASE_REF="$PULL_REQUEST_BASE_REF" fi echo "PR_BASE_REF=$BASE_REF" >> "$GITHUB_ENV" + echo "base_ref=$BASE_REF" >> "$GITHUB_OUTPUT" + + - name: Checkout trusted public scanner + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ steps.public-scan-context.outputs.base_ref }} + path: trusted-security-base + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 @@ -229,6 +239,18 @@ jobs: - name: Run postinstall run: npm run postinstall || true + - name: Run public sensitive content scan + shell: bash + run: | + set -euo pipefail + TRUSTED_SCANNER="trusted-security-base/scripts/security/check-public-sensitive-content.js" + if [ -f "$TRUSTED_SCANNER" ]; then + node "$TRUSTED_SCANNER" "$PWD" + else + echo "::warning::Trusted public scanner is missing on ${PR_BASE_REF}; using PR scanner for bootstrap." + node scripts/security/check-public-sensitive-content.js "$PWD" + fi + - name: Install prek run: npm install -g @j178/prek @@ -725,7 +747,7 @@ jobs: BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Contents/Info.plist")" VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PATH/Contents/Info.plist")" - BRIDGE_SUMMARY="$(node -e "const fs=require('fs'); const m=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); if (m.placeholder !== true) { console.error('::error::Expected thin-app-smoke to bundle the diagnostic Bridge placeholder'); process.exit(1); } console.log('diagnostic-placeholder')" "$BRIDGE_MANIFEST")" + BRIDGE_SUMMARY="$(node -e "const fs=require('fs'); const m=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const sha=/^[0-9a-f]{40}$/i; if (m.placeholder === true) { console.log('diagnostic-placeholder'); process.exit(0); } if (!sha.test(String(m.requestedSourceRef || '')) || !sha.test(String(m.sourceCommit || '')) || m.requestedSourceRef !== m.sourceCommit) { console.error('::error::Expected thin-app-smoke to bundle a diagnostic Bridge placeholder or the pinned real Bridge source'); process.exit(1); } console.log('pinned-real-bridge@' + String(m.sourceCommit).slice(0, 12));" "$BRIDGE_MANIFEST")" node scripts/evaosVerifyRendererDependencyPrune.js "$APP_PATH" { @@ -738,7 +760,7 @@ jobs: echo "- Bridge manifest: \`$BRIDGE_SUMMARY\`" echo "- Renderer dependency prune: verified \`app.asar\` and \`app.asar.unpacked/node_modules\`" echo - echo "This PR artifact is intentionally unpacked and may use a diagnostic Bridge placeholder when private source access is unavailable. It may be ad-hoc/non-Developer-ID signed by packaging hooks. It produces no DMG, no release metadata, no notarization/stapling proof, no TCC or permission proof, and no public release proof." + echo "This PR artifact is intentionally unpacked and may use a diagnostic Bridge placeholder when private source access is unavailable, or a pinned real Bridge source when CI can fetch it. It may be ad-hoc/non-Developer-ID signed by packaging hooks. It produces no DMG, no release metadata, no notarization/stapling proof, no TCC or permission proof, and no public release proof." echo echo "### Size" echo '```' diff --git a/.github/workflows/public-security-scan.yml b/.github/workflows/public-security-scan.yml new file mode 100644 index 0000000000..cef6090b51 --- /dev/null +++ b/.github/workflows/public-security-scan.yml @@ -0,0 +1,87 @@ +name: Public Security Scan + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + public-sensitive-content: + name: Public Sensitive Content + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Checkout trusted public scanner + if: github.event_name == 'pull_request' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.base_ref }} + path: trusted-security-base + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24 + + - name: Run repo-owned public scan + shell: bash + run: | + set -euo pipefail + TRUSTED_SCANNER="trusted-security-base/scripts/security/check-public-sensitive-content.js" + if [ "${{ github.event_name }}" = "pull_request" ] && [ -f "$TRUSTED_SCANNER" ]; then + node "$TRUSTED_SCANNER" "$PWD" + else + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "::warning::Trusted public scanner is missing on ${GITHUB_BASE_REF}; using PR scanner for bootstrap." + fi + node scripts/security/check-public-sensitive-content.js "$PWD" + fi + + gitleaks: + name: Gitleaks Full History + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: false + GITLEAKS_NOTIFY_USER_LIST: '' + + trufflehog: + name: TruffleHog Verified + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run TruffleHog verified full-history scan + run: | + docker run --rm \ + -v "$PWD:/repo:ro" \ + ghcr.io/trufflesecurity/trufflehog:3.95.7 \ + git file:///repo \ + --only-verified \ + --no-update \ + --fail diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000000..cba2c4f870 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,34 @@ +title = "AionUi public repository Gitleaks configuration" + +[extend] +useDefault = true + +[[allowlists]] +description = "Fake short OpenAI protocol detector fixture" +condition = "AND" +paths = ['''^tests/unit/common/protocolDetector\.test\.ts$'''] +regexes = ['''sk-abc123def456ghi789'''] + +[[allowlists]] +description = "Fake long OpenAI protocol detector fixture" +condition = "AND" +paths = ['''^tests/unit/common/protocolDetector\.test\.ts$'''] +regexes = ['''sk-abc123def456ghi789jkl012'''] + +[[allowlists]] +description = "Fake Google API key protocol detector fixture" +condition = "AND" +paths = ['''^tests/unit/common/protocolDetector\.test\.ts$'''] +regexes = ['''AIzaa{35}'''] + +[[allowlists]] +description = "Fake bridge resource token fixture" +condition = "AND" +paths = ['''^tests/unit/bootstrap/prepareEvaosDesktopBridgeResource\.test\.ts$'''] +regexes = ['''super-secret-token'''] + +[[allowlists]] +description = "Fake bridge resource credential URL fixture" +condition = "AND" +paths = ['''^tests/unit/bootstrap/prepareEvaosDesktopBridgeResource\.test\.ts$'''] +regexes = ['''https://x-access-token:super-secret-token@github\.com/electricsheephq/evaos-desktop-bridge\.git'''] diff --git a/mobile/eas.json b/mobile/eas.json index c20490d5c4..160cf28b8b 100644 --- a/mobile/eas.json +++ b/mobile/eas.json @@ -27,14 +27,18 @@ "submit": { "production": { "ios": { - "appleId": "liangzhewei@gmail.com", - "appleTeamId": "M4AG47ZV62" + "appName": "AionUi Mobile", + "bundleIdentifier": "ai.resopod.aionui", + "language": "en-US", + "sku": "aionui-mobile" } }, "preview": { "ios": { - "appleId": "liangzhewei@gmail.com", - "appleTeamId": "M4AG47ZV62" + "appName": "AionUi Mobile", + "bundleIdentifier": "ai.resopod.aionui", + "language": "en-US", + "sku": "aionui-mobile-preview" } } } diff --git a/mobile/scripts/build.js b/mobile/scripts/build.js index 2d64ef5e6f..38f0d707dc 100755 --- a/mobile/scripts/build.js +++ b/mobile/scripts/build.js @@ -13,6 +13,8 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); +const projectRoot = path.join(__dirname, '..'); + // Parse command line arguments const args = process.argv.slice(2); const profileIndex = args.indexOf('--profile'); @@ -28,6 +30,31 @@ if (!profile) { process.exit(1); } +function requireEnv(name, purpose) { + const value = process.env[name]; + if (!value) { + console.error(`Error: ${name} is required for ${purpose}.`); + process.exit(1); + } + return value; +} + +const expoAppleId = process.env.EXPO_APPLE_ID || process.env.APPLE_ID || process.env.appleId; +const expoAppleTeamId = process.env.EXPO_APPLE_TEAM_ID || process.env.TEAM_ID || process.env.teamId; + +if (!isLocal && directSubmit) { + console.error('Error: --direct-submit is only supported with --local builds.'); + process.exit(1); +} + +if (platform === 'ios' && (autoSubmit || directSubmit) && !expoAppleId) { + requireEnv('EXPO_APPLE_ID', 'iOS submission'); +} + +if (platform === 'ios' && autoSubmit && !expoAppleTeamId) { + requireEnv('EXPO_APPLE_TEAM_ID', 'EAS iOS submission'); +} + // Read current version const versionPath = path.join(__dirname, '..', 'versions', 'version.json'); let versionData; @@ -53,9 +80,13 @@ try { // Build eas command args const outputExt = platform === 'ios' ? '.ipa' : '.apk'; -const localOutputPath = path.join(__dirname, '..', `build-${Date.now()}${outputExt}`); +const localOutputPath = path.join(projectRoot, `build-${Date.now()}${outputExt}`); let buildArgs = args.filter((a) => a !== '--auto-submit' && a !== '--direct-submit'); +if (!isLocal && autoSubmit && !buildArgs.includes('--auto-submit-with-profile')) { + buildArgs.push('--auto-submit-with-profile', profile); +} + // For local builds with submit, capture output path for later submission if (isLocal && (autoSubmit || directSubmit)) { if (!buildArgs.includes('--output')) { @@ -83,21 +114,31 @@ if (isLocal && platform === 'ios') { // Build the eas command const easCommand = `eas build ${buildArgs.join(' ')}`; console.log(`\nRunning: ${easCommand}\n`); - -// Apple-specific env vars (only needed for iOS builds) -const appleEnv = - platform === 'ios' - ? { - EXPO_APPLE_TEAM_ID: process.env.EXPO_APPLE_TEAM_ID || 'M4AG47ZV62', - EXPO_APPLE_ID: process.env.EXPO_APPLE_ID || 'liangzhewei@gmail.com', - ...(applePassword ? { EXPO_APPLE_PASSWORD: applePassword } : {}), - } - : {}; +const appleEnv = {}; +if (platform === 'ios') { + if (expoAppleTeamId) { + appleEnv.EXPO_APPLE_TEAM_ID = expoAppleTeamId; + } + if (expoAppleId) { + appleEnv.EXPO_APPLE_ID = expoAppleId; + } + if (applePassword) { + appleEnv.EXPO_APPLE_PASSWORD = applePassword; + } + if (autoSubmit || directSubmit) { + appleEnv.EXPO_APPLE_ID = expoAppleId || requireEnv('EXPO_APPLE_ID', 'iOS submission'); + } + if (autoSubmit) { + appleEnv.EXPO_APPLE_TEAM_ID = + expoAppleTeamId || requireEnv('EXPO_APPLE_TEAM_ID', 'EAS iOS submission'); + } +} // Execute eas build try { execSync(easCommand, { stdio: 'inherit', + cwd: projectRoot, env: { ...process.env, PATH: `/usr/bin:${process.env.PATH}`, @@ -123,7 +164,7 @@ if (platform === 'ios' && isLocal && (autoSubmit || directSubmit)) { if (directSubmit) { // Upload directly to App Store Connect via xcrun altool (bypasses EAS) - const appleId = process.env.APPLE_ID || 'liangzhewei@gmail.com'; + const appleId = expoAppleId || requireEnv('EXPO_APPLE_ID', 'direct iOS submission'); const submitCommand = `xcrun altool --upload-app -f "${outputFile}" -t ${platform} -u "${appleId}" -p "@keychain:AC_PASSWORD"`; console.log(`\nUploading directly to TestFlight: xcrun altool --upload-app\n`); try { @@ -135,17 +176,18 @@ if (platform === 'ios' && isLocal && (autoSubmit || directSubmit)) { ' Make sure your App-Specific Password is saved in Keychain as "AC_PASSWORD".', ); console.error( - ' To save it: security add-generic-password -a "liangzhewei@gmail.com" -s "AC_PASSWORD" -w "" -U', + ' To save it: security add-generic-password -a "$EXPO_APPLE_ID" -s "AC_PASSWORD" -w "" -U', ); process.exit(1); } } else { // Upload via EAS submit - const submitCommand = `eas submit --platform ${platform} --path ${outputFile} --non-interactive`; + const submitCommand = `eas submit --platform ${platform} --profile ${profile} --path ${outputFile} --non-interactive`; console.log(`\nSubmitting to TestFlight: ${submitCommand}\n`); try { execSync(submitCommand, { stdio: 'inherit', + cwd: projectRoot, env: { ...process.env, ...appleEnv, diff --git a/package.json b/package.json index 6d90ff31a3..95b850b3ce 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "lint:fix": "oxlint --fix", "format": "oxfmt", "format:check": "oxfmt --check", + "security:public-scan": "node scripts/security/check-public-sensitive-content.js", "i18n:types": "node scripts/generate-i18n-types.js", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/security/check-public-sensitive-content.js b/scripts/security/check-public-sensitive-content.js new file mode 100644 index 0000000000..d2ca06d200 --- /dev/null +++ b/scripts/security/check-public-sensitive-content.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +const { execFileSync } = require('node:child_process'); +const { existsSync, readFileSync, statSync } = require('node:fs'); +const { resolve } = require('node:path'); + +const MAX_TEXT_FILE_BYTES = 8 * 1024 * 1024; + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function exactLinePattern(parts) { + return new RegExp(parts.map(escapeRegExp).join('')); +} + +function hasUnsafeLiteralAssignment(line, keys, valuePattern) { + const keyPattern = keys.map(escapeRegExp).join('|'); + const jsonLikeAssignment = line.match( + new RegExp(`(?:^\\s*|[{,]\\s*)["']?(?:${keyPattern})["']?\\s*:\\s*["']([^"']+)["']`, 'i') + ); + const envAssignment = line.match(new RegExp(`(?:^|\\s)(?:${keyPattern})\\s*=\\s*["']?([^"'\\s]+)["']?`, 'i')); + const fallbackAssignment = line.match( + new RegExp(`(?:${keyPattern})[^\\n]*(?:\\|\\||\\?\\?)\\s*["']([^"']+)["']`, 'i') + ); + const values = [jsonLikeAssignment?.[1], envAssignment?.[1], fallbackAssignment?.[1]] + .map((value) => (value || '').trim()) + .filter(Boolean); + return values.some((value) => { + if ( + value.startsWith('${') || + value.startsWith('$') || + value.startsWith('process.env.') || + /^(?:<|YOUR_|REPLACE_|example|test|fake|dummy)/i.test(value) + ) { + return false; + } + return valuePattern.test(value); + }); +} + +const CONTENT_RULES = [ + { + id: 'hardcoded-apple-account-id', + message: 'Hardcoded Apple account identifiers must use environment-backed configuration.', + test: (line) => + hasUnsafeLiteralAssignment(line, ['appleId', 'EXPO_APPLE_ID', 'APPLE_ID'], /^[^\s@]+@[^\s@]+\.[^\s@]+$/), + }, + { + id: 'hardcoded-apple-team-id', + message: 'Hardcoded Apple team identifiers must use environment-backed configuration.', + test: (line) => + hasUnsafeLiteralAssignment( + line, + ['appleTeamId', 'appleTeamID', 'teamId', 'TEAM_ID', 'EXPO_APPLE_TEAM_ID', 'APPLE_TEAM_ID'], + /^[A-Z0-9]{10}$/ + ), + }, + { + id: 'private-key-material', + message: 'Private key material must never be committed.', + pattern: /-----BEGIN (?:RSA |OPENSSH |EC |DSA |ENCRYPTED )?PRIVATE KEY-----/, + }, + { + id: 'openai-api-key', + message: 'Provider API keys must never be committed.', + pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/, + }, + { + id: 'github-token', + message: 'GitHub tokens must never be committed.', + pattern: /\b(?:gh[opusr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/, + }, + { + id: 'google-api-key', + message: 'Google API keys must never be committed.', + pattern: /\bAIza[0-9A-Za-z_-]{35}\b/, + }, + { + id: 'aws-access-key', + message: 'AWS access key identifiers must never be committed.', + pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, + }, + { + id: 'slack-token', + message: 'Slack tokens must never be committed.', + pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, + }, + { + id: 'github-token-in-url', + message: 'Credential-bearing GitHub URLs must be sanitized before logging or committing.', + pattern: /https:\/\/x-access-token:(?!\$\{|\$\{\{)[^/@]{12,}@github\.com\//, + }, + { + id: 'test-fixture-secret', + message: 'Secret-looking test fixtures must stay in approved test files only.', + pattern: /\bsuper-secret-token\b/, + }, +]; + +const RISKY_FILE_PATH_PATTERN = + /(^|\/)(\.env(\..*)?|.*\.(pem|p12|p8|key|mobileprovision|provisionprofile|sqlite|db|log)|id_rsa|id_ed25519|credentials?\.(json|ya?ml|toml|ini)|secrets?\.(json|ya?ml|toml|ini)|service-account.*\.json|GoogleService-Info\.plist|google-services\.json)$/i; + +const ALLOWLIST = [ + { + filePath: '.gitleaks.toml', + ruleIds: ['openai-api-key'], + patterns: [exactLinePattern(["'''sk-", "abc123def456ghi789jkl012'''"])], + }, + { + filePath: '.gitleaks.toml', + ruleIds: ['google-api-key'], + patterns: [/'''AIzaa\{35\}'''/], + }, + { + filePath: '.gitleaks.toml', + ruleIds: ['test-fixture-secret'], + patterns: [ + exactLinePattern(["'''super", '-secret', "-token'''"]), + exactLinePattern([ + "'''https://x-access-token:super", + '-secret', + "-token@github\\.com/electricsheephq/evaos-desktop-bridge\\.git'''", + ]), + ], + }, + { + filePath: '.gitleaks.toml', + ruleIds: ['github-token-in-url'], + patterns: [ + exactLinePattern([ + "'''https://x-access-token:super", + '-secret', + "-token@github\\.com/electricsheephq/evaos-desktop-bridge\\.git'''", + ]), + ], + }, + { + filePath: 'tests/unit/common/protocolDetector.test.ts', + ruleIds: ['openai-api-key', 'google-api-key'], + patterns: [ + exactLinePattern(["'sk-", "abc123def456ghi789jkl012'"]), + exactLinePattern(["'sk-proj-", "abc123def456ghi789jkl012'"]), + exactLinePattern(["'AIza' + 'a'.repeat(35)"]), + ], + }, + { + filePath: 'tests/unit/bootstrap/prepareEvaosDesktopBridgeResource.test.ts', + ruleIds: ['test-fixture-secret', 'github-token-in-url'], + patterns: [ + exactLinePattern(["EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN = 'super", '-secret', "-token'"]), + exactLinePattern([ + 'https://x-access-token:super', + '-secret', + '-token@github.com/electricsheephq/evaos-desktop-bridge.git failed', + ]), + exactLinePattern(["not.toContain('super", '-secret', "-token')"]), + ], + }, +]; + +function normalizePath(filePath) { + return filePath.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +function isAllowed(filePath, ruleId, line) { + const normalized = normalizePath(filePath); + return ALLOWLIST.some( + (entry) => + entry.filePath === normalized && + entry.ruleIds.includes(ruleId) && + (!entry.patterns || entry.patterns.some((pattern) => pattern.test(line))) + ); +} + +function makeFinding(filePath, line, rule) { + return { + filePath: normalizePath(filePath), + line, + ruleId: rule.id, + message: rule.message, + preview: `[REDACTED:${rule.id}]`, + }; +} + +/** + * Scans a tracked repository path for secret-bearing file names. + */ +function scanPath(filePath) { + const normalized = normalizePath(filePath); + if (!RISKY_FILE_PATH_PATTERN.test(normalized)) { + return []; + } + return [ + { + filePath: normalized, + ruleId: 'risky-tracked-file-name', + message: 'Risky secret-bearing file name should not be tracked in a public repository.', + preview: '[REDACTED:risky-tracked-file-name]', + }, + ]; +} + +/** + * Scans text content for public-repository sensitive material. + */ +function scanText({ filePath, text }) { + const findings = []; + const lines = text.split(/\r?\n/); + + lines.forEach((line, index) => { + for (const rule of CONTENT_RULES) { + const matched = rule.test ? rule.test(line) : rule.pattern.test(line); + if (matched && !isAllowed(filePath, rule.id, line)) { + findings.push(makeFinding(filePath, index + 1, rule)); + } + } + }); + + return findings; +} + +function isProbablyTextBuffer(buffer) { + if (buffer.includes(0)) { + return false; + } + return true; +} + +function trackedFiles(cwd) { + const output = execFileSync('git', ['ls-files', '-z'], { + cwd, + encoding: 'buffer', + }); + return output.toString('utf8').split('\0').filter(Boolean); +} + +/** + * Scans all tracked text files in a repository checkout. + */ +function scanRepository({ cwd = process.cwd() } = {}) { + const findings = []; + for (const filePath of trackedFiles(cwd)) { + findings.push(...scanPath(filePath)); + + const absolutePath = `${cwd}/${filePath}`; + if (!existsSync(absolutePath)) { + continue; + } + const stats = statSync(absolutePath); + if (!stats.isFile() || stats.size > MAX_TEXT_FILE_BYTES) { + continue; + } + + const buffer = readFileSync(absolutePath); + if (!isProbablyTextBuffer(buffer)) { + continue; + } + findings.push(...scanText({ filePath, text: buffer.toString('utf8') })); + } + return findings; +} + +function runCli() { + const cwd = process.argv[2] ? resolve(process.argv[2]) : process.cwd(); + const findings = scanRepository({ cwd }); + if (findings.length === 0) { + console.log('Public sensitive content check passed: no findings.'); + return; + } + + console.error(`Public sensitive content check failed: ${findings.length} finding(s).`); + for (const finding of findings.slice(0, 200)) { + const location = finding.line ? `${finding.filePath}:${finding.line}` : finding.filePath; + console.error(`${location} ${finding.ruleId} ${finding.message} ${finding.preview}`); + } + if (findings.length > 200) { + console.error(`... ${findings.length - 200} additional finding(s) omitted.`); + } + process.exit(1); +} + +if (require.main === module) { + runCli(); +} + +module.exports = { + scanPath, + scanRepository, + scanText, +}; diff --git a/tests/unit/process/checkPublicSensitiveContent.test.ts b/tests/unit/process/checkPublicSensitiveContent.test.ts new file mode 100644 index 0000000000..316cd24af3 --- /dev/null +++ b/tests/unit/process/checkPublicSensitiveContent.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; + +const scanner = require('../../../scripts/security/check-public-sensitive-content.js') as { + scanPath: (filePath: string) => Array<{ ruleId: string; message: string }>; + scanText: (input: { + filePath: string; + text: string; + }) => Array<{ line: number; ruleId: string; message: string; preview: string }>; +}; + +describe('check-public-sensitive-content', () => { + const fakeAppleEmail = 'developer@example.com'; + const fakeAppleTeamId = 'AB12CD34EF'; + const fakeOpenAiKey = ['sk', 'abc123def456ghi789jkl012'].join('-'); + const fakeDisallowedOpenAiKey = ['sk', 'realproviderkey1234567890'].join('-'); + + it('flags public Apple account defaults without echoing the sensitive value', () => { + const findings = scanner.scanText({ + filePath: 'mobile/eas.json', + text: ['{', ` "appleId": "${fakeAppleEmail}",`, ` "appleTeamId": "${fakeAppleTeamId}"`, '}'].join('\n'), + }); + + expect(findings.map((finding) => finding.ruleId)).toEqual([ + 'hardcoded-apple-account-id', + 'hardcoded-apple-team-id', + ]); + expect(findings.map((finding) => finding.line)).toEqual([2, 3]); + expect(JSON.stringify(findings)).not.toContain(fakeAppleEmail); + expect(JSON.stringify(findings)).not.toContain(fakeAppleTeamId); + }); + + it('flags public Apple aliases and fallback literals', () => { + const findings = scanner.scanText({ + filePath: 'mobile/scripts/build.js', + text: [ + `const appleId = process.env.APPLE_ID || '${fakeAppleEmail}';`, + `const teamId = process.env.TEAM_ID ?? '${fakeAppleTeamId}';`, + `TEAM_ID=${fakeAppleTeamId}`, + ].join('\n'), + }); + + expect(findings.map((finding) => finding.ruleId)).toEqual([ + 'hardcoded-apple-account-id', + 'hardcoded-apple-team-id', + 'hardcoded-apple-team-id', + ]); + expect(findings.map((finding) => finding.line)).toEqual([1, 2, 3]); + expect(JSON.stringify(findings)).not.toContain(fakeAppleEmail); + expect(JSON.stringify(findings)).not.toContain(fakeAppleTeamId); + }); + + it('flags encrypted PKCS#8 private key headers', () => { + expect( + scanner.scanText({ + filePath: 'docs/private-key.md', + text: ['-----BEGIN ENCRYPTED', 'PRIVATE KEY-----'].join(' '), + }) + ).toEqual([ + expect.objectContaining({ + line: 1, + ruleId: 'private-key-material', + }), + ]); + }); + + it('allows known fake provider-key fixtures only in exact test files', () => { + expect( + scanner.scanText({ + filePath: 'tests/unit/common/protocolDetector.test.ts', + text: `expect(guessProtocolFromKey('${fakeOpenAiKey}')).toBe('openai');`, + }) + ).toEqual([]); + + expect( + scanner.scanText({ + filePath: 'packages/desktop/src/common/leak.ts', + text: `export const apiKey = '${fakeOpenAiKey}';`, + }) + ).toEqual([ + expect.objectContaining({ + line: 1, + ruleId: 'openai-api-key', + }), + ]); + + expect( + scanner.scanText({ + filePath: 'tests/unit/common/protocolDetector.test.ts', + text: `const accidentalRealKey = '${fakeDisallowedOpenAiKey}';`, + }) + ).toEqual([ + expect.objectContaining({ + line: 1, + ruleId: 'openai-api-key', + }), + ]); + }); + + it('flags risky tracked file names before content inspection', () => { + expect(scanner.scanPath('config/service-account.prod.json')).toEqual([ + expect.objectContaining({ + ruleId: 'risky-tracked-file-name', + }), + ]); + expect(scanner.scanPath('tests/unit/common/protocolDetector.test.ts')).toEqual([]); + }); +});