diff --git a/.github/scripts/pr-malware-scan.js b/.github/scripts/pr-malware-scan.js new file mode 100644 index 0000000..86b3173 --- /dev/null +++ b/.github/scripts/pr-malware-scan.js @@ -0,0 +1,374 @@ +// Scans a PR's changes for malware and supply-chain tricks: editor/devcontainer +// autorun tasks, install hooks, untrusted GitHub Actions, payloads disguised +// as fonts or images, base64/whitespace obfuscation, exfiltration endpoints, +// reverse shells and miners. +// +// It treats the PR purely as data. Files are read through the API (patches, +// or raw contents for large files) and matched against patterns; nothing from +// the PR is checked out, installed or executed. +// +// HIGH findings fail the check, add the `security-review` label and +// @mention the author. WARN findings are listed for the reviewing maintainer. +// The comment is edited in place on every push, so a PR has one scan comment. + +const MARKER = ''; +const LABEL = 'security-review'; +const MAX_FETCH_BYTES = 2 * 1024 * 1024; + +// Owners whose Actions this org already uses. Anything else in `uses:` fails. +const ALLOWED_ACTION_OWNERS = [ + 'actions', 'github', 'dtolnay', 'Swatinem', 'taiki-e', 'oven-sh', 'denoland', 'softprops', +]; + +// Popular packages that typosquats imitate, plus this org's own stack. +const POPULAR_PACKAGES = [ + 'react', 'react-dom', 'react-router', 'react-router-dom', 'react-hook-form', 'react-intl', 'zod', 'zustand', + 'axios', 'express', 'lodash', 'moment', 'dayjs', 'date-fns', 'date-fns-tz', 'vite', 'vitest', 'jest', 'typescript', + 'eslint', 'prettier', 'webpack', 'babel', 'chalk', 'commander', 'dotenv', 'jsonwebtoken', 'bcrypt', 'bcryptjs', + 'prisma', '@prisma/client', 'pg', 'mongoose', 'redis', 'ioredis', 'socket.io', 'cors', 'helmet', 'uuid', 'nanoid', + 'yaml', 'qrcode', 'jspdf', 'html2canvas', 'buffer', 'crypto-js', 'ethers', 'web3', 'tweetnacl', 'bignumber.js', + 'node-fetch', 'cross-env', 'nodemon', 'ts-node', 'tsx', 'playwright', '@playwright/test', 'puppeteer', + '@stellar/stellar-sdk', '@stellar/freighter-api', 'stellar-sdk', 'soroban-client', '@sentry/react', '@sentry/browser', + 'workbox-core', 'vite-plugin-pwa', 'msw', 'supertest', 'zxcvbn', 'otplib', 'speakeasy', 'nodemailer', +]; + +const TEXT_EXT = /\.(c|m)?(j|t)sx?$|\.(json|jsonc|md|mdx|rs|toml|ya?ml|css|scss|html|sql|prisma|lock|svg|txt|snap|sh|py|graphql|spec|example|env|xml|csv|ini|cfg|conf|dict|gitignore|gitattributes|editorconfig|nvmrc|npmrc|dockerignore|mts|cts)$/i; +const MEDIA_EXT = /\.(woff2?|ttf|otf|eot|png|jpe?g|gif|ico|webp|bmp|pdf|mp3|mp4|wav|zip|gz|tgz|wasm)$/i; +const DOC_EXT = /\.(md|mdx|txt)$/i; +const GENERATED = /(^|\/)(package-lock\.json|Cargo\.lock|yarn\.lock|pnpm-lock\.yaml)$|(^|\/)test_snapshots\/|(^|\/)generated\/|\.snap$|\.min\.(js|css)$|\.svg$/; +const JS_LIKE = /\brequire\s*\(|\bimport\s+[\w{*]|=>|\bfunction\s*\(|\beval\s*\(|\bprocess\.(env|argv)|module\.exports|child_process/; +const SELF = '.github/scripts/pr-malware-scan.js'; + +// Code patterns checked on every added line. Docs only get WARN for these, +// since READMEs legitimately show install one-liners. +const LINE_RULES = [ + { id: 'pipe-to-shell', sev: 'high', re: /\b(curl|wget)\b[^|\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/, msg: 'Downloads and pipes straight into a shell.' }, + { id: 'powershell-encoded', sev: 'high', re: /powershell[^\n]*(-enc(odedcommand)?\b|\biex\b|invoke-expression)/i, msg: 'Encoded or downloaded PowerShell execution.' }, + { id: 'reverse-shell', sev: 'high', re: /\/dev\/tcp\/|\bnc\b[^\n]*\s-e\s|\bbash\s+-i\s*>&|\bsocat\b[^\n]*exec:/, msg: 'Reverse-shell pattern.' }, + { id: 'exfil-endpoint', sev: 'high', re: /discord(app)?\.com\/api\/webhooks|api\.telegram\.org\/bot|pastebin\.com\/raw|webhook\.site|requestbin|pipedream\.net|\.ngrok(-free)?\.(io|app)|interact\.sh|burpcollaborator/i, msg: 'Sends data to a known exfiltration or paste endpoint.' }, + { id: 'miner', sev: 'high', re: /\bxmrig\b|stratum\+(tcp|ssl):\/\/|coinhive|cryptonight/i, msg: 'Cryptocurrency-miner indicator.' }, + { id: 'decode-and-run', sev: 'high', re: /(eval|Function|require|exec(Sync)?)\s*\([^\n]*(atob\s*\(|Buffer\.from\([^)]*['"](base64|hex)['"])/, msg: 'Decodes base64/hex and executes the result.' }, + { id: 'charcode-payload', sev: 'high', re: /fromCharCode\s*\(\s*(\d+\s*,\s*){8,}/, msg: 'Builds a string from a long run of char codes (common obfuscation).' }, + { id: 'hex-escapes', sev: 'high', re: /(\\x[0-9a-fA-F]{2}){20,}/, msg: 'Long run of \\x escapes (obfuscated string).' }, + { id: 'env-dump', sev: 'high', re: /JSON\.stringify\(\s*process\.env\s*\)|Object\.(entries|keys)\(\s*process\.env\s*\)[^\n]*(fetch|post|send|request)/, msg: 'Serialises the whole environment (secrets) for sending.' }, + { id: 'credential-files', sev: 'warn', re: /\.ssh\/(id_|authorized_keys)|\.aws\/credentials|\.npmrc['"]|Login Data|Local State|keychain|\.config\/solana\/id\.json|wallet\.dat|keystore\/UTC/i, msg: 'Touches local credential or wallet files.' }, + { id: 'dynamic-code', sev: 'warn', re: /\beval\s*\(|\bnew\s+Function\s*\(/, msg: 'Dynamic code execution (eval / new Function).' }, + { id: 'node-runs-odd-file', sev: 'high', re: /\bnode\s+(\.\/)?[\w./-]+\.(?!(c|m)?js\b|ts\b|mts\b)[a-z0-9]{2,5}\b/i, msg: 'Runs `node` on a file that is not a .js/.ts file (payload disguised by extension).' }, +]; + +module.exports = async ({ github, context, core, pr: prOverride, dryRun = false }) => { + const { owner, repo } = context.repo; + const pr = prOverride || context.payload.pull_request; + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + + const findings = []; + const add = (sev, file, line, rule, msg) => findings.push({ sev, file, line, rule, msg }); + + for (const f of files) { + if (f.status === 'removed') continue; + const path = f.filename; + + let lines; + if (typeof f.patch === 'string') { + lines = addedLines(f.patch); + } else { + // No patch: the file is binary or too large for the API diff. + const raw = await fetchRaw(github, pr, path); + if (raw === null) { + if (!MEDIA_EXT.test(path) && f.changes > 0) add('warn', path, null, 'unscanned', 'Too large to scan automatically; review by hand.'); + continue; + } + if (raw.binary) { + continue; + } + lines = raw.text.split('\n').map((text, i) => ({ n: i + 1, text })); + } + scanFile(path, f.status, lines, add); + if (/(^|\/)package\.json$/.test(path)) await scanPackageJson(github, pr, path, f.status, add); + } + + const high = findings.filter((x) => x.sev === 'high'); + const warn = findings.filter((x) => x.sev === 'warn'); + const body = renderComment(pr, high, warn); + + core.info(`Scanned ${files.length} files: ${high.length} high, ${warn.length} warn.`); + for (const x of findings) core.info(`[${x.sev}] ${x.file}${x.line ? `:${x.line}` : ''} ${x.rule}: ${x.msg}`); + if (core.summary) await core.summary.addRaw(body.replace(MARKER, '')).write(); + + if (!dryRun) await publish(github, owner, repo, pr, body, findings.length > 0, high.length > 0); + if (high.length && core.setFailed) core.setFailed(`Malware scan: ${high.length} high-severity finding(s). A maintainer must review this PR.`); + return { high, warn }; +}; + +function scanFile(path, status, lines, add) { + const isDoc = DOC_EXT.test(path); + const isGenerated = GENERATED.test(path); + const all = lines.map((l) => l.text).join('\n'); + const first = lines[0] ? lines[0].n : null; + + // Editor and devcontainer configs that execute on open. + if (/^\.vscode\/tasks\.json$/.test(path) && /"runOn"\s*:\s*"folderOpen"/.test(all)) { + add('high', path, first, 'editor-autorun', 'VS Code task set to run automatically when the folder is opened.'); + } else if (/^\.(vscode|idea|fleet|zed)\//.test(path)) { + add('warn', path, first, 'editor-config', 'Adds or changes editor config; check it runs nothing on open.'); + } + if (/^\.devcontainer\//.test(path)) { + const host = /"initializeCommand"/.test(all); + add(host ? 'high' : 'warn', path, first, 'devcontainer-command', + host ? 'devcontainer `initializeCommand` runs on the host machine.' : 'Changes devcontainer config; check its post-create/start commands.'); + } + + // Install-time and build-time hooks (package.json is handled in scanPackageJson). + if (/(^|\/)package-lock\.json$/.test(path)) { + for (const l of lines) { + const m = /"resolved"\s*:\s*"([^"]+)"/.exec(l.text); + // Relative paths are local workspace packages; only remote sources matter. + if (m && /^[a-z+]+:/i.test(m[1]) && !m[1].startsWith('https://registry.npmjs.org/')) { + add('high', path, l.n, 'lockfile-source', `Lockfile resolves a package from \`${m[1]}\`, not registry.npmjs.org.`); + } + } + } + if (/(^|\/)\.(npmrc|yarnrc(\.yml)?)$/.test(path)) { + const reg = /registry\s*[=:]/.test(all) && !/registry\.npmjs\.org/.test(all); + add(reg ? 'high' : 'warn', path, first, 'npm-config', reg ? 'Points npm at a non-default registry.' : 'Changes npm/yarn config.'); + } + if (/(^|\/)build\.rs$/.test(path)) { + const risky = /std::process|Command::new|TcpStream|reqwest|ureq|std::net/.test(all); + add(risky ? 'high' : 'warn', path, first, 'build-script', + risky ? 'Cargo build script spawns processes or opens network connections at compile time.' : 'Adds or changes a Cargo build script (runs at compile time).'); + } + if (/(^|\/)Cargo\.toml$/.test(path)) { + for (const l of lines) { + if (/\bgit\s*=\s*"/.test(l.text)) add('warn', path, l.n, 'git-dependency', 'Crate pulled from a git repository instead of crates.io.'); + } + } + if (/^\.husky\/|(^|\/)\.git-hooks?\//.test(path)) add('warn', path, first, 'git-hook', 'Adds or changes git hooks.'); + + // GitHub Actions. + if (/^\.github\/workflows\//.test(path)) { + add('warn', path, first, 'workflow-change', 'Changes a CI workflow; a maintainer must review before approving runs.'); + for (const l of lines) { + const u = /^\s*-?\s*uses:\s*['"]?([^@\s'"]+)@/.exec(l.text); + if (u && !u[1].startsWith('./')) { + const owner = u[1].split('/')[0]; + if (!ALLOWED_ACTION_OWNERS.includes(owner)) { + const near = typosquatOf(owner, ALLOWED_ACTION_OWNERS, true); + add('high', path, l.n, 'untrusted-action', near + ? `\`${u[1]}\` looks like a typosquat of \`${near}/โ€ฆ\`.` + : `\`${u[1]}\` is not from an allowed Actions publisher (${ALLOWED_ACTION_OWNERS.join(', ')}).`); + } + } + if (/\bpull_request_target\b/.test(l.text)) add('high', path, l.n, 'pull-request-target', '`pull_request_target` runs with a write token and secrets on fork PRs.'); + if (/\$\{\{\s*secrets\./.test(l.text)) add('warn', path, l.n, 'workflow-secret', 'Workflow uses a repository secret.'); + } + } + + // Payload disguised as a font, image or other non-code file. + if (MEDIA_EXT.test(path) || (!TEXT_EXT.test(path) && /\.[a-z0-9]{2,5}$/i.test(path) && !/(^|\/)[A-Z][A-Z_]*$|Dockerfile|Makefile|LICENSE/.test(path))) { + if (JS_LIKE.test(all)) { + add('high', path, first, 'disguised-code', 'Non-code file extension, but the content is JavaScript. Typical of a hidden payload.'); + } + } + + if (path === SELF) return; // the scanner's own pattern list would match itself + + for (const l of lines) { + const t = l.text; + if (!isGenerated && t.length >= 200) { + const compact = t.replace(/\s/g, ''); + const b64 = compact.replace(/[^A-Za-z0-9+/=]/g, ''); + if (b64.length >= 200 && b64.length / compact.length > 0.98 && looksLikeBase64Text(compact)) { + add('high', path, l.n, 'base64-file', 'Content is committed as base64. It hides what the file really contains, and merging overwrites the real file.'); + break; + } + const ws = (t.match(/\s/g) || []).length; + if (ws / t.length > 0.9 && /\S/.test(t)) { + add('high', path, l.n, 'whitespace-hiding', 'Code is hidden among large runs of whitespace.'); + break; + } + if (/\S\s{150,}\S/.test(t)) { + add('high', path, l.n, 'offscreen-code', 'Code is pushed far off-screen by a long run of spaces.'); + break; + } + } + if (isGenerated) continue; + for (const r of LINE_RULES) { + if (r.re.test(t)) add(isDoc && r.sev === 'high' ? 'warn' : r.sev, path, l.n, r.id, r.msg); + } + } +} + +const LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublish', 'preprepare', 'postprepare']; +const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; +// A prepare script that only builds the package itself is normal; anything else runs arbitrary code on install. +const BENIGN_PREPARE = /^(npm run [\w:-]+|tsc\b[^;&|]*|husky( install)?|vite build|node-gyp rebuild|patch-package)( && (npm run [\w:-]+|tsc\b[^;&|]*))*$/; + +async function scanPackageJson(github, pr, path, status, add) { + const head = await fetchRaw(github, pr, path); + const base = status === 'added' ? { text: '{}' } : await fetchRaw(github, { head: { repo: pr.base.repo, sha: pr.base.sha } }, path); + if (!head || head.binary) { + add('warn', path, null, 'package-json-unparsed', 'package.json could not be fetched; review its scripts and dependencies by hand.'); + return; + } + let h; let b; + try { + h = JSON.parse(head.text); + b = JSON.parse(base ? base.text : '{}'); + } catch { + add('warn', path, null, 'package-json-unparsed', 'package.json could not be parsed; review its scripts and dependencies by hand.'); + return; + } + const lineOf = (key) => { + const i = head.text.split('\n').findIndex((l) => l.includes(`"${key}"`)); + return i >= 0 ? i + 1 : null; + }; + + for (const key of LIFECYCLE) { + const now = h.scripts && h.scripts[key]; + if (!now || (b.scripts && b.scripts[key] === now)) continue; + const benign = (key === 'prepare' || key === 'prepublish') && BENIGN_PREPARE.test(now.trim()); + add(benign ? 'warn' : 'high', path, lineOf(key), 'install-script', + `Adds or changes the \`${key}\` script (\`${now.slice(0, 80)}\`), which runs automatically on \`npm install\`.`); + } + + for (const field of DEP_FIELDS) { + for (const [name, spec] of Object.entries(h[field] || {})) { + if (b[field] && b[field][name] === spec) continue; + const local = /^(file|link|workspace):(\.\/)?(?!\.\.|\/)/.test(spec) || /^workspace:/.test(spec); + if (local) { + add('warn', path, lineOf(name), 'dependency-source', `\`${name}\` is a local package (\`${spec}\`); check that path is part of this PR's reviewed code.`); + } else if (/^(https?:|git(\+\w+)?:|github:|gitlab:|bitbucket:|file:|link:)/.test(spec) || /^[\w-]+\/[\w.-]+(#.*)?$/.test(spec)) { + add('high', path, lineOf(name), 'dependency-source', `\`${name}\` is installed from \`${spec}\` instead of the npm registry.`); + } + const squat = typosquatOf(name, POPULAR_PACKAGES); + if (squat) add('high', path, lineOf(name), 'typosquat', `\`${name}\` looks like a typosquat of \`${squat}\`.`); + } + } +} + +function addedLines(patch) { + const out = []; + let n = 0; + for (const line of patch.split('\n')) { + const h = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line); + if (h) { n = Number(h[1]); continue; } + if (line.startsWith('+')) out.push({ n: n++, text: line.slice(1) }); + else if (!line.startsWith('-') && !line.startsWith('\\')) n++; + } + return out; +} + +async function fetchRaw(github, pr, path) { + const head = pr.head.repo; + if (!head) return null; // fork deleted + try { + const { data } = await github.rest.repos.getContent({ owner: head.owner.login, repo: head.name, path, ref: pr.head.sha }); + if (Array.isArray(data) || data.type !== 'file' || data.size > MAX_FETCH_BYTES) return null; + let buf; + if (data.content) { + buf = Buffer.from(data.content, 'base64'); + } else { + const { data: blob } = await github.rest.git.getBlob({ owner: head.owner.login, repo: head.name, file_sha: data.sha }); + buf = Buffer.from(blob.content, 'base64'); + } + if (buf.includes(0)) return { binary: true }; + return { text: buf.toString('utf8') }; + } catch { + return null; + } +} + +// Decodes the longest clean base64 run, so a stray character can't shift the +// alignment and hide the content. +function looksLikeBase64Text(s) { + const run = (s.match(/[A-Za-z0-9+/]{200,}/g) || []).sort((a, b) => b.length - a.length)[0]; + if (!run) return false; + const decoded = Buffer.from(run.slice(0, 4000 - (Math.min(run.length, 4000) % 4)), 'base64').toString('utf8'); + if (decoded.length < 100) return false; + const printable = (decoded.match(/[\x20-\x7e\n\r\t]/g) || []).length; + return printable / decoded.length > 0.9; +} + +// `prefix` also flags lookalikes sharing the first 4 letters (dtolstech vs dtolnay). +// Only used for Action owners: npm names legitimately share prefixes (react-dom, react-query). +function typosquatOf(name, known, prefix = false) { + const lower = name.toLowerCase(); + if (known.some((k) => k.toLowerCase() === lower)) return null; + for (const k of known) { + const kl = k.toLowerCase(); + if (Math.abs(kl.length - lower.length) > 2 || kl.length < 4) continue; + const d = distance(lower, kl); + if (d === 1 || (d === 2 && kl.length >= 7)) return k; + } + for (const k of prefix ? known : []) { + const kl = k.toLowerCase(); + if (kl.length >= 5 && lower.length >= 5 && lower.slice(0, 4) === kl.slice(0, 4) && !lower.startsWith(kl) && !kl.startsWith(lower)) return k; + } + return null; +} + +function distance(a, b) { + const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); + for (let j = 1; j <= b.length; j++) dp[0][j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); + } + } + return dp[a.length][b.length]; +} + +function renderComment(pr, high, warn) { + const row = (x) => `| \`${x.file}${x.line ? `:${x.line}` : ''}\` | ${x.rule} | ${x.msg} |`; + const table = (list) => ['| Where | Rule | Why |', '|---|---|---|', ...list.slice(0, 50).map(row), + ...(list.length > 50 ? [`| โ€ฆ | | ${list.length - 50} more in the workflow log |`] : [])].join('\n'); + const sha = pr.head.sha.slice(0, 7); + + if (high.length) { + return `${MARKER} +### ๐Ÿšจ Malware scan: needs security review + +@${pr.user.login} The automated scan of \`${sha}\` flagged code that can run on a maintainer's machine or in CI, or that hides what it contains. This PR won't be reviewed, and its CI won't be approved, until a maintainer has checked these: + +${table(high)} +${warn.length ? `\n
${warn.length} lower-severity note(s) for the reviewer\n\n${table(warn)}\n
\n` : ''} +If this is a false positive, reply here explaining why and a maintainer will clear it. If it isn't intentional, remove it and push again: the scan re-runs on every push and updates this comment.`; + } + if (warn.length) { + return `${MARKER} +### Malware scan: passed, with notes for the reviewer + +Nothing malicious was detected in \`${sha}\`. These changes touch sensitive areas and should get a careful look before CI is approved: + +${table(warn)}`; + } + return `${MARKER} +### โœ… Malware scan: clean + +The earlier findings are resolved as of \`${sha}\`.`; +} + +async function publish(github, owner, repo, pr, body, hasFindings, hasHigh) { + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); + const existing = comments.find((c) => c.body && c.body.includes(MARKER) && c.user.type === 'Bot'); + if (existing) { + if (existing.body !== body) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else if (hasFindings) { + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + } + + const labelled = pr.labels.some((l) => l.name === LABEL); + if (hasHigh && !labelled) { + try { + await github.rest.issues.getLabel({ owner, repo, name: LABEL }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'b60205', description: 'Malware scan flagged this PR; a maintainer must review' }); + } + await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); + } else if (!hasHigh && labelled) { + await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }); + } +} diff --git a/.github/workflows/pr-malware-scan.yml b/.github/workflows/pr-malware-scan.yml new file mode 100644 index 0000000..663d98b --- /dev/null +++ b/.github/workflows/pr-malware-scan.yml @@ -0,0 +1,44 @@ +name: PR malware scan + +# Scans every PR for malware and supply-chain tricks (see +# .github/scripts/pr-malware-scan.js). High-severity findings fail this check, +# label the PR `security-review` and @mention the author. +# +# Why pull_request_target: it is the only trigger that runs on fork PRs +# without a maintainer first approving the run, so the scan has finished +# before anyone decides whether to approve CI. It is safe here because this +# job never checks out, installs or executes anything from the PR. The PR is +# read as data through the API, and the scanner itself comes from `main`. +# Never add a step that checks out the PR head to this workflow. + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: pr-malware-scan-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + scan: + name: Malware scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # pull_request_target checks out the base branch (main), never the PR. + - uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + persist-credentials: false + + - name: Scan PR changes + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const scan = require('./.github/scripts/pr-malware-scan.js'); + await scan({ github, context, core }); diff --git a/ROADMAP.md b/ROADMAP.md index 9d3d167..db50965 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,6 +125,7 @@ for status and issue links. ## Phase 9 โ€” Deploy & CI **Status: Not started for real production. A Render free-tier preview exists โ€” see note. Remaining items scoped into issues #21โ€“#22.** +- [x] **PR malware scan** โ€” `.github/workflows/pr-malware-scan.yml` + `.github/scripts/pr-malware-scan.js`, on every PR push. Flags editor/devcontainer autorun tasks, npm install hooks, typosquatted packages and Actions, non-registry dependency sources, payloads disguised as fonts/images, base64/whitespace obfuscation, exfiltration endpoints, reverse shells and miners. HIGH findings fail the `Malware scan` check, label the PR `security-review` and @mention the author; one comment per PR, updated in place. Reads the PR as data only (never checks out or runs it). Added 2026-10-20 after a VS Code autorun payload (escrow #163) and a typosquatted action (escrow #176). - [ ] Choose deployment target (ARCHITECTURE.md ยง8 open decision) โ€” record as an ADR. **Still not resolved.** A Render free-tier web service + Postgres now runs `backend/`, so `docforum-web`'s GitHub Pages preview has a live API to call โ€” see `docs/adr/0003-render-preview-deployment.md`. This is explicitly a preview, not the production decision: no secrets manager, no backups, no staging/prod split, free-tier Postgres expires 2026-11-06 (renewed 2026-10-07; 30 days, 14-day grace period) unless recreated/upgraded before then โ€” renewal steps in ADR 0003's "Renewing the free database" section. Tracked as [issue #22](https://github.com/DocForum/docforum-core/issues/22) (Medium, 150 pts). - [x] Real CI pipeline (replaced `.github/workflows/ci.yml` placeholder โ€” was `pull_request`-only, `echo "TODO"`, never once run). Now runs on push to `main` and on PRs: `npm audit --omit=dev` at high severity (`audit:prod`, Phase 8 issue #47) โ†’ `prisma:generate` โ†’ `typecheck` โ†’ unit tests โ†’ **integration tests (the self-contained embedded-Postgres double-booking proof)** โ†’ `build`. Verified locally end-to-end before pushing (14 unit + 1 integration test passing) and confirmed green in Actions. No lint script exists yet in `backend/package.json` โ€” not added; `tsc --noEmit` is the closest static check currently available. Lint now tracked as [issue #28](https://github.com/DocForum/docforum-core/issues/28) (Trivial, 100 pts). Closes [issue #21](https://github.com/DocForum/docforum-core/issues/21) (Medium, 150 pts). - [ ] Staging environment. Blocked on issue #22's decision landing first โ€” not yet scoped into its own issue.