from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BASELINE = ROOT / "scripts" / "csprng-baseline.txt"
MARKER_RNG = re.compile(r"//\s*DETERMINISTIC-RNG\s*:(.*)$")
MARKER_DP = re.compile(r"//\s*FLOAT-DP-ACCEPTED\s*:(.*)$")
MIN_REASON = 12
STATE_ASSIGN = re.compile(r"\bself\.state\s*=(?!=)")
GENERATOR_FN = re.compile(r"\bfn\s+(?:next_(?:u8|u16|u32|u64|u128|usize|i\d+|f32|f64|bool|range)\w*|gen_\w+|random\w*)\s*[(<]")
NAMED_PRNG = re.compile(r"\b(?:struct|enum)\s+(XorShift\w*|SplitMix\w*|Lcg\w*|Pcg\w*|WyRand\w*)\b")
CLOCK = re.compile(r"\b(?:SystemTime::now|Instant::now)\s*\(")
SEED_SINK = re.compile(r"\b(?:seed|from_entropy|from_seed)\b")
DP_FN = re.compile(r"\bfn\s+(\w*(?:laplace|sample|privatize|noise|randomize)\w*)\s*[(<]", re.I)
TRANSCENDENTAL = re.compile(r"\b(?:ln|ln64|ln32|exp|exp64|exp32)\s*\(|\.ln\(\)|\.exp\(\)")
DP_SAFE = re.compile(r"\b(?:snapping|discrete_laplace|geometric_mechanism|integer_laplace)\b")
SECRET_PATH = re.compile(r"(?:privacy|\bdp\b|auth|token|nonce|secret|keygen|csprng)", re.I)
CSPRNG = re.compile(r"\b(?:ChaCha20|SecureRng|OsRng|getrandom|alice_crypto::dp)\b")
def remove_cfg_test(code: str) -> str:
out = code
for m in list(re.finditer(r"#\[cfg\(test\)\]", code)):
brace = out.find("{", m.end())
if brace < 0:
continue
depth, i = 0, brace
while i < len(out):
if out[i] == "{":
depth += 1
elif out[i] == "}":
depth -= 1
if depth == 0:
break
i += 1
span = out[m.start() : i + 1]
out = out[: m.start()] + re.sub(r"[^\n]", " ", span) + out[i + 1 :]
return out
def strip_line_comments(src: str) -> str:
out = []
for line in src.splitlines():
in_str = False
esc = False
cut = len(line)
i = 0
while i < len(line) - 1:
c = line[i]
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = not in_str
elif not in_str and c == "/" and line[i + 1] == "/":
cut = i
break
i += 1
out.append(line[:cut])
return "\n".join(out)
def marker_above(lines: list[str], idx: int, pat: re.Pattern[str]) -> str | None:
j = idx - 1
while j >= 0:
s = lines[j].strip()
if not s:
j -= 1
continue
m = pat.search(lines[j])
if m:
return m.group(1).strip()
if s.startswith("#[") or s.startswith("///") or s.startswith("//!"):
j -= 1
continue
return None
return None
def impl_bodies(code: str) -> list[tuple[int, str]]:
out: list[tuple[int, str]] = []
for m in re.finditer(r"\bimpl\b", code):
brace = code.find("{", m.end())
if brace < 0:
continue
if ";" in code[m.end() : brace]:
continue
depth, i = 0, brace
while i < len(code):
if code[i] == "{":
depth += 1
elif code[i] == "}":
depth -= 1
if depth == 0:
break
i += 1
out.append((brace, code[brace : i + 1]))
return out
def fn_bodies(code: str) -> list[tuple[str, int, str]]:
out: list[tuple[str, int, str]] = []
for m in re.finditer(r"\bfn\s+(\w+)", code):
brace = code.find("{", m.end())
if brace < 0:
continue
depth = 0
i = brace
while i < len(code):
if code[i] == "{":
depth += 1
elif code[i] == "}":
depth -= 1
if depth == 0:
break
i += 1
out.append((m.group(1), code[: m.start()].count("\n"), code[brace : i + 1]))
return out
def rs_files(root: Path) -> list[Path]:
src = root / "src"
if not src.is_dir():
return []
return sorted(p for p in src.rglob("*.rs") if "/target/" not in str(p))
def audit(root: Path) -> tuple[list[str], int]:
violations: list[str] = []
checked = 0
for path in rs_files(root):
rel = str(path.relative_to(root))
raw = path.read_text(encoding="utf-8")
lines = raw.splitlines()
code = remove_cfg_test(strip_line_comments(raw))
secret = bool(SECRET_PATH.search(rel))
has_csprng = bool(CSPRNG.search(code))
sites: list[int] = []
for brace, body in impl_bodies(code):
if not GENERATOR_FN.search(body):
continue
for m in STATE_ASSIGN.finditer(body):
sites.append(brace + m.start())
sites.extend(m.start() for m in NAMED_PRNG.finditer(code))
for pos in sorted(set(sites)):
ln = code[:pos].count("\n")
checked += 1
if has_csprng and not secret:
continue
reason = marker_above(lines, ln, MARKER_RNG)
if secret:
violations.append(
f"{rel}:{ln + 1}: 秘匿が要る経路に予測可能な PRNG がある "
f"(理由を書いても認めない — CSPRNG を使う)"
)
elif reason is None:
violations.append(
f"{rel}:{ln + 1}: 予測可能な PRNG の定義に "
f"`// DETERMINISTIC-RNG: <理由>` が無い"
)
elif len(reason) < MIN_REASON:
violations.append(
f"{rel}:{ln + 1}: DETERMINISTIC-RNG の理由が短い "
f"({len(reason)} 字、{MIN_REASON} 字以上)"
)
for name, ln, body in fn_bodies(code):
if not CLOCK.search(body):
continue
if not (SEED_SINK.search(body) or SEED_SINK.search(name) or "rng" in name.lower()):
continue
checked += 1
if CSPRNG.search(body):
continue
violations.append(
f"{rel}:{ln + 1}: `{name}` が時刻を seed にしている "
f"(hash を挟んでも seed 空間が時刻幅に縮む — CSPRNG を使う)"
)
for m in DP_FN.finditer(code):
name = m.group(1)
brace = code.find("{", m.end())
if brace < 0:
continue
depth, i = 0, brace
while i < len(code):
if code[i] == "{":
depth += 1
elif code[i] == "}":
depth -= 1
if depth == 0:
break
i += 1
body = code[brace : i + 1]
if not TRANSCENDENTAL.search(body):
continue
ln = code[: m.start()].count("\n")
checked += 1
if DP_SAFE.search(body):
continue
reason = marker_above(lines, ln, MARKER_DP)
if reason is None:
violations.append(
f"{rel}:{ln + 1}: `{name}` が浮動小数点の逆変換で noise を作っている "
f"(Mironov CCS 2012) snapping / 離散 noise にするか "
f"`// FLOAT-DP-ACCEPTED: <理由>` を置く"
)
elif len(reason) < MIN_REASON:
violations.append(
f"{rel}:{ln + 1}: FLOAT-DP-ACCEPTED の理由が短い "
f"({len(reason)} 字、{MIN_REASON} 字以上)"
)
return violations, checked
def load_baseline() -> set[str]:
if not BASELINE.is_file():
return set()
return {
ln.strip()
for ln in BASELINE.read_text(encoding="utf-8").splitlines()
if ln.strip() and not ln.startswith("#")
}
def main() -> int:
violations, checked = audit(ROOT)
if "--baseline-write" in sys.argv:
BASELINE.write_text(
"# csprng_guard.py の baseline — 既存の違反を記録するラチェット\n"
"# 新規の違反だけが fail する 解消したら行を消す (残っていても fail)\n"
"# ⚠️ ここに載っている行は「予測可能な noise が残っている」という負債の一覧\n"
+ "".join(f"{v}\n" for v in violations),
encoding="utf-8",
)
print(f"[csprng-guard] baseline を書いた: {len(violations)} 件 (検査 {checked} 箇所)")
return 0
if checked == 0:
print(
"[csprng-guard] FAIL: 検査対象を 1 件も見つけなかった 検査器が空振りしている",
file=sys.stderr,
)
return 1
base = load_baseline()
now = set(violations)
new = sorted(now - base)
stale = sorted(base - now)
rc = 0
if new:
print(f"[csprng-guard] FAIL: 新規の違反が {len(new)} 件", file=sys.stderr)
for v in new:
print(f" {v}", file=sys.stderr)
rc = 1
if stale:
print(
f"[csprng-guard] FAIL: baseline に解消済の entry が {len(stale)} 件残っている "
f"(`--baseline-write` で締め直す)",
file=sys.stderr,
)
for v in stale:
print(f" {v}", file=sys.stderr)
rc = 1
if rc == 0:
print(
f"[csprng-guard] OK: 検査 {checked} 箇所 / 新規違反 0 "
f"(baseline に既存 {len(base)} 件)"
)
return rc
if __name__ == "__main__":
sys.exit(main())