Files

310 lines
11 KiB
JavaScript

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 = 2
export const MAX_MAIN_OBFUSCATION_GROWTH_BYTES = 3 * 1024 * 1024
export const PROTECTED_MAIN_PLAINTEXT_MARKERS = [
{
id: 'brain-system-prompt',
value: '根据已有信息自主决策下一步工具的使用来推进任务'
},
{
id: 'delivery-contract',
value: '停止调用工具,直接输出最终交付的 markdown 正文'
}
]
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
}