Secure Lens history reads and verify shipped browser dependencies

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-11 18:12:30 +02:00
1 parent fa05e114f2
commit cf41bdc6d6
13 files changed
+804 -51

No files matched your search

+3 -1
View File
@@ -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"
+110
View File
@@ -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<string>();
const externals = new Set<string>();
const chunks = new Set<string>();
const generatedAssets: Record<string, string> = {};
const inputs: Record<string, string> = {};
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<string, string> = {};
const runtimeInputs: Record<string, string> = {};
// 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)] };
}
+4 -2
View File
@@ -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()),
},
+17
View File
@@ -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()
+13 -4
View File
@@ -3,9 +3,8 @@
Usage: license-gate-check-npm.py <graph-ui-dir> <license-policy.json>
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__":
+6
View File
@@ -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"
+11 -1
View File
@@ -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
+15 -15
View File
@@ -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."
+112
View File
@@ -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")
+12 -28
View File
@@ -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);
@@ -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
+148
View File
@@ -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);
+247
View File
@@ -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('<script src="assets/main.js"></script>')
(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()