feat: implement main process obfuscation and verification
- Add package-main-obfuscation.mjs to handle obfuscation of main process JavaScript files. - Introduce obfuscatePackageMain function to obfuscate main files and generate a report. - Implement verifyPackageMainObfuscation function to validate obfuscation reports. - Create obfuscate-package-main.mjs script for command-line obfuscation execution. - Add audit-packaged-app.mjs to check for protected plaintext exposure in packaged apps. - Enhance tests to cover obfuscation and verification processes, including protected markers. - Ensure obfuscation does not exceed specified growth limits and maintains original functionality.
This commit is contained in:
1 parent
c5c1d8c651
commit
7fb3e2bc25
8 files changed
+1070
-4
No files matched your search
+4
-1
@@ -42,9 +42,11 @@
|
||||
"start": "electron-vite preview",
|
||||
"dev": "node scripts/prepare-better-sqlite3-native.mjs ensure electron && node scripts/prepare-node-pty-native.mjs ensure electron && node scripts/ensure-web-crawler-sidecar.mjs && electron-vite dev",
|
||||
"dev:no-sidecar": "node scripts/prepare-better-sqlite3-native.mjs ensure electron && node scripts/prepare-node-pty-native.mjs ensure electron && electron-vite dev",
|
||||
"build": "node scripts/prepare-better-sqlite3-native.mjs ensure node && node scripts/prepare-node-pty-native.mjs ensure node && tsc --noEmit -p tsconfig.node.json --composite false && tsc --noEmit -p tsconfig.web.json --composite false && node scripts/build-web-crawler-sidecar.mjs && node scripts/prepare-better-sqlite3-native.mjs export-electron-resource && electron-vite build && node scripts/verify-runtime-bundles.mjs && node scripts/prepare-node-pty-native.mjs ensure electron && node scripts/prepare-package-stage.mjs",
|
||||
"build": "node scripts/prepare-better-sqlite3-native.mjs ensure node && node scripts/prepare-node-pty-native.mjs ensure node && tsc --noEmit -p tsconfig.node.json --composite false && tsc --noEmit -p tsconfig.web.json --composite false && node scripts/build-web-crawler-sidecar.mjs && node scripts/prepare-better-sqlite3-native.mjs export-electron-resource && electron-vite build && node scripts/verify-runtime-bundles.mjs && node scripts/prepare-node-pty-native.mjs ensure electron && node scripts/prepare-package-stage.mjs && node scripts/obfuscate-package-main.mjs && node scripts/verify-package-main-obfuscation.mjs && node scripts/audit-package-stage.mjs",
|
||||
"postinstall": "node scripts/postinstall.mjs",
|
||||
"package:audit:stage": "node scripts/audit-package-stage.mjs",
|
||||
"package:obfuscate:main": "node scripts/obfuscate-package-main.mjs",
|
||||
"package:obfuscation:verify": "node scripts/verify-package-main-obfuscation.mjs",
|
||||
"package:audit": "node scripts/audit-packaged-app.mjs dist",
|
||||
"package:audit:summary": "node scripts/print-package-audit-summary.mjs dist/package-audit.json",
|
||||
"build:unpack": "pnpm run build && electron-builder --dir && pnpm run package:audit",
|
||||
@@ -141,6 +143,7 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"javascript-obfuscator": "5.5.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"less": "^4.5.1",
|
||||
"prettier": "^3.7.4",
|
||||
|
||||
Generated
+561
-3
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ import {
|
||||
normalizeRelativePath,
|
||||
scanTextForSecrets
|
||||
} from './package-content-policy.mjs'
|
||||
import { assertProtectedMainPlaintextAbsent } from './package-main-obfuscation.mjs'
|
||||
import { evaluatePackageSizeBudget, loadPackageSizeBudgetConfig } from './package-size-budget.mjs'
|
||||
|
||||
const inputDirectory = path.resolve(process.argv[2] || 'dist')
|
||||
@@ -107,6 +108,9 @@ for (const archivePath of archives) {
|
||||
if (!/\.(?:js|cjs|mjs|json|html|css)$/u.test(item.relativePath)) continue
|
||||
const source = extractArchiveFile(archivePath, item.relativePath).toString('utf8')
|
||||
scanTextForSecrets(source, `${archivePath}:${item.relativePath}`)
|
||||
if (item.relativePath.startsWith('out/main/')) {
|
||||
assertProtectedMainPlaintextAbsent(source, `${archivePath}:${item.relativePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const appRoot = findApplicationRoot(archivePath)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { obfuscatePackageMain } from './package-main-obfuscation.mjs'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const stageDirectory = path.resolve(process.argv[2] || path.join(root, 'release-stage', 'app'))
|
||||
const reportPath = path.resolve(
|
||||
process.argv[3] || path.join(root, 'release-stage', 'main-obfuscation-report.json')
|
||||
)
|
||||
|
||||
const report = obfuscatePackageMain({ stageDirectory, reportPath })
|
||||
process.stdout.write(
|
||||
`[package] main obfuscation passed: ${String(report.totals.files)} files, ` +
|
||||
`${(report.totals.originalBytes / 1024 / 1024).toFixed(1)} MiB -> ` +
|
||||
`${(report.totals.obfuscatedBytes / 1024 / 1024).toFixed(1)} MiB\n`
|
||||
)
|
||||
process.stdout.write(`[package] main obfuscation report: ${reportPath}\n`)
|
||||
@@ -0,0 +1,309 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import JavaScriptObfuscator from 'javascript-obfuscator'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const obfuscatorPackage = require('javascript-obfuscator/package.json')
|
||||
|
||||
export const MAIN_OBFUSCATION_SCHEMA_VERSION = 1
|
||||
export const MAIN_OBFUSCATION_CONFIGURATION_VERSION = 1
|
||||
export const MAX_MAIN_OBFUSCATION_GROWTH_BYTES = 3 * 1024 * 1024
|
||||
|
||||
export const PROTECTED_MAIN_PLAINTEXT_MARKERS = [
|
||||
{
|
||||
id: 'brain-system-prompt',
|
||||
value: '像一个判断稳定的人类执行者一样工作'
|
||||
},
|
||||
{
|
||||
id: 'mouth-system-prompt',
|
||||
value: '你的职责是基于已经提供的信息,面向用户交付最终结果'
|
||||
}
|
||||
]
|
||||
|
||||
const JAVASCRIPT_EXTENSIONS = new Set(['.js', '.cjs', '.mjs'])
|
||||
|
||||
const normalizeRelativePath = (value) => value.replaceAll(path.sep, '/')
|
||||
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex')
|
||||
|
||||
const deriveSeed = (relativePath) => {
|
||||
const digest = crypto
|
||||
.createHash('sha256')
|
||||
.update(`ncurator-main-obfuscation-v1\0${normalizeRelativePath(relativePath)}`)
|
||||
.digest()
|
||||
return Math.max(1, digest.readUInt32BE(0) & 0x7fffffff)
|
||||
}
|
||||
|
||||
export const createMainObfuscationOptions = (relativePath) => ({
|
||||
compact: true,
|
||||
target: 'node',
|
||||
identifierNamesGenerator: 'hexadecimal',
|
||||
renameGlobals: false,
|
||||
renameProperties: false,
|
||||
transformObjectKeys: false,
|
||||
stringArray: true,
|
||||
stringArrayThreshold: 1,
|
||||
stringArrayEncoding: ['rc4'],
|
||||
stringArrayRotate: true,
|
||||
stringArrayShuffle: true,
|
||||
splitStrings: true,
|
||||
splitStringsChunkLength: 8,
|
||||
controlFlowFlattening: false,
|
||||
deadCodeInjection: false,
|
||||
debugProtection: false,
|
||||
selfDefending: false,
|
||||
disableConsoleOutput: false,
|
||||
numbersToExpressions: false,
|
||||
unicodeEscapeSequence: false,
|
||||
sourceMap: false,
|
||||
seed: deriveSeed(relativePath)
|
||||
})
|
||||
|
||||
export const obfuscateMainJavaScript = (source, relativePath) =>
|
||||
JavaScriptObfuscator.obfuscate(
|
||||
source,
|
||||
createMainObfuscationOptions(relativePath)
|
||||
).getObfuscatedCode()
|
||||
|
||||
const listJavaScriptFiles = (rootDirectory) => {
|
||||
const result = []
|
||||
const walk = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolutePath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(absolutePath)
|
||||
continue
|
||||
}
|
||||
if (entry.isFile() && JAVASCRIPT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
||||
result.push(absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rootDirectory)
|
||||
return result.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
const assertJavaScriptSyntax = (filePath) => {
|
||||
const result = spawnSync(process.execPath, ['--check', filePath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`obfuscated JavaScript syntax check failed for ${filePath}:\n${String(result.stderr || result.stdout).trim()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const assertProtectedMarkersPresent = (sources) => {
|
||||
const missing = PROTECTED_MAIN_PLAINTEXT_MARKERS.filter(
|
||||
(marker) => !sources.some((source) => source.includes(marker.value))
|
||||
)
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`main obfuscation protection markers were not found before obfuscation: ${missing
|
||||
.map((marker) => marker.id)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const assertProtectedMainPlaintextAbsent = (source, sourceLabel) => {
|
||||
const exposed = PROTECTED_MAIN_PLAINTEXT_MARKERS.filter((marker) => source.includes(marker.value))
|
||||
if (exposed.length > 0) {
|
||||
throw new Error(
|
||||
`${sourceLabel} exposes protected main-process plaintext: ${exposed
|
||||
.map((marker) => marker.id)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const writeJsonAtomically = (filePath, value) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
const temporaryPath = `${filePath}.tmp-${String(process.pid)}`
|
||||
try {
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
fs.renameSync(temporaryPath, filePath)
|
||||
} finally {
|
||||
fs.rmSync(temporaryPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export const obfuscatePackageMain = ({ stageDirectory, reportPath }) => {
|
||||
const stageRoot = path.resolve(stageDirectory)
|
||||
const mainDirectory = path.join(stageRoot, 'out', 'main')
|
||||
const resolvedReportPath = path.resolve(reportPath)
|
||||
fs.rmSync(resolvedReportPath, { force: true })
|
||||
|
||||
if (!fs.existsSync(mainDirectory) || !fs.statSync(mainDirectory).isDirectory()) {
|
||||
throw new Error(`staged main-process directory not found: ${mainDirectory}`)
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(stageRoot, 'package.json')
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
||||
const files = listJavaScriptFiles(mainDirectory)
|
||||
if (files.length === 0)
|
||||
throw new Error(`no staged main-process JavaScript found: ${mainDirectory}`)
|
||||
|
||||
const originalSources = files.map((filePath) => fs.readFileSync(filePath, 'utf8'))
|
||||
assertProtectedMarkersPresent(originalSources)
|
||||
|
||||
const pending = []
|
||||
let originalBytes = 0
|
||||
let obfuscatedBytes = 0
|
||||
let growthBytes = 0
|
||||
try {
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const filePath = files[index]
|
||||
const relativePath = normalizeRelativePath(path.relative(stageRoot, filePath))
|
||||
const originalSource = originalSources[index]
|
||||
const obfuscatedSource = obfuscateMainJavaScript(originalSource, relativePath)
|
||||
if (!obfuscatedSource || obfuscatedSource === originalSource) {
|
||||
throw new Error(`main-process JavaScript was not transformed: ${relativePath}`)
|
||||
}
|
||||
assertProtectedMainPlaintextAbsent(obfuscatedSource, relativePath)
|
||||
if (
|
||||
/(?:\/\/[#@]\s*sourceMappingURL\s*=|\/\*[#@]\s*sourceMappingURL\s*=)/u.test(
|
||||
obfuscatedSource
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`obfuscated main-process JavaScript references a source map: ${relativePath}`
|
||||
)
|
||||
}
|
||||
|
||||
const extension = path.extname(filePath)
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(filePath),
|
||||
`.${path.basename(filePath, extension)}.obfuscating-${String(process.pid)}${extension}`
|
||||
)
|
||||
fs.writeFileSync(temporaryPath, obfuscatedSource, 'utf8')
|
||||
pending.push({
|
||||
filePath,
|
||||
temporaryPath,
|
||||
relativePath,
|
||||
originalBytes: Buffer.byteLength(originalSource),
|
||||
obfuscatedBytes: Buffer.byteLength(obfuscatedSource),
|
||||
originalSha256: sha256(originalSource),
|
||||
obfuscatedSha256: sha256(obfuscatedSource)
|
||||
})
|
||||
assertJavaScriptSyntax(temporaryPath)
|
||||
}
|
||||
|
||||
originalBytes = pending.reduce((sum, item) => sum + item.originalBytes, 0)
|
||||
obfuscatedBytes = pending.reduce((sum, item) => sum + item.obfuscatedBytes, 0)
|
||||
growthBytes = obfuscatedBytes - originalBytes
|
||||
if (growthBytes > MAX_MAIN_OBFUSCATION_GROWTH_BYTES) {
|
||||
throw new Error(
|
||||
`main-process obfuscation grew by ${(growthBytes / 1024 / 1024).toFixed(1)} MiB; limit is ${(MAX_MAIN_OBFUSCATION_GROWTH_BYTES / 1024 / 1024).toFixed(1)} MiB`
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of pending) fs.renameSync(item.temporaryPath, item.filePath)
|
||||
} finally {
|
||||
for (const item of pending) fs.rmSync(item.temporaryPath, { force: true })
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: MAIN_OBFUSCATION_SCHEMA_VERSION,
|
||||
configurationVersion: MAIN_OBFUSCATION_CONFIGURATION_VERSION,
|
||||
status: 'passed',
|
||||
generatedAt: new Date().toISOString(),
|
||||
packageVersion: String(packageJson.version || ''),
|
||||
tool: {
|
||||
name: 'javascript-obfuscator',
|
||||
version: String(obfuscatorPackage.version)
|
||||
},
|
||||
protectedMarkers: PROTECTED_MAIN_PLAINTEXT_MARKERS.map((marker) => marker.id),
|
||||
totals: {
|
||||
files: pending.length,
|
||||
originalBytes,
|
||||
obfuscatedBytes,
|
||||
growthBytes,
|
||||
growthPercent: originalBytes > 0 ? (growthBytes / originalBytes) * 100 : 0
|
||||
},
|
||||
files: pending.map((item) => ({
|
||||
path: item.relativePath,
|
||||
originalBytes: item.originalBytes,
|
||||
obfuscatedBytes: item.obfuscatedBytes,
|
||||
originalSha256: item.originalSha256,
|
||||
obfuscatedSha256: item.obfuscatedSha256
|
||||
}))
|
||||
}
|
||||
writeJsonAtomically(resolvedReportPath, report)
|
||||
return report
|
||||
}
|
||||
|
||||
export const verifyPackageMainObfuscation = ({ stageDirectory, reportPath }) => {
|
||||
const stageRoot = path.resolve(stageDirectory)
|
||||
const mainDirectory = path.join(stageRoot, 'out', 'main')
|
||||
const resolvedReportPath = path.resolve(reportPath)
|
||||
if (!fs.existsSync(resolvedReportPath)) {
|
||||
throw new Error(`main-process obfuscation report not found: ${resolvedReportPath}`)
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync(resolvedReportPath, 'utf8'))
|
||||
if (
|
||||
report.schemaVersion !== MAIN_OBFUSCATION_SCHEMA_VERSION ||
|
||||
report.configurationVersion !== MAIN_OBFUSCATION_CONFIGURATION_VERSION ||
|
||||
report.status !== 'passed'
|
||||
) {
|
||||
throw new Error('main-process obfuscation report has an unsupported or failed status')
|
||||
}
|
||||
if (
|
||||
report.tool?.name !== 'javascript-obfuscator' ||
|
||||
report.tool?.version !== String(obfuscatorPackage.version) ||
|
||||
JSON.stringify(report.protectedMarkers) !==
|
||||
JSON.stringify(PROTECTED_MAIN_PLAINTEXT_MARKERS.map((marker) => marker.id))
|
||||
) {
|
||||
throw new Error('main-process obfuscation report does not match the active protection policy')
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(stageRoot, 'package.json'), 'utf8'))
|
||||
if (report.packageVersion !== String(packageJson.version || '')) {
|
||||
throw new Error('main-process obfuscation report does not match the staged package version')
|
||||
}
|
||||
|
||||
const currentFiles = listJavaScriptFiles(mainDirectory).map((filePath) =>
|
||||
normalizeRelativePath(path.relative(stageRoot, filePath))
|
||||
)
|
||||
const reportedFiles = report.files.map((item) => item.path)
|
||||
if (JSON.stringify(currentFiles) !== JSON.stringify(reportedFiles)) {
|
||||
throw new Error('main-process obfuscation report does not match the staged JavaScript files')
|
||||
}
|
||||
|
||||
for (const item of report.files) {
|
||||
const filePath = path.join(stageRoot, item.path)
|
||||
const source = fs.readFileSync(filePath, 'utf8')
|
||||
if (sha256(source) !== item.obfuscatedSha256 || item.originalSha256 === item.obfuscatedSha256) {
|
||||
throw new Error(`main-process obfuscation hash mismatch: ${item.path}`)
|
||||
}
|
||||
assertProtectedMainPlaintextAbsent(source, item.path)
|
||||
assertJavaScriptSyntax(filePath)
|
||||
}
|
||||
|
||||
const originalBytes = report.files.reduce((sum, item) => sum + Number(item.originalBytes || 0), 0)
|
||||
const obfuscatedBytes = report.files.reduce(
|
||||
(sum, item) => sum + Number(item.obfuscatedBytes || 0),
|
||||
0
|
||||
)
|
||||
if (
|
||||
report.totals.files !== report.files.length ||
|
||||
report.totals.originalBytes !== originalBytes ||
|
||||
report.totals.obfuscatedBytes !== obfuscatedBytes ||
|
||||
report.totals.growthBytes !== obfuscatedBytes - originalBytes
|
||||
) {
|
||||
throw new Error('main-process obfuscation report totals are inconsistent')
|
||||
}
|
||||
if (report.totals.growthBytes > MAX_MAIN_OBFUSCATION_GROWTH_BYTES) {
|
||||
throw new Error('main-process obfuscation report exceeds the allowed growth budget')
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { verifyPackageMainObfuscation } from './package-main-obfuscation.mjs'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const stageDirectory = path.resolve(process.argv[2] || path.join(root, 'release-stage', 'app'))
|
||||
const reportPath = path.resolve(
|
||||
process.argv[3] || path.join(root, 'release-stage', 'main-obfuscation-report.json')
|
||||
)
|
||||
|
||||
const report = verifyPackageMainObfuscation({ stageDirectory, reportPath })
|
||||
process.stdout.write(
|
||||
`[package] main obfuscation verified: ${String(report.totals.files)} files, ` +
|
||||
`growth ${(report.totals.growthBytes / 1024 / 1024).toFixed(1)} MiB\n`
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
|
||||
import { createPackage } from '@electron/asar'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { normalizeRelativePath } from '../../../../scripts/package-content-policy.mjs'
|
||||
import { PROTECTED_MAIN_PLAINTEXT_MARKERS } from '../../../../scripts/package-main-obfuscation.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
let temporaryDirectory = ''
|
||||
@@ -66,4 +67,39 @@ describe('package content policy paths', () => {
|
||||
expect.objectContaining({ root: 'win-unpacked', archiveFileCount: 2 })
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a packaged app that exposes protected main-process prompts', async () => {
|
||||
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'ncurator-package-audit-'))
|
||||
const appSource = path.join(temporaryDirectory, 'app-source')
|
||||
const packagedApp = path.join(temporaryDirectory, 'package', 'win-unpacked')
|
||||
const archivePath = path.join(packagedApp, 'resources', 'app.asar')
|
||||
await mkdir(path.join(appSource, 'out', 'main'), { recursive: true })
|
||||
await mkdir(path.dirname(archivePath), { recursive: true })
|
||||
await writeFile(
|
||||
path.join(appSource, 'out', 'main', 'index.js'),
|
||||
`const prompt = ${JSON.stringify(PROTECTED_MAIN_PLAINTEXT_MARKERS[0].value)}\n`
|
||||
)
|
||||
await writeFile(
|
||||
path.join(appSource, 'package.json'),
|
||||
`${JSON.stringify({
|
||||
name: 'ncurator-audit-fixture',
|
||||
version: '1.0.0',
|
||||
main: './out/main/index.js',
|
||||
private: true,
|
||||
dependencies: {}
|
||||
})}\n`
|
||||
)
|
||||
await createPackage(appSource, archivePath)
|
||||
|
||||
let stderr = ''
|
||||
try {
|
||||
await execFileAsync(process.execPath, [
|
||||
path.resolve('scripts/audit-packaged-app.mjs'),
|
||||
path.join(temporaryDirectory, 'package')
|
||||
])
|
||||
} catch (error) {
|
||||
stderr = String((error as { stderr?: string }).stderr || error)
|
||||
}
|
||||
expect(stderr).toContain('exposes protected main-process plaintext')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import vm from 'node:vm'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_MAIN_OBFUSCATION_GROWTH_BYTES,
|
||||
PROTECTED_MAIN_PLAINTEXT_MARKERS,
|
||||
obfuscateMainJavaScript,
|
||||
obfuscatePackageMain,
|
||||
verifyPackageMainObfuscation
|
||||
} from '../../../../scripts/package-main-obfuscation.mjs'
|
||||
|
||||
let temporaryDirectory = ''
|
||||
|
||||
afterEach(async () => {
|
||||
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true })
|
||||
temporaryDirectory = ''
|
||||
})
|
||||
|
||||
const fixtureSource = `
|
||||
'use strict'
|
||||
const path = require('node:path')
|
||||
const buildPrompt = (input) => [
|
||||
'<system>',
|
||||
'像一个判断稳定的人类执行者一样工作',
|
||||
'你的职责是基于已经提供的信息,面向用户交付最终结果',
|
||||
JSON.stringify({ input, schema: { type: 'object', required: ['answer'] } }, null, 2),
|
||||
'</system>',
|
||||
path.sep
|
||||
].join('\\n')
|
||||
globalThis.__ncuratorPromptResult = buildPrompt('中文、emoji 🚀、换行\\n保持一致')
|
||||
`
|
||||
|
||||
const executeFixture = (source: string): string => {
|
||||
const requiredSpecifiers: string[] = []
|
||||
const context: Record<string, unknown> = {
|
||||
require: (specifier: string) => {
|
||||
requiredSpecifiers.push(specifier)
|
||||
return { sep: '/' }
|
||||
}
|
||||
}
|
||||
vm.runInNewContext(source, context, { timeout: 10_000 })
|
||||
return JSON.stringify({
|
||||
prompt: String(context.__ncuratorPromptResult || ''),
|
||||
requiredSpecifiers
|
||||
})
|
||||
}
|
||||
|
||||
describe('package-main-obfuscation main-process release protection', () => {
|
||||
it('preserves the exact runtime prompt while hiding protected plaintext', () => {
|
||||
const originalPrompt = executeFixture(fixtureSource)
|
||||
const obfuscated = obfuscateMainJavaScript(fixtureSource, 'out/main/index.js')
|
||||
const repeated = obfuscateMainJavaScript(fixtureSource, 'out/main/index.js')
|
||||
const otherFile = obfuscateMainJavaScript(fixtureSource, 'out/main/worker.js')
|
||||
const obfuscatedPrompt = executeFixture(obfuscated)
|
||||
|
||||
expect(obfuscatedPrompt).toBe(originalPrompt)
|
||||
expect(obfuscated).not.toBe(fixtureSource)
|
||||
expect(repeated).toBe(obfuscated)
|
||||
expect(otherFile).not.toBe(obfuscated)
|
||||
for (const marker of PROTECTED_MAIN_PLAINTEXT_MARKERS) {
|
||||
expect(fixtureSource).toContain(marker.value)
|
||||
expect(obfuscated).not.toContain(marker.value)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes and verifies a report tied to every staged main-process file', async () => {
|
||||
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'ncurator-main-obfuscation-'))
|
||||
const stageDirectory = path.join(temporaryDirectory, 'app')
|
||||
const mainDirectory = path.join(stageDirectory, 'out', 'main')
|
||||
const reportPath = path.join(temporaryDirectory, 'main-obfuscation-report.json')
|
||||
await mkdir(mainDirectory, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(stageDirectory, 'package.json'),
|
||||
`${JSON.stringify({ name: 'fixture', version: '1.2.3' })}\n`,
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(path.join(mainDirectory, 'index.js'), fixtureSource, 'utf8')
|
||||
await writeFile(
|
||||
path.join(mainDirectory, 'worker.js'),
|
||||
`'use strict'; globalThis.__workerReady = true\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const report = obfuscatePackageMain({ stageDirectory, reportPath })
|
||||
const verified = verifyPackageMainObfuscation({ stageDirectory, reportPath })
|
||||
const stagedSource = await readFile(path.join(mainDirectory, 'index.js'), 'utf8')
|
||||
|
||||
expect(report.status).toBe('passed')
|
||||
expect(verified.packageVersion).toBe('1.2.3')
|
||||
expect(verified.files.map((item: { path: string }) => item.path)).toEqual([
|
||||
'out/main/index.js',
|
||||
'out/main/worker.js'
|
||||
])
|
||||
expect(verified.totals.growthBytes).toBeLessThanOrEqual(MAX_MAIN_OBFUSCATION_GROWTH_BYTES)
|
||||
for (const marker of PROTECTED_MAIN_PLAINTEXT_MARKERS) {
|
||||
expect(stagedSource).not.toContain(marker.value)
|
||||
}
|
||||
|
||||
await writeFile(path.join(mainDirectory, 'index.js'), `${stagedSource}\n// tampered\n`, 'utf8')
|
||||
expect(() => verifyPackageMainObfuscation({ stageDirectory, reportPath })).toThrow(
|
||||
'hash mismatch'
|
||||
)
|
||||
})
|
||||
|
||||
it('runs only after dependency collection and keeps packaged-app protection auditing', async () => {
|
||||
const packageJson = JSON.parse(await readFile(path.resolve('package.json'), 'utf8'))
|
||||
const buildScript = String(packageJson.scripts.build || '')
|
||||
const prepareIndex = buildScript.indexOf('prepare-package-stage.mjs')
|
||||
const obfuscateIndex = buildScript.indexOf('obfuscate-package-main.mjs')
|
||||
const verifyIndex = buildScript.indexOf('verify-package-main-obfuscation.mjs')
|
||||
const auditIndex = buildScript.lastIndexOf('audit-package-stage.mjs')
|
||||
const packagedAudit = await readFile(path.resolve('scripts/audit-packaged-app.mjs'), 'utf8')
|
||||
|
||||
expect(prepareIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(obfuscateIndex).toBeGreaterThan(prepareIndex)
|
||||
expect(verifyIndex).toBeGreaterThan(obfuscateIndex)
|
||||
expect(auditIndex).toBeGreaterThan(verifyIndex)
|
||||
expect(packagedAudit).toContain('assertProtectedMainPlaintextAbsent')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user