docs(security): add supply chain contract manifest [skip ci]
This commit is contained in:
203
scripts/security/git-remote-refs-probe.py
Normal file
203
scripts/security/git-remote-refs-probe.py
Normal file
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Git remote refs 只讀探測工具。
|
||||
|
||||
此工具用 `git ls-remote --heads --tags` 讀取指定本機 repo 的 remote refs,
|
||||
並比對本機 HEAD / branch。它不 fetch、不 clone、不 push,也不修改 remote。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
|
||||
def redact_url(value: str) -> str:
|
||||
if "://" not in value:
|
||||
if "@" in value and ":" in value.split("@", 1)[1]:
|
||||
return value.split("@", 1)[1]
|
||||
return value
|
||||
parts = urlsplit(value)
|
||||
netloc = parts.netloc.split("@", 1)[-1]
|
||||
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
def run_git(repo: Path, args: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(["git", *args], 124, "", "git command timeout")
|
||||
|
||||
|
||||
def git_value(repo: Path, args: list[str], timeout: int) -> str:
|
||||
result = run_git(repo, args, timeout)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def parse_refs(output: str) -> tuple[dict[str, str], dict[str, str]]:
|
||||
heads: dict[str, str] = {}
|
||||
tags: dict[str, str] = {}
|
||||
for line in output.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
sha, ref = parts
|
||||
if ref.startswith("refs/heads/"):
|
||||
heads[ref.removeprefix("refs/heads/")] = sha
|
||||
elif ref.startswith("refs/tags/"):
|
||||
tag = ref.removeprefix("refs/tags/").removesuffix("^{}")
|
||||
tags[tag] = sha
|
||||
return heads, tags
|
||||
|
||||
|
||||
def parse_repo_arg(value: str) -> tuple[str, Path, str]:
|
||||
parts = value.split("=")
|
||||
if len(parts) not in (2, 3):
|
||||
raise argparse.ArgumentTypeError("--repo 必須是 label=/absolute/path 或 label=/absolute/path=remote")
|
||||
label = parts[0].strip()
|
||||
path = Path(parts[1].strip()).expanduser().resolve()
|
||||
remote = parts[2].strip() if len(parts) == 3 else "origin"
|
||||
if not label or not str(path) or not remote:
|
||||
raise argparse.ArgumentTypeError("--repo label/path/remote 不可為空")
|
||||
return label, path, remote
|
||||
|
||||
|
||||
def probe_repo(label: str, repo: Path, remote: str, timeout: int) -> dict[str, object]:
|
||||
local_head = git_value(repo, ["rev-parse", "HEAD"], timeout)
|
||||
local_branch = git_value(repo, ["branch", "--show-current"], timeout)
|
||||
remote_url = git_value(repo, ["remote", "get-url", remote], timeout)
|
||||
ls_remote = run_git(repo, ["ls-remote", "--heads", "--tags", remote], timeout)
|
||||
if ls_remote.returncode == 0:
|
||||
heads, tags = parse_refs(ls_remote.stdout)
|
||||
remote_branch_sha = heads.get(local_branch, "")
|
||||
if local_branch and remote_branch_sha and remote_branch_sha == local_head:
|
||||
status = "aligned_current_branch"
|
||||
elif heads:
|
||||
status = "reachable_drift_or_unknown"
|
||||
else:
|
||||
status = "reachable_no_heads"
|
||||
error_summary = ""
|
||||
else:
|
||||
heads = {}
|
||||
tags = {}
|
||||
remote_branch_sha = ""
|
||||
status = "unreachable"
|
||||
stderr = ls_remote.stderr.strip()
|
||||
if "Permission denied" in stderr:
|
||||
error_summary = "SSH 權限不足或 remote 不可讀"
|
||||
elif "Repository not found" in stderr:
|
||||
error_summary = "remote repo not found 或未授權"
|
||||
else:
|
||||
tail = (stderr or ls_remote.stdout.strip()).splitlines()[-1:]
|
||||
error_summary = tail[0] if tail else "git ls-remote failed"
|
||||
|
||||
return {
|
||||
"label": label,
|
||||
"repo_path": str(repo),
|
||||
"remote": remote,
|
||||
"remote_url_redacted": redact_url(remote_url),
|
||||
"status": status,
|
||||
"local_branch": local_branch,
|
||||
"local_head": local_head,
|
||||
"remote_current_branch_sha": remote_branch_sha,
|
||||
"head_count": len(heads),
|
||||
"tag_count": len(tags),
|
||||
"heads": [{"name": name, "sha": sha} for name, sha in sorted(heads.items())],
|
||||
"tags": [{"name": name, "sha": sha} for name, sha in sorted(tags.items())],
|
||||
"error_summary": error_summary,
|
||||
}
|
||||
|
||||
|
||||
def build_payload(group_name: str, repo_args: list[tuple[str, Path, str]], timeout: int) -> dict[str, object]:
|
||||
repos = [probe_repo(label, path, remote, timeout) for label, path, remote in repo_args]
|
||||
aligned = sum(1 for repo in repos if repo["status"] == "aligned_current_branch")
|
||||
unreachable = sum(1 for repo in repos if repo["status"] == "unreachable")
|
||||
return {
|
||||
"schema_version": "git_remote_refs_probe_v1",
|
||||
"group_name": group_name,
|
||||
"status": "ok" if unreachable == 0 else "partial",
|
||||
"repo_count": len(repos),
|
||||
"aligned_current_branch_count": aligned,
|
||||
"unreachable_count": unreachable,
|
||||
"repos": repos,
|
||||
}
|
||||
|
||||
|
||||
def write_markdown(payload: dict[str, object], path: Path) -> None:
|
||||
lines = [
|
||||
"# Git Remote Refs Probe 快照",
|
||||
"",
|
||||
"| 項目 | 值 |",
|
||||
"|------|----|",
|
||||
f"| 群組 | `{payload['group_name']}` |",
|
||||
f"| 狀態 | `{payload['status']}` |",
|
||||
f"| repo 數 | `{payload['repo_count']}` |",
|
||||
f"| aligned current branch | `{payload['aligned_current_branch_count']}` |",
|
||||
f"| unreachable | `{payload['unreachable_count']}` |",
|
||||
"",
|
||||
"## Repo refs",
|
||||
"",
|
||||
"| Label | Remote URL | Status | Local branch | Local HEAD | Remote branch SHA | Heads | Tags | Error |",
|
||||
"|-------|------------|--------|--------------|------------|-------------------|-------|------|-------|",
|
||||
]
|
||||
for repo in payload.get("repos", []):
|
||||
if not isinstance(repo, dict):
|
||||
continue
|
||||
lines.append(
|
||||
"| "
|
||||
+ " | ".join(
|
||||
[
|
||||
f"`{repo.get('label', '')}`",
|
||||
f"`{repo.get('remote_url_redacted', '')}`",
|
||||
f"`{repo.get('status', '')}`",
|
||||
f"`{repo.get('local_branch', '')}`",
|
||||
f"`{str(repo.get('local_head', ''))[:7]}`",
|
||||
f"`{str(repo.get('remote_current_branch_sha', ''))[:7]}`",
|
||||
f"`{repo.get('head_count', 0)}`",
|
||||
f"`{repo.get('tag_count', 0)}`",
|
||||
str(repo.get("error_summary", "") or "無"),
|
||||
]
|
||||
)
|
||||
+ " |"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"> 注意:本檔只使用 `git ls-remote` 做 read-only refs 探測;未 fetch、未 clone、未 push。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--group-name", required=True)
|
||||
parser.add_argument("--repo", action="append", type=parse_repo_arg, required=True)
|
||||
parser.add_argument("--output-json", required=True)
|
||||
parser.add_argument("--output-md", required=True)
|
||||
parser.add_argument("--timeout", type=int, default=10)
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = build_payload(args.group_name, args.repo, args.timeout)
|
||||
Path(args.output_json).write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_markdown(payload, Path(args.output_md))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user