@yottameta/yotta-chain 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,10 +32,11 @@ from pathlib import Path
32
32
 
33
33
  try:
34
34
  sys.stdout.reconfigure(encoding="utf-8")
35
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
35
36
  except Exception:
36
37
  pass
37
38
 
38
- VERSION = "0.1.2"
39
+ VERSION = "0.1.5"
39
40
 
40
41
  SEVERITY_ORDER = ["info", "low", "medium", "high"]
41
42
  SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITY_ORDER)}
@@ -817,6 +818,187 @@ def _v1_name(key):
817
818
  return key.split("@")[0]
818
819
 
819
820
 
821
+ def _descriptor_name_version(descriptor):
822
+ """Split an npm descriptor such as @scope/pkg@1.2.3 or lodash@^4."""
823
+ s = str(descriptor or "").strip().strip("\"'")
824
+ if not s:
825
+ return None, None
826
+ if s.startswith("@"):
827
+ slash = s.find("/")
828
+ at = s.find("@", slash + 1) if slash >= 0 else -1
829
+ if at > slash:
830
+ return s[:at], s[at + 1:].split("(", 1)[0]
831
+ at = s.rfind("@")
832
+ if at > 0:
833
+ return s[:at], s[at + 1:].split("(", 1)[0]
834
+ return s, None
835
+
836
+
837
+ def _jsonc_load(text):
838
+ """Parse the JSONC-like bun text lockfile without third-party modules."""
839
+ cleaned = re.sub(r"(?m)^\s*//.*$", "", text or "")
840
+ cleaned = re.sub(r",(\s*[}\]])", r"\1", cleaned)
841
+ return json.loads(cleaned)
842
+
843
+
844
+ def parse_yarn_lock(text):
845
+ """Parse the name/version/resolved/integrity subset of yarn.lock."""
846
+ packages = {}
847
+ current = None
848
+ for raw in (text or "").splitlines():
849
+ if raw and not raw.startswith((" ", "\t", "#")) and raw.rstrip().endswith(":"):
850
+ descriptor_text = raw.rstrip()[:-1]
851
+ current = None
852
+ for item in descriptor_text.split(","):
853
+ name, _ = _descriptor_name_version(item)
854
+ if not name:
855
+ continue
856
+ current = name
857
+ packages.setdefault(name, [])
858
+ break
859
+ continue
860
+ if current is None:
861
+ continue
862
+ m = re.match(r"^\s+version\s+\"?([^\"\s]+)\"?\s*$", raw)
863
+ if m:
864
+ entry = {"version": m.group(1), "resolved": None, "integrity": None,
865
+ "dev": False, "optional": False, "deps": {}, "key": current}
866
+ packages.setdefault(current, []).append(entry)
867
+ continue
868
+ if not packages.get(current):
869
+ continue
870
+ entry = packages[current][-1]
871
+ m = re.match(r"^\s+resolved\s+\"?([^\"\s]+)\"?\s*$", raw)
872
+ if m:
873
+ entry["resolved"] = m.group(1)
874
+ continue
875
+ m = re.match(r"^\s+integrity\s+\"?([^\"\s]+)\"?\s*$", raw)
876
+ if m:
877
+ entry["integrity"] = m.group(1)
878
+ return {"lockfileVersion": None, "root": {}, "packages": packages}
879
+
880
+
881
+ def _pnpm_key_name_version(key):
882
+ s = str(key or "").strip().strip("\"'").split("(", 1)[0]
883
+ if not s:
884
+ return None, None
885
+ if s.startswith("/"):
886
+ s = s[1:]
887
+ if s.startswith("@"):
888
+ slash = s.find("/")
889
+ at = s.rfind("@")
890
+ if slash >= 0 and at > slash:
891
+ return s[:at], s[at + 1:]
892
+ if slash >= 0:
893
+ name, _, version = s.rpartition("/")
894
+ return (name, version) if version else (None, None)
895
+ return None, None
896
+ if "@" in s:
897
+ name, _, version = s.rpartition("@")
898
+ return (name, version) if name and version else (None, None)
899
+ if "/" in s:
900
+ name, _, version = s.rpartition("/")
901
+ return (name, version) if name and version else (None, None)
902
+ return None, None
903
+
904
+
905
+ def parse_pnpm_lock(text):
906
+ """Parse common pnpm-lock.yaml package keys without a YAML dependency."""
907
+ packages = {}
908
+ current = None
909
+ in_packages = False
910
+ lock_version = None
911
+ for raw in (text or "").splitlines():
912
+ m = re.match(r"^lockfileVersion:\s*['\"]?([^'\"\s]+)", raw)
913
+ if m:
914
+ try:
915
+ lock_version = int(float(m.group(1)))
916
+ except ValueError:
917
+ lock_version = m.group(1)
918
+ continue
919
+ if re.match(r"^packages:\s*$", raw):
920
+ in_packages = True
921
+ continue
922
+ if in_packages and raw and not raw.startswith((" ", "\t")):
923
+ in_packages = False
924
+ current = None
925
+ continue
926
+ if not in_packages:
927
+ continue
928
+ m = re.match(r"^\s{2}(['\"]?)(.+?)\1:\s*$", raw)
929
+ if m:
930
+ name, version = _pnpm_key_name_version(m.group(2))
931
+ if name and version and version[:1].isdigit():
932
+ current = name
933
+ packages.setdefault(name, []).append({
934
+ "version": version, "resolved": None, "integrity": None,
935
+ "dev": False, "optional": False, "deps": {}, "key": m.group(2),
936
+ })
937
+ else:
938
+ current = None
939
+ continue
940
+ if current and packages.get(current):
941
+ m = re.search(r"integrity:\s*([^,}\s]+)", raw)
942
+ if m:
943
+ packages[current][-1]["integrity"] = m.group(1).strip("\"'")
944
+ return {"lockfileVersion": lock_version, "root": {}, "packages": packages}
945
+
946
+
947
+ def parse_bun_lock(text):
948
+ """Parse bun.lock (JSONC-like text). bun.lockb is intentionally unsupported."""
949
+ try:
950
+ data = _jsonc_load(text)
951
+ except Exception:
952
+ return None
953
+ if not isinstance(data, dict):
954
+ return None
955
+ packages = {}
956
+ for key, ent in (data.get("packages") or {}).items():
957
+ name = None
958
+ version = None
959
+ integrity = None
960
+ if isinstance(ent, list) and ent:
961
+ name, version = _descriptor_name_version(ent[0])
962
+ integrity = ent[3] if len(ent) > 3 else None
963
+ elif isinstance(ent, dict):
964
+ name = ent.get("name") or key
965
+ version = ent.get("version")
966
+ integrity = ent.get("integrity")
967
+ if not name:
968
+ name = key
969
+ packages.setdefault(name, []).append({
970
+ "version": str(version) if version else None,
971
+ "resolved": None, "integrity": integrity,
972
+ "dev": False, "optional": False, "deps": {}, "key": key,
973
+ })
974
+ workspace = (data.get("workspaces") or {}).get("") or {}
975
+ root = {
976
+ "name": workspace.get("name") or data.get("name"),
977
+ "version": workspace.get("version") or data.get("version"),
978
+ }
979
+ return {"lockfileVersion": data.get("lockfileVersion"), "root": root, "packages": packages}
980
+
981
+
982
+ def _select_npm_lockfile(base, pj):
983
+ manager = str(pj.get("packageManager") or "").split("@", 1)[0].lower()
984
+ by_manager = {
985
+ "npm": ("package-lock.json", "npm-shrinkwrap.json"),
986
+ "yarn": ("yarn.lock",),
987
+ "pnpm": ("pnpm-lock.yaml",),
988
+ "bun": ("bun.lock", "bun.lockb"),
989
+ }
990
+ candidates = list(by_manager.get(manager, ()))
991
+ for name in ("package-lock.json", "npm-shrinkwrap.json", "yarn.lock",
992
+ "pnpm-lock.yaml", "bun.lock", "bun.lockb"):
993
+ if name not in candidates:
994
+ candidates.append(name)
995
+ for name in candidates:
996
+ path = base / name
997
+ if path.is_file():
998
+ return path
999
+ return None
1000
+
1001
+
820
1002
  def parse_package_lock(text):
