mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
161 lines
6.4 KiB
JavaScript
161 lines
6.4 KiB
JavaScript
import * as parser from '@babel/parser'
|
|
import traverse from '@babel/traverse'
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
|
|
import { eagerOutputs, jsOutputs, readToolbarMetafile } from './toolbar-metafile.mjs'
|
|
|
|
// Parse every shipped toolbar JS file (the dist/toolbar.js loader + the ESM app outputs in
|
|
// dist/toolbar/) and check for anything that might trigger CSP script-src violations.
|
|
|
|
// The threat model here is:
|
|
// * We're not trying to protect against an attacker hiding an eval like window["ev" + "al"]("...").
|
|
// * This is OK because any actual violations will be caught by the customer's actual CSP rules.
|
|
// * We are just trying to prevent false positives in the CSP report.
|
|
// * This script is just meant to protect against us accidentally adding evals, which wouldn't be maliciously hidden.
|
|
|
|
// Identifiers that become code when the *first* argument is a string
|
|
const UNSAFE_TIMERS = new Set(['setTimeout', 'setInterval', 'setImmediate', 'execScript'])
|
|
const FUNCTION_CTORS = new Set(['Function', 'AsyncFunction', 'GeneratorFunction', 'AsyncGeneratorFunction'])
|
|
|
|
// Known violations from vendored dependencies that we can't avoid, scoped to where they're
|
|
// allowed to live. If a count changes (e.g. after a dependency version bump), update it here.
|
|
// The loader is held to zero: it runs on every customer page that enables the toolbar, before
|
|
// the user interacts at all, so it must never show up in a CSP report.
|
|
const ALLOWED_VIOLATIONS = {
|
|
'new Function()': {
|
|
loader: 0,
|
|
eager: 1, // toolbarLogic's deliberate CSP support probe
|
|
total: 5, // + pixi.js via @hanzo/mascot-mode (4), in the lazy mascot chunks
|
|
source: 'toolbarLogic CSP probe (1, eager) + pixi.js via @hanzo/mascot-mode (4, lazy)',
|
|
},
|
|
}
|
|
|
|
function scanFile(filePath) {
|
|
const source = fs.readFileSync(path.resolve(process.cwd(), filePath), 'utf-8')
|
|
const ast = parser.parse(source, {
|
|
sourceType: 'unambiguous',
|
|
})
|
|
|
|
const evals = []
|
|
|
|
traverse.default(ast, {
|
|
CallExpression(p) {
|
|
const callee = p.get('callee')
|
|
|
|
// eval(...)
|
|
if (callee.isIdentifier({ name: 'eval' })) {
|
|
if (p.node.loc) {
|
|
evals.push({
|
|
type: 'eval()',
|
|
start: p.node.loc.start,
|
|
})
|
|
}
|
|
}
|
|
|
|
// window['eval'](...)
|
|
else if (
|
|
callee.isMemberExpression() &&
|
|
callee.node.computed &&
|
|
callee.get('object').isIdentifier({ name: 'window' }) &&
|
|
callee.get('property').isStringLiteral({ value: 'eval' })
|
|
) {
|
|
if (p.node.loc) {
|
|
evals.push({ type: 'window["eval"]()', start: p.node.loc.start })
|
|
}
|
|
}
|
|
|
|
// === 3. setTimeout + other functions with a string argument
|
|
else if (
|
|
(callee.isIdentifier() && UNSAFE_TIMERS.has(callee.node.name)) ||
|
|
(callee.isMemberExpression() &&
|
|
!callee.node.computed &&
|
|
callee.get('object').isIdentifier({ name: 'window' }) &&
|
|
callee.get('property').isIdentifier() &&
|
|
UNSAFE_TIMERS.has(callee.get('property').node.name))
|
|
) {
|
|
const firstArg = p.get('arguments')[0]
|
|
if (firstArg?.isStringLiteral()) {
|
|
evals.push({ type: `${callee.node.name}(string)`, start: p.node.loc })
|
|
}
|
|
}
|
|
},
|
|
|
|
NewExpression(p) {
|
|
const callee = p.get('callee')
|
|
|
|
// new Function(...) + other Function constructors
|
|
if (callee.isIdentifier() && FUNCTION_CTORS.has(callee.node.name)) {
|
|
evals.push({
|
|
type: `new ${callee.node.name}()`,
|
|
start: p.node.loc.start,
|
|
})
|
|
}
|
|
},
|
|
})
|
|
|
|
return evals.map((v) => ({ ...v, file: filePath }))
|
|
}
|
|
|
|
function countByType(violations) {
|
|
const counts = {}
|
|
for (const { type } of violations) {
|
|
counts[type] = (counts[type] || 0) + 1
|
|
}
|
|
return counts
|
|
}
|
|
|
|
function main() {
|
|
const metafile = readToolbarMetafile()
|
|
const outputs = metafile.outputs
|
|
|
|
const loaderViolations = scanFile('dist/toolbar.js')
|
|
const eagerJs = [...eagerOutputs(outputs)].filter((o) => o.endsWith('.js'))
|
|
const appViolations = jsOutputs(outputs).flatMap((file) => scanFile(file))
|
|
const eagerViolations = appViolations.filter((v) => eagerJs.includes(v.file))
|
|
|
|
const scopes = [
|
|
{ name: 'loader (dist/toolbar.js)', key: 'loader', counts: countByType(loaderViolations) },
|
|
{ name: 'eager app closure', key: 'eager', counts: countByType(eagerViolations) },
|
|
{
|
|
name: 'all app outputs + loader',
|
|
key: 'total',
|
|
counts: countByType([...loaderViolations, ...appViolations]),
|
|
},
|
|
]
|
|
|
|
const mismatchedTypes = new Set()
|
|
|
|
for (const { name, key, counts } of scopes) {
|
|
const typesToCheck = new Set([...Object.keys(counts), ...Object.keys(ALLOWED_VIOLATIONS)])
|
|
for (const type of typesToCheck) {
|
|
const found = counts[type] || 0
|
|
const expected = ALLOWED_VIOLATIONS[type]?.[key] ?? 0
|
|
if (found !== expected) {
|
|
const source = ALLOWED_VIOLATIONS[type]?.source
|
|
console.error(
|
|
`✗ ${type} in ${name}: found ${found}, expected ${expected}${source ? ` (${source})` : ''}. ` +
|
|
'Update ALLOWED_VIOLATIONS if this is intentional.'
|
|
)
|
|
mismatchedTypes.add(type)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mismatchedTypes.size > 0) {
|
|
// Only dump the violation types whose count is off — the allowed ones would drown the
|
|
// offender. Every instance of a mismatched type prints (counts can't tell which
|
|
// occurrence is new), tagged by scope since an expected total can still fail a scope.
|
|
const eagerSet = new Set(eagerJs)
|
|
for (const { type, file, start } of [...loaderViolations, ...appViolations]) {
|
|
if (mismatchedTypes.has(type)) {
|
|
const scope = file === 'dist/toolbar.js' ? 'loader' : eagerSet.has(file) ? 'eager' : 'deferred'
|
|
console.error(` ${type} [${scope}]: ${file} line ${start.line}:${start.column}`)
|
|
}
|
|
}
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
main()
|