From 819ed20e980afdad44eed588c3c23ce390d5f11d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 19 May 2026 04:06:21 +0000 Subject: [PATCH] audit: scan bun.lock alongside package-lock.json + Cargo.lock Adds audit_bun_lockfile() that parses Bun's text lockfile format (bun.lock, lockfileVersion 1) and applies the same supply-chain checks already used on npm and cargo lockfiles: - non-registry-resolved-url (git+/file:/tarball sources) - missing-integrity-hash (no sha-prefixed tail on registry entry) - blocked-known-malicious (BLOCKED_NPM_VERSIONS hit) - known-ioc-string (IOC substring in raw body) - missing-lockfile (path doesn't exist) - unreadable-lockfile (chmod 000 / OSError) - malformed-lockfile (JSONC parse failure) - unsupported-lockfile-version (anything != 1) bun.lock is JSONC (valid JSON with trailing commas allowed); the parser strips trailing commas via a single regex before json.loads. Each package entry is a 4-element array: ["name@version", "", {metadata}, "sha512-..."] Default scope adds the three bun.lock paths committed alongside the three package-lock.json paths. New repeatable --bun-lockfile flag mirrors --npm-lockfile and --cargo-lockfile. Verified locally: positive (3 npm + 3 bun + 1 cargo, real PR lockfiles): exit 0 unreadable bun.lock (chmod 000): exit 1, [unreadable-lockfile] malformed bun.lock: exit 1, [malformed-lockfile] missing bun.lock: exit 1, [missing-lockfile] --- scripts/lockfile_supply_chain_audit.py | 199 ++++++++++++++++++++++++- 1 file changed, 195 insertions(+), 4 deletions(-) diff --git a/scripts/lockfile_supply_chain_audit.py b/scripts/lockfile_supply_chain_audit.py index f2ba521c91..ff19703aee 100644 --- a/scripts/lockfile_supply_chain_audit.py +++ b/scripts/lockfile_supply_chain_audit.py @@ -576,6 +576,171 @@ def _first_line_containing(text: str, needle: str) -> int | None: _PACKAGE_HEADER = re.compile(r"^\[\[package\]\]\s*$") +# Bun's text lockfile (bun.lock, Bun >= 1.2) is JSONC -- valid JSON with +# trailing commas allowed. Strip them before json.loads. +_BUN_LOCK_TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])") + + +def _parse_bun_lockfile_text(raw: str) -> dict: + """Parse bun.lock JSONC text by stripping trailing commas.""" + cleaned = _BUN_LOCK_TRAILING_COMMA_RE.sub(r"\1", raw) + return json.loads(cleaned) + + +def audit_bun_lockfile(path: Path) -> list[Finding]: + """Pre-install audit for Bun's text lockfile (bun.lock, version 1). + + Each package entry is a JSON array shaped like: + "pkg-name": [ + "pkg-name@version", + "", + {"optionalDependencies": ..., "bin": ..., "os": ..., ...}, + "sha512-" # integrity, registry-resolved only + ] + git/file/tarball-resolved entries carry the source URL in element 1 + and lack the sha512 tail. We flag any non-default-registry source + and any registry entry without a sha512 tail. + """ + findings: list[Finding] = [] + if not path.exists(): + findings.append( + Finding( + path = str(path), + package = "", + kind = "missing-lockfile", + detail = ( + "expected lockfile not found; refusing to silently " + "report a clean audit for a path that was not scanned" + ), + ) + ) + return findings + + try: + raw = path.read_text(encoding = "utf-8") + except OSError as exc: + findings.append( + Finding( + path = str(path), + package = "", + kind = "unreadable-lockfile", + detail = f"could not read file: {exc}", + ) + ) + return findings + + try: + lock = _parse_bun_lockfile_text(raw) + except json.JSONDecodeError as exc: + findings.append( + Finding( + path = str(path), + package = "", + kind = "malformed-lockfile", + detail = f"could not parse bun.lock as JSONC: {exc}", + ) + ) + return findings + + lockfile_version = lock.get("lockfileVersion") + if lockfile_version != 1: + findings.append( + Finding( + path = str(path), + package = "", + kind = "unsupported-lockfile-version", + detail = f"only bun lockfileVersion 1 audited; got {lockfile_version}", + ) + ) + + packages = lock.get("packages") or {} + for pkg_name, entry in packages.items(): + if not isinstance(entry, list) or len(entry) < 2: + continue + + # entry[0] is "name@version"; entry[1] is the source URL when + # non-default-registry (e.g. "git+https://..." or "file:./..."), + # or "" when resolved against the default npm registry. + nv = entry[0] if isinstance(entry[0], str) else "" + # version is everything after the LAST '@' (handles scoped names + # like "@scope/name@1.2.3"). + version = nv.rsplit("@", 1)[-1] if "@" in nv else "" + + source = entry[1] if len(entry) > 1 and isinstance(entry[1], str) else "" + + # 1. Source origin: empty string means default registry. Anything + # else (git+, file:, http://github.com/, tarball URL) is flagged + # the same way audit_npm_lockfile flags non-registry resolved URLs. + if source and not any( + source.startswith(p) for p in NPM_REGISTRY_PREFIXES_ALLOWED + ): + findings.append( + Finding( + path = str(path), + package = pkg_name, + kind = "non-registry-resolved-url", + detail = ( + f"source={source!r}; only {NPM_REGISTRY_PREFIX} " + "is permitted. Direct GitHub / git / file references " + "are the Shai-Hulud injection vector." + ), + ) + ) + + # 2. Integrity hash: registry-resolved entries carry a sha-prefixed + # tail element. Missing tail on a registry entry is a finding. + has_sha_tail = ( + isinstance(entry[-1], str) + and (entry[-1].startswith("sha512-") or entry[-1].startswith("sha256-") or entry[-1].startswith("sha1-")) + ) + if not source and not has_sha_tail: + findings.append( + Finding( + path = str(path), + package = pkg_name, + kind = "missing-integrity-hash", + detail = ( + "no sha-prefixed integrity tail in bun.lock entry; " + "bun cannot verify the tarball SHA against the " + "registry-published hash" + ), + ) + ) + + # 3. Blocked-malicious-version list. + blocked = BLOCKED_NPM_VERSIONS.get(pkg_name, set()) + if version and version in blocked: + findings.append( + Finding( + path = str(path), + package = pkg_name, + kind = "blocked-known-malicious", + detail = ( + f"{pkg_name}@{version} is on the BLOCKED_NPM_VERSIONS list" + ), + ) + ) + + # 4. IOC string scan against the raw file body (mirrors audit_npm_lockfile). + for ioc in NPM_IOC_STRINGS: + if ioc in raw: + line_no = _first_line_containing(raw, ioc) + findings.append( + Finding( + path = f"{path}:{line_no}" if line_no else str(path), + package = "", + kind = "known-ioc-string", + detail = ( + f"matched known IOC substring {ioc!r}; this is " + "a public indicator of a recent supply-chain " + "compromise. Refuse to install." + ), + ) + ) + + return findings + + def audit_cargo_lockfile(path: Path) -> list[Finding]: findings: list[Finding] = [] if not path.exists(): @@ -706,6 +871,11 @@ DEFAULT_NPM_LOCKFILES = ( "studio/backend/core/data_recipe/oxc-validator/package-lock.json", "studio/package-lock.json", ) +DEFAULT_BUN_LOCKFILES = ( + "studio/frontend/bun.lock", + "studio/backend/core/data_recipe/oxc-validator/bun.lock", + "studio/bun.lock", +) DEFAULT_CARGO_LOCKFILES = ("studio/src-tauri/Cargo.lock",) @@ -729,6 +899,17 @@ def main(argv: list[str] | None = None) -> int: "and studio/package-lock.json (Tauri CLI for desktop release)." ), ) + parser.add_argument( + "--bun-lockfile", + action = "append", + default = None, + help = ( + "Path to a bun.lock (repeatable). " + "Default: studio/frontend/bun.lock, " + "studio/backend/core/data_recipe/oxc-validator/bun.lock, " + "and studio/bun.lock (Tauri CLI for desktop release)." + ), + ) parser.add_argument( "--cargo-lockfile", action = "append", @@ -770,20 +951,29 @@ def main(argv: list[str] | None = None) -> int: return 0 root = Path(args.root).resolve() - # Explicit --npm-lockfile/--cargo-lockfile scopes the scan to those - # paths; defaults apply only to the no-args CI invocation. - _user_explicit = args.npm_lockfile is not None or args.cargo_lockfile is not None + # Explicit --npm-lockfile/--bun-lockfile/--cargo-lockfile scopes the + # scan to those paths; defaults apply only to the no-args CI invocation. + _user_explicit = ( + args.npm_lockfile is not None + or args.bun_lockfile is not None + or args.cargo_lockfile is not None + ) if _user_explicit: npm_paths = [root / p for p in (args.npm_lockfile or ())] + bun_paths = [root / p for p in (args.bun_lockfile or ())] cargo_paths = [root / p for p in (args.cargo_lockfile or ())] else: npm_paths = [root / p for p in DEFAULT_NPM_LOCKFILES] + bun_paths = [root / p for p in DEFAULT_BUN_LOCKFILES] cargo_paths = [root / p for p in DEFAULT_CARGO_LOCKFILES] all_findings: list[Finding] = [] for p in npm_paths: print(f"[lockfile-audit] npm: {p}", flush = True) all_findings.extend(audit_npm_lockfile(p)) + for p in bun_paths: + print(f"[lockfile-audit] bun: {p}", flush = True) + all_findings.extend(audit_bun_lockfile(p)) for p in cargo_paths: print(f"[lockfile-audit] cargo: {p}", flush = True) all_findings.extend(audit_cargo_lockfile(p)) @@ -791,7 +981,8 @@ def main(argv: list[str] | None = None) -> int: if not all_findings: print( f"[lockfile-audit] OK: 0 findings across " - f"{len(npm_paths)} npm + {len(cargo_paths)} cargo lockfile(s)", + f"{len(npm_paths)} npm + {len(bun_paths)} bun + " + f"{len(cargo_paths)} cargo lockfile(s)", flush = True, ) return 0