From cf41bdc6d6fabfd17f3a23f5c90d8b92d153a891 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 11 Oct 2026 18:12:30 +0200 Subject: [PATCH] Secure Lens history reads and verify shipped browser dependencies Signed-off-by: Martin Vogel --- .github/workflows/_security.yml | 4 +- graph-ui/tools/license-build-inventory.ts | 110 ++++++++ graph-ui/vite.config.ts | 6 +- scripts/gen-ui-licenses.py | 17 ++ scripts/license-gate-check-npm.py | 17 +- scripts/license-gate.sh | 6 + scripts/security-allowlist.txt | 12 +- scripts/security-audit.sh | 30 +-- scripts/ui_browser_licenses.py | 112 ++++++++ src/ui/atlas_metrics.c | 40 +-- tests/test_security_audit_subprocess_calls.sh | 106 ++++++++ tests/test_ui.c | 148 +++++++++++ tests/test_ui_license_inventory.py | 247 ++++++++++++++++++ 13 files changed, 804 insertions(+), 51 deletions(-) create mode 100644 graph-ui/tools/license-build-inventory.ts create mode 100644 scripts/ui_browser_licenses.py create mode 100644 tests/test_security_audit_subprocess_calls.sh create mode 100644 tests/test_ui_license_inventory.py diff --git a/.github/workflows/_security.yml b/.github/workflows/_security.yml index 54d3a5a3d..2e52b6d10 100644 --- a/.github/workflows/_security.yml +++ b/.github/workflows/_security.yml @@ -21,7 +21,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Layer 1: Static allow-list audit" - run: scripts/security-audit.sh + run: | + bash tests/test_security_audit_subprocess_calls.sh + scripts/security-audit.sh - name: "Layer 6: UI security audit" run: scripts/security-ui.sh - name: "Layer 8: Vendored dependency integrity" diff --git a/graph-ui/tools/license-build-inventory.ts b/graph-ui/tools/license-build-inventory.ts new file mode 100644 index 000000000..fb08435e2 --- /dev/null +++ b/graph-ui/tools/license-build-inventory.ts @@ -0,0 +1,110 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import type { Plugin } from 'vite'; + +/** Evidence for the narrow browser-only license decision. Never shipped in dist. */ +export function licenseBuildInventory(root: string): { app: Plugin; workers: () => Plugin[] } { + const proofPath = join(root, '.vite/license-build.json'); + const modules = new Set(); + const externals = new Set(); + const chunks = new Set(); + const generatedAssets: Record = {}; + const inputs: Record = {}; + let workerBuilds = 0; + let failed = false; + let outDir = join(root, 'dist'); + const hash = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex'); + const slash = (path: string) => path.replaceAll('\\', '/'); + const inside = (base: string, path: string) => { + const rel = relative(base, path); + return rel !== '..' && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && !isAbsolute(rel); + }; + function localPath(path: string): string { + const actual = realpathSync(path); + const dependencies = realpathSync(join(root, 'node_modules')); + if (inside(dependencies, actual)) return `node_modules/${slash(relative(dependencies, actual))}`; + if (inside(realpathSync(root), actual)) return slash(relative(realpathSync(root), actual)); + throw new Error(`License inventory input outside the UI: ${path}`); + } + function record(path: string): string | undefined { + const clean = path.split('?')[0]; + if (!clean || clean.includes('\0') || !isAbsolute(clean) || !existsSync(clean) || !statSync(clean).isFile()) return; + const name = localPath(clean); + inputs[name] = hash(clean); + return name; + } + function files(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const path = join(dir, entry.name); + return entry.isDirectory() ? files(path) : [path]; + }); + } + const plugin = (worker: boolean): Plugin => ({ + name: `cbm-license-inventory-${worker ? 'worker' : 'app'}`, + apply: 'build', + configResolved(config) { + if (!worker) outDir = resolve(config.root, config.build.outDir); + }, + buildStart() { + if (worker) { workerBuilds++; return; } + rmSync(proofPath, { force: true }); + modules.clear(); externals.clear(); chunks.clear(); + for (const key of Object.keys(inputs)) delete inputs[key]; + for (const key of Object.keys(generatedAssets)) delete generatedAssets[key]; + workerBuilds = 0; + failed = false; + for (const file of ['vite.config.ts', 'tools/license-build-inventory.ts', 'package.json', + 'node_modules/@huggingface/transformers/package.json']) record(join(root, file)); + }, + buildEnd(error) { if (error) failed = true; }, + generateBundle: { order: 'post', handler(_options, bundle) { + for (const id of this.getModuleIds()) { + const info = this.getModuleInfo(id); + if (info?.isExternal) externals.add(id); + else modules.add(record(id) ?? id); + } + for (const path of this.getWatchFiles()) record(path); + for (const [name, output] of Object.entries(bundle)) { + if (output.type === 'chunk') chunks.add(name); + else if (name === 'index.html' || name.endsWith('.css')) { + // Only bundler outputs qualify. Files copied from public never + // enter this hook; record bytes before inspecting the dist tree. + generatedAssets[name] = createHash('sha256').update(output.source).digest('hex'); + } + } + } }, + closeBundle() { + if (worker || failed || !existsSync(outDir)) return; + const outputs: Record = {}; + const runtimeInputs: Record = {}; + // Vite emits ONNX's runtime through new URL(..., import.meta.url). Match + // bytes to its installed input, rather than trusting an output filename. + const ortDir = join(root, 'node_modules/onnxruntime-web/dist'); + const runtimeSources = existsSync(ortDir) ? files(ortDir).filter(path => /\.(wasm|m?js)$/.test(path)) : []; + const runtimeHashes = new Map(runtimeSources.map(path => [hash(path), path])); + for (const path of files(outDir).sort()) { + const name = slash(relative(outDir, path)); + outputs[name] = hash(path); + if (/\.(wasm|m?js)$/.test(name)) { + const source = runtimeHashes.get(outputs[name]); + if (source) runtimeInputs[name] = record(source)!; + } + } + // Public copies are not Rollup modules. Record their inputs, while the + // verifier still rejects copied executable payloads outside the graph. + const publicDir = join(root, 'public'); + if (existsSync(publicDir)) for (const path of files(publicDir)) record(path); + mkdirSync(join(root, '.vite'), { recursive: true }); + writeFileSync(proofPath, JSON.stringify({ + version: 1, + lock_sha256: hash(join(root, 'package-lock.json')), + inputs: Object.fromEntries(Object.entries(inputs).sort()), + modules: [...modules].sort(), externals: [...externals].sort(), + chunks: [...chunks].sort(), worker_builds: workerBuilds, + outputs, runtime_inputs: runtimeInputs, generated_assets: generatedAssets, + }, null, 2) + '\n'); + }, + }); + return { app: plugin(false), workers: () => [plugin(true)] }; +} diff --git a/graph-ui/vite.config.ts b/graph-ui/vite.config.ts index 46e75deb0..5b5451ca2 100644 --- a/graph-ui/vite.config.ts +++ b/graph-ui/vite.config.ts @@ -5,8 +5,10 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { defineConfig, type ProxyOptions } from 'vite'; import react from '@vitejs/plugin-react'; +import { licenseBuildInventory } from './tools/license-build-inventory'; const projectRoot = dirname(fileURLToPath(import.meta.url)); +const licenseInventory = licenseBuildInventory(projectRoot); /** * Der Versions-Chip der Kopfzeile, zur Buildzeit festgeschrieben. @@ -73,8 +75,8 @@ const uiBackendProxy = (): ProxyOptions => ({ }); export default defineConfig({ - plugins: [react()], - worker: { format: 'es' }, + plugins: [react(), licenseInventory.app], + worker: { format: 'es', plugins: licenseInventory.workers }, define: { __ATLAS_VERSION__: JSON.stringify(atlasVersion()), }, diff --git a/scripts/gen-ui-licenses.py b/scripts/gen-ui-licenses.py index 97fea571e..ee38b4c4b 100755 --- a/scripts/gen-ui-licenses.py +++ b/scripts/gen-ui-licenses.py @@ -16,6 +16,7 @@ import json import os import subprocess import sys +from ui_browser_licenses import verified_browser_exclusions def collect(ui_dir, dep_tree, out, excluded): @@ -77,12 +78,20 @@ def main(): pkgs = set() excluded = set() collect(ui_dir, tree.get("dependencies"), pkgs, excluded) + try: + browser_excluded = verified_browser_exclusions(ui_dir, pkgs) + except ValueError as exc: + print(f"Cannot generate UI notices: {exc}", file=sys.stderr) + sys.exit(1) + pkgs -= browser_excluded print("## External Graph UI asset pack — bundled npm packages") print() print("The `-ui` archives ship a compiled frontend asset pack beside the binary.") print("The packages below are its production dependency tree; license texts are") print("reproduced verbatim from the packages as installed at build time.") + print("The build inventory excludes only the reviewed optional package listed below;") + print("other production dependencies remain covered conservatively.") print() for name, version in sorted(pkgs): @@ -108,6 +117,14 @@ def main(): print(f"- {name}@{version}") print() + if browser_excluded: + print("### Installed optional package absent from the browser pack") + print() + print("Verified against the current application and worker build inputs and asset hashes.") + for name, version in sorted(browser_excluded): + print(f"- {name}@{version}: Transformers uses its reviewed empty browser stub.") + print() + if __name__ == "__main__": main() diff --git a/scripts/license-gate-check-npm.py b/scripts/license-gate-check-npm.py index b16f6ca6a..714408966 100755 --- a/scripts/license-gate-check-npm.py +++ b/scripts/license-gate-check-npm.py @@ -3,9 +3,8 @@ Usage: license-gate-check-npm.py -Walks the production tree (the packages whose code is compiled into the UI -bundle; platform-restricted native build tooling is excluded the same way -gen-ui-licenses.py excludes it) and fails if ANY package's license is not +Conservatively walks the production tree (including prebundled dependencies; +exclusions are shared with gen-ui-licenses.py) and fails if ANY package's license is not on the policy allow-list. Packages with no declared license fall back to the first line of their shipped license file; if that cannot be resolved either, the gate fails — unknown is not allowed. @@ -15,6 +14,7 @@ import os import re import subprocess import sys +from ui_browser_licenses import verified_browser_exclusions FILE_HEADER_MAP = { "mit license": "MIT", @@ -81,8 +81,17 @@ def main(): "is node_modules installed?") sys.exit(1) + try: + browser_excluded = verified_browser_exclusions(ui_dir, pkgs) + except ValueError as exc: + print(f"BLOCKED: cannot verify browser-only package exclusion: {exc}") + sys.exit(1) + violations = [] for (name, version), (lic, pkg_dir) in sorted(pkgs.items()): + if (name, version) in browser_excluded: + print(f"Not shipped (verified app + worker build): {name}@{version}") + continue if name in ignored_pkgs: continue if not lic: @@ -102,7 +111,7 @@ def main(): for pkg, lic in violations[:25]: print(f" {pkg}: {lic}") sys.exit(1) - print(f"OK: {len(pkgs)} bundled npm packages, all allow-listed") + print(f"OK: {len(pkgs) - len(browser_excluded)} npm packages covered, all allow-listed") if __name__ == "__main__": diff --git a/scripts/license-gate.sh b/scripts/license-gate.sh index afc98f17c..a9929e0f1 100755 --- a/scripts/license-gate.sh +++ b/scripts/license-gate.sh @@ -115,6 +115,12 @@ if command -v npm &>/dev/null && [ -f graph-ui/package-lock.json ]; then if [ ! -d graph-ui/node_modules ]; then (cd graph-ui && npm ci --ignore-scripts --silent) fi + # The optional native image-processing package may be absent from the + # browser export. Establish that from this candidate's actual app/worker + # build, not package-name or platform guesses. Evidence stays outside dist. + (cd graph-ui && npm run build) + python3 scripts/ui_browser_licenses.py graph-ui + python3 tests/test_ui_license_inventory.py python3 scripts/license-gate-check-npm.py graph-ui scripts/license-policy.json else echo "FAIL: npm or graph-ui/package-lock.json unavailable — UI tree unchecked" diff --git a/scripts/security-allowlist.txt b/scripts/security-allowlist.txt index cd855e640..720c60211 100644 --- a/scripts/security-allowlist.txt +++ b/scripts/security-allowlist.txt @@ -25,10 +25,19 @@ NETWORK:src/daemon/ipc.c:connect:1:unix:local_socket_connect:single sockaddr_un- NETWORK:src/main.c:socket:1:loopback-v4:main_daemon_ctl_ui_endpoint_ready:single AF_INET stream client for authenticated daemon UI readiness NETWORK:src/main.c:connect:1:loopback-v4:main_daemon_ctl_socket_connected:single sockaddr_in connect whose address is hardcoded to 127.0.0.1 and whose response must prove the daemon generation HMAC -# ── Coordination daemon: count-bounded private internal file writes ──────── +# ── Reviewed count-bounded file writes: private or caller-selected paths ─── # Format: WRITE:file:expected-count:justification WRITE:src/main.c:3:supervised worker response path; cache-scoped hook notice marker; test-seam-only browser-open marker at the caller-supplied test path WRITE:src/daemon/application.c:1:recovery truncates only its private uniquely-created per-job marker file +WRITE:src/cli/config_json_like.c:1:Windows exclusive temporary file beside the caller-selected config for atomic replacement +WRITE:src/cli/config_text_edit.c:1:Windows exclusive temporary file beside the caller-selected config for atomic replacement and descriptor-bound identity checks +WRITE:src/cli/config_yaml_edit.c:1:Windows exclusive temporary file beside the caller-selected config for atomic replacement +WRITE:src/foundation/compat_fs.c:1:stream-copy fallback writes only the clone/copy destination selected by its caller +WRITE:src/foundation/mem.c:1:opt-in allocator diagnostics write to the explicit CBM_MEM_STATS_OUT destination +WRITE:src/mcp/test_impact_artifact.c:1:receipt staging file in the caller-selected artifact bundle before atomic replacement +WRITE:src/mcp/test_impact_engine.c:1:merge-base configuration staged inside the engine's private working directory +WRITE:src/mcp/test_impact_engine_artifact.c:1:reviewed coverage bytes copied into the newly-created output bundle +WRITE:src/mcp/test_impact_git.c:1:bounded Git batch stdin staged in the private capture directory # ── CLI: update command (user-initiated, interactive) ────────────────────── src/cli/cli.c:cbm_popen:sha256 checksum verification (update cmd) @@ -65,6 +74,7 @@ src/pipeline/artifact.c:popen:via cbm_popen wrapper calls src/ui/http_server.c:popen:ps process listing for metrics endpoint src/ui/http_server.c:fork:spawn indexing subprocess src/ui/http_server.c:execl:exec indexing binary in child process +src/ui/atlas_metrics.c:cbm_popen:local git churn/history/origin reads with validated paths and fixed log arguments; child Git environment isolation via cbm_popen_git # ── Allowed URLs ─────────────────────────────────────────────────────────── # Format: URL:justification diff --git a/scripts/security-audit.sh b/scripts/security-audit.sh index a1906d915..6b69beaf7 100755 --- a/scripts/security-audit.sh +++ b/scripts/security-audit.sh @@ -37,21 +37,19 @@ while IFS= read -r file; do relfile="${file#"$ROOT/"}" for func in $FUNC_LIST; do - # Build precise grep pattern to avoid substring matches: - # 'popen(' must NOT match 'cbm_popen(' — use [^a-z] negative class + # Match complete C identifiers, including calls at column one and + # whitespace before '('. Git wrappers use the existing cbm_popen key; + # neither wrapper family may be mistaken for raw popen. case "$func" in - cbm_popen) pattern="cbm_popen(" ;; - popen) pattern="[^a-z]popen(" ;; - system) pattern="[^a-z_]system(" ;; - fork) pattern="[^a-z_]fork(" ;; - *) pattern="[^a-z_]${func}(" ;; + cbm_popen) pattern='(^|[^[:alnum:]_])cbm_popen(_git)?[[:space:]]*\(' ;; + *) pattern="(^|[^[:alnum:]_])${func}[[:space:]]*\\(" ;; esac # Grep for pattern, excluding comments and #define lines - if grep -n "$pattern" "$file" 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' > /dev/null 2>&1; then + if grep -nE "$pattern" "$file" 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' > /dev/null 2>&1; then if ! grep -q "^${relfile}:${func}:" "$ALLOWLIST" 2>/dev/null; then echo "BLOCKED: ${relfile}: contains ${func}() — not on allow-list" - grep -n "$pattern" "$file" 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' | head -3 | sed 's/^/ /' + grep -nE "$pattern" "$file" 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' | head -3 | sed 's/^/ /' fail fi fi @@ -193,7 +191,7 @@ while IFS= read -r file; do printf '%s\n' "$matches" | sed 's/^/ /' FOPEN_FOUND=true else - echo "REVIEWED: ${relfile}: ${actual_count} count-bounded private write(s)" + echo "REVIEWED: ${relfile}: ${actual_count} count-bounded file write(s)" fi ;; esac @@ -215,7 +213,7 @@ while IFS= read -r file; do relfile="${file#"$ROOT/"}" # Extract line numbers of dangerous calls - DANGER_LINES=$(grep -n 'system(\|popen(\|cbm_popen(\|connect(\|execl(' "$file" 2>/dev/null \ + DANGER_LINES=$(grep -nE '(^|[^[:alnum:]_])(system|popen|cbm_popen(_git)?|connect|execl)[[:space:]]*\(' "$file" 2>/dev/null \ | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' \ | cut -d: -f1 || true) @@ -261,10 +259,12 @@ if [ -f "$MCP_FILE" ]; then # - HTTP transport: reads the incoming request body (content-length bound) # Count fopen/fread calls and compare against expected FOPEN_COUNT=$(grep -c 'fopen\|fread\|read_file' "$MCP_FILE" 2>/dev/null || echo "0") - # Update this when legitimate reads are added. 15 reads audited as of the - # search/ADR/Windows-support commits — all path-contained or transport - # reads, no new exfiltration surface. - EXPECTED_MAX=15 + # Current main inventory: 26 matching source lines (previous 15 + 14 added + # - 3 removed). Reviewed additions: 3 comments, 1 bounded grep-output read, + # 1 path-contained Git first-line read, 3 changed-file hash branches, and + # 6 revision/merge-base/path/status/hunk reads. This lexical count remains + # a growth tripwire: newly matching lines require review before raising it. + EXPECTED_MAX=26 if [ "$FOPEN_COUNT" -gt "$EXPECTED_MAX" ]; then echo "REVIEW: src/mcp/mcp.c has $FOPEN_COUNT file read operations (expected max $EXPECTED_MAX)" echo " New file reads in MCP tool handlers must be reviewed for data exfiltration risk." diff --git a/scripts/ui_browser_licenses.py b/scripts/ui_browser_licenses.py new file mode 100644 index 000000000..6a40033e1 --- /dev/null +++ b/scripts/ui_browser_licenses.py @@ -0,0 +1,112 @@ +"""One build-backed exception for an installed, unshipped optional package. + +Keep the production dependency inventory conservative. Only the reviewed +Transformers browser build may omit this exact native image-processing package; +missing evidence, upgrades, new payloads, or stale builds fail closed. +""" +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import sys + + +OPTIONAL_PACKAGE = ("@img/sharp-wasm32", "0.35.0") +BROWSER_ENTRY = "node_modules/@huggingface/transformers/dist/transformers.web.js" +# Transformers 4.2.0's published browser distribution contains the upstream +# empty `ignore-modules:sharp` stub. Updating it requires a new source review. +BROWSER_SHA256 = "25e0cbdf5df922996299fcd2cf835101ba979b134389a0dcc54f92022ca7e0ff" +SHARP_INPUT = re.compile(r"(^|[/\\])(sharp|@img[/\\]sharp[^/\\?]*)([/\\?]|$)") + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def checked_path(root, name): + path = PurePosixPath(name) + if not name or path.is_absolute() or ".." in path.parts or "\\" in name or ":" in name: + raise ValueError("invalid path in browser build evidence") + return root.joinpath(*path.parts) + + +def verified_browser_exclusions(ui_dir, packages): + if OPTIONAL_PACKAGE not in packages: + return set() + try: + meta = json.loads((Path(ui_dir) / "node_modules/@img/sharp-wasm32/package.json").read_text()) + if (meta["name"], meta["version"]) != OPTIONAL_PACKAGE: + raise ValueError("optional package identity changed") + except (OSError, KeyError, TypeError, json.JSONDecodeError) as exc: + raise ValueError("optional package identity is unavailable") from exc + validate_browser_build(ui_dir) + return {OPTIONAL_PACKAGE} + + +def validate_browser_build(ui_dir): + """Also check build evidence on hosts where the optional package is absent.""" + root = Path(ui_dir) + try: + proof = json.loads((root / ".vite/license-build.json").read_text()) + if proof["version"] != 1 or proof["worker_builds"] < 1: + raise ValueError("app and worker build inventories are required") + if proof["lock_sha256"] != sha256(root / "package-lock.json"): + raise ValueError("browser build lockfile changed") + inputs, outputs = proof["inputs"], proof["outputs"] + if not inputs or not outputs: + raise ValueError("empty browser build inventory") + for name, expected in inputs.items(): + if sha256(checked_path(root, name)) != expected: + raise ValueError(f"browser build input changed: {name}") + for name in [*proof["modules"], *proof["externals"], *inputs]: + if SHARP_INPUT.search(name.replace("\0", "/")): + raise ValueError("Sharp code appears in the browser build graph") + if "transformers.node." in name or "onnxruntime-node" in name: + raise ValueError("Node inference code appears in the browser build graph") + # A browser build must resolve this reviewed export, including in the + # inference worker. Do not infer it from npm's dependency declarations. + meta = json.loads((root / "node_modules/@huggingface/transformers/package.json").read_text()) + if (meta["version"] != "4.2.0" or + meta["exports"]["default"]["default"] != "./dist/transformers.web.js" or + BROWSER_ENTRY not in proof["modules"] or + inputs.get(BROWSER_ENTRY) != BROWSER_SHA256): + raise ValueError("Transformers browser export needs a new license review") + dist = root / "dist" + actual = {p.relative_to(dist).as_posix() for p in dist.rglob("*") if p.is_file()} + if actual != set(outputs): + raise ValueError("browser build asset inventory changed") + for name, expected in outputs.items(): + path = checked_path(dist, name) + if sha256(path) != expected: + raise ValueError(f"browser build output changed: {name}") + suffix = path.suffix.lower() + # Hashing a public copy does not make it a bundler output. Bind + # generated HTML/CSS to bytes observed in the Rollup output hook. + generated = proof["generated_assets"].get(name) + if suffix == ".css" and generated == expected: + continue + if (name == "index.html" and generated == expected and + "index.html" in inputs and "index.html" in proof["modules"]): + continue + if suffix in {".js", ".mjs"} and name in proof["chunks"]: + continue + # ONNX ships a prebuilt runtime module and Wasm via Vite URL + # assets. Accept only an exact copy of the inventoried package + # input, never a filename or extension as evidence of provenance. + source = proof["runtime_inputs"].get(name, "") + if (suffix in {".wasm", ".js", ".mjs"} and + source.startswith("node_modules/onnxruntime-web/dist/") and + inputs.get(source) == expected): + continue + raise ValueError(f"browser asset has unknown provenance: {name}") + except (OSError, KeyError, TypeError, json.JSONDecodeError) as exc: + raise ValueError("browser build evidence is missing or incomplete; run npm run build") from exc + + +if __name__ == "__main__": + try: + validate_browser_build(sys.argv[1]) + except ValueError as error: + print(f"BLOCKED: {error}", file=sys.stderr) + sys.exit(1) + print("OK: current application, worker, and copied-runtime build evidence verified") diff --git a/src/ui/atlas_metrics.c b/src/ui/atlas_metrics.c index 9bf884aa2..59fb395a7 100644 --- a/src/ui/atlas_metrics.c +++ b/src/ui/atlas_metrics.c @@ -13,6 +13,7 @@ */ #include "foundation/constants.h" #include "ui/atlas.h" +#include "ui/http_server.h" #include "foundation/compat_fs.h" #include "foundation/hash_table.h" #include "foundation/str_util.h" @@ -199,7 +200,7 @@ static void mx_churn_scan(const char *root_path, mx_churn_t *churn) { "git -C \"%s\" log --name-only --pretty=format:COMMIT:%%h:%%ct:%%an:%%s " "--since=\"1 year ago\" --max-count=10000 2>%s", root_path, null_dev); - FILE *fp = cbm_popen(cmd, "r"); + FILE *fp = cbm_popen_git(cmd); if (!fp) return; churn->commits = malloc((size_t)MX_COMMIT_CAP * sizeof(mx_commit_t)); @@ -890,9 +891,8 @@ char *cbm_atlas_who_json(cbm_store_t *store, const char *project, const char *fi /* ── Rationale evidence: forge base URL + per-symbol history ────── */ -/* Normalize the origin remote to a browsable https base ("" if none): - * git@host:owner/repo(.git) and ssh://git@host/owner/repo(.git) become - * https://host/owner/repo; https URLs just lose the .git suffix. The +/* Normalize the origin remote to a credential-free https base ("" if none): + * git@host:owner/repo(.git), ssh:// and http(s) URLs become https://host/owner/repo. * UI turns commit hashes and #123 refs in subjects into links with it. */ static void mx_remote_url(const char *root_path, char *out, size_t cap) { out[0] = '\0'; @@ -906,7 +906,7 @@ static void mx_remote_url(const char *root_path, char *out, size_t cap) { char cmd[CBM_SZ_2K]; snprintf(cmd, sizeof(cmd), "git -C \"%s\" config --get remote.origin.url 2>%s", root_path, null_dev); - FILE *fp = cbm_popen(cmd, "r"); + FILE *fp = cbm_popen_git(cmd); if (!fp) return; char line[CBM_SZ_1K] = {0}; @@ -920,31 +920,15 @@ static void mx_remote_url(const char *root_path, char *out, size_t cap) { line[--len] = '\0'; if (len == 0) return; - char https[CBM_SZ_1K] = {0}; - if (strncmp(line, "git@", 4) == 0) { - /* git@host:owner/repo -> https://host/owner/repo */ - char *colon = strchr(line + 4, ':'); - if (!colon) - return; - *colon = '\0'; - snprintf(https, sizeof(https), "https://%s/%s", line + 4, colon + 1); - } else if (strncmp(line, "ssh://", 6) == 0) { - const char *rest = line + 6; - const char *at = strchr(rest, '@'); - if (at) - rest = at + 1; - snprintf(https, sizeof(https), "https://%s", rest); - } else if (strncmp(line, "https://", 8) == 0 || strncmp(line, "http://", 7) == 0) { - snprintf(https, sizeof(https), "%s", line); - } else { + if (strncmp(line, "git@", 4) != 0 && strncmp(line, "ssh://", 6) != 0 && + strncmp(line, "https://", 8) != 0 && strncmp(line, "http://", 7) != 0) { return; /* local paths and exotic transports: no link base */ } - size_t hlen = strlen(https); - if (hlen > 4 && strcmp(https + hlen - 4, ".git") == 0) - https[hlen - 4] = '\0'; - while ((hlen = strlen(https)) > 0 && https[hlen - 1] == '/') - https[hlen - 1] = '\0'; + char *https = cbm_ui_git_web_base(line); + if (!https) + return; snprintf(out, cap, "%s", https); + free(https); } char *cbm_atlas_symbol_history_json(cbm_store_t *store, const char *project, const char *file_path, @@ -989,7 +973,7 @@ char *cbm_atlas_symbol_history_json(cbm_store_t *store, const char *project, con "git -C \"%s\" log --no-patch --max-count=12 " "--pretty=format:COMMIT:%%h:%%ct:%%an:%%s -L %lld,%lld:\"%s\" 2>%s", root_path, start_line, end_line, file_path, null_dev); - FILE *fp = cbm_popen(cmd, "r"); + FILE *fp = cbm_popen_git(cmd); if (!fp) { yyjson_mut_obj_add_bool(doc, root, "available", false); char *json = yyjson_mut_write(doc, 0, NULL); diff --git a/tests/test_security_audit_subprocess_calls.sh b/tests/test_security_audit_subprocess_calls.sh new file mode 100644 index 000000000..9785504a3 --- /dev/null +++ b/tests/test_security_audit_subprocess_calls.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Exercise the real source audit against isolated subprocess-call fixtures. +# Wrapper allowances must cover cbm_popen and cbm_popen_git, while raw popen +# requires its own allowance. Identifier substrings must match neither family. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +python3 - "$ROOT" <<'PY' +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +root = Path(sys.argv[1]) +failures = [] +passed = 0 + +with tempfile.TemporaryDirectory(prefix="cbm-security-subprocess-") as directory: + fixture = Path(directory) + (fixture / "scripts").mkdir() + (fixture / "src").mkdir() + for name in ("security-audit.sh", "security-network-source-audit.py"): + shutil.copy2(root / "scripts" / name, fixture / "scripts" / name) + + def check(label, source, allowances, status, required, forbidden=()): + global passed + (fixture / "src" / "probe.c").write_text(source, encoding="utf-8") + (fixture / "scripts" / "security-allowlist.txt").write_text( + allowances, encoding="utf-8" + ) + result = subprocess.run( + ["bash", str(fixture / "scripts" / "security-audit.sh")], + cwd=fixture, text=True, capture_output=True, + ) + output = result.stdout + result.stderr + if (result.returncode == status + and all(marker in output for marker in required) + and all(marker not in output for marker in forbidden)): + print(f"PASS: {label}") + passed += 1 + else: + failures.append(label) + print(f"FAIL: {label}: exit {result.returncode}, expected {status}") + for marker in required: + if marker not in output: + print(f" missing: {marker}") + for marker in forbidden: + if marker in output: + print(f" unexpected: {marker}") + for line in output.splitlines(): + if line.startswith(("BLOCKED:", "REVIEW:", "FAIL:", "policy-error:")): + print(f" {line}") + + wrapper_blocked = "BLOCKED: src/probe.c: contains cbm_popen()" + raw_blocked = "BLOCKED: src/probe.c: contains popen()" + audited = "=== Security audit passed ===" + time_review = "time-related call near dangerous function" + wrapper_allowance = "src/probe.c:cbm_popen:reviewed synthetic wrapper fixture\n" + raw_allowance = "src/probe.c:popen:reviewed synthetic raw-call fixture\n" + + for name in ("cbm_popen", "cbm_popen_git"): + source = f'void probe(void) {{ {name}("fixture"); }}\n' + check(f"unreviewed {name} is blocked", source, "", 1, + (wrapper_blocked,), (raw_blocked,)) + check(f"{name} uses only the wrapper allowance", source, + wrapper_allowance, 0, (audited,), ("BLOCKED:",)) + check(f"raw allowance cannot authorize {name}", source, + raw_allowance, 1, (wrapper_blocked,), (raw_blocked,)) + spaced = f'void probe(void) {{\n{name} ("fixture");\n}}\n' + check(f"{name} at column one with call whitespace is blocked", spaced, + "", 1, (wrapper_blocked,), (raw_blocked,)) + + raw_source = 'void probe(void) { popen("fixture", "r"); }\n' + check("unreviewed raw popen remains blocked", raw_source, "", 1, + (raw_blocked,), (wrapper_blocked,)) + check("wrapper allowance cannot authorize raw popen", raw_source, + wrapper_allowance, 1, (raw_blocked,), (wrapper_blocked,)) + check("reviewed raw popen is allowed", raw_source, raw_allowance, + 0, (audited,), ("BLOCKED:",)) + check("raw popen at column one with call whitespace is blocked", + 'void probe(void) {\npopen ("fixture", "r");\n}\n', "", 1, + (raw_blocked,), (wrapper_blocked,)) + + lookalikes = ( + "my_popen", "MY_popen", "prefix9popen", "_popen", + "my_cbm_popen", "my_cbm_popen_git", "cbm_popen_git_extra", + ) + source = "void probe(void) {\n" + "".join( + f' {name}("fixture");\n' for name in lookalikes + ) + "}\n" + check("identifier substrings do not match subprocess functions", source, + "", 0, (audited,), ("BLOCKED:",)) + + for name in ("cbm_popen", "cbm_popen_git"): + source = f'void probe(void) {{\n time(0);\n {name}("fixture");\n}}\n' + check(f"time proximity includes {name}", source, wrapper_allowance, + 0, (audited, time_review), ("BLOCKED:",)) + source = 'void probe(void) { time(0); my_popen("fixture"); }\n' + check("time proximity excludes identifier substrings", source, "", 0, + (audited,), ("BLOCKED:", time_review)) + +print(f"=== subprocess source audit: {passed} passed, {len(failures)} failed ===") +sys.exit(1 if failures else 0) +PY diff --git a/tests/test_ui.c b/tests/test_ui.c index 7cac5b3d5..9d298c4c8 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -2230,6 +2230,152 @@ static bool impact_test_write(const char *root, const char *file, const char *va return fclose(fp) == 0; } +static bool atlas_history_test_repo(const char *root, const char *subject, const char *remote) { + const char *init[] = {"init", "-q", NULL}; + const char *add[] = {"add", "sample.c", NULL}; + const char *commit[] = {"-c", "core.hooksPath=", "commit", "-qm", subject, NULL}; + const char *origin[] = {"remote", "add", "origin", remote, NULL}; + return impact_test_git(root, init) && + impact_test_write(root, "sample.c", "int sample(void) {\n return 1;\n}\n") && + impact_test_git(root, add) && impact_test_git(root, commit) && + impact_test_git(root, origin); +} + +static bool atlas_history_test_commit(const char *json, const char *array, const char *subject) { + yyjson_doc *doc = json ? yyjson_read(json, strlen(json), 0) : NULL; + if (!doc) + return false; + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *commits = yyjson_obj_get(root, array); + const char *got = yyjson_get_str(yyjson_obj_get(yyjson_arr_get(commits, 0), "subject")); + bool matches = yyjson_get_bool(yyjson_obj_get(root, "available")) && + yyjson_arr_size(commits) == 1 && got && strcmp(got, subject) == 0; + yyjson_doc_free(doc); + return matches; +} + +static bool atlas_history_test_remote(const char *json, const char *expected) { + yyjson_doc *doc = json ? yyjson_read(json, strlen(json), 0) : NULL; + if (!doc) + return false; + const char *got = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "remote_url")); + bool matches = got && strcmp(got, expected) == 0; + yyjson_doc_free(doc); + return matches; +} + +TEST(atlas_history_ignores_inherited_git_context) { + char temp[256] = "/tmp/cbm_atlas_git_context_XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(temp)); + char selected[1024], decoy[1024], git_dir[1100], git_config[1100]; + snprintf(selected, sizeof(selected), "%s/selected", temp); + snprintf(decoy, sizeof(decoy), "%s/decoy", temp); + ASSERT_EQ(th_mkdir_p(selected), 0); + ASSERT_EQ(th_mkdir_p(decoy), 0); + ASSERT_TRUE(atlas_history_test_repo(selected, "selected repository history", + "https://example.invalid/selected/repo.git")); + ASSERT_TRUE(atlas_history_test_repo(decoy, "decoy repository history", + "https://example.invalid/decoy/repo.git")); + snprintf(git_dir, sizeof(git_dir), "%s/.git", decoy); + snprintf(git_config, sizeof(git_config), "%s/.git/config", decoy); + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "atlas-git-context", selected); + + const char *names[] = { + "GIT_DIR", "GIT_WORK_TREE", "GIT_CONFIG", "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "GIT_CONFIG_PARAMETERS"}; + const char *values[] = {git_dir, + decoy, + git_config, + "1", + "remote.origin.url", + "https://example.invalid/injected/repo.git", + "'remote.origin.url=https://example.invalid/parameters/repo.git'"}; + char *saved[7] = {0}; + bool save_ok = true; + for (int i = 0; i < 7; i++) { + const char *value = getenv(names[i]); + saved[i] = value ? strdup(value) : NULL; + save_ok = save_ok && (!value || saved[i]); + } + ASSERT_TRUE(save_ok); + bool env_ready = true; + for (int i = 0; i < 7; i++) + env_ready = cbm_setenv(names[i], values[i], 1) == 0 && env_ready; + + /* Force the churn scan under the hostile environment, even if another + * history test populated the process-wide cache. */ + cbm_atlas_metrics_cache_clear(); + char *metrics = cbm_atlas_metrics_json(store, "atlas-git-context"); + char *file = cbm_atlas_file_history_json(store, "atlas-git-context", "sample.c"); + char *symbol = cbm_atlas_symbol_history_json(store, "atlas-git-context", "sample.c", 1, 3); + bool file_history_ok = atlas_history_test_commit(file, "recent", "selected repository history"); + bool symbol_history_ok = + atlas_history_test_commit(symbol, "commits", "selected repository history"); + bool file_remote_ok = atlas_history_test_remote(file, "https://example.invalid/selected/repo"); + bool symbol_remote_ok = + atlas_history_test_remote(symbol, "https://example.invalid/selected/repo"); + bool parent_unchanged = true; + for (int i = 0; i < 7; i++) { + const char *value = getenv(names[i]); + parent_unchanged = parent_unchanged && value && strcmp(value, values[i]) == 0; + if (saved[i]) + cbm_setenv(names[i], saved[i], 1); + else + cbm_unsetenv(names[i]); + free(saved[i]); + } + free(metrics); + free(file); + free(symbol); + cbm_store_close(store); + cbm_atlas_metrics_cache_clear(); + (void)th_rmtree(temp); + ASSERT_TRUE(env_ready); + ASSERT_TRUE(parent_unchanged); + ASSERT_TRUE(file_history_ok); + ASSERT_TRUE(symbol_history_ok); + ASSERT_TRUE(file_remote_ok); + ASSERT_TRUE(symbol_remote_ok); + PASS(); +} + +TEST(atlas_history_remote_urls_strip_credentials_and_force_https) { + char temp[256] = "/tmp/cbm_atlas_remote_XXXXXX"; + ASSERT_NOT_NULL(cbm_mkdtemp(temp)); + /* All credentials are synthetic fixtures; neither endpoint may echo them. */ + const char *remotes[] = {"https://fixture-user:fixture-token@example.invalid/org/repo.git", + "http://fixture-user:fixture-token@example.invalid/org/repo.git"}; + ASSERT_TRUE(atlas_history_test_repo(temp, "remote fixture history", remotes[0])); + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "atlas-remote-credentials", temp); + cbm_atlas_metrics_cache_clear(); + bool setup_ok = true; + bool file_ok[2] = {false, false}, symbol_ok[2] = {false, false}; + for (int i = 0; i < 2; i++) { + const char *set_url[] = {"remote", "set-url", "origin", remotes[i], NULL}; + setup_ok = impact_test_git(temp, set_url) && setup_ok; + char *file = cbm_atlas_file_history_json(store, "atlas-remote-credentials", "sample.c"); + char *symbol = + cbm_atlas_symbol_history_json(store, "atlas-remote-credentials", "sample.c", 1, 3); + file_ok[i] = atlas_history_test_remote(file, "https://example.invalid/org/repo"); + symbol_ok[i] = atlas_history_test_remote(symbol, "https://example.invalid/org/repo"); + free(file); + free(symbol); + } + cbm_store_close(store); + cbm_atlas_metrics_cache_clear(); + (void)th_rmtree(temp); + ASSERT_TRUE(setup_ok); + ASSERT_TRUE(file_ok[0]); + ASSERT_TRUE(symbol_ok[0]); + ASSERT_TRUE(file_ok[1]); + ASSERT_TRUE(symbol_ok[1]); + PASS(); +} + TEST(atlas_selection_impact_git_filters_and_worktree) { char tmpl[256] = "/tmp/cbm_impact_git_XXXXXX"; char *root = cbm_mkdtemp(tmpl); @@ -2452,6 +2598,8 @@ SUITE(ui) { RUN_TEST(atlas_attach_observed_marks_hops); RUN_TEST(atlas_who_reports_authorship_evidence); RUN_TEST(atlas_symbol_history_log_l_and_remote); + RUN_TEST(atlas_history_ignores_inherited_git_context); + RUN_TEST(atlas_history_remote_urls_strip_credentials_and_force_https); RUN_TEST(atlas_why_extracts_guard_chains); RUN_TEST(atlas_selection_impact_evidence_and_missing_history); RUN_TEST(atlas_selection_impact_git_filters_and_worktree); diff --git a/tests/test_ui_license_inventory.py b/tests/test_ui_license_inventory.py new file mode 100644 index 000000000..5908136b8 --- /dev/null +++ b/tests/test_ui_license_inventory.py @@ -0,0 +1,247 @@ +"""License decisions must describe the built browser pack, including workers.""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class BrowserLicenseInventory(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.ui = Path(self.tmp.name) / "graph-ui" + self.ui.mkdir() + self.package("@img/sharp-wasm32", "0.35.0", + "Apache-2.0 AND LGPL-3.0-or-later AND MIT") + self.package("@huggingface/transformers", "4.2.0", "Apache-2.0") + source = ROOT / "graph-ui/node_modules/@huggingface/transformers/dist/transformers.web.js" + if not source.is_file(): + self.fail("Install graph-ui dependencies before these packaging tests") + browser = self.ui / "node_modules/@huggingface/transformers/dist/transformers.web.js" + browser.parent.mkdir() + shutil.copyfile(source, browser) + self.browser_path = str(browser.relative_to(self.ui)).replace(os.sep, "/") + meta = self.ui / "node_modules/@huggingface/transformers/package.json" + package = json.loads(meta.read_text()) + package["exports"] = {"default": {"default": "./dist/transformers.web.js"}} + meta.write_text(json.dumps(package)) + (self.ui / "package-lock.json").write_text('{"lockfileVersion":3}') + (self.ui / "src").mkdir() + (self.ui / "src/main.ts").write_text("new Worker('worker.js');") + (self.ui / "src/worker.ts").write_text("import '@huggingface/transformers';") + (self.ui / "index.html").write_text("The source HTML template") + (self.ui / "dist/assets").mkdir(parents=True) + (self.ui / "dist/index.html").write_text('') + (self.ui / "dist/assets/main.js").write_text("new Worker('worker.js');") + (self.ui / "dist/assets/worker.js").write_text("postMessage('ready');") + inputs = ["src/main.ts", "src/worker.ts", self.browser_path, + "node_modules/@huggingface/transformers/package.json"] + inputs.append("index.html") + self.proof = { + "version": 1, + "lock_sha256": digest(self.ui / "package-lock.json"), + "inputs": {name: digest(self.ui / name) for name in inputs}, + "modules": inputs[:3] + ["index.html"], "externals": [], + "chunks": ["assets/main.js", "assets/worker.js"], + "worker_builds": 1, + "outputs": {str(p.relative_to(self.ui / "dist")).replace(os.sep, "/"): digest(p) + for p in (self.ui / "dist").rglob("*") if p.is_file()}, + "runtime_inputs": {}, + "generated_assets": {"index.html": digest(self.ui / "dist/index.html")}, + } + self.write_proof() + self.tree = {"dependencies": { + "@huggingface/transformers": {"version": "4.2.0", "dependencies": { + "@img/sharp-wasm32": {"version": "0.35.0"}}}}} + self.bin = Path(self.tmp.name) / "bin" + self.bin.mkdir() + shim = self.bin / "npm.py" + shim.write_text("from pathlib import Path\nprint(Path('npm-tree.json').read_text())\n") + (self.bin / "npm").write_text("#!/usr/bin/env python3\n" + shim.read_text()) + (self.bin / "npm").chmod(0o755) + (self.bin / "npm.cmd").write_text('@python "%~dp0npm.py"\r\n') + + def package(self, name, version, license_id): + folder = self.ui / "node_modules" / name + folder.mkdir(parents=True) + (folder / "package.json").write_text(json.dumps({ + "name": name, "version": version, "license": license_id})) + (folder / "LICENSE").write_text(license_id) + + def write_proof(self): + path = self.ui / ".vite/license-build.json" + path.parent.mkdir(exist_ok=True) + path.write_text(json.dumps(self.proof)) + + def run_script(self, script): + (self.ui / "npm-tree.json").write_text(json.dumps(self.tree)) + args = [os.sys.executable, str(ROOT / "scripts" / script), str(self.ui)] + if script == "license-gate-check-npm.py": + args.append(str(ROOT / "scripts/license-policy.json")) + return subprocess.run(args, capture_output=True, text=True, + env={**os.environ, "PATH": str(self.bin) + os.pathsep + os.environ["PATH"]}) + + def assert_blocked(self): + result = self.run_script("license-gate-check-npm.py") + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_verified_absent_package_does_not_block_browser_release(self): + result = self.run_script("license-gate-check-npm.py") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("@img/sharp-wasm32@0.35.0", result.stdout) + + def test_notices_use_the_same_shipped_package_decision(self): + result = self.run_script("gen-ui-licenses.py") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("### @img/sharp-wasm32@", result.stdout) + self.assertIn("### @huggingface/transformers@4.2.0", result.stdout) + + def test_missing_proof_blocks(self): + (self.ui / ".vite/license-build.json").unlink() + self.assert_blocked() + + def test_changed_lockfile_blocks(self): + (self.ui / "package-lock.json").write_text("{}") + self.assert_blocked() + + def test_changed_source_blocks(self): + (self.ui / "src/worker.ts").write_text("import 'sharp';") + self.assert_blocked() + + def test_changed_output_blocks(self): + (self.ui / "dist/assets/worker.js").write_text("import 'sharp';") + self.assert_blocked() + + def test_uninventoried_output_blocks(self): + (self.ui / "dist/assets/extra.js").write_text("// copied payload") + self.assert_blocked() + + def test_copied_javascript_is_not_a_bundle_chunk(self): + self.proof["chunks"].remove("assets/worker.js") + self.write_proof() + self.assert_blocked() + + def test_unknown_binary_blocks_even_if_hashed(self): + path = self.ui / "dist/assets/unknown.wasm" + path.write_bytes(b"unknown payload") + self.proof["outputs"]["assets/unknown.wasm"] = digest(path) + self.write_proof() + self.assert_blocked() + + def test_copied_public_html_blocks_even_if_hashed(self): + source = self.ui / "public/copied.html" + source.parent.mkdir() + source.write_text("A copied document outside the bundler graph") + output = self.ui / "dist/copied.html" + shutil.copyfile(source, output) + self.proof["inputs"]["public/copied.html"] = digest(source) + self.proof["outputs"]["copied.html"] = digest(output) + self.write_proof() + self.assert_blocked() + + def test_copied_public_css_blocks_even_if_hashed(self): + source = self.ui / "public/copied.css" + source.parent.mkdir() + source.write_text("body { color: red; }") + output = self.ui / "dist/assets/copied.css" + shutil.copyfile(source, output) + self.proof["inputs"]["public/copied.css"] = digest(source) + self.proof["outputs"]["assets/copied.css"] = digest(output) + self.write_proof() + self.assert_blocked() + + def test_generated_css_matches_bundler_output(self): + output = self.ui / "dist/assets/generated.css" + output.write_text("body { color: green; }") + self.proof["outputs"]["assets/generated.css"] = digest(output) + self.proof["generated_assets"]["assets/generated.css"] = digest(output) + self.write_proof() + result = self.run_script("license-gate-check-npm.py") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_worker_sharp_module_blocks(self): + self.proof["modules"].append("node_modules/@img/sharp-wasm32/lib/sharp.node") + self.write_proof() + self.assert_blocked() + + def test_sharp_external_blocks(self): + self.proof["externals"].append("sharp") + self.write_proof() + self.assert_blocked() + + def test_node_prebundle_blocks(self): + self.proof["modules"].append("node_modules/@huggingface/transformers/dist/transformers.node.mjs") + self.write_proof() + self.assert_blocked() + + def test_virtual_sharp_module_blocks(self): + self.proof["modules"].append("\0sharp?commonjs-external") + self.write_proof() + self.assert_blocked() + + def test_runtime_asset_requires_matching_package_input(self): + source = self.ui / "node_modules/onnxruntime-web/dist/runtime.wasm" + source.parent.mkdir(parents=True) + source.write_bytes(b"package runtime") + output = self.ui / "dist/assets/runtime.wasm" + output.write_bytes(b"different runtime") + name = source.relative_to(self.ui).as_posix() + self.proof["inputs"][name] = digest(source) + self.proof["outputs"]["assets/runtime.wasm"] = digest(output) + self.proof["runtime_inputs"]["assets/runtime.wasm"] = name + self.write_proof() + self.assert_blocked() + + def test_copied_runtime_matches_inventoried_package_input(self): + source = self.ui / "node_modules/onnxruntime-web/dist/runtime.wasm" + source.parent.mkdir(parents=True) + source.write_bytes(b"package runtime") + output = self.ui / "dist/assets/runtime.wasm" + shutil.copyfile(source, output) + name = source.relative_to(self.ui).as_posix() + self.proof["inputs"][name] = digest(source) + self.proof["outputs"]["assets/runtime.wasm"] = digest(output) + self.proof["runtime_inputs"]["assets/runtime.wasm"] = name + self.write_proof() + result = self.run_script("license-gate-check-npm.py") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_missing_worker_inventory_blocks(self): + self.proof["worker_builds"] = 0 + self.write_proof() + self.assert_blocked() + + def test_changed_browser_entry_blocks(self): + path = self.ui / self.browser_path + path.write_text("export { default } from 'sharp';") + self.proof["inputs"][self.browser_path] = digest(path) + self.write_proof() + self.assert_blocked() + + def test_unrelated_disallowed_dependency_still_blocks(self): + self.package("unrelated", "1.0.0", "GPL-3.0-only") + self.tree["dependencies"]["unrelated"] = {"version": "1.0.0"} + self.assert_blocked() + + def test_new_package_version_requires_review(self): + package = self.ui / "node_modules/@img/sharp-wasm32/package.json" + meta = json.loads(package.read_text()) + meta["version"] = "0.36.0" + package.write_text(json.dumps(meta)) + self.tree["dependencies"]["@huggingface/transformers"]["dependencies"]["@img/sharp-wasm32"]["version"] = "0.36.0" + self.assert_blocked() + + +if __name__ == "__main__": + unittest.main()