821
1003
  """Parse package-lock.json / npm-shrinkwrap.json.
822
1004
 
@@ -894,27 +1076,36 @@ def check_npm(project_dir, findings, sbom_pkgs):
894
1076
  if isinstance(pub_cfg, dict) and pub_cfg.get("registry"):
895
1077
  npmrc["registry"] = npmrc["registry"] or pub_cfg["registry"]
896
1078
 
897
- lock_path = None
898
- for cand in ("package-lock.json", "npm-shrinkwrap.json"):
899
- if (base / cand).exists():
900
- lock_path = base / cand
901
- break
902
-
1079
+ lock_path = _select_npm_lockfile(base, pj)
903
1080
  if lock_path is None:
904
1081
  if manifest_deps:
905
1082
  findings.append(Finding(
906
1083
  "missing_lockfile", "medium", "package.json", None,
907
- "缺少锁文件(package-lock.json / npm-shrinkwrap.json)",
908
- "依赖版本未锁定,安装结果不可复现;建议提交 package-lock.json 并使用 npm ci", ecosystem="npm"))
1084
+ "缺少锁文件(支持 package-lock.json / npm-shrinkwrap.json / yarn.lock / pnpm-lock.yaml / bun.lock)",
1085
+ "依赖版本未锁定,安装结果不可复现;建议按项目包管理器提交对应锁文件", ecosystem="npm"))
909
1086
  else:
910
- lock = parse_package_lock(_read_text(lock_path))
911
- if lock is None:
1087
+ lock = None
1088
+ if lock_path.name == "bun.lockb":
1089
+ findings.append(Finding(
1090
+ "lockfile_parse_unsupported", "info", lock_path.name, None,
1091
+ "检测到 bun.lockb,但二进制锁文件本版本不做深度解析",
1092
+ "已按锁文件存在处理,不误报 missing_lockfile;需要深度一致性校验时请改用 bun.lock 文本锁文件", ecosystem="npm"))
1093
+ elif lock_path.name in ("package-lock.json", "npm-shrinkwrap.json"):
1094
+ lock = parse_package_lock(_read_text(lock_path))
1095
+ elif lock_path.name == "yarn.lock":
1096
+ lock = parse_yarn_lock(_read_text(lock_path))
1097
+ elif lock_path.name == "pnpm-lock.yaml":
1098
+ lock = parse_pnpm_lock(_read_text(lock_path))
1099
+ elif lock_path.name == "bun.lock":
1100
+ lock = parse_bun_lock(_read_text(lock_path))
1101
+ if lock is None and lock_path.name != "bun.lockb":
912
1102
  findings.append(Finding(
913
1103
  "lockfile_parse_error", "medium", lock_path.name, None,
914
- "锁文件解析失败(JSON 不合法或结构异常)",
915
- "请用 npm install 重新生成锁文件", ecosystem="npm"))
1104
+ "锁文件解析失败(结构异常或格式不受支持)",
1105
+ "请用对应包管理器重新生成锁文件", ecosystem="npm"))
916
1106
  else:
917
- _check_npm_lock(base, lock_path, lock, pj, manifest_deps, npmrc, findings, sbom_pkgs)
1107
+ if lock is not None:
1108
+ _check_npm_lock(base, lock_path, lock, pj, manifest_deps, npmrc, findings, sbom_pkgs)
918
1109
 
919
1110
  for name, info in manifest_deps.items():
920
1111
  rng = (info["range"] or "").strip()
@@ -1242,17 +1433,27 @@ def check_python(project_dir, findings, sbom_pkgs):
1242
1433
  py = parse_toml(_read_text(pp_path))
1243
1434
  proj_deps, poetry_deps = pyproject_deps(py)
1244
1435
  declared = proj_deps + poetry_deps
1245
- lock_path = base / "poetry.lock"
1246
- lock = None
1247
- if lock_path.exists():
1248
- lock = parse_toml(_read_text(lock_path))
1249
- if declared and lock is None:
1436
+ poetry_lock_path = base / "poetry.lock"
1437
+ uv_lock_path = base / "uv.lock"
1438
+ lock_path = None
1439
+ lock_kind = None
1440
+ if poetry_deps and poetry_lock_path.exists():
1441
+ lock_path, lock_kind = poetry_lock_path, "poetry"
1442
+ elif uv_lock_path.exists():
1443
+ lock_path, lock_kind = uv_lock_path, "uv"
1444
+ elif poetry_lock_path.exists():
1445
+ lock_path, lock_kind = poetry_lock_path, "poetry"
1446
+ lock = parse_toml(_read_text(lock_path)) if lock_path is not None else None
1447
+ if declared and lock_path is None:
1250
1448
  findings.append(Finding(
1251
1449
  "missing_lockfile", "medium", "pyproject.toml", None,
1252
- "pyproject.toml 声明了 %d 个依赖但没有 poetry.lock" % len(declared),
1253
- "依赖版本未锁定,安装结果不可复现;建议 poetry lock 并提交 poetry.lock", ecosystem="python"))
1450
+ "pyproject.toml 声明了 %d 个依赖但没有 poetry.lock / uv.lock" % len(declared),
1451
+ "依赖版本未锁定,安装结果不可复现;请按所用工具提交 poetry.lock 或 uv.lock", ecosystem="python"))
1254
1452
  elif lock is not None:
1255
- _check_poetry_lock(lock, declared, findings, sbom_pkgs)
1453
+ if lock_kind == "poetry":
1454
+ _check_poetry_lock(lock, declared, findings, sbom_pkgs)
1455
+ else:
1456
+ _check_uv_lock(lock, declared, findings, sbom_pkgs)
1256
1457
  for src in poetry_sources(py):
1257
1458
  url = src.get("url")
1258
1459
  if not url:
@@ -1325,6 +1526,59 @@ def _check_poetry_lock(lock, declared, findings, sbom_pkgs):
1325
1526
  })
1326
1527
 
1327
1528
 
1529
+ def _python_dep_name(spec):
1530
+ s = str(spec or "").strip().strip("\"'")
1531
+ if " @ " in s:
1532
+ return s.split(" @ ", 1)[0].strip()
1533
+ if ";" in s:
1534
+ s = s.split(";", 1)[0].strip()
1535
+ return re.split(r"[<>=!~\[\s]", s, 1)[0].strip()
1536
+
1537
+
1538
+ def _check_uv_lock(lock, declared, findings, sbom_pkgs):
1539
+ pkgs = {}
1540
+ for p in (lock.get("package") or []):
1541
+ if isinstance(p, dict) and p.get("name"):
1542
+ pkgs[p["name"]] = p
1543
+ for name, spec in declared:
1544
+ p = pkgs.get(name)
1545
+ if p is None:
1546
+ findings.append(Finding(
1547
+ "lockfile_missing_entry", "high", "uv.lock", name,
1548
+ "pyproject.toml 声明了 %s(%s),但 uv.lock 中没有该包" % (name, spec),
1549
+ "锁文件过期或手工改动,运行 uv lock 重新生成", ecosystem="python"))
1550
+ continue
1551
+ ver = str(p.get("version") or "")
1552
+ if ver and spec not in ("", "*") and not pep440_satisfies(ver, spec):
1553
+ findings.append(Finding(
1554
+ "lockfile_range_unsatisfied", "high", "uv.lock", name,
1555
+ "uv.lock 中 %s=%s 不满足 pyproject.toml 声明 %s" % (name, ver, spec),
1556
+ "声明与锁定不一致,安装可能拉取意外版本", ecosystem="python"))
1557
+ for name, p in pkgs.items():
1558
+ deps = p.get("dependencies") or []
1559
+ dep_names = []
1560
+ if isinstance(deps, dict):
1561
+ dep_names = list(deps.keys())
1562
+ elif isinstance(deps, list):
1563
+ dep_names = [_python_dep_name(x) for x in deps if _python_dep_name(x)]
1564
+ for dn in dep_names:
1565
+ if dn not in pkgs:
1566
+ findings.append(Finding(
1567
+ "lockfile_dangling_ref", "high", "uv.lock", name,
1568
+ "uv.lock 里 %s 依赖的 %s 不存在于锁文件包列表" % (name, dn),
1569
+ "依赖图断裂,安装可能失败或行为异常", ecosystem="python"))
1570
+ sbom_pkgs.append({
1571
+ "ecosystem": "python",
1572
+ "name": name,
1573
+ "version": str(p.get("version") or ""),
1574
+ "resolved": "",
1575
+ "integrity": "",
1576
+ "scope": "optional" if p.get("optional") else "required",
1577
+ "direct": name in dict(declared),
1578
+ "deps": dep_names,
1579
+ })
1580
+
1581
+
1328
1582
  def check_pipfile(project_dir, findings, sbom_pkgs):
1329
1583
  base = Path(project_dir)
1330
1584
  pf = base / "Pipfile"
@@ -1654,7 +1908,30 @@ def _csv_escape(v):
1654
1908
  return s
1655
1909
 
1656
1910
 
1657
- def _collect(project_dir, findings, sbom_pkgs, root_component):
1911
+ def _scanned_files(base, eco):
1912
+ """Return the input files relevant to the detected ecosystems."""
1913
+ out = []
1914
+ if "npm" in eco:
1915
+ out.append("package.json")
1916
+ pj = parse_package_json(_read_text(base / "package.json")) or {}
1917
+ lock = _select_npm_lockfile(base, pj)
1918
+ if lock is not None:
1919
+ out.append(lock.name)
1920
+ if (base / ".npmrc").is_file():
1921
+ out.append(".npmrc")
1922
+ if "python" in eco:
1923
+ for path in sorted(base.glob("requirements*.txt")):
1924
+ out.append(path.name)
1925
+ for name in ("pyproject.toml", "poetry.lock", "uv.lock",
1926
+ "Pipfile", "Pipfile.lock"):
1927
+ if (base / name).is_file():
1928
+ out.append(name)
1929
+ if "maven" in eco and (base / "pom.xml").is_file():
1930
+ out.append("pom.xml")
1931
+ return sorted(set(out))
1932
+
1933
+
1934
+ def _collect(project_dir, findings, sbom_pkgs, root_component, scanned_files=None):
1658
1935
  """Run all ecosystem checks; returns sorted ecosystem list."""
1659
1936
  base = Path(project_dir)
1660
1937
  eco = []
@@ -1671,6 +1948,8 @@ def _collect(project_dir, findings, sbom_pkgs, root_component):
1671
1948
  root_component.update({"ecosystem": "python", "name": proj["name"], "version": str(proj.get("version") or "")})
1672
1949
  if check_maven(project_dir, findings, sbom_pkgs):
1673
1950
  eco.append("maven")
1951
+ if scanned_files is not None:
1952
+ scanned_files.extend(_scanned_files(base, eco))
1674
1953
  return eco
1675
1954
 
1676
1955
 
@@ -1682,7 +1961,8 @@ def cmd_scan(args):
1682
1961
  findings = []
1683
1962
  sbom_pkgs = []
1684
1963
  root_component = {}
1685
- eco = _collect(args.path, findings, sbom_pkgs, root_component)
1964
+ scanned_files = []
1965
+ eco = _collect(args.path, findings, sbom_pkgs, root_component, scanned_files=scanned_files)
1686
1966
  if not eco:
1687
1967
  print("错误:%s 下未发现支持的依赖清单/锁文件(package.json / requirements*.txt / pyproject.toml / Pipfile / pom.xml)" % base, file=sys.stderr)
1688
1968
  return 4
@@ -1699,6 +1979,7 @@ def cmd_scan(args):
1699
1979
  "version": VERSION,
1700
1980
  "project": str(base),
1701
1981
  "ecosystems": eco,
1982
+ "scannedFiles": sorted(set(scanned_files)),
1702
1983
  "files": sorted({f.file for f in findings}),
1703
1984
  "summary": {s: sum(1 for f in findings if f.severity == s) for s in SEVERITY_ORDER},
1704
1985
  "findings": [f.to_dict() for f in shown],
@@ -1714,6 +1995,8 @@ def cmd_scan(args):
1714
1995
  else:
1715
1996
  lines = ["元链 yotta-chain %s — 供应链依赖校验" % VERSION]
1716
1997
  lines.append("项目:%s 生态:%s" % (base, ", ".join(eco)))
1998
+ if scanned_files:
1999
+ lines.append("输入文件:%s" % ", ".join(sorted(set(scanned_files))))
1717
2000
  if not shown:
1718
2001
  lines.append("未发现 %s 及以上风险项" % args.level)
1719
2002
  for s in SEVERITY_ORDER: