Files
insights/frontend/bin/check-toolbar-csp-eval.mjs
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

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()