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", "<registry-url-or-empty>", {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]
This commit is contained in:
parent
9c0372d132
commit
819ed20e98
1 changed files with 195 additions and 4 deletions
|
|
@ -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",
|
||||
"<registry-url-or-empty>",
|
||||
{"optionalDependencies": ..., "bin": ..., "os": ..., ...},
|
||||
"sha512-<base64>" # 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 = "<root>",
|
||||
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 = "<root>",
|
||||
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 = "<root>",
|
||||
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 = "<root>",
|
||||
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 = "<ioc-match>",
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue