From e373d73c377de861697bb2dd6510f1c1de1faca6 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Tue, 21 Jul 2026 15:05:23 -0300 Subject: [PATCH 1/5] Add base allow_functions audit module and catalog --- packages/syft-restrict/README.md | 10 +- packages/syft-restrict/docs/blacklist.md | 41 +++- .../src/syft_restrict/__init__.py | 4 + .../syft-restrict/src/syft_restrict/audit.py | 187 ++++++++++++++++++ .../src/syft_restrict/catalog/README.md | 43 ++++ .../catalog/_common/default/catalog.json | 11 ++ .../catalog/flax/0.12/catalog.json | 10 + .../catalog/jax/0.11/catalog.json | 39 ++++ .../src/syft_restrict/catalog/lint.py | 101 ++++++++++ packages/syft-restrict/tests/test_audit.py | 86 ++++++++ 10 files changed, 519 insertions(+), 13 deletions(-) create mode 100644 packages/syft-restrict/src/syft_restrict/audit.py create mode 100644 packages/syft-restrict/src/syft_restrict/catalog/README.md create mode 100644 packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json create mode 100644 packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json create mode 100644 packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json create mode 100644 packages/syft-restrict/src/syft_restrict/catalog/lint.py create mode 100644 packages/syft-restrict/tests/test_audit.py diff --git a/packages/syft-restrict/README.md b/packages/syft-restrict/README.md index 6c50fa05066..a76a314c053 100644 --- a/packages/syft-restrict/README.md +++ b/packages/syft-restrict/README.md @@ -76,10 +76,12 @@ result = restrict.run( > [!IMPORTANT] > List the **specific** paths your model calls, as above — this is the default-deny posture the -> tool is built for: everything not named is denied. Avoid broad globs like `jax.*`: they pull in -> JAX's own host-callback and disk-IO functions (`jax.numpy.save`, `jax.debug.callback`, -> `jax.experimental.io_callback`, …) that the private region could call to exfiltrate data. If you -> must use a broad glob, pair it with a `disallow_functions` floor — see +> tool is built for: everything not named is denied, so no `disallow_functions` is needed. **Avoid +> broad globs like `jax.*`/`flax.*`**: a glob silently pulls in the library's host-callback, disk-IO, +> and network surface (`jax.numpy.save`, `jax.debug.callback`, `jax.profiler.start_server`, +> `jax.distributed.initialize`, …) that the private region could use to exfiltrate data. A glob can +> be paired with a `disallow_functions` floor, but that is a **leaky backstop** — it only blocks +> what you remembered to list — not a substitute for a tight allow. See > [docs/blacklist.md](docs/blacklist.md#optional-disallow_functions). The private region is designated **only** by `# syft-restrict: ...` comment markers in the diff --git a/packages/syft-restrict/docs/blacklist.md b/packages/syft-restrict/docs/blacklist.md index 3de41730dd4..3e01d0e250e 100644 --- a/packages/syft-restrict/docs/blacklist.md +++ b/packages/syft-restrict/docs/blacklist.md @@ -247,22 +247,45 @@ reports **`operator-disabled`**. ## Optional `disallow_functions` There is no built-in library denylist. Everything not explicitly allow-listed is -rejected. Safety comes from **`allow_functions`**. +rejected — safety comes from **`allow_functions`**, and the model is default-deny. -When you use a broad allow (`jax.*`), pass `disallow_functions=[...]` for a hard floor. Hits report -**`call-not-allowed`**. +**Prefer a tight `allow_functions`: list the exact leaves your code calls** (e.g. +`"jax.numpy.einsum"`, `"flax.linen.Module"`), not a glob. Under a tight allow-list `disallow_functions` +is unnecessary — a function that isn't named is already denied, so there is nothing to subtract. -Useful patterns under broad JAX/Flax allows: +**Avoid glob allows like `jax.*` / `flax.*`.** A glob silently allows in the library's entire surface, +including host-callback, disk-serialization, and network functions the private region could use to +exfiltrate data. If you nonetheless use a glob, add `disallow_functions=[...]` as a hard floor (hits +report **`call-not-allowed`**) — but treat this as a leaky backstop, not a substitute for a tight +allow: the floor only blocks what you remembered to list. -- Host / debug / experimental: `jax.experimental.*`, `jax.debug.*`, `jax.pure_callback`, `*.io_callback`, `*.host_callback*` -- FFI / interop: `jax.dlpack.*`, `jax.ffi*` -- Array ↔ disk: `jax.numpy.save`, `load`, `tofile`, `fromfile`, `memmap`, … -- Checkpointing: `flax.serialization.*`, `flax.training.checkpoints.*`, `orbax.*` +Recommended floor when a broad glob is unavoidable (dangerous JAX/Flax I/O surface): + +```python +disallow_functions=[ + # host / debug / experimental callbacks (arbitrary host code, stdout) + "jax.debug.*", "jax.experimental.*", "jax.pure_callback", + "*.io_callback", "*.host_callback*", + # profiler — writes traces to disk AND accepts gs://... paths / opens a network server + "jax.profiler.*", + # multi-host / distributed — outbound network connections + "jax.distributed.*", "jax.monitoring.*", "jax.experimental.multihost_utils.*", + # FFI / interop + "jax.dlpack.*", "jax.ffi*", + # array <-> disk (save/savez/savez_compressed/savetxt, load/loadtxt, tofile/fromfile/memmap) + "jax.numpy.save*", "jax.numpy.load*", "*.tofile", "*.fromfile", "*.memmap", + # checkpointing / serialization (write to disk, incl. gs://) + "flax.serialization.*", "flax.training.checkpoints.*", "orbax.*", +] +``` + +Even this list is not exhaustive — that is the point of preferring a tight allow. A disallow glob is +resolved through the import table like any path, so a renamed re-export is still caught: ```python # public import, private use — still resolved and checked from jax.numpy import save as persist -persist(x, "out.npz") # call-not-allowed if disallow includes jax.numpy.save +persist(x, "out.npz") # call-not-allowed if disallow includes jax.numpy.save* ``` --- diff --git a/packages/syft-restrict/src/syft_restrict/__init__.py b/packages/syft-restrict/src/syft_restrict/__init__.py index 193a23aa3c8..d57ac486fb1 100644 --- a/packages/syft-restrict/src/syft_restrict/__init__.py +++ b/packages/syft-restrict/src/syft_restrict/__init__.py @@ -9,6 +9,7 @@ __version__ = "0.1.0" +from .audit import AuditReport, PathAudit, audit_allow_functions from .errors import MarkerError, PolicyViolation, RestrictError from .markers import parse_markers from .obfuscator import obfuscate @@ -21,6 +22,9 @@ "verify", "obfuscate", "parse_markers", + "audit_allow_functions", + "AuditReport", + "PathAudit", "Policy", "RunResult", "VerifyResult", diff --git a/packages/syft-restrict/src/syft_restrict/audit.py b/packages/syft-restrict/src/syft_restrict/audit.py new file mode 100644 index 00000000000..6d42f37504b --- /dev/null +++ b/packages/syft-restrict/src/syft_restrict/audit.py @@ -0,0 +1,187 @@ +"""Advisory audit of an ``allow_functions`` list: classify each allowed dotted path by risk. + +This is **defense-in-depth, not a soundness proof.** The verifier's default-deny already decides what +the private region may call; this tool helps an author or reviewer see — *before* running — whether +their allow-list grants any known disk/network/host-callback capability, or any path a human should +eyeball. It generalizes across models: feed it whatever ``allow_functions`` a given model needs. + +Each allowed entry is classified against a **curated catalog** (the ``catalog/`` directory, laid out +as ``catalog///catalog.json`` plus ``catalog/_common/default`` for library-agnostic +rules — kept out of the code so assessments can be revised per release; see ``catalog/README.md``): + +- ``"unsafe"`` — matches a catalog entry for known disk/network/host-callback surface, OR is a glob + (``jax.*``) that grants a whole namespace. Remove it or tighten the allow. +- ``"safe"`` — matches a curated entry for a vetted pure-compute path. The explanation also flags any + *residual output-channel risk to review in combination*, kept deliberately vague (not a how-to). +- ``"review"`` — neither unsafe nor in the safe catalog. The audit makes **no** guess about it: it is + reported as uncatalogued and deferred to human review. Unknowns are never assumed safe. + +Limits (state them plainly to whoever reads a report): + +- Classification is **only** catalog matching — no source inspection, no inference. A path the catalog + does not know is deferred to a human; the tool does not try to guess whether it does I/O. +- The catalog is curated per library version; the report records the versions it saw. + +Anything not matched as unsafe or safe defaults to ``"review"``, never silently to ``"safe"``. +""" + +from __future__ import annotations + +import fnmatch +import importlib +import json +from functools import lru_cache +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field + +__all__ = ["audit_allow_functions", "AuditReport", "PathAudit"] + +# Catalog laid out as catalog///catalog.json, plus catalog/_common/default for +# library-agnostic rules. See catalog/README.md for the scheme. +_CATALOG_DIR = Path(__file__).with_name("catalog") +_COMMON_LIB = "_common" +_COMMON_VERSION = "default" + +Verdict = Literal["safe", "unsafe", "review"] + + +class PathAudit(BaseModel): + path: str + verdict: Verdict + reason: str + + +class AuditReport(BaseModel): + entries: list[PathAudit] = Field(default_factory=list) + versions: dict[str, str] = Field(default_factory=dict) # top-level package -> version seen + + @property + def unsafe(self) -> list[PathAudit]: + return [e for e in self.entries if e.verdict == "unsafe"] + + @property + def review(self) -> list[PathAudit]: + return [e for e in self.entries if e.verdict == "review"] + + @property + def safe(self) -> list[PathAudit]: + return [e for e in self.entries if e.verdict == "safe"] + + @property + def ok(self) -> bool: + """True if nothing is unsafe. ``review`` entries do not fail it -- they need a human.""" + return not self.unsafe + + def format(self) -> str: + vers = ", ".join(f"{k} {v}" for k, v in sorted(self.versions.items())) or "no versions detected" + lines = [f"allow-list audit ({vers})"] + for label, group in (("UNSAFE", self.unsafe), ("REVIEW", self.review), ("SAFE", self.safe)): + if not group: + continue + lines.append(f" {label} ({len(group)}):") + for e in group: + lines.append(f" - {e.path}{' — ' + e.reason if e.reason else ''}") + lines.append(f" => ok={self.ok} (unsafe entries fail; review entries need a human)") + return "\n".join(lines) + + def __str__(self) -> str: + return self.format() + + +def audit_allow_functions(allow_functions: list[str] | None) -> AuditReport: + """Classify each entry of an ``allow_functions`` list; see the module docstring for semantics. + + Classification is catalog matching only: a path the catalog does not know is reported as + ``"review"`` and deferred to a human. Unknowns are never assumed safe. + """ + paths = [p for p in (s.strip() for s in (allow_functions or [])) if p] + versions = _detect_versions(paths) + entries = [_classify(p, versions) for p in paths] + return AuditReport(entries=entries, versions=versions) + + +def _classify(path: str, versions: dict[str, str]) -> PathAudit: + if "*" in path or "?" in path: + return PathAudit( + path=path, + verdict="unsafe", + reason="glob allow grants an entire namespace (may include disk/network/callbacks); " + "list exact leaves instead", + ) + library = path.split(".", 1)[0] + unsafe_rules, safe_rules = _rules_for(library, versions.get(library, "")) + for pattern, reason in unsafe_rules.items(): + if fnmatch.fnmatchcase(path, pattern): + return PathAudit(path=path, verdict="unsafe", reason=reason) + for pattern, reason in safe_rules.items(): + if fnmatch.fnmatchcase(path, pattern): + return PathAudit(path=path, verdict="safe", reason=reason) + return PathAudit( + path=path, + verdict="review", + reason="not in the curated catalog; defer to human review (unknowns are not assumed safe)", + ) + + +def _rules_for(library: str, version: str) -> tuple[dict[str, str], dict[str, str]]: + """Merge the library-agnostic ``_common`` rules with the version-matched library rules.""" + unsafe: dict[str, str] = {} + safe: dict[str, str] = {} + common = _load_ruleset(_COMMON_LIB, _COMMON_VERSION) + version_dir = _match_version_dir(library, version) + lib_rules = _load_ruleset(library, version_dir) if version_dir is not None else {} + for ruleset in (common, lib_rules): + unsafe.update(ruleset.get("unsafe", {})) + safe.update(ruleset.get("safe", {})) + return unsafe, safe + + +@lru_cache(maxsize=None) +def _load_ruleset(library: str, version_dir: str) -> dict: + """Read ``catalog///catalog.json``; empty dict if the file is absent.""" + path = _CATALOG_DIR / library / version_dir / "catalog.json" + if not path.is_file(): + return {} + return json.loads(path.read_text()) + + +def _match_version_dir(library: str, version: str) -> str | None: + """Longest version-dir under ``catalog//`` matching ``version`` on a dot boundary. + + Returns ``None`` when no directory matches — there is no version-agnostic fallback, so an + uncovered version simply contributes no library rules. + """ + lib_dir = _CATALOG_DIR / library + if not lib_dir.is_dir(): + return None + keys = [child.name for child in lib_dir.iterdir() if child.is_dir()] + return _best_version_key(keys, version) + + +def _best_version_key(keys: list[str], version: str) -> str | None: + """Longest key that equals ``version`` or is a dot-bounded prefix of it: "0.1" covers 0.1.x, + never 0.11.x / 0.19.x. Returns ``None`` if nothing matches (no baseline fallback).""" + best, best_len = None, -1 + if not version: + return None + for key in keys: + if (version == key or version.startswith(key + ".")) and len(key) > best_len: + best, best_len = key, len(key) + return best + + +def _detect_versions(paths) -> dict[str, str]: + versions: dict[str, str] = {} + for path in paths: + root = path.split(".", 1)[0].lstrip("*") + if not root or root in versions: + continue + try: + mod = importlib.import_module(root) + except ImportError: + versions[root] = "not installed" + else: + versions[root] = getattr(mod, "__version__", "unknown") + return versions diff --git a/packages/syft-restrict/src/syft_restrict/catalog/README.md b/packages/syft-restrict/src/syft_restrict/catalog/README.md new file mode 100644 index 00000000000..fdb3068af98 --- /dev/null +++ b/packages/syft-restrict/src/syft_restrict/catalog/README.md @@ -0,0 +1,43 @@ +# Risk catalog + +Curated, advisory risk rules for the allow-list audit (`syft_restrict.audit`). **Advisory, not a +proof** — the verifier's default-deny is what actually gates calls; this catalog only helps a human +see, before running, what capabilities an `allow_functions` list grants. + +## Layout + +``` +catalog/ + _common/ + default/catalog.json # library-agnostic patterns, merged into every path + / + /catalog.json # rules for when the installed version matches +``` + +- `` is the first dotted component of an allowed path (`jax`, `flax`, …). +- `` is a version prefix (`0.11`, `0.19`). The audit picks the **longest** version dir whose + name matches the installed version on a dot boundary — `0.11` covers `0.11.x`, never `0.11` vs + `0.19`. **There is no version-agnostic fallback per library:** if no version dir matches the + installed version, that library contributes no rules and its paths fall to `review`. Add a version + dir to cover a release. +- `_common/default/catalog.json` is always merged in (its `default` segment is a fixed name, not a + version). It holds truly cross-library patterns (`*.io_callback`, `*.tofile`, …) and blanket rules + for libraries whose import root cannot be version-keyed (e.g. `orbax`: the `orbax` import root is a + namespace package with no `__version__`; the distribution is `orbax-checkpoint`). + +## File shape + +Each `catalog.json` is: + +```json +{ + "_about": "free-text note", + "unsafe": { "": "why it is unsafe" }, + "safe": { "": "why it is safe, plus a vague flag of any residual risk" } +} +``` + +`unsafe` = known disk/network/host-callback surface. `safe` = vetted pure-compute; its note also +flags any *residual output-channel risk to review in combination* — kept deliberately vague, not a +how-to (don't spell out abuse mechanics). Anything matched by neither defaults to `review` — never +silently to `safe`. diff --git a/packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json b/packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json new file mode 100644 index 00000000000..dc510a4d78d --- /dev/null +++ b/packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json @@ -0,0 +1,11 @@ +{ + "_about": "Library-agnostic risk rules, merged into every path's audit regardless of library or version. Patterns are dotted-path globs (fnmatch); 'unsafe' = disk/network/host-callback. Also holds blanket rules for libraries whose import root cannot be version-keyed (e.g. orbax: the 'orbax' import root is a namespace package with no __version__; the distribution is orbax-checkpoint). This is advisory, not a proof.", + "unsafe": { + "*.fromfile": "Reads raw bytes from a file on disk.", + "*.host_callback*": "Legacy host-callback API — runs host Python.", + "*.io_callback": "Runs a host Python callback with side effects (I/O).", + "*.memmap": "Memory-maps a file on disk.", + "*.tofile": "Writes raw array bytes to a file on disk.", + "orbax.*": "Orbax checkpointing — writes to disk and cloud storage (gs://, s3://). Blanket rule: orbax has no safe compute surface and its import root is not version-keyable." + } +} diff --git a/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json b/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json new file mode 100644 index 00000000000..ae53193cd01 --- /dev/null +++ b/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json @@ -0,0 +1,10 @@ +{ + "_about": "Risk rules for flax 0.12.x. 'unsafe' = disk/network/host-callback; 'safe' = vetted surface, whose note flags any residual risk to review in combination (kept deliberately vague). Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", + "safe": { + "flax.linen.Module": "Flax module base class — required to define a model; not a channel itself. Residual module-state risk (not host I/O) is widened by allow_base_class_attributes=True; keep that flag False to require explicit assignment." + }, + "unsafe": { + "flax.serialization.*": "Serializes model state to bytes / disk.", + "flax.training.checkpoints.*": "Writes / reads checkpoints to disk (path accepts gs://)." + } +} diff --git a/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json b/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json new file mode 100644 index 00000000000..788b19a9490 --- /dev/null +++ b/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json @@ -0,0 +1,39 @@ +{ + "_about": "Risk rules for jax 0.11.x. 'unsafe' = disk/network/host-callback; 'safe' = vetted pure-compute, whose note flags any residual output-channel risk to review in combination (kept deliberately vague). Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", + "safe": { + "jax.lax": "Module reference, present so deep-path calls (jax.lax.rsqrt) resolve. Does NOT permit calling other jax.lax members (exact-path match, not a glob).", + "jax.lax.rsqrt": "Reciprocal square root. Pure; some residual output-channel risk — review in combination.", + "jax.nn": "Module reference, present so deep-path calls (jax.nn.softmax / gelu) resolve. Does NOT permit calling other jax.nn members.", + "jax.nn.gelu": "GELU activation. Pure; residual output-channel risk only.", + "jax.nn.softmax": "Softmax activation. Pure, required for attention; residual output-channel risk — review in combination.", + "jax.numpy.arange": "Integer range generator. Pure; produces constants, no secret enters.", + "jax.numpy.array": "Materializes input into an array. Pure; low residual risk — review in context.", + "jax.numpy.bool_": "Cast to boolean. Pure; residual output-channel risk only.", + "jax.numpy.concatenate": "Joins arrays along an axis. Pure; residual output-shaping risk — review in combination.", + "jax.numpy.cos": "Element-wise cosine. Pure; residual output-channel risk only.", + "jax.numpy.einsum": "Einstein-summation contraction. Pure math, required for attention and projections; very expressive, so it carries the most residual output-channel risk — bound output entropy at the enclave and review in combination.", + "jax.numpy.float32": "Cast to float32. Pure; residual output-channel risk only.", + "jax.numpy.mean": "Reduction to a mean. Pure; residual risk via the returned value only.", + "jax.numpy.ones": "Constant-ones tensor. Pure; scaffolding only.", + "jax.numpy.repeat": "Repeats elements. Pure; residual output-shaping risk — review in combination.", + "jax.numpy.sin": "Element-wise sine. Pure; residual output-channel risk only.", + "jax.numpy.sqrt": "Element-wise square root. Pure; some residual output-channel risk — review in combination.", + "jax.numpy.square": "Element-wise square. Pure; no channel of its own (a statistic amplifier, e.g. in RMSNorm).", + "jax.numpy.transpose": "Reorders axes. Pure; residual output-shaping risk only.", + "jax.numpy.tril": "Lower-triangular mask. Pure; builds attention masks (compositional, low bandwidth).", + "jax.numpy.triu": "Upper-triangular mask. Pure; as tril.", + "jax.numpy.where": "Element-wise conditional select. Pure; residual output-channel risk — review in combination." + }, + "unsafe": { + "jax.debug.*": "Debug host callbacks / stdout (jax.debug.print, jax.debug.callback) — runs host Python and writes to the process stdout.", + "jax.distributed.*": "Multi-host coordination — opens outbound network connections to a coordinator address.", + "jax.dlpack.*": "Hands raw device buffers to other frameworks (interop escape hatch).", + "jax.experimental.*": "Experimental surface — includes io_callback, export, and array_serialization (writes arrays to disk and gs:// cloud storage).", + "jax.ffi*": "Foreign-function interface into native code.", + "jax.monitoring.*": "Telemetry / metrics reporting hooks.", + "jax.numpy.load*": "Reads array(s) from disk (load / loadtxt).", + "jax.numpy.save*": "Serializes array(s) to disk (save / savez / savez_compressed / savetxt).", + "jax.profiler.*": "Profiler — writes trace / memory-profile files to disk (logdir accepts gs://), and start_server opens a network listener.", + "jax.pure_callback": "Runs an arbitrary host Python callback during execution." + } +} diff --git a/packages/syft-restrict/src/syft_restrict/catalog/lint.py b/packages/syft-restrict/src/syft_restrict/catalog/lint.py new file mode 100644 index 00000000000..6e10d09da91 --- /dev/null +++ b/packages/syft-restrict/src/syft_restrict/catalog/lint.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Lint every ``catalog.json`` under this directory tree. + +Enforces a canonical, diff-friendly form: + +- keys sorted at every mapping level (so reordering entries never shows up as a diff), +- 2-space indent, UTF-8 kept verbatim (no ``\\uXXXX`` escaping), trailing newline, +- no line breaks inside any string value (each entry description stays on one line). + +Usage:: + + python lint.py # check only; non-zero exit if anything is off + python lint.py --fix # rewrite files into canonical form in place + +``--fix`` reformats (sorts + indents) and collapses any line break inside a value into a single +space, so every entry description ends up on one line. +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import re +import sys +from pathlib import Path + +_CATALOG_DIR = Path(__file__).resolve().parent + +# A run of whitespace containing at least one line break -> a single space. +_LINE_BREAK = re.compile(r"\s*[\r\n]+\s*") + + +def _canonical(data: object) -> str: + return json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True) + "\n" + + +def _normalize(data: object) -> object: + """Recursively collapse line breaks in every string value to a single space.""" + if isinstance(data, dict): + return {key: _normalize(value) for key, value in data.items()} + if isinstance(data, list): + return [_normalize(value) for value in data] + if isinstance(data, str): + return _LINE_BREAK.sub(" ", data) + return data + + +def _lint_file(path: Path, *, fix: bool) -> list[str]: + """Return a list of human-readable problems with ``path`` (empty if clean).""" + rel = path.relative_to(_CATALOG_DIR) + text = path.read_text(encoding="utf-8") + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + return [f"{rel}: invalid JSON ({exc})"] + + canonical = _canonical(_normalize(data)) + if text == canonical: + return [] + + if fix: + path.write_text(canonical, encoding="utf-8") + return [] + + diff = "".join( + difflib.unified_diff( + text.splitlines(keepends=True), + canonical.splitlines(keepends=True), + fromfile=f"{rel} (current)", + tofile=f"{rel} (canonical)", + ) + ) + return [f"{rel}: not in canonical form (run --fix)\n{diff}".rstrip()] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fix", action="store_true", help="rewrite files into canonical form") + args = parser.parse_args(argv) + + files = sorted(_CATALOG_DIR.rglob("catalog.json")) + if not files: + print(f"no catalog.json found under {_CATALOG_DIR}", file=sys.stderr) + return 1 + + problems: list[str] = [] + for path in files: + problems += _lint_file(path, fix=args.fix) + + if problems: + for problem in problems: + print(problem, file=sys.stderr) + return 1 # --fix leaves no problems; reaching here means check-mode found some + + print(f"catalog lint: {len(files)} file(s) OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/syft-restrict/tests/test_audit.py b/packages/syft-restrict/tests/test_audit.py new file mode 100644 index 00000000000..c6abe9ead09 --- /dev/null +++ b/packages/syft-restrict/tests/test_audit.py @@ -0,0 +1,86 @@ +"""Tests for the advisory allow-list audit (syft_restrict.audit).""" + +from syft_restrict import AuditReport, audit_allow_functions +from syft_restrict.audit import _best_version_key + + +def _entry(report: AuditReport, path: str): + return next(e for e in report.entries if e.path == path) + + +def test_known_unsafe_paths_are_flagged(): + paths = [ + "jax.profiler.start_server", # network server / disk trace (the floor-gap we found) + "jax.distributed.initialize", # outbound network + "jax.numpy.save", # disk + "jax.experimental.io_callback", # host callback + "flax.training.checkpoints.save_checkpoint", # disk + ] + report = audit_allow_functions(paths) + assert all(_entry(report, p).verdict == "unsafe" for p in paths) + assert all(_entry(report, p).reason for p in paths) # every entry carries an explanation + assert not report.ok # unsafe entries fail the report + + +def test_glob_allow_is_flagged_unsafe(): + report = audit_allow_functions(["jax.*", "flax.linen.*"]) + assert _entry(report, "jax.*").verdict == "unsafe" + assert _entry(report, "flax.linen.*").verdict == "unsafe" + assert "glob" in _entry(report, "jax.*").reason + + +def test_vetted_pure_compute_paths_are_safe_with_explanations(): + paths = ["jax.numpy.einsum", "jax.nn.softmax", "flax.linen.Module", "jax.lax"] + report = audit_allow_functions(paths) + assert all(_entry(report, p).verdict == "safe" for p in paths) + assert all(_entry(report, p).reason for p in paths) # safe entries also explained + # the explanation must convey residual risk to review, not just "it's fine" -- but stay vague + # (no abuse how-to): flag the risk with words like "residual" / "review", never a recipe. + einsum_reason = _entry(report, "jax.numpy.einsum").reason + assert "residual" in einsum_reason and "review" in einsum_reason + assert report.ok # only-safe list passes + + +def test_uncatalogued_path_is_deferred_to_review_without_assumptions(): + # An unknown path is neither safe nor unsafe: the audit makes no guess, it defers to a human. + # This holds regardless of whether the path is importable — no source inspection happens. + report = audit_allow_functions(["totally.made.up.symbol", "shutil.copyfile"]) + for path in ("totally.made.up.symbol", "shutil.copyfile"): + e = _entry(report, path) + assert e.verdict == "review" + assert "catalog" in e.reason # reported as uncatalogued, deferred to human review + assert report.ok # review entries do not fail the report; they need a human + + +def test_cross_library_pattern_matches_any_library(): + # `*.io_callback` lives in the library-agnostic _common catalog + report = audit_allow_functions(["somelib.io_callback"]) + assert _entry(report, "somelib.io_callback").verdict == "unsafe" + + +def test_orbax_is_flagged_unsafe_without_a_version_dir(): + # orbax has no version-keyable import root, so its blanket rule lives in _common and must fire + # regardless of whether any orbax version is detected. + report = audit_allow_functions(["orbax.checkpoint.save"]) + assert _entry(report, "orbax.checkpoint.save").verdict == "unsafe" + + +def test_report_format_has_sections_and_ok_flag(): + report = audit_allow_functions( + ["jax.numpy.einsum", "jax.profiler.start_server"] + ) + text = report.format() + assert "UNSAFE" in text and "SAFE" in text + assert "ok=False" in text + + +def test_best_version_key_matches_on_dot_boundaries_only(): + # A version dir must match a whole version component, not a raw string prefix: the "0.1" dir + # applies to 0.1.x, never to 0.11.x / 0.19.x (which look like "0.1" prefixes as bare strings). + # There is no baseline fallback — an uncovered version resolves to None (no library rules). + keys = ["0.1", "0.11"] + assert _best_version_key(keys, "0.1.7") == "0.1" + assert _best_version_key(keys, "0.11.0") == "0.11" # not "0.1" + assert _best_version_key(keys, "0.19.2") is None # no baseline; uncovered -> no rules + assert _best_version_key(keys, "0.2.0") is None + assert _best_version_key(keys, "") is None # unknown/undetected version matches nothing From fe58cc1ae7e416518cf441a51b110067d08d4398 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Wed, 22 Jul 2026 13:32:08 -0300 Subject: [PATCH 2/5] Add dual_use category to audit catalog --- .../syft-restrict/src/syft_restrict/audit.py | 71 ++++++++++++------- .../src/syft_restrict/catalog/README.md | 17 +++-- .../catalog/flax/0.12/catalog.json | 6 +- .../catalog/jax/0.11/catalog.json | 44 ++++++------ packages/syft-restrict/tests/test_audit.py | 29 +++++--- 5 files changed, 101 insertions(+), 66 deletions(-) diff --git a/packages/syft-restrict/src/syft_restrict/audit.py b/packages/syft-restrict/src/syft_restrict/audit.py index 6d42f37504b..61a11db70ea 100644 --- a/packages/syft-restrict/src/syft_restrict/audit.py +++ b/packages/syft-restrict/src/syft_restrict/audit.py @@ -11,10 +11,13 @@ - ``"unsafe"`` — matches a catalog entry for known disk/network/host-callback surface, OR is a glob (``jax.*``) that grants a whole namespace. Remove it or tighten the allow. -- ``"safe"`` — matches a curated entry for a vetted pure-compute path. The explanation also flags any - *residual output-channel risk to review in combination*, kept deliberately vague (not a how-to). -- ``"review"`` — neither unsafe nor in the safe catalog. The audit makes **no** guess about it: it is - reported as uncatalogued and deferred to human review. Unknowns are never assumed safe. +- ``"dual_use"`` — a useful, mostly-safe op that can still be abused in combination (e.g. ``einsum``, + ``softmax``, ``where``). Allowed, but flagged: the *category itself* carries the "handle with care" + signal, so entry notes stay terse and vague — never an abuse how-to. +- ``"safe"`` — matches a curated entry for a genuinely inert path (constants, masks, module refs) with + no residual output channel of its own. +- ``"review"`` — none of the above. The audit makes **no** guess about it: it is reported as + uncatalogued and deferred to human review. Unknowns are never assumed safe. Limits (state them plainly to whoever reads a report): @@ -22,7 +25,7 @@ does not know is deferred to a human; the tool does not try to guess whether it does I/O. - The catalog is curated per library version; the report records the versions it saw. -Anything not matched as unsafe or safe defaults to ``"review"``, never silently to ``"safe"``. +Anything not matched as unsafe, dual_use, or safe defaults to ``"review"``, never silently to safe. """ from __future__ import annotations @@ -44,7 +47,11 @@ _COMMON_LIB = "_common" _COMMON_VERSION = "default" -Verdict = Literal["safe", "unsafe", "review"] +# Catalog buckets, in the order they are matched (first hit wins): the strictest verdict a path +# qualifies for is assigned, so unsafe beats dual_use beats safe. +_BUCKETS: tuple[str, ...] = ("unsafe", "dual_use", "safe") + +Verdict = Literal["safe", "dual_use", "unsafe", "review"] class PathAudit(BaseModel): @@ -57,33 +64,47 @@ class AuditReport(BaseModel): entries: list[PathAudit] = Field(default_factory=list) versions: dict[str, str] = Field(default_factory=dict) # top-level package -> version seen + def _by_verdict(self, verdict: Verdict) -> list[PathAudit]: + return [e for e in self.entries if e.verdict == verdict] + @property def unsafe(self) -> list[PathAudit]: - return [e for e in self.entries if e.verdict == "unsafe"] + return self._by_verdict("unsafe") + + @property + def dual_use(self) -> list[PathAudit]: + return self._by_verdict("dual_use") @property def review(self) -> list[PathAudit]: - return [e for e in self.entries if e.verdict == "review"] + return self._by_verdict("review") @property def safe(self) -> list[PathAudit]: - return [e for e in self.entries if e.verdict == "safe"] + return self._by_verdict("safe") @property def ok(self) -> bool: - """True if nothing is unsafe. ``review`` entries do not fail it -- they need a human.""" + """True if nothing is unsafe. ``dual_use`` and ``review`` entries do not fail it -- they are + allowed-but-flagged and need a human's eye, not a hard block.""" return not self.unsafe def format(self) -> str: vers = ", ".join(f"{k} {v}" for k, v in sorted(self.versions.items())) or "no versions detected" lines = [f"allow-list audit ({vers})"] - for label, group in (("UNSAFE", self.unsafe), ("REVIEW", self.review), ("SAFE", self.safe)): + groups = ( + ("UNSAFE", self.unsafe), + ("DUAL-USE", self.dual_use), + ("REVIEW", self.review), + ("SAFE", self.safe), + ) + for label, group in groups: if not group: continue lines.append(f" {label} ({len(group)}):") for e in group: lines.append(f" - {e.path}{' — ' + e.reason if e.reason else ''}") - lines.append(f" => ok={self.ok} (unsafe entries fail; review entries need a human)") + lines.append(f" => ok={self.ok} (unsafe entries fail; dual-use and review entries need a human)") return "\n".join(lines) def __str__(self) -> str: @@ -111,13 +132,11 @@ def _classify(path: str, versions: dict[str, str]) -> PathAudit: "list exact leaves instead", ) library = path.split(".", 1)[0] - unsafe_rules, safe_rules = _rules_for(library, versions.get(library, "")) - for pattern, reason in unsafe_rules.items(): - if fnmatch.fnmatchcase(path, pattern): - return PathAudit(path=path, verdict="unsafe", reason=reason) - for pattern, reason in safe_rules.items(): - if fnmatch.fnmatchcase(path, pattern): - return PathAudit(path=path, verdict="safe", reason=reason) + rules = _rules_for(library, versions.get(library, "")) + for verdict in _BUCKETS: # unsafe -> dual_use -> safe: strictest match wins + for pattern, reason in rules[verdict].items(): + if fnmatch.fnmatchcase(path, pattern): + return PathAudit(path=path, verdict=verdict, reason=reason) return PathAudit( path=path, verdict="review", @@ -125,17 +144,17 @@ def _classify(path: str, versions: dict[str, str]) -> PathAudit: ) -def _rules_for(library: str, version: str) -> tuple[dict[str, str], dict[str, str]]: - """Merge the library-agnostic ``_common`` rules with the version-matched library rules.""" - unsafe: dict[str, str] = {} - safe: dict[str, str] = {} +def _rules_for(library: str, version: str) -> dict[str, dict[str, str]]: + """Merge the library-agnostic ``_common`` rules with the version-matched library rules, per + bucket (``unsafe`` / ``dual_use`` / ``safe``).""" + merged: dict[str, dict[str, str]] = {bucket: {} for bucket in _BUCKETS} common = _load_ruleset(_COMMON_LIB, _COMMON_VERSION) version_dir = _match_version_dir(library, version) lib_rules = _load_ruleset(library, version_dir) if version_dir is not None else {} for ruleset in (common, lib_rules): - unsafe.update(ruleset.get("unsafe", {})) - safe.update(ruleset.get("safe", {})) - return unsafe, safe + for bucket in _BUCKETS: + merged[bucket].update(ruleset.get(bucket, {})) + return merged @lru_cache(maxsize=None) diff --git a/packages/syft-restrict/src/syft_restrict/catalog/README.md b/packages/syft-restrict/src/syft_restrict/catalog/README.md index fdb3068af98..7f552d04138 100644 --- a/packages/syft-restrict/src/syft_restrict/catalog/README.md +++ b/packages/syft-restrict/src/syft_restrict/catalog/README.md @@ -32,12 +32,17 @@ Each `catalog.json` is: ```json { "_about": "free-text note", - "unsafe": { "": "why it is unsafe" }, - "safe": { "": "why it is safe, plus a vague flag of any residual risk" } + "unsafe": { "": "why it is unsafe" }, + "dual_use": { "": "what the op is (terse; the category carries the caution)" }, + "safe": { "": "why it is genuinely inert" } } ``` -`unsafe` = known disk/network/host-callback surface. `safe` = vetted pure-compute; its note also -flags any *residual output-channel risk to review in combination* — kept deliberately vague, not a -how-to (don't spell out abuse mechanics). Anything matched by neither defaults to `review` — never -silently to `safe`. +- `unsafe` = known disk/network/host-callback surface. +- `dual_use` = a useful op that is mostly safe but can be **abused in combination** (`einsum`, + `softmax`, `where`, …). Allowed but flagged. The *category* is the caution, so keep each note a + terse description of what the op is — do **not** spell out abuse mechanics (no how-to). +- `safe` = genuinely inert (constants, masks, module refs) with no residual channel of its own. + +A path is matched strictest-first (`unsafe` → `dual_use` → `safe`). Anything matched by none defaults +to `review` — never silently to `safe`. diff --git a/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json b/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json index ae53193cd01..844f2ead1f8 100644 --- a/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json +++ b/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json @@ -1,7 +1,7 @@ { - "_about": "Risk rules for flax 0.12.x. 'unsafe' = disk/network/host-callback; 'safe' = vetted surface, whose note flags any residual risk to review in combination (kept deliberately vague). Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", - "safe": { - "flax.linen.Module": "Flax module base class — required to define a model; not a channel itself. Residual module-state risk (not host I/O) is widened by allow_base_class_attributes=True; keep that flag False to require explicit assignment." + "_about": "Risk rules for flax 0.12.x. 'unsafe' = disk/network/host-callback; 'dual_use' = useful surface that is mostly safe but can be abused in combination (the category itself is the caution, so notes stay terse and vague); 'safe' = genuinely inert. Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", + "dual_use": { + "flax.linen.Module": "Flax module base class; required to define a model. Keep allow_base_class_attributes=False to require explicit assignment." }, "unsafe": { "flax.serialization.*": "Serializes model state to bytes / disk.", diff --git a/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json b/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json index 788b19a9490..aae9c704e92 100644 --- a/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json +++ b/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json @@ -1,28 +1,30 @@ { - "_about": "Risk rules for jax 0.11.x. 'unsafe' = disk/network/host-callback; 'safe' = vetted pure-compute, whose note flags any residual output-channel risk to review in combination (kept deliberately vague). Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", + "_about": "Risk rules for jax 0.11.x. 'unsafe' = disk/network/host-callback; 'dual_use' = useful op that is mostly safe but can be abused in combination (the category itself is the caution, so notes stay terse and vague); 'safe' = genuinely inert (constants, masks, module refs). Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", + "dual_use": { + "jax.lax.rsqrt": "Reciprocal square root.", + "jax.nn.gelu": "GELU activation.", + "jax.nn.softmax": "Softmax activation; required for attention.", + "jax.numpy.array": "Materializes input into an array.", + "jax.numpy.bool_": "Cast to boolean.", + "jax.numpy.concatenate": "Joins arrays along an axis.", + "jax.numpy.cos": "Element-wise cosine.", + "jax.numpy.einsum": "Einstein-summation contraction; required for attention and projections.", + "jax.numpy.float32": "Cast to float32.", + "jax.numpy.mean": "Reduction to a mean.", + "jax.numpy.repeat": "Repeats elements along an axis.", + "jax.numpy.sin": "Element-wise sine.", + "jax.numpy.sqrt": "Element-wise square root.", + "jax.numpy.transpose": "Reorders axes.", + "jax.numpy.where": "Element-wise conditional select." + }, "safe": { "jax.lax": "Module reference, present so deep-path calls (jax.lax.rsqrt) resolve. Does NOT permit calling other jax.lax members (exact-path match, not a glob).", - "jax.lax.rsqrt": "Reciprocal square root. Pure; some residual output-channel risk — review in combination.", "jax.nn": "Module reference, present so deep-path calls (jax.nn.softmax / gelu) resolve. Does NOT permit calling other jax.nn members.", - "jax.nn.gelu": "GELU activation. Pure; residual output-channel risk only.", - "jax.nn.softmax": "Softmax activation. Pure, required for attention; residual output-channel risk — review in combination.", - "jax.numpy.arange": "Integer range generator. Pure; produces constants, no secret enters.", - "jax.numpy.array": "Materializes input into an array. Pure; low residual risk — review in context.", - "jax.numpy.bool_": "Cast to boolean. Pure; residual output-channel risk only.", - "jax.numpy.concatenate": "Joins arrays along an axis. Pure; residual output-shaping risk — review in combination.", - "jax.numpy.cos": "Element-wise cosine. Pure; residual output-channel risk only.", - "jax.numpy.einsum": "Einstein-summation contraction. Pure math, required for attention and projections; very expressive, so it carries the most residual output-channel risk — bound output entropy at the enclave and review in combination.", - "jax.numpy.float32": "Cast to float32. Pure; residual output-channel risk only.", - "jax.numpy.mean": "Reduction to a mean. Pure; residual risk via the returned value only.", - "jax.numpy.ones": "Constant-ones tensor. Pure; scaffolding only.", - "jax.numpy.repeat": "Repeats elements. Pure; residual output-shaping risk — review in combination.", - "jax.numpy.sin": "Element-wise sine. Pure; residual output-channel risk only.", - "jax.numpy.sqrt": "Element-wise square root. Pure; some residual output-channel risk — review in combination.", - "jax.numpy.square": "Element-wise square. Pure; no channel of its own (a statistic amplifier, e.g. in RMSNorm).", - "jax.numpy.transpose": "Reorders axes. Pure; residual output-shaping risk only.", - "jax.numpy.tril": "Lower-triangular mask. Pure; builds attention masks (compositional, low bandwidth).", - "jax.numpy.triu": "Upper-triangular mask. Pure; as tril.", - "jax.numpy.where": "Element-wise conditional select. Pure; residual output-channel risk — review in combination." + "jax.numpy.arange": "Integer range generator; produces constants, no secret enters.", + "jax.numpy.ones": "Constant-ones tensor; scaffolding only.", + "jax.numpy.square": "Element-wise square; no channel of its own (a statistic amplifier, e.g. in RMSNorm).", + "jax.numpy.tril": "Lower-triangular mask; builds attention masks.", + "jax.numpy.triu": "Upper-triangular mask; as tril." }, "unsafe": { "jax.debug.*": "Debug host callbacks / stdout (jax.debug.print, jax.debug.callback) — runs host Python and writes to the process stdout.", diff --git a/packages/syft-restrict/tests/test_audit.py b/packages/syft-restrict/tests/test_audit.py index c6abe9ead09..128a6c63c78 100644 --- a/packages/syft-restrict/tests/test_audit.py +++ b/packages/syft-restrict/tests/test_audit.py @@ -29,18 +29,27 @@ def test_glob_allow_is_flagged_unsafe(): assert "glob" in _entry(report, "jax.*").reason -def test_vetted_pure_compute_paths_are_safe_with_explanations(): - paths = ["jax.numpy.einsum", "jax.nn.softmax", "flax.linen.Module", "jax.lax"] +def test_genuinely_inert_paths_are_safe(): + # constants, masks, and module refs have no residual channel of their own. + paths = ["jax.numpy.arange", "jax.numpy.ones", "jax.numpy.tril", "jax.lax"] report = audit_allow_functions(paths) assert all(_entry(report, p).verdict == "safe" for p in paths) - assert all(_entry(report, p).reason for p in paths) # safe entries also explained - # the explanation must convey residual risk to review, not just "it's fine" -- but stay vague - # (no abuse how-to): flag the risk with words like "residual" / "review", never a recipe. - einsum_reason = _entry(report, "jax.numpy.einsum").reason - assert "residual" in einsum_reason and "review" in einsum_reason + assert all(_entry(report, p).reason for p in paths) # safe entries still explained assert report.ok # only-safe list passes +def test_dual_use_paths_are_flagged_between_safe_and_unsafe(): + # useful ops that are mostly safe but abusable in combination land in dual_use, not safe. + paths = ["jax.numpy.einsum", "jax.nn.softmax", "jax.numpy.where", "flax.linen.Module"] + report = audit_allow_functions(paths) + assert all(_entry(report, p).verdict == "dual_use" for p in paths) + assert all(_entry(report, p).reason for p in paths) # dual_use entries carry a terse note + # the category carries the caution, so the note stays vague -- no abuse how-to / recipe wording. + einsum_reason = _entry(report, "jax.numpy.einsum").reason + assert "encode" not in einsum_reason and "secret" not in einsum_reason + assert report.ok # dual_use does not fail the report (allowed-but-flagged) + + def test_uncatalogued_path_is_deferred_to_review_without_assumptions(): # An unknown path is neither safe nor unsafe: the audit makes no guess, it defers to a human. # This holds regardless of whether the path is importable — no source inspection happens. @@ -67,11 +76,11 @@ def test_orbax_is_flagged_unsafe_without_a_version_dir(): def test_report_format_has_sections_and_ok_flag(): report = audit_allow_functions( - ["jax.numpy.einsum", "jax.profiler.start_server"] + ["jax.profiler.start_server", "jax.numpy.einsum", "jax.numpy.ones"] ) text = report.format() - assert "UNSAFE" in text and "SAFE" in text - assert "ok=False" in text + assert "UNSAFE" in text and "DUAL-USE" in text and "SAFE" in text + assert "ok=False" in text # the unsafe profiler entry fails the report def test_best_version_key_matches_on_dot_boundaries_only(): From 01ab872e58c6daf680ff0bac16f2ff6ac8f9563b Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Wed, 22 Jul 2026 23:00:08 -0300 Subject: [PATCH 3/5] Refine audit categorization and documentation for dual_use and safe paths in JAX and Flax --- packages/syft-restrict/docs/blacklist.md | 2 +- .../syft-restrict/src/syft_restrict/audit.py | 10 +- .../src/syft_restrict/catalog/README.md | 20 +- .../catalog/_common/default/catalog.json | 1 - .../catalog/flax/0.12/catalog.json | 99 +++++- .../catalog/jax/0.11/catalog.json | 285 +++++++++++++++++- packages/syft-restrict/tests/test_audit.py | 72 ++++- 7 files changed, 447 insertions(+), 42 deletions(-) diff --git a/packages/syft-restrict/docs/blacklist.md b/packages/syft-restrict/docs/blacklist.md index 3e01d0e250e..89cb164ca86 100644 --- a/packages/syft-restrict/docs/blacklist.md +++ b/packages/syft-restrict/docs/blacklist.md @@ -271,7 +271,7 @@ disallow_functions=[ # multi-host / distributed — outbound network connections "jax.distributed.*", "jax.monitoring.*", "jax.experimental.multihost_utils.*", # FFI / interop - "jax.dlpack.*", "jax.ffi*", + "jax.dlpack.*", "jax.ffi.*", # array <-> disk (save/savez/savez_compressed/savetxt, load/loadtxt, tofile/fromfile/memmap) "jax.numpy.save*", "jax.numpy.load*", "*.tofile", "*.fromfile", "*.memmap", # checkpointing / serialization (write to disk, incl. gs://) diff --git a/packages/syft-restrict/src/syft_restrict/audit.py b/packages/syft-restrict/src/syft_restrict/audit.py index 61a11db70ea..b69742aa75e 100644 --- a/packages/syft-restrict/src/syft_restrict/audit.py +++ b/packages/syft-restrict/src/syft_restrict/audit.py @@ -11,11 +11,11 @@ - ``"unsafe"`` — matches a catalog entry for known disk/network/host-callback surface, OR is a glob (``jax.*``) that grants a whole namespace. Remove it or tighten the allow. -- ``"dual_use"`` — a useful, mostly-safe op that can still be abused in combination (e.g. ``einsum``, - ``softmax``, ``where``). Allowed, but flagged: the *category itself* carries the "handle with care" - signal, so entry notes stay terse and vague — never an abuse how-to. -- ``"safe"`` — matches a curated entry for a genuinely inert path (constants, masks, module refs) with - no residual output channel of its own. +- ``"safe"`` — pure computation: ordinary math (``einsum``, ``matmul``, activations, reductions, + comparisons, reshapes), constants, RNG, and initializers. +- ``"dual_use"`` — a path flagged for a specific capability beyond pure computation; each entry + states its own concrete reason (e.g. crossing the host/device boundary, or widening what the + verifier accepts as valid attribute access). - ``"review"`` — none of the above. The audit makes **no** guess about it: it is reported as uncatalogued and deferred to human review. Unknowns are never assumed safe. diff --git a/packages/syft-restrict/src/syft_restrict/catalog/README.md b/packages/syft-restrict/src/syft_restrict/catalog/README.md index 7f552d04138..dbe09eab79d 100644 --- a/packages/syft-restrict/src/syft_restrict/catalog/README.md +++ b/packages/syft-restrict/src/syft_restrict/catalog/README.md @@ -20,8 +20,7 @@ catalog/ `0.19`. **There is no version-agnostic fallback per library:** if no version dir matches the installed version, that library contributes no rules and its paths fall to `review`. Add a version dir to cover a release. -- `_common/default/catalog.json` is always merged in (its `default` segment is a fixed name, not a - version). It holds truly cross-library patterns (`*.io_callback`, `*.tofile`, …) and blanket rules +- `_common/default/catalog.json` is always merged in. It holds truly cross-library patterns (`*.io_callback`, `*.tofile`, …) and blanket rules for libraries whose import root cannot be version-keyed (e.g. `orbax`: the `orbax` import root is a namespace package with no `__version__`; the distribution is `orbax-checkpoint`). @@ -33,16 +32,21 @@ Each `catalog.json` is: { "_about": "free-text note", "unsafe": { "": "why it is unsafe" }, - "dual_use": { "": "what the op is (terse; the category carries the caution)" }, - "safe": { "": "why it is genuinely inert" } + "dual_use": { "": "the concrete reason it is flagged" }, + "safe": { "": "what the op is (terse)" } } ``` - `unsafe` = known disk/network/host-callback surface. -- `dual_use` = a useful op that is mostly safe but can be **abused in combination** (`einsum`, - `softmax`, `where`, …). Allowed but flagged. The *category* is the caution, so keep each note a - terse description of what the op is — do **not** spell out abuse mechanics (no how-to). -- `safe` = genuinely inert (constants, masks, module refs) with no residual channel of its own. +- `safe` = pure computation: ordinary math (`einsum`, `matmul`, activations, reductions, reshapes), + constants, RNG, and initializers. +- `dual_use` = a path flagged for a specific capability beyond pure computation. Each entry must + state its own concrete reason (e.g. crossing the host/device boundary). A path is matched strictest-first (`unsafe` → `dual_use` → `safe`). Anything matched by none defaults to `review` — never silently to `safe`. + +> [!Note] +> +> `safe` means "no disk/network/host-callback capability", not "no information +> flow". The catalog lists capabilities, not guarantees. diff --git a/packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json b/packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json index dc510a4d78d..2c271641063 100644 --- a/packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json +++ b/packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json @@ -2,7 +2,6 @@ "_about": "Library-agnostic risk rules, merged into every path's audit regardless of library or version. Patterns are dotted-path globs (fnmatch); 'unsafe' = disk/network/host-callback. Also holds blanket rules for libraries whose import root cannot be version-keyed (e.g. orbax: the 'orbax' import root is a namespace package with no __version__; the distribution is orbax-checkpoint). This is advisory, not a proof.", "unsafe": { "*.fromfile": "Reads raw bytes from a file on disk.", - "*.host_callback*": "Legacy host-callback API — runs host Python.", "*.io_callback": "Runs a host Python callback with side effects (I/O).", "*.memmap": "Memory-maps a file on disk.", "*.tofile": "Writes raw array bytes to a file on disk.", diff --git a/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json b/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json index 844f2ead1f8..ea64f438425 100644 --- a/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json +++ b/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json @@ -1,9 +1,104 @@ { - "_about": "Risk rules for flax 0.12.x. 'unsafe' = disk/network/host-callback; 'dual_use' = useful surface that is mostly safe but can be abused in combination (the category itself is the caution, so notes stay terse and vague); 'safe' = genuinely inert. Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", + "_about": "Risk rules for flax 0.12.x. 'unsafe' = disk/network/host-callback; 'safe' = pure computation; 'dual_use' = flagged for a specific capability beyond pure computation, reason stated per entry. Covers the commonly-used flax.linen surface; anything uncatalogued falls through to 'review'. Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", "dual_use": { - "flax.linen.Module": "Flax module base class; required to define a model. Keep allow_base_class_attributes=False to require explicit assignment." + "flax.linen.Module": "Flax module base class; required to define a model. allow_base_class_attributes widens what the verifier treats as valid attribute access — keep it False to require explicit assignment." + }, + "safe": { + "flax.linen.BatchNorm": "Batch normalization.", + "flax.linen.Bidirectional": "Bidirectional wrapper running a cell forward and backward.", + "flax.linen.Conv": "Convolution layer.", + "flax.linen.ConvLSTMCell": "Convolutional LSTM cell.", + "flax.linen.ConvLocal": "Locally-connected (unshared-weight) convolution layer.", + "flax.linen.ConvTranspose": "Transposed (fractionally-strided) convolution layer.", + "flax.linen.Dense": "Fully-connected (dense) layer.", + "flax.linen.DenseGeneral": "Dense layer contracting over arbitrary axes.", + "flax.linen.Dropout": "Stochastic dropout layer.", + "flax.linen.Einsum": "Einsum layer with a learned operand.", + "flax.linen.Embed": "Embedding lookup layer.", + "flax.linen.GRUCell": "GRU recurrent cell.", + "flax.linen.GroupNorm": "Group normalization.", + "flax.linen.InstanceNorm": "Instance normalization.", + "flax.linen.LSTMCell": "LSTM recurrent cell.", + "flax.linen.LayerNorm": "Layer normalization.", + "flax.linen.MultiHeadAttention": "Multi-head attention layer.", + "flax.linen.MultiHeadDotProductAttention": "Multi-head dot-product attention layer.", + "flax.linen.OptimizedLSTMCell": "LSTM cell with a fused implementation.", + "flax.linen.PReLU": "Parametric-ReLU activation layer.", + "flax.linen.Partitioned": "Sharding-metadata wrapper for a parameter; structural annotation.", + "flax.linen.RMSNorm": "Root-mean-square layer normalization.", + "flax.linen.RNN": "Recurrent layer applying a cell over a sequence.", + "flax.linen.SelfAttention": "Multi-head self-attention layer.", + "flax.linen.Sequential": "Container applying sub-layers in order; the sub-layers are allow-listed separately.", + "flax.linen.SimpleCell": "Elman (simple) recurrent cell.", + "flax.linen.SpectralNorm": "Spectral-normalization wrapper.", + "flax.linen.Variable": "Handle to a module variable; a state reference, not compute or IO.", + "flax.linen.WeightNorm": "Weight-normalization wrapper.", + "flax.linen.avg_pool": "Average pooling over windows.", + "flax.linen.celu": "CELU activation.", + "flax.linen.checkpoint": "Rematerialization transform (alias of remat); not disk checkpointing.", + "flax.linen.combine_masks": "Combines boolean masks with logical AND.", + "flax.linen.compact": "Decorator marking a module's inline-submodule method; structural.", + "flax.linen.cond": "Lifted conditional executing one of two branches.", + "flax.linen.dot_product_attention": "Dot-product attention function.", + "flax.linen.dot_product_attention_weights": "Dot-product attention weights (softmax over scores).", + "flax.linen.elu": "ELU activation.", + "flax.linen.gelu": "GELU activation.", + "flax.linen.glu": "Gated-linear-unit activation.", + "flax.linen.hard_sigmoid": "Hard-sigmoid activation.", + "flax.linen.hard_silu": "Hard-SiLU / hard-swish activation.", + "flax.linen.hard_swish": "Hard-swish / hard-SiLU activation.", + "flax.linen.hard_tanh": "Hard-tanh activation.", + "flax.linen.initializers.constant": "Constant-value parameter initializer.", + "flax.linen.initializers.delta_orthogonal": "Delta-orthogonal initializer for convolution kernels.", + "flax.linen.initializers.glorot_normal": "Glorot (Xavier) normal initializer.", + "flax.linen.initializers.glorot_uniform": "Glorot (Xavier) uniform initializer.", + "flax.linen.initializers.he_normal": "He (Kaiming) normal initializer.", + "flax.linen.initializers.he_uniform": "He (Kaiming) uniform initializer.", + "flax.linen.initializers.kaiming_normal": "Kaiming (He) normal initializer.", + "flax.linen.initializers.kaiming_uniform": "Kaiming (He) uniform initializer.", + "flax.linen.initializers.lecun_normal": "LeCun normal initializer.", + "flax.linen.initializers.lecun_uniform": "LeCun uniform initializer.", + "flax.linen.initializers.normal": "Normal-distribution parameter initializer.", + "flax.linen.initializers.ones": "All-ones parameter initializer.", + "flax.linen.initializers.orthogonal": "Orthogonal-matrix parameter initializer.", + "flax.linen.initializers.truncated_normal": "Truncated-normal parameter initializer.", + "flax.linen.initializers.uniform": "Uniform-distribution parameter initializer.", + "flax.linen.initializers.variance_scaling": "Variance-scaling parameter initializer.", + "flax.linen.initializers.xavier_normal": "Xavier (Glorot) normal initializer.", + "flax.linen.initializers.xavier_uniform": "Xavier (Glorot) uniform initializer.", + "flax.linen.initializers.zeros": "All-zeros parameter initializer.", + "flax.linen.jit": "Lifted JIT compilation of a module.", + "flax.linen.leaky_relu": "Leaky-ReLU activation.", + "flax.linen.log_sigmoid": "Log-sigmoid activation.", + "flax.linen.log_softmax": "Log-softmax activation.", + "flax.linen.logsumexp": "Log-sum-exp reduction.", + "flax.linen.make_attention_mask": "Builds an attention mask by broadcasting a query/key position comparison.", + "flax.linen.make_causal_mask": "Builds a causal (lower-triangular) attention mask from a shape.", + "flax.linen.map_variables": "Transform mapping over a module's variable collections.", + "flax.linen.max_pool": "Max pooling over windows.", + "flax.linen.nowrap": "Decorator opting a method out of module auto-wrapping; structural.", + "flax.linen.one_hot": "One-hot encoding of integer indices.", + "flax.linen.pool": "Generic windowed pooling reduction.", + "flax.linen.relu": "ReLU activation.", + "flax.linen.relu6": "ReLU6 activation.", + "flax.linen.remat": "Rematerialization (gradient-checkpointing) transform.", + "flax.linen.remat_scan": "Rematerialized scan transform.", + "flax.linen.scan": "Lifted scan over a module along an axis.", + "flax.linen.selu": "SELU activation.", + "flax.linen.sigmoid": "Sigmoid activation.", + "flax.linen.silu": "SiLU / swish activation.", + "flax.linen.soft_sign": "Soft-sign activation.", + "flax.linen.softmax": "Softmax activation.", + "flax.linen.softplus": "Softplus activation.", + "flax.linen.standardize": "Standardizes to zero-mean, unit-variance along an axis.", + "flax.linen.swish": "Swish / SiLU activation.", + "flax.linen.switch": "Lifted multi-branch switch.", + "flax.linen.tanh": "Hyperbolic-tangent activation.", + "flax.linen.vmap": "Lifted vectorizing map over a module.", + "flax.linen.while_loop": "Lifted while loop." }, "unsafe": { + "flax.io.*": "File IO shim — reads/writes files on disk (and gs:// cloud storage when available).", "flax.serialization.*": "Serializes model state to bytes / disk.", "flax.training.checkpoints.*": "Writes / reads checkpoints to disk (path accepts gs://)." } diff --git a/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json b/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json index aae9c704e92..17de2ffefab 100644 --- a/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json +++ b/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json @@ -1,37 +1,298 @@ { - "_about": "Risk rules for jax 0.11.x. 'unsafe' = disk/network/host-callback; 'dual_use' = useful op that is mostly safe but can be abused in combination (the category itself is the caution, so notes stay terse and vague); 'safe' = genuinely inert (constants, masks, module refs). Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", + "_about": "Risk rules for jax 0.11.x. 'unsafe' = disk/network/host-callback; 'safe' = pure computation; 'dual_use' = flagged for a specific capability beyond pure computation, reason stated per entry. Cross-library rules in _common/default are merged in on top of these. This is advisory, not a proof.", "dual_use": { + "jax.device_get": "Copies arrays off-device to host memory — a host/device boundary crossing, not computation.", + "jax.device_put": "Moves arrays onto a device across the host/device boundary — not computation." + }, + "safe": { + "jax.block_until_ready": "Blocks until arrays are ready.", + "jax.checkpoint": "Rematerialization (gradient-checkpointing) transform.", + "jax.closure_convert": "Closure-conversion helper.", + "jax.custom_jvp": "Custom forward-mode rule wrapper.", + "jax.custom_vjp": "Custom reverse-mode rule wrapper.", + "jax.ensure_compile_time_eval": "Compile-time eval context.", + "jax.eval_shape": "Shape/dtype inference without computing.", + "jax.grad": "Reverse-mode gradient transform.", + "jax.hessian": "Hessian transform.", + "jax.jacfwd": "Forward-mode Jacobian transform.", + "jax.jacobian": "Jacobian transform.", + "jax.jacrev": "Reverse-mode Jacobian transform.", + "jax.jit": "JIT compilation of a function.", + "jax.jvp": "Jacobian-vector product transform.", + "jax.lax": "Module reference, present so deep-path calls (jax.lax.rsqrt) resolve. Does NOT permit calling other jax.lax members (exact-path match, not a glob).", + "jax.lax.broadcast": "Broadcasts to a shape.", + "jax.lax.clamp": "Clamps values to a range.", + "jax.lax.concatenate": "Concatenates arrays.", + "jax.lax.cond": "Conditional (executes one branch).", + "jax.lax.cummax": "Cumulative maximum.", + "jax.lax.cumprod": "Cumulative product.", + "jax.lax.cumsum": "Cumulative sum.", + "jax.lax.dynamic_slice": "Dynamic-index slice.", + "jax.lax.dynamic_update_slice": "Dynamic-index slice update.", + "jax.lax.erf": "Error function.", + "jax.lax.fori_loop": "For-loop with an integer bound.", + "jax.lax.pad": "Pads an array.", + "jax.lax.reshape": "Reshapes an array.", + "jax.lax.rng_uniform": "Uniform RNG primitive.", "jax.lax.rsqrt": "Reciprocal square root.", + "jax.lax.scan": "Scan over a leading axis.", + "jax.lax.select": "Element-wise select.", + "jax.lax.select_n": "Multi-way element-wise select.", + "jax.lax.stop_gradient": "Blocks gradient flow through a value.", + "jax.lax.top_k": "Top-k values/indices.", + "jax.lax.transpose": "Transposes axes.", + "jax.lax.while_loop": "While loop.", + "jax.linearize": "Linearization transform.", + "jax.named_call": "Names a call for tracing/profiling.", + "jax.nn": "Module reference, present so deep-path calls (jax.nn.softmax / gelu) resolve. Does NOT permit calling other jax.nn members.", + "jax.nn.celu": "CELU activation.", + "jax.nn.elu": "ELU activation.", "jax.nn.gelu": "GELU activation.", + "jax.nn.glu": "Gated-linear-unit activation.", + "jax.nn.hard_sigmoid": "Hard-sigmoid activation.", + "jax.nn.hard_silu": "Hard-SiLU activation.", + "jax.nn.hard_swish": "Hard-swish activation.", + "jax.nn.hard_tanh": "Hard-tanh activation.", + "jax.nn.initializers.constant": "Constant-value initializer.", + "jax.nn.initializers.delta_orthogonal": "Delta-orthogonal initializer.", + "jax.nn.initializers.glorot_normal": "Glorot (Xavier) normal initializer.", + "jax.nn.initializers.glorot_uniform": "Glorot (Xavier) uniform initializer.", + "jax.nn.initializers.he_normal": "He (Kaiming) normal initializer.", + "jax.nn.initializers.he_uniform": "He (Kaiming) uniform initializer.", + "jax.nn.initializers.kaiming_normal": "Kaiming (He) normal initializer.", + "jax.nn.initializers.kaiming_uniform": "Kaiming (He) uniform initializer.", + "jax.nn.initializers.lecun_normal": "LeCun normal initializer.", + "jax.nn.initializers.lecun_uniform": "LeCun uniform initializer.", + "jax.nn.initializers.normal": "Normal-distribution initializer.", + "jax.nn.initializers.ones": "All-ones parameter initializer.", + "jax.nn.initializers.orthogonal": "Orthogonal-matrix initializer.", + "jax.nn.initializers.truncated_normal": "Truncated-normal initializer.", + "jax.nn.initializers.uniform": "Uniform-distribution initializer.", + "jax.nn.initializers.variance_scaling": "Variance-scaling initializer.", + "jax.nn.initializers.xavier_normal": "Xavier (Glorot) normal initializer.", + "jax.nn.initializers.xavier_uniform": "Xavier (Glorot) uniform initializer.", + "jax.nn.initializers.zeros": "All-zeros parameter initializer.", + "jax.nn.leaky_relu": "Leaky-ReLU activation.", + "jax.nn.log_sigmoid": "Log-sigmoid activation.", + "jax.nn.log_softmax": "Log-softmax activation.", + "jax.nn.logsumexp": "Log-sum-exp reduction.", + "jax.nn.mish": "Mish activation.", + "jax.nn.one_hot": "One-hot encoding of indices.", + "jax.nn.relu": "ReLU activation.", + "jax.nn.relu6": "ReLU6 activation.", + "jax.nn.selu": "SELU activation.", + "jax.nn.sigmoid": "Sigmoid activation.", + "jax.nn.silu": "SiLU / swish activation.", + "jax.nn.soft_sign": "Soft-sign activation.", "jax.nn.softmax": "Softmax activation; required for attention.", + "jax.nn.softplus": "Softplus activation.", + "jax.nn.squareplus": "Squareplus activation.", + "jax.nn.standardize": "Standardizes along an axis.", + "jax.nn.swish": "Swish / SiLU activation.", + "jax.numpy": "Module reference (present so deep-path calls resolve); does NOT permit other jax.numpy members.", + "jax.numpy.abs": "Element-wise absolute value.", + "jax.numpy.absolute": "Element-wise absolute value.", + "jax.numpy.add": "Element-wise addition.", + "jax.numpy.amax": "Maximum reduction.", + "jax.numpy.amin": "Minimum reduction.", + "jax.numpy.arange": "Integer range generator.", + "jax.numpy.arccos": "Element-wise arccosine.", + "jax.numpy.arcsin": "Element-wise arcsine.", + "jax.numpy.arctan": "Element-wise arctangent.", + "jax.numpy.arctan2": "Element-wise two-argument arctangent.", + "jax.numpy.arctanh": "Element-wise inverse hyperbolic tangent.", + "jax.numpy.argmax": "Argmax index reduction.", + "jax.numpy.argmin": "Argmin index reduction.", + "jax.numpy.argsort": "Argsort indices.", "jax.numpy.array": "Materializes input into an array.", + "jax.numpy.array_split": "Splits an array (uneven allowed).", + "jax.numpy.asarray": "Materializes input as an array.", + "jax.numpy.astype": "Casts to a dtype.", + "jax.numpy.average": "Weighted-average reduction.", "jax.numpy.bool_": "Cast to boolean.", + "jax.numpy.broadcast_to": "Broadcasts to a shape.", + "jax.numpy.cbrt": "Element-wise cube root.", + "jax.numpy.ceil": "Element-wise ceiling.", + "jax.numpy.clip": "Clamps values to a range.", "jax.numpy.concatenate": "Joins arrays along an axis.", "jax.numpy.cos": "Element-wise cosine.", + "jax.numpy.cosh": "Element-wise hyperbolic cosine.", + "jax.numpy.cross": "Cross product.", + "jax.numpy.cumprod": "Cumulative product.", + "jax.numpy.cumsum": "Cumulative sum.", + "jax.numpy.diagonal": "Extracts a diagonal.", + "jax.numpy.diff": "Discrete difference.", + "jax.numpy.divide": "Element-wise division.", + "jax.numpy.dot": "Dot product.", + "jax.numpy.e": "Euler's number constant.", "jax.numpy.einsum": "Einstein-summation contraction; required for attention and projections.", + "jax.numpy.empty": "Uninitialized array (shape only).", + "jax.numpy.empty_like": "Uninitialized array matching shape/dtype.", + "jax.numpy.equal": "Element-wise equality.", + "jax.numpy.exp": "Element-wise exponential.", + "jax.numpy.exp2": "Element-wise base-2 exponential.", + "jax.numpy.expand_dims": "Adds a size-1 axis.", + "jax.numpy.expm1": "Element-wise exp(x)-1.", + "jax.numpy.eye": "Identity-matrix constant.", + "jax.numpy.flatnonzero": "Flat indices of non-zero elements.", + "jax.numpy.flip": "Reverses along an axis.", "jax.numpy.float32": "Cast to float32.", + "jax.numpy.float64": "Cast to float64.", + "jax.numpy.floor": "Element-wise floor.", + "jax.numpy.floor_divide": "Element-wise floor division.", + "jax.numpy.full": "Constant-filled array.", + "jax.numpy.full_like": "Constant matching an array's shape/dtype.", + "jax.numpy.geomspace": "Geometrically-spaced values.", + "jax.numpy.greater": "Element-wise greater-than.", + "jax.numpy.greater_equal": "Element-wise greater-or-equal.", + "jax.numpy.hstack": "Horizontal stack.", + "jax.numpy.identity": "Identity-matrix constant.", + "jax.numpy.indices": "Coordinate-index grid.", + "jax.numpy.inf": "Positive-infinity constant.", + "jax.numpy.inner": "Inner product.", + "jax.numpy.int32": "Cast to int32.", + "jax.numpy.isfinite": "Element-wise finiteness test.", + "jax.numpy.isinf": "Element-wise infinity test.", + "jax.numpy.isnan": "Element-wise NaN test.", + "jax.numpy.kron": "Kronecker product.", + "jax.numpy.less": "Element-wise less-than.", + "jax.numpy.less_equal": "Element-wise less-or-equal.", + "jax.numpy.linalg.cholesky": "Cholesky decomposition.", + "jax.numpy.linalg.det": "Matrix determinant.", + "jax.numpy.linalg.eig": "Eigendecomposition.", + "jax.numpy.linalg.eigh": "Hermitian eigendecomposition.", + "jax.numpy.linalg.eigvals": "Eigenvalues.", + "jax.numpy.linalg.eigvalsh": "Hermitian eigenvalues.", + "jax.numpy.linalg.inv": "Matrix inverse.", + "jax.numpy.linalg.lstsq": "Least-squares solve.", + "jax.numpy.linalg.matrix_power": "Integer matrix power.", + "jax.numpy.linalg.matrix_rank": "Matrix rank.", + "jax.numpy.linalg.norm": "Matrix/vector norm.", + "jax.numpy.linalg.pinv": "Pseudo-inverse.", + "jax.numpy.linalg.qr": "QR decomposition.", + "jax.numpy.linalg.slogdet": "Sign and log-determinant.", + "jax.numpy.linalg.solve": "Solves a linear system.", + "jax.numpy.linalg.svd": "Singular-value decomposition.", + "jax.numpy.linspace": "Evenly-spaced values over a range.", + "jax.numpy.log": "Element-wise natural log.", + "jax.numpy.log10": "Element-wise base-10 log.", + "jax.numpy.log1p": "Element-wise log(1+x).", + "jax.numpy.log2": "Element-wise base-2 log.", + "jax.numpy.logical_and": "Element-wise logical AND.", + "jax.numpy.logical_not": "Element-wise logical NOT.", + "jax.numpy.logical_or": "Element-wise logical OR.", + "jax.numpy.logical_xor": "Element-wise logical XOR.", + "jax.numpy.logspace": "Log-spaced values over a range.", + "jax.numpy.matmul": "Matrix multiplication.", + "jax.numpy.max": "Maximum reduction.", + "jax.numpy.maximum": "Element-wise maximum.", "jax.numpy.mean": "Reduction to a mean.", + "jax.numpy.median": "Median reduction.", + "jax.numpy.meshgrid": "Coordinate meshgrid.", + "jax.numpy.min": "Minimum reduction.", + "jax.numpy.minimum": "Element-wise minimum.", + "jax.numpy.mod": "Element-wise modulo.", + "jax.numpy.moveaxis": "Moves axes.", + "jax.numpy.multiply": "Element-wise multiplication.", + "jax.numpy.nan": "NaN constant.", + "jax.numpy.nanmean": "NaN-ignoring mean.", + "jax.numpy.nansum": "NaN-ignoring sum.", + "jax.numpy.negative": "Element-wise negation.", + "jax.numpy.newaxis": "Indexing helper for adding an axis.", + "jax.numpy.nonzero": "Indices of non-zero elements.", + "jax.numpy.not_equal": "Element-wise inequality.", + "jax.numpy.ones": "Constant-ones array.", + "jax.numpy.ones_like": "Ones matching an array's shape/dtype.", + "jax.numpy.outer": "Outer product.", + "jax.numpy.pad": "Pads an array.", + "jax.numpy.pi": "The constant pi.", + "jax.numpy.power": "Element-wise power.", + "jax.numpy.prod": "Product reduction.", + "jax.numpy.ravel": "Flattens to 1-D.", + "jax.numpy.reciprocal": "Element-wise reciprocal.", + "jax.numpy.remainder": "Element-wise remainder.", "jax.numpy.repeat": "Repeats elements along an axis.", + "jax.numpy.reshape": "Reshapes an array.", + "jax.numpy.roll": "Rolls elements along an axis.", + "jax.numpy.round": "Element-wise rounding.", + "jax.numpy.searchsorted": "Binary-search insertion points.", + "jax.numpy.select": "Element-wise multi-condition select.", + "jax.numpy.sign": "Element-wise sign.", "jax.numpy.sin": "Element-wise sine.", + "jax.numpy.sinh": "Element-wise hyperbolic sine.", + "jax.numpy.sort": "Sorts along an axis.", + "jax.numpy.split": "Splits an array.", "jax.numpy.sqrt": "Element-wise square root.", + "jax.numpy.square": "Element-wise square.", + "jax.numpy.squeeze": "Removes size-1 axes.", + "jax.numpy.stack": "Stacks arrays on a new axis.", + "jax.numpy.std": "Standard-deviation reduction.", + "jax.numpy.subtract": "Element-wise subtraction.", + "jax.numpy.sum": "Sum reduction.", + "jax.numpy.swapaxes": "Swaps two axes.", + "jax.numpy.take": "Gathers elements by index.", + "jax.numpy.take_along_axis": "Gathers along an axis by index.", + "jax.numpy.tan": "Element-wise tangent.", + "jax.numpy.tanh": "Element-wise hyperbolic tangent.", + "jax.numpy.tensordot": "Tensor contraction.", + "jax.numpy.tile": "Tiles an array.", + "jax.numpy.trace": "Trace (diagonal sum).", "jax.numpy.transpose": "Reorders axes.", - "jax.numpy.where": "Element-wise conditional select." - }, - "safe": { - "jax.lax": "Module reference, present so deep-path calls (jax.lax.rsqrt) resolve. Does NOT permit calling other jax.lax members (exact-path match, not a glob).", - "jax.nn": "Module reference, present so deep-path calls (jax.nn.softmax / gelu) resolve. Does NOT permit calling other jax.nn members.", - "jax.numpy.arange": "Integer range generator; produces constants, no secret enters.", - "jax.numpy.ones": "Constant-ones tensor; scaffolding only.", - "jax.numpy.square": "Element-wise square; no channel of its own (a statistic amplifier, e.g. in RMSNorm).", + "jax.numpy.tri": "Triangular ones constant.", "jax.numpy.tril": "Lower-triangular mask; builds attention masks.", - "jax.numpy.triu": "Upper-triangular mask; as tril." + "jax.numpy.triu": "Upper-triangular mask; as tril.", + "jax.numpy.true_divide": "Element-wise true division.", + "jax.numpy.trunc": "Element-wise truncation.", + "jax.numpy.unique": "Unique elements.", + "jax.numpy.var": "Variance reduction.", + "jax.numpy.vdot": "Vector dot product.", + "jax.numpy.vstack": "Vertical stack.", + "jax.numpy.where": "Element-wise conditional select.", + "jax.numpy.zeros": "All-zeros array constant.", + "jax.numpy.zeros_like": "Zeros matching an array's shape/dtype.", + "jax.pmap": "Parallel map across devices.", + "jax.random": "Module reference (present so deep-path calls resolve); does NOT permit other jax.random members.", + "jax.random.PRNGKey": "Creates a PRNG key from a seed.", + "jax.random.ball": "Uniform samples in a ball.", + "jax.random.bernoulli": "Bernoulli samples.", + "jax.random.beta": "Beta samples.", + "jax.random.categorical": "Categorical samples from logits (takes data).", + "jax.random.choice": "Random choice from data/probabilities.", + "jax.random.clone": "Clones a PRNG key.", + "jax.random.exponential": "Exponential samples.", + "jax.random.fold_in": "Folds data into a PRNG key.", + "jax.random.gamma": "Gamma samples.", + "jax.random.key": "Creates a typed PRNG key.", + "jax.random.normal": "Standard-normal samples (key + shape).", + "jax.random.permutation": "Random permutation of input data.", + "jax.random.poisson": "Poisson samples.", + "jax.random.rademacher": "Rademacher samples.", + "jax.random.randint": "Uniform integer samples.", + "jax.random.split": "Splits a PRNG key.", + "jax.random.truncated_normal": "Truncated-normal samples.", + "jax.random.uniform": "Uniform samples (key + shape).", + "jax.remat": "Rematerialization transform.", + "jax.tree_util.Partial": "Partial-application pytree wrapper.", + "jax.tree_util.register_pytree_node": "Registers a custom pytree node type.", + "jax.tree_util.register_pytree_node_class": "Registers a pytree node class.", + "jax.tree_util.tree_all": "Boolean-all over pytree leaves.", + "jax.tree_util.tree_flatten": "Flattens a pytree to leaves + structure.", + "jax.tree_util.tree_leaves": "Extracts a pytree's leaves.", + "jax.tree_util.tree_map": "Maps a function over pytree leaves (carries data).", + "jax.tree_util.tree_reduce": "Reduces over pytree leaves.", + "jax.tree_util.tree_structure": "Returns a pytree's structure (no values).", + "jax.tree_util.tree_unflatten": "Rebuilds a pytree from leaves.", + "jax.tree_util.treedef_tuple": "Composes tree structures.", + "jax.value_and_grad": "Value-and-gradient transform.", + "jax.vjp": "Vector-Jacobian product transform.", + "jax.vmap": "Vectorizing map transform." }, "unsafe": { "jax.debug.*": "Debug host callbacks / stdout (jax.debug.print, jax.debug.callback) — runs host Python and writes to the process stdout.", "jax.distributed.*": "Multi-host coordination — opens outbound network connections to a coordinator address.", "jax.dlpack.*": "Hands raw device buffers to other frameworks (interop escape hatch).", - "jax.experimental.*": "Experimental surface — includes io_callback, export, and array_serialization (writes arrays to disk and gs:// cloud storage).", - "jax.ffi*": "Foreign-function interface into native code.", + "jax.experimental.*": "Experimental surface — unvetted by design; includes io_callback host callbacks.", + "jax.ffi.*": "Foreign-function interface into native code.", "jax.monitoring.*": "Telemetry / metrics reporting hooks.", "jax.numpy.load*": "Reads array(s) from disk (load / loadtxt).", "jax.numpy.save*": "Serializes array(s) to disk (save / savez / savez_compressed / savetxt).", diff --git a/packages/syft-restrict/tests/test_audit.py b/packages/syft-restrict/tests/test_audit.py index 128a6c63c78..b488a60af70 100644 --- a/packages/syft-restrict/tests/test_audit.py +++ b/packages/syft-restrict/tests/test_audit.py @@ -29,24 +29,25 @@ def test_glob_allow_is_flagged_unsafe(): assert "glob" in _entry(report, "jax.*").reason -def test_genuinely_inert_paths_are_safe(): - # constants, masks, and module refs have no residual channel of their own. - paths = ["jax.numpy.arange", "jax.numpy.ones", "jax.numpy.tril", "jax.lax"] +def test_pure_computation_is_safe(): + # Pure math is safe, including the most expressive ops (einsum, matmul, softmax). + paths = [ + "jax.numpy.einsum", "jax.numpy.matmul", "jax.nn.softmax", "jax.numpy.where", + "jax.numpy.sort", "jax.random.categorical", "flax.linen.Dense", "flax.linen.relu", + ] report = audit_allow_functions(paths) assert all(_entry(report, p).verdict == "safe" for p in paths) - assert all(_entry(report, p).reason for p in paths) # safe entries still explained - assert report.ok # only-safe list passes + assert all(_entry(report, p).reason for p in paths) + assert report.ok -def test_dual_use_paths_are_flagged_between_safe_and_unsafe(): - # useful ops that are mostly safe but abusable in combination land in dual_use, not safe. - paths = ["jax.numpy.einsum", "jax.nn.softmax", "jax.numpy.where", "flax.linen.Module"] +def test_dual_use_is_narrow(): + # dual_use is reserved for a specific capability beyond pure computation: the host/device + # boundary crossers, and flax's attribute-access knob. + paths = ["jax.device_get", "jax.device_put", "flax.linen.Module"] report = audit_allow_functions(paths) assert all(_entry(report, p).verdict == "dual_use" for p in paths) - assert all(_entry(report, p).reason for p in paths) # dual_use entries carry a terse note - # the category carries the caution, so the note stays vague -- no abuse how-to / recipe wording. - einsum_reason = _entry(report, "jax.numpy.einsum").reason - assert "encode" not in einsum_reason and "secret" not in einsum_reason + assert all(_entry(report, p).reason for p in paths) # each carries its own concrete reason assert report.ok # dual_use does not fail the report (allowed-but-flagged) @@ -74,9 +75,54 @@ def test_orbax_is_flagged_unsafe_without_a_version_dir(): assert _entry(report, "orbax.checkpoint.save").verdict == "unsafe" +def test_flax_linen_common_surface_is_catalogued(): + # Spot-check the flax.linen coverage: pure compute is safe, only Module is dual_use, io is unsafe. + expected = { + "flax.linen.Dense": "safe", # pure computation + "flax.linen.MultiHeadDotProductAttention": "safe", + "flax.linen.relu": "safe", # activations are pure math -> safe + "flax.linen.softmax": "safe", + "flax.linen.scan": "safe", # lifted transform over pure compute + "flax.linen.make_causal_mask": "safe", # structural mask helper + "flax.linen.compact": "safe", # decorator / machinery + "flax.linen.initializers.lecun_normal": "safe", # init, no data channel + "flax.linen.Module": "dual_use", # attribute-access knob + "flax.io.read_file": "unsafe", # flax.io.* glob -> disk IO + } + report = audit_allow_functions(list(expected)) + got = {e.path: e.verdict for e in report.entries} + assert got == expected + assert all(_entry(report, p).reason for p in expected) # every entry carries a note + + +def test_jax_common_surface_is_catalogued(): + # Spot-check the jax coverage: pure compute (transforms, math, samplers) is safe; only the + # host/device boundary crossers are dual_use; IO/callbacks are unsafe. + expected = { + "jax.jit": "safe", # transform over pure compute + "jax.grad": "safe", + "jax.numpy.matmul": "safe", # pure compute + "jax.numpy.sum": "safe", # reduction + "jax.nn.relu": "safe", # activation + "jax.numpy.linalg.svd": "safe", + "jax.random.categorical": "safe", # pure compute on logits + "jax.numpy.zeros": "safe", # constant creation + "jax.random.split": "safe", # key management + "jax.nn.initializers.he_normal": "safe", # init + "jax.device_get": "dual_use", # host/device boundary crossing + "jax.device_put": "dual_use", + "jax.numpy.save": "unsafe", # disk IO (jax.numpy.save* rule) + "jax.debug.print": "unsafe", # host callback + } + report = audit_allow_functions(list(expected)) + got = {e.path: e.verdict for e in report.entries} + assert got == expected + assert all(_entry(report, p).reason for p in expected) + + def test_report_format_has_sections_and_ok_flag(): report = audit_allow_functions( - ["jax.profiler.start_server", "jax.numpy.einsum", "jax.numpy.ones"] + ["jax.profiler.start_server", "jax.device_get", "jax.numpy.einsum"] ) text = report.format() assert "UNSAFE" in text and "DUAL-USE" in text and "SAFE" in text From 12d03f91f856528c5a3e827be17258027a7232d9 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 23 Jul 2026 13:39:09 -0300 Subject: [PATCH 4/5] Refactor code formatting and improve documentation clarity in README, audit, and test files --- packages/syft-restrict/README.md | 39 ++++-- packages/syft-restrict/docs/audit.md | 103 ++++++++++++++++ .../catalog/_common/default/catalog.json | 0 .../catalog/flax/0.12/catalog.json | 0 .../catalog/jax/0.11/catalog.json | 0 packages/syft-restrict/pyproject.toml | 5 + .../src/syft_restrict/__init__.py | 2 +- .../syft_restrict/{audit.py => auditor.py} | 111 +++++++++-------- .../src/syft_restrict/catalog/README.md | 52 -------- .../{catalog/lint.py => catalog_lint.py} | 39 +++--- .../syft-restrict/src/syft_restrict/runner.py | 15 ++- .../tests/{test_audit.py => test_auditor.py} | 112 ++++++++++++++---- packages/syft-restrict/tests/test_run.py | 34 ++++++ 13 files changed, 361 insertions(+), 151 deletions(-) create mode 100644 packages/syft-restrict/docs/audit.md rename packages/syft-restrict/{src/syft_restrict => examples}/catalog/_common/default/catalog.json (100%) rename packages/syft-restrict/{src/syft_restrict => examples}/catalog/flax/0.12/catalog.json (100%) rename packages/syft-restrict/{src/syft_restrict => examples}/catalog/jax/0.11/catalog.json (100%) rename packages/syft-restrict/src/syft_restrict/{audit.py => auditor.py} (58%) delete mode 100644 packages/syft-restrict/src/syft_restrict/catalog/README.md rename packages/syft-restrict/src/syft_restrict/{catalog/lint.py => catalog_lint.py} (65%) rename packages/syft-restrict/tests/{test_audit.py => test_auditor.py} (58%) diff --git a/packages/syft-restrict/README.md b/packages/syft-restrict/README.md index a76a314c053..55aff62ac3c 100644 --- a/packages/syft-restrict/README.md +++ b/packages/syft-restrict/README.md @@ -13,14 +13,18 @@ constructs in a **private** region, typically the model logic that must be kept - **Public** — every other line outside the marked ranges: imports, data loading, wrappers. It is never checked or obfuscated. The recipient of the obfuscated file can read it directly. -Given that split, `syft-restrict` executes the following two steps: +Given that split, `syft-restrict` executes the following three steps: +- **The auditor** checks the allow-list of library calls and operators against a + catalog of known safe/dual-use/unsafe paths, and warns on any uncatalogued + paths. - **The verifier** statically walks the private region and analyzes it against a default-deny policy. Every construct must be explicitly allowed, or verification fails and reports each offending line. -- **The obfuscator** runs after a clean verify. It creates an obfuscated copy by rewriting the private - region — renaming identifiers, blanking constants, replacing lines with `■■■■■■■■` — so the logic can't be read - back out, while the public region is copied through untouched. +- **The obfuscator** runs after a clean verify. It creates an obfuscated copy by + rewriting the private region — renaming identifiers, blanking constants, + replacing lines with `■■■■■■■■` — so the logic can't be read back out, while + the public region is copied through untouched. The **verifier** is inspired by [**RestrictedPython**](https://github.com/zopefoundation/RestrictedPython), but @@ -74,14 +78,26 @@ result = restrict.run( # If the file has no `# syft-restrict: ...` markers: raises MarkerError. ``` +The **auditor** warns on any `allow_functions` that are categorized as dual-use +or unsafe, whether they are used or not, but the verifier fails only on actual +violations in the private region. The auditor is **advisory only**, and does not +block verification. See [docs/audit.md](docs/audit.md) for instructions on how +to build the catalog for the specific libraries in use. + > [!IMPORTANT] -> List the **specific** paths your model calls, as above — this is the default-deny posture the -> tool is built for: everything not named is denied, so no `disallow_functions` is needed. **Avoid -> broad globs like `jax.*`/`flax.*`**: a glob silently pulls in the library's host-callback, disk-IO, -> and network surface (`jax.numpy.save`, `jax.debug.callback`, `jax.profiler.start_server`, -> `jax.distributed.initialize`, …) that the private region could use to exfiltrate data. A glob can -> be paired with a `disallow_functions` floor, but that is a **leaky backstop** — it only blocks -> what you remembered to list — not a substitute for a tight allow. See +> +> List the **specific** paths your model calls, as above. This is the +> default-deny posture the tool is built for: everything not named is denied, so +> no `disallow_functions` is needed. +> +> **Avoid broad globs like `jax.*`/`flax.*`**: a glob silently pulls in the +> library's host-callback, disk-IO, and network surface (`jax.numpy.save`, +> `jax.debug.callback`, `jax.profiler.start_server`, +> `jax.distributed.initialize`, …) that can be abused. +> +> A glob can be paired with a `disallow_functions` floor, but that is a **leaky +> backstop** — it only blocks what you remembered to list — not a substitute for +> a tight allow. See > [docs/blacklist.md](docs/blacklist.md#optional-disallow_functions). The private region is designated **only** by `# syft-restrict: ...` comment markers in the @@ -110,4 +126,5 @@ instead of an exception. - [docs/verify.md](docs/verify.md) — how verification works and what private code may do (allow side). - [docs/blacklist.md](docs/blacklist.md) — default-deny catalog and violation codes (deny side). +- [docs/audit.md](docs/audit.md) — advisory allow-list audit and the risk catalog. - [docs/code-layout.md](docs/code-layout.md) — source modules and test layout. diff --git a/packages/syft-restrict/docs/audit.md b/packages/syft-restrict/docs/audit.md new file mode 100644 index 00000000000..8b9f74b5c37 --- /dev/null +++ b/packages/syft-restrict/docs/audit.md @@ -0,0 +1,103 @@ +# Allow-list audit + +`syft_restrict.auditor.audit_allow_functions` classifies each path in an `allow_functions` list +against a curated catalog. It is advisory — it shows what an allow-list grants before a run; the +verifier's default-deny is what controls calls. `run()` performs this audit and attaches the report +to `result.audit`; it never affects `result.ok`. + +## Verdicts + +- **`unsafe`** — reads or writes files, opens network connections, or runs host callbacks. +- **`safe`** — pure computation (math, reductions, indexing, activations, constants, RNG, + initializers, and autodiff/compile transforms that wrap pure computation). +- **`dual_use`** — not pure computation and not a clear file/network/callback op, but with one other + capability the verifier cannot analyze; each entry states it. Rare — the examples are host/device + transfers (`jax.device_get`/`device_put`) and `flax.linen.Module`'s `allow_base_class_attributes` + flag. +- **`review`** — anything uncatalogued. A warning for a human, never a silent pass; unknown paths are + never assumed safe. + +Matching is strictest-first: (`unsafe` → `dual_use` → `safe`); an unmatched path is `review`. + +## Catalog layout + +No catalog ships with the package. A catalog is a directory passed as `catalog_dir`, laid out as: + +``` +/ + _common/default/catalog.json # cross-library patterns, merged into every path + //catalog.json # rules for at a matching version +``` + +- `` is a prefix matched on dot boundaries: `0.11` covers `0.11.x`, never `0.19`. There is + no per-library fallback — an unmatched version contributes no rules, so its paths fall to `review`. +- `_common/default` holds cross-library patterns (`*.io_callback`, `*.tofile`, …) and rules for + libraries with no version-keyable import root (e.g. `orbax`). + +A worked example lives in `examples/catalog`. Each file is +`{"_about": …, "unsafe": {…}, "dual_use": {…}, "safe": {…}}` with single-line string values. Keep it +sorted and normalized with the linter (`PATH` is the file or directory to lint): + +```bash +uv run syft-restrict-lint PATH --fix +``` + +## Generate a catalog for a library + +syft-restrict does **not** maintain catalogs; `examples/catalog` (`jax/0.11`, `flax/0.12`) is a worked +example. Uncatalogued paths fall to `review`, so a partial catalog is safe as long as it covers the +actual calls. + +Keep the catalog in its own directory and point the auditor at it with `run(..., catalog_dir=…)`. + +Fill in `{LIBRARY}` and `{VERSION}` below and paste it into an AI coding agent with Python and shell +access where that version is installed. Review every `unsafe` and `dual_use` entry before trusting +it. + +```text +You make a risk catalog for a static allow-list audit tool. It classifies each dotted import path a +user allow-lists. Produce the catalog JSON for the commonly-used public API of {LIBRARY} {VERSION}. + +CONTEXT +- Advisory tool: it shows a reviewer what an allow-list grants before a run. +- Any path you do NOT list becomes "review" (a warning). A partial catalog is fine. Cover the + functions and classes that real models and tutorials use, and leave out the rest. +- {LIBRARY} {VERSION} is installed. Verify every path resolves in that version (import the module and + check the attribute); drop any that do not. + +CATEGORIES +- "unsafe": reads or writes files, opens network connections, or runs host callbacks (arbitrary host + code). Be complete. Use fnmatch globs for whole risky namespaces (e.g. "{LIBRARY}.io.*"). +- "safe": pure computation — returns a value with no external effect. Arithmetic, linear algebra, + reductions, comparisons, indexing, activations, constants, random numbers, initializers, and + compile/autodiff transforms that wrap pure computation. +- "dual_use": not pure computation and not a clear file/network/callback op, but with one other + capability a static checker cannot analyze (e.g. moving data across the host/device boundary, or a + flag that widens what the checker accepts). State that capability. This category is often empty. + +RULES +- If the only notable thing about a function is that it computes on data, it is "safe" — even when + very flexible. "dual_use" needs a capability that is not computation. +- Never put an uncertain path in "safe"; leave it out (it becomes "review"). Do not use a wide glob + like "{LIBRARY}.*" in "safe". +- Notes: one line, plain description. For "unsafe"/"dual_use", state the specific reason (for + "dual_use", something other than "computes on data"). Never describe how to misuse anything. + +OUTPUT +Produce one JSON object in this shape (keys in any order; the linter sorts them): + +{ + "_about": "Risk rules for {LIBRARY} {VERSION}. 'unsafe' = disk/network/host-callback; 'safe' = pure computation; 'dual_use' = flagged for a specific capability beyond pure computation, reason stated per entry. This is advisory, not a proof.", + "unsafe": { "": "why it is unsafe" }, + "dual_use": { "": "the specific non-computation capability it has" }, + "safe": { "": "what the op is" } +} + +Omit an empty category. Every value is a single-line string. Use the primary public import path +(e.g. "{LIBRARY}.nn.relu"), not a deep private module path. + +WRITE AND VALIDATE +- Write it to "catalog/{LIBRARY}/{VERSION}/catalog.json" (create the directories). +- Run "uv run syft-restrict-lint catalog --fix"; fix whatever it reports. +- Re-import every catalogued path in {LIBRARY} {VERSION}, drop any that fail, and lint again. +``` diff --git a/packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json b/packages/syft-restrict/examples/catalog/_common/default/catalog.json similarity index 100% rename from packages/syft-restrict/src/syft_restrict/catalog/_common/default/catalog.json rename to packages/syft-restrict/examples/catalog/_common/default/catalog.json diff --git a/packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json b/packages/syft-restrict/examples/catalog/flax/0.12/catalog.json similarity index 100% rename from packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json rename to packages/syft-restrict/examples/catalog/flax/0.12/catalog.json diff --git a/packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json b/packages/syft-restrict/examples/catalog/jax/0.11/catalog.json similarity index 100% rename from packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json rename to packages/syft-restrict/examples/catalog/jax/0.11/catalog.json diff --git a/packages/syft-restrict/pyproject.toml b/packages/syft-restrict/pyproject.toml index 5e1e17d5ab1..5eb61579f74 100644 --- a/packages/syft-restrict/pyproject.toml +++ b/packages/syft-restrict/pyproject.toml @@ -10,6 +10,11 @@ requires-python = ">=3.10" # Static analysis on the stdlib (ast, tokenize, hashlib, fnmatch); pydantic models the results. dependencies = ["pydantic>=2"] +# Console script for the catalog linter, so authors can lint their own catalog after install +# (e.g. `uv run syft-restrict-lint path/to/catalog --fix`) without a source-tree path. +[project.scripts] +syft-restrict-lint = "syft_restrict.catalog_lint:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/packages/syft-restrict/src/syft_restrict/__init__.py b/packages/syft-restrict/src/syft_restrict/__init__.py index d57ac486fb1..671641c7dfd 100644 --- a/packages/syft-restrict/src/syft_restrict/__init__.py +++ b/packages/syft-restrict/src/syft_restrict/__init__.py @@ -9,7 +9,7 @@ __version__ = "0.1.0" -from .audit import AuditReport, PathAudit, audit_allow_functions +from .auditor import AuditReport, PathAudit, audit_allow_functions from .errors import MarkerError, PolicyViolation, RestrictError from .markers import parse_markers from .obfuscator import obfuscate diff --git a/packages/syft-restrict/src/syft_restrict/audit.py b/packages/syft-restrict/src/syft_restrict/auditor.py similarity index 58% rename from packages/syft-restrict/src/syft_restrict/audit.py rename to packages/syft-restrict/src/syft_restrict/auditor.py index b69742aa75e..f25a982bada 100644 --- a/packages/syft-restrict/src/syft_restrict/audit.py +++ b/packages/syft-restrict/src/syft_restrict/auditor.py @@ -1,31 +1,9 @@ -"""Advisory audit of an ``allow_functions`` list: classify each allowed dotted path by risk. - -This is **defense-in-depth, not a soundness proof.** The verifier's default-deny already decides what -the private region may call; this tool helps an author or reviewer see — *before* running — whether -their allow-list grants any known disk/network/host-callback capability, or any path a human should -eyeball. It generalizes across models: feed it whatever ``allow_functions`` a given model needs. - -Each allowed entry is classified against a **curated catalog** (the ``catalog/`` directory, laid out -as ``catalog///catalog.json`` plus ``catalog/_common/default`` for library-agnostic -rules — kept out of the code so assessments can be revised per release; see ``catalog/README.md``): - -- ``"unsafe"`` — matches a catalog entry for known disk/network/host-callback surface, OR is a glob - (``jax.*``) that grants a whole namespace. Remove it or tighten the allow. -- ``"safe"`` — pure computation: ordinary math (``einsum``, ``matmul``, activations, reductions, - comparisons, reshapes), constants, RNG, and initializers. -- ``"dual_use"`` — a path flagged for a specific capability beyond pure computation; each entry - states its own concrete reason (e.g. crossing the host/device boundary, or widening what the - verifier accepts as valid attribute access). -- ``"review"`` — none of the above. The audit makes **no** guess about it: it is reported as - uncatalogued and deferred to human review. Unknowns are never assumed safe. - -Limits (state them plainly to whoever reads a report): - -- Classification is **only** catalog matching — no source inspection, no inference. A path the catalog - does not know is deferred to a human; the tool does not try to guess whether it does I/O. -- The catalog is curated per library version; the report records the versions it saw. - -Anything not matched as unsafe, dual_use, or safe defaults to ``"review"``, never silently to safe. +"""Advisory audit of an ``allow_functions`` list: classify each dotted path as ``unsafe`` / +``dual_use`` / ``safe`` / ``review`` against a catalog passed via ``catalog_dir``. + +Advisory, not a proof — the verifier's default-deny is what gates calls. Classification is catalog +matching only (no source inspection); an uncatalogued path is ``review``, never assumed safe. No +catalog ships with the package. See docs/audit.md for the verdicts, catalog layout, and workflow. """ from __future__ import annotations @@ -41,9 +19,9 @@ __all__ = ["audit_allow_functions", "AuditReport", "PathAudit"] -# Catalog laid out as catalog///catalog.json, plus catalog/_common/default for -# library-agnostic rules. See catalog/README.md for the scheme. -_CATALOG_DIR = Path(__file__).with_name("catalog") +# A catalog root is a directory laid out as //catalog.json, plus _common/default +# for library-agnostic rules. None ships with the package; callers pass one via ``catalog_dir``. +# See docs/audit.md for the scheme and examples/catalog for a worked example. _COMMON_LIB = "_common" _COMMON_VERSION = "default" @@ -62,7 +40,9 @@ class PathAudit(BaseModel): class AuditReport(BaseModel): entries: list[PathAudit] = Field(default_factory=list) - versions: dict[str, str] = Field(default_factory=dict) # top-level package -> version seen + versions: dict[str, str] = Field( + default_factory=dict + ) # top-level package -> version seen def _by_verdict(self, verdict: Verdict) -> list[PathAudit]: return [e for e in self.entries if e.verdict == verdict] @@ -90,7 +70,10 @@ def ok(self) -> bool: return not self.unsafe def format(self) -> str: - vers = ", ".join(f"{k} {v}" for k, v in sorted(self.versions.items())) or "no versions detected" + vers = ( + ", ".join(f"{k} {v}" for k, v in sorted(self.versions.items())) + or "no versions detected" + ) lines = [f"allow-list audit ({vers})"] groups = ( ("UNSAFE", self.unsafe), @@ -104,26 +87,42 @@ def format(self) -> str: lines.append(f" {label} ({len(group)}):") for e in group: lines.append(f" - {e.path}{' — ' + e.reason if e.reason else ''}") - lines.append(f" => ok={self.ok} (unsafe entries fail; dual-use and review entries need a human)") + lines.append( + f" => ok={self.ok} (unsafe entries fail; dual-use and review entries need a human)" + ) return "\n".join(lines) def __str__(self) -> str: return self.format() -def audit_allow_functions(allow_functions: list[str] | None) -> AuditReport: +def audit_allow_functions( + allow_functions: list[str] | None, *, catalog_dir: str | Path | None = None +) -> AuditReport: """Classify each entry of an ``allow_functions`` list; see the module docstring for semantics. Classification is catalog matching only: a path the catalog does not know is reported as ``"review"`` and deferred to a human. Unknowns are never assumed safe. + + ``catalog_dir`` is the external catalog root (``//catalog.json`` layout). No + catalog ships with the package, so without ``catalog_dir`` there are no rules and every path is + reported as ``"review"``. A worked example lives in ``examples/catalog``. """ paths = [p for p in (s.strip() for s in (allow_functions or [])) if p] versions = _detect_versions(paths) - entries = [_classify(p, versions) for p in paths] + roots = _roots(catalog_dir) + entries = [_classify(p, versions, roots) for p in paths] return AuditReport(entries=entries, versions=versions) -def _classify(path: str, versions: dict[str, str]) -> PathAudit: +def _roots(catalog_dir: str | Path | None) -> tuple[Path, ...]: + """The catalog roots to consult (empty when no ``catalog_dir`` is given).""" + return (Path(catalog_dir),) if catalog_dir is not None else () + + +def _classify( + path: str, versions: dict[str, str], roots: tuple[Path, ...] +) -> PathAudit: if "*" in path or "?" in path: return PathAudit( path=path, @@ -132,7 +131,7 @@ def _classify(path: str, versions: dict[str, str]) -> PathAudit: "list exact leaves instead", ) library = path.split(".", 1)[0] - rules = _rules_for(library, versions.get(library, "")) + rules = _rules_for(library, versions.get(library, ""), roots) for verdict in _BUCKETS: # unsafe -> dual_use -> safe: strictest match wins for pattern, reason in rules[verdict].items(): if fnmatch.fnmatchcase(path, pattern): @@ -144,35 +143,43 @@ def _classify(path: str, versions: dict[str, str]) -> PathAudit: ) -def _rules_for(library: str, version: str) -> dict[str, dict[str, str]]: +def _rules_for( + library: str, version: str, roots: tuple[Path, ...] +) -> dict[str, dict[str, str]]: """Merge the library-agnostic ``_common`` rules with the version-matched library rules, per - bucket (``unsafe`` / ``dual_use`` / ``safe``).""" + bucket (``unsafe`` / ``dual_use`` / ``safe``). Roots are overlaid lowest-precedence first, so an + earlier root wins on a key conflict. With no ``catalog_dir`` there are no roots and no rules.""" merged: dict[str, dict[str, str]] = {bucket: {} for bucket in _BUCKETS} - common = _load_ruleset(_COMMON_LIB, _COMMON_VERSION) - version_dir = _match_version_dir(library, version) - lib_rules = _load_ruleset(library, version_dir) if version_dir is not None else {} - for ruleset in (common, lib_rules): - for bucket in _BUCKETS: - merged[bucket].update(ruleset.get(bucket, {})) + for root in reversed( + roots + ): # earlier (higher-precedence) roots applied last -> they win + common = _load_ruleset(root, _COMMON_LIB, _COMMON_VERSION) + version_dir = _match_version_dir(root, library, version) + lib_rules = ( + _load_ruleset(root, library, version_dir) if version_dir is not None else {} + ) + for ruleset in (common, lib_rules): + for bucket in _BUCKETS: + merged[bucket].update(ruleset.get(bucket, {})) return merged @lru_cache(maxsize=None) -def _load_ruleset(library: str, version_dir: str) -> dict: - """Read ``catalog///catalog.json``; empty dict if the file is absent.""" - path = _CATALOG_DIR / library / version_dir / "catalog.json" +def _load_ruleset(root: Path, library: str, version_dir: str) -> dict: + """Read ``///catalog.json``; empty dict if the file is absent.""" + path = root / library / version_dir / "catalog.json" if not path.is_file(): return {} return json.loads(path.read_text()) -def _match_version_dir(library: str, version: str) -> str | None: - """Longest version-dir under ``catalog//`` matching ``version`` on a dot boundary. +def _match_version_dir(root: Path, library: str, version: str) -> str | None: + """Longest version-dir under ``//`` matching ``version`` on a dot boundary. Returns ``None`` when no directory matches — there is no version-agnostic fallback, so an uncovered version simply contributes no library rules. """ - lib_dir = _CATALOG_DIR / library + lib_dir = root / library if not lib_dir.is_dir(): return None keys = [child.name for child in lib_dir.iterdir() if child.is_dir()] diff --git a/packages/syft-restrict/src/syft_restrict/catalog/README.md b/packages/syft-restrict/src/syft_restrict/catalog/README.md deleted file mode 100644 index dbe09eab79d..00000000000 --- a/packages/syft-restrict/src/syft_restrict/catalog/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Risk catalog - -Curated, advisory risk rules for the allow-list audit (`syft_restrict.audit`). **Advisory, not a -proof** — the verifier's default-deny is what actually gates calls; this catalog only helps a human -see, before running, what capabilities an `allow_functions` list grants. - -## Layout - -``` -catalog/ - _common/ - default/catalog.json # library-agnostic patterns, merged into every path - / - /catalog.json # rules for when the installed version matches -``` - -- `` is the first dotted component of an allowed path (`jax`, `flax`, …). -- `` is a version prefix (`0.11`, `0.19`). The audit picks the **longest** version dir whose - name matches the installed version on a dot boundary — `0.11` covers `0.11.x`, never `0.11` vs - `0.19`. **There is no version-agnostic fallback per library:** if no version dir matches the - installed version, that library contributes no rules and its paths fall to `review`. Add a version - dir to cover a release. -- `_common/default/catalog.json` is always merged in. It holds truly cross-library patterns (`*.io_callback`, `*.tofile`, …) and blanket rules - for libraries whose import root cannot be version-keyed (e.g. `orbax`: the `orbax` import root is a - namespace package with no `__version__`; the distribution is `orbax-checkpoint`). - -## File shape - -Each `catalog.json` is: - -```json -{ - "_about": "free-text note", - "unsafe": { "": "why it is unsafe" }, - "dual_use": { "": "the concrete reason it is flagged" }, - "safe": { "": "what the op is (terse)" } -} -``` - -- `unsafe` = known disk/network/host-callback surface. -- `safe` = pure computation: ordinary math (`einsum`, `matmul`, activations, reductions, reshapes), - constants, RNG, and initializers. -- `dual_use` = a path flagged for a specific capability beyond pure computation. Each entry must - state its own concrete reason (e.g. crossing the host/device boundary). - -A path is matched strictest-first (`unsafe` → `dual_use` → `safe`). Anything matched by none defaults -to `review` — never silently to `safe`. - -> [!Note] -> -> `safe` means "no disk/network/host-callback capability", not "no information -> flow". The catalog lists capabilities, not guarantees. diff --git a/packages/syft-restrict/src/syft_restrict/catalog/lint.py b/packages/syft-restrict/src/syft_restrict/catalog_lint.py similarity index 65% rename from packages/syft-restrict/src/syft_restrict/catalog/lint.py rename to packages/syft-restrict/src/syft_restrict/catalog_lint.py index 6e10d09da91..9e6ae8933f6 100644 --- a/packages/syft-restrict/src/syft_restrict/catalog/lint.py +++ b/packages/syft-restrict/src/syft_restrict/catalog_lint.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Lint every ``catalog.json`` under this directory tree. +"""Lint the ``catalog.json`` files a benchmark owner writes for the allow-list audit. Enforces a canonical, diff-friendly form: @@ -7,13 +7,13 @@ - 2-space indent, UTF-8 kept verbatim (no ``\\uXXXX`` escaping), trailing newline, - no line breaks inside any string value (each entry description stays on one line). -Usage:: +Usage (installed as the ``syft-restrict-lint`` console script):: - python lint.py # check only; non-zero exit if anything is off - python lint.py --fix # rewrite files into canonical form in place + uv run syft-restrict-lint PATH # check only; non-zero exit if anything is off + uv run syft-restrict-lint PATH --fix # rewrite files into canonical form in place -``--fix`` reformats (sorts + indents) and collapses any line break inside a value into a single -space, so every entry description ends up on one line. +``PATH`` is a ``catalog.json`` file or a directory to lint recursively. ``--fix`` reformats (sorts + +indents) and collapses any line break inside a value into a single space. """ from __future__ import annotations @@ -25,8 +25,6 @@ import sys from pathlib import Path -_CATALOG_DIR = Path(__file__).resolve().parent - # A run of whitespace containing at least one line break -> a single space. _LINE_BREAK = re.compile(r"\s*[\r\n]+\s*") @@ -46,9 +44,9 @@ def _normalize(data: object) -> object: return data -def _lint_file(path: Path, *, fix: bool) -> list[str]: +def _lint_file(path: Path, root: Path, *, fix: bool) -> list[str]: """Return a list of human-readable problems with ``path`` (empty if clean).""" - rel = path.relative_to(_CATALOG_DIR) + rel = path.relative_to(root) text = path.read_text(encoding="utf-8") try: data = json.loads(text) @@ -76,17 +74,30 @@ def _lint_file(path: Path, *, fix: bool) -> list[str]: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--fix", action="store_true", help="rewrite files into canonical form") + parser.add_argument( + "path", + help="a catalog.json file, or a directory of catalog.json files to lint recursively", + ) + parser.add_argument( + "--fix", action="store_true", help="rewrite files into canonical form" + ) args = parser.parse_args(argv) - files = sorted(_CATALOG_DIR.rglob("catalog.json")) + target = Path(args.path) + if not target.exists(): + print(f"path not found: {target}", file=sys.stderr) + return 1 + if target.is_file(): + root, files = target.parent, [target] + else: + root, files = target, sorted(target.rglob("catalog.json")) if not files: - print(f"no catalog.json found under {_CATALOG_DIR}", file=sys.stderr) + print(f"no catalog.json found under {target}", file=sys.stderr) return 1 problems: list[str] = [] for path in files: - problems += _lint_file(path, fix=args.fix) + problems += _lint_file(path, root, fix=args.fix) if problems: for problem in problems: diff --git a/packages/syft-restrict/src/syft_restrict/runner.py b/packages/syft-restrict/src/syft_restrict/runner.py index 89b3ea9ea60..b7c51cec0ff 100644 --- a/packages/syft-restrict/src/syft_restrict/runner.py +++ b/packages/syft-restrict/src/syft_restrict/runner.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field from .astutil import normalize_ranges, scan_file +from .auditor import AuditReport, audit_allow_functions from .errors import PolicyViolation from .markers import parse_markers from .obfuscator import obfuscate as _obfuscate @@ -23,6 +24,9 @@ class RunResult(BaseModel): violations: list[Violation] = Field(default_factory=list) obfuscated_path: str | None = None certificate: dict | None = None + # Advisory audit of ``allow_functions`` (see auditor.py). Never affects ``ok`` -- the verifier + # decides pass/fail; this only flags what the allow-list grants. + audit: AuditReport | None = None def run( @@ -34,6 +38,7 @@ def run( allow_base_class_attributes: bool = True, out: str | Path | None = None, strict: bool = True, + catalog_dir: str | Path | None = None, ) -> RunResult: """Verify the private region, then (on success) write a display copy. @@ -58,6 +63,9 @@ def run( out: where to write the obfuscated file (default ``.obfuscated.py`` next to the source). strict: if True (default), raise ``PolicyViolation`` when verification fails; otherwise return a ``RunResult`` with ``ok=False`` and no output written. + catalog_dir: risk catalog root for the advisory audit (see auditor.py). None ships with the + package, so without it the audit reports every path as ``review``. Does not affect + verification. """ path = Path(path) # Read the source exactly once and hand it to _run, so marker resolution, verification, and @@ -76,6 +84,7 @@ def run( out=out, strict=strict, source=source, + catalog_dir=catalog_dir, ) @@ -91,6 +100,7 @@ def _run( out: str | Path | None = None, strict: bool = True, source: str | None = None, + catalog_dir: str | Path | None = None, ) -> RunResult: """Verify and obfuscate using explicit 1-based line ranges (no marker scanning). @@ -115,6 +125,8 @@ def _run( allow_local_assignments, allow_base_class_attributes, ) + # Advisory only: classify the allow-list, but never let it change pass/fail. + audit = audit_allow_functions(allow_functions, catalog_dir=catalog_dir) obfuscate_ranges = obfuscate or [] hide_ranges = hide or [] @@ -124,7 +136,7 @@ def _run( if not result.ok: if strict: raise PolicyViolation(result.violations) - return RunResult(ok=False, violations=result.violations) + return RunResult(ok=False, violations=result.violations, audit=audit) scan = scan_file(ast.parse(source), normalize_ranges(private)) obfuscated = _obfuscate(source, obfuscate_ranges, hide_ranges, scan) @@ -146,6 +158,7 @@ def _run( violations=[], obfuscated_path=str(out_path), certificate=certificate, + audit=audit, ) diff --git a/packages/syft-restrict/tests/test_audit.py b/packages/syft-restrict/tests/test_auditor.py similarity index 58% rename from packages/syft-restrict/tests/test_audit.py rename to packages/syft-restrict/tests/test_auditor.py index b488a60af70..467b887ecae 100644 --- a/packages/syft-restrict/tests/test_audit.py +++ b/packages/syft-restrict/tests/test_auditor.py @@ -1,13 +1,32 @@ -"""Tests for the advisory allow-list audit (syft_restrict.audit).""" +"""Tests for the advisory allow-list audit (syft_restrict.auditor).""" + +import json +from pathlib import Path + +import jax from syft_restrict import AuditReport, audit_allow_functions -from syft_restrict.audit import _best_version_key +from syft_restrict.auditor import _best_version_key +from syft_restrict.catalog_lint import main as lint_main + +# The example catalog is not bundled in the package; tests point the audit at it explicitly. +EXAMPLE_CATALOG = Path(__file__).resolve().parent.parent / "examples" / "catalog" def _entry(report: AuditReport, path: str): return next(e for e in report.entries if e.path == path) +def test_without_catalog_dir_everything_is_review(): + # No catalog ships with the package. With no catalog_dir there are no rules, so every non-glob + # path is deferred to review (never silently safe). + report = audit_allow_functions( + ["jax.numpy.einsum", "jax.numpy.save", "flax.linen.Module"] + ) + assert all(e.verdict == "review" for e in report.entries) + assert report.ok # review does not fail the report + + def test_known_unsafe_paths_are_flagged(): paths = [ "jax.profiler.start_server", # network server / disk trace (the floor-gap we found) @@ -16,13 +35,16 @@ def test_known_unsafe_paths_are_flagged(): "jax.experimental.io_callback", # host callback "flax.training.checkpoints.save_checkpoint", # disk ] - report = audit_allow_functions(paths) + report = audit_allow_functions(paths, catalog_dir=EXAMPLE_CATALOG) assert all(_entry(report, p).verdict == "unsafe" for p in paths) - assert all(_entry(report, p).reason for p in paths) # every entry carries an explanation + assert all( + _entry(report, p).reason for p in paths + ) # every entry carries an explanation assert not report.ok # unsafe entries fail the report def test_glob_allow_is_flagged_unsafe(): + # Globs are flagged by the classifier itself, so this holds with or without a catalog. report = audit_allow_functions(["jax.*", "flax.linen.*"]) assert _entry(report, "jax.*").verdict == "unsafe" assert _entry(report, "flax.linen.*").verdict == "unsafe" @@ -32,10 +54,16 @@ def test_glob_allow_is_flagged_unsafe(): def test_pure_computation_is_safe(): # Pure math is safe, including the most expressive ops (einsum, matmul, softmax). paths = [ - "jax.numpy.einsum", "jax.numpy.matmul", "jax.nn.softmax", "jax.numpy.where", - "jax.numpy.sort", "jax.random.categorical", "flax.linen.Dense", "flax.linen.relu", + "jax.numpy.einsum", + "jax.numpy.matmul", + "jax.nn.softmax", + "jax.numpy.where", + "jax.numpy.sort", + "jax.random.categorical", + "flax.linen.Dense", + "flax.linen.relu", ] - report = audit_allow_functions(paths) + report = audit_allow_functions(paths, catalog_dir=EXAMPLE_CATALOG) assert all(_entry(report, p).verdict == "safe" for p in paths) assert all(_entry(report, p).reason for p in paths) assert report.ok @@ -45,33 +73,41 @@ def test_dual_use_is_narrow(): # dual_use is reserved for a specific capability beyond pure computation: the host/device # boundary crossers, and flax's attribute-access knob. paths = ["jax.device_get", "jax.device_put", "flax.linen.Module"] - report = audit_allow_functions(paths) + report = audit_allow_functions(paths, catalog_dir=EXAMPLE_CATALOG) assert all(_entry(report, p).verdict == "dual_use" for p in paths) - assert all(_entry(report, p).reason for p in paths) # each carries its own concrete reason + assert all( + _entry(report, p).reason for p in paths + ) # each carries its own concrete reason assert report.ok # dual_use does not fail the report (allowed-but-flagged) def test_uncatalogued_path_is_deferred_to_review_without_assumptions(): - # An unknown path is neither safe nor unsafe: the audit makes no guess, it defers to a human. - # This holds regardless of whether the path is importable — no source inspection happens. - report = audit_allow_functions(["totally.made.up.symbol", "shutil.copyfile"]) + # Even with a catalog present, an unknown path is neither safe nor unsafe: the audit makes no + # guess and defers to a human, regardless of whether the path is importable. + report = audit_allow_functions( + ["totally.made.up.symbol", "shutil.copyfile"], catalog_dir=EXAMPLE_CATALOG + ) for path in ("totally.made.up.symbol", "shutil.copyfile"): e = _entry(report, path) assert e.verdict == "review" - assert "catalog" in e.reason # reported as uncatalogued, deferred to human review + assert ( + "catalog" in e.reason + ) # reported as uncatalogued, deferred to human review assert report.ok # review entries do not fail the report; they need a human def test_cross_library_pattern_matches_any_library(): # `*.io_callback` lives in the library-agnostic _common catalog - report = audit_allow_functions(["somelib.io_callback"]) + report = audit_allow_functions(["somelib.io_callback"], catalog_dir=EXAMPLE_CATALOG) assert _entry(report, "somelib.io_callback").verdict == "unsafe" def test_orbax_is_flagged_unsafe_without_a_version_dir(): # orbax has no version-keyable import root, so its blanket rule lives in _common and must fire # regardless of whether any orbax version is detected. - report = audit_allow_functions(["orbax.checkpoint.save"]) + report = audit_allow_functions( + ["orbax.checkpoint.save"], catalog_dir=EXAMPLE_CATALOG + ) assert _entry(report, "orbax.checkpoint.save").verdict == "unsafe" @@ -89,7 +125,7 @@ def test_flax_linen_common_surface_is_catalogued(): "flax.linen.Module": "dual_use", # attribute-access knob "flax.io.read_file": "unsafe", # flax.io.* glob -> disk IO } - report = audit_allow_functions(list(expected)) + report = audit_allow_functions(list(expected), catalog_dir=EXAMPLE_CATALOG) got = {e.path: e.verdict for e in report.entries} assert got == expected assert all(_entry(report, p).reason for p in expected) # every entry carries a note @@ -114,15 +150,47 @@ def test_jax_common_surface_is_catalogued(): "jax.numpy.save": "unsafe", # disk IO (jax.numpy.save* rule) "jax.debug.print": "unsafe", # host callback } - report = audit_allow_functions(list(expected)) + report = audit_allow_functions(list(expected), catalog_dir=EXAMPLE_CATALOG) got = {e.path: e.verdict for e in report.entries} assert got == expected assert all(_entry(report, p).reason for p in expected) +def test_catalog_dir_supplies_the_rules(tmp_path): + # Without a catalog_dir a path is 'review'; a catalog_dir is what provides its rules. + assert ( + _entry(audit_allow_functions(["jax.numpy.einsum"]), "jax.numpy.einsum").verdict + == "review" + ) + version_dir = ".".join(jax.__version__.split(".")[:2]) # e.g. "0.11" + ext = tmp_path / "jax" / version_dir + ext.mkdir(parents=True) + (ext / "catalog.json").write_text( + json.dumps({"unsafe": {"jax.numpy.einsum": "custom rule"}}) + ) + report = audit_allow_functions(["jax.numpy.einsum"], catalog_dir=tmp_path) + einsum = _entry(report, "jax.numpy.einsum") + assert einsum.verdict == "unsafe" + assert einsum.reason == "custom rule" + + +def test_lint_accepts_a_path_and_fixes(tmp_path): + cat = tmp_path / "mylib" / "1.0" + cat.mkdir(parents=True) + f = cat / "catalog.json" + f.write_text( + '{\n "safe": {"b": "two", "a": "one"}\n}\n' + ) # unsorted, not canonical + assert lint_main([str(tmp_path)]) == 1 # check mode flags it + assert lint_main([str(tmp_path), "--fix"]) == 0 # --fix rewrites it + assert lint_main([str(tmp_path)]) == 0 # now canonical + assert list(json.loads(f.read_text())["safe"]) == ["a", "b"] # keys sorted + + def test_report_format_has_sections_and_ok_flag(): report = audit_allow_functions( - ["jax.profiler.start_server", "jax.device_get", "jax.numpy.einsum"] + ["jax.profiler.start_server", "jax.device_get", "jax.numpy.einsum"], + catalog_dir=EXAMPLE_CATALOG, ) text = report.format() assert "UNSAFE" in text and "DUAL-USE" in text and "SAFE" in text @@ -136,6 +204,10 @@ def test_best_version_key_matches_on_dot_boundaries_only(): keys = ["0.1", "0.11"] assert _best_version_key(keys, "0.1.7") == "0.1" assert _best_version_key(keys, "0.11.0") == "0.11" # not "0.1" - assert _best_version_key(keys, "0.19.2") is None # no baseline; uncovered -> no rules + assert ( + _best_version_key(keys, "0.19.2") is None + ) # no baseline; uncovered -> no rules assert _best_version_key(keys, "0.2.0") is None - assert _best_version_key(keys, "") is None # unknown/undetected version matches nothing + assert ( + _best_version_key(keys, "") is None + ) # unknown/undetected version matches nothing diff --git a/packages/syft-restrict/tests/test_run.py b/packages/syft-restrict/tests/test_run.py index c2f2a1fe941..0a2a80c0687 100644 --- a/packages/syft-restrict/tests/test_run.py +++ b/packages/syft-restrict/tests/test_run.py @@ -9,6 +9,7 @@ from verify.helpers import normalize_source FIXTURES = Path(__file__).parent / "fixtures" +EXAMPLE_CATALOG = Path(__file__).parent.parent / "examples" / "catalog" ALLOW_FUNCTIONS = ["jax.*", "flax.linen.*"] ALLOW_OPERATORS = ["arithmetic", "indexing", "comparison"] @@ -38,6 +39,39 @@ def test_run_success_writes_obfuscated_and_certificate(tmp_path): assert result.certificate["n_calls_checked"] > 0 +def test_run_attaches_advisory_audit_without_affecting_ok(tmp_path): + src = tmp_path / "model.py" + shutil.copy(FIXTURES / "compliant_model.py", src) + result = _run( + src, + obfuscate=_private(src.read_text()), + allow_functions=ALLOW_FUNCTIONS, # globs -> the audit flags them unsafe + allow_operators=ALLOW_OPERATORS, + ) + assert result.ok # a clean verify still passes + assert result.audit is not None + assert result.audit.unsafe # the globs are flagged unsafe by the audit + assert ( + not result.audit.ok + ) # the audit is unhappy, yet run().ok stayed True (advisory only) + + +def test_run_audit_attached_on_verification_failure(tmp_path): + src = tmp_path / "bad.py" + src.write_text("CONFIG = dict(dim=8)\nleak = x.reshape(1)\n") + result = _run( + src, + obfuscate=[[1, 2]], + allow_functions=["jax.numpy.einsum"], + allow_operators=ALLOW_OPERATORS, + strict=False, + catalog_dir=EXAMPLE_CATALOG, + ) + assert not result.ok # verification failed + assert result.audit is not None # the audit is still attached + assert any(e.path == "jax.numpy.einsum" for e in result.audit.safe) + + def test_run_strict_raises_and_writes_nothing(tmp_path): src = tmp_path / "bad.py" src.write_text("CONFIG = dict(dim=8)\nimport os\nleak = os.getcwd()\n") From 3f9c61cb237973466bfc8a86cd6ae316c1428cc4 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 23 Jul 2026 20:31:10 -0300 Subject: [PATCH 5/5] Fix error handling in audit catalog loading --- .../src/syft_restrict/auditor.py | 95 +++++++++++-------- .../tests/obfuscate/test_obfuscate.py | 2 + packages/syft-restrict/tests/test_auditor.py | 10 ++ packages/syft-restrict/tests/test_run.py | 20 ++++ 4 files changed, 87 insertions(+), 40 deletions(-) diff --git a/packages/syft-restrict/src/syft_restrict/auditor.py b/packages/syft-restrict/src/syft_restrict/auditor.py index f25a982bada..ba5e5e2e881 100644 --- a/packages/syft-restrict/src/syft_restrict/auditor.py +++ b/packages/syft-restrict/src/syft_restrict/auditor.py @@ -9,9 +9,10 @@ from __future__ import annotations import fnmatch -import importlib +import importlib.metadata +import importlib.util import json -from functools import lru_cache +from collections.abc import Mapping from pathlib import Path from typing import Literal @@ -25,11 +26,11 @@ _COMMON_LIB = "_common" _COMMON_VERSION = "default" +Verdict = Literal["safe", "dual_use", "unsafe", "review"] + # Catalog buckets, in the order they are matched (first hit wins): the strictest verdict a path # qualifies for is assigned, so unsafe beats dual_use beats safe. -_BUCKETS: tuple[str, ...] = ("unsafe", "dual_use", "safe") - -Verdict = Literal["safe", "dual_use", "unsafe", "review"] +_BUCKETS: tuple[Verdict, ...] = ("unsafe", "dual_use", "safe") class PathAudit(BaseModel): @@ -110,19 +111,12 @@ def audit_allow_functions( """ paths = [p for p in (s.strip() for s in (allow_functions or [])) if p] versions = _detect_versions(paths) - roots = _roots(catalog_dir) - entries = [_classify(p, versions, roots) for p in paths] + root = Path(catalog_dir) if catalog_dir is not None else None + entries = [_classify(p, versions, root) for p in paths] return AuditReport(entries=entries, versions=versions) -def _roots(catalog_dir: str | Path | None) -> tuple[Path, ...]: - """The catalog roots to consult (empty when no ``catalog_dir`` is given).""" - return (Path(catalog_dir),) if catalog_dir is not None else () - - -def _classify( - path: str, versions: dict[str, str], roots: tuple[Path, ...] -) -> PathAudit: +def _classify(path: str, versions: dict[str, str], root: Path | None) -> PathAudit: if "*" in path or "?" in path: return PathAudit( path=path, @@ -131,7 +125,7 @@ def _classify( "list exact leaves instead", ) library = path.split(".", 1)[0] - rules = _rules_for(library, versions.get(library, ""), roots) + rules = _rules_for(library, versions.get(library, ""), root) for verdict in _BUCKETS: # unsafe -> dual_use -> safe: strictest match wins for pattern, reason in rules[verdict].items(): if fnmatch.fnmatchcase(path, pattern): @@ -144,33 +138,33 @@ def _classify( def _rules_for( - library: str, version: str, roots: tuple[Path, ...] + library: str, version: str, root: Path | None ) -> dict[str, dict[str, str]]: - """Merge the library-agnostic ``_common`` rules with the version-matched library rules, per - bucket (``unsafe`` / ``dual_use`` / ``safe``). Roots are overlaid lowest-precedence first, so an - earlier root wins on a key conflict. With no ``catalog_dir`` there are no roots and no rules.""" + """Merge the library-agnostic ``_common`` rules with the version-matched library rules from the + catalog ``root``, per bucket (``unsafe`` / ``dual_use`` / ``safe``). No ``root`` -> no rules.""" merged: dict[str, dict[str, str]] = {bucket: {} for bucket in _BUCKETS} - for root in reversed( - roots - ): # earlier (higher-precedence) roots applied last -> they win - common = _load_ruleset(root, _COMMON_LIB, _COMMON_VERSION) - version_dir = _match_version_dir(root, library, version) - lib_rules = ( - _load_ruleset(root, library, version_dir) if version_dir is not None else {} - ) - for ruleset in (common, lib_rules): - for bucket in _BUCKETS: - merged[bucket].update(ruleset.get(bucket, {})) + if root is None: + return merged + common = _load_ruleset(root, _COMMON_LIB, _COMMON_VERSION) + version_dir = _match_version_dir(root, library, version) + lib_rules = ( + _load_ruleset(root, library, version_dir) if version_dir is not None else {} + ) + for ruleset in (common, lib_rules): + for bucket in _BUCKETS: + merged[bucket].update(ruleset.get(bucket, {})) return merged -@lru_cache(maxsize=None) def _load_ruleset(root: Path, library: str, version_dir: str) -> dict: - """Read ``///catalog.json``; empty dict if the file is absent.""" + """Read ``///catalog.json``; empty dict if it is absent, unreadable, + or malformed. A broken catalog file yields no rules (its paths fall to ``review``) rather than + crashing the advisory audit.""" path = root / library / version_dir / "catalog.json" - if not path.is_file(): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): return {} - return json.loads(path.read_text()) def _match_version_dir(root: Path, library: str, version: str) -> str | None: @@ -199,15 +193,36 @@ def _best_version_key(keys: list[str], version: str) -> str | None: def _detect_versions(paths) -> dict[str, str]: + """Resolve the installed version of each top-level package named in ``paths`` **without importing + it** (no import side effects): locate it with ``find_spec`` and read distribution metadata.""" + dist_map = ( + importlib.metadata.packages_distributions() + ) # import name -> [distribution names] versions: dict[str, str] = {} for path in paths: root = path.split(".", 1)[0].lstrip("*") if not root or root in versions: continue try: - mod = importlib.import_module(root) - except ImportError: - versions[root] = "not installed" - else: - versions[root] = getattr(mod, "__version__", "unknown") + spec = importlib.util.find_spec(root) + except (ImportError, ValueError): + spec = None + versions[root] = ( + _dist_version(root, dist_map) if spec is not None else "not installed" + ) return versions + + +def _dist_version(root: str, dist_map: Mapping[str, list[str]]) -> str: + """Version of the distribution providing top-level import ``root``, or ``"unknown"`` if it has no + resolvable distribution metadata (e.g. a namespace package or a local module).""" + candidates = [ + *dist_map.get(root, []), + root, + ] # metadata name may differ from the import name + for dist in candidates: + try: + return importlib.metadata.version(dist) + except importlib.metadata.PackageNotFoundError: + continue + return "unknown" diff --git a/packages/syft-restrict/tests/obfuscate/test_obfuscate.py b/packages/syft-restrict/tests/obfuscate/test_obfuscate.py index d9b826632d8..3a659f54f0b 100644 --- a/packages/syft-restrict/tests/obfuscate/test_obfuscate.py +++ b/packages/syft-restrict/tests/obfuscate/test_obfuscate.py @@ -31,6 +31,7 @@ def _obfuscate_fixture(tmp_path: Path): allow_functions=ALLOW_FUNCTIONS, allow_operators=ALLOW_OPERATORS, ) + assert result.obfuscated_path is not None obf = Path(result.obfuscated_path).read_text() return source, obf, config_line @@ -75,6 +76,7 @@ def test_hide_blanks_whole_body_keeping_indentation(tmp_path): allow_functions=ALLOW_FUNCTIONS, allow_operators=ALLOW_OPERATORS, ) + assert result.obfuscated_path is not None and result.certificate is not None obf_lines = Path(result.obfuscated_path).read_text().splitlines() note = "# hidden/obfuscated lines can only execute restricted python" # the signature line survives (not hidden); the body is blanked with indentation kept diff --git a/packages/syft-restrict/tests/test_auditor.py b/packages/syft-restrict/tests/test_auditor.py index 467b887ecae..a5cab0b1fa0 100644 --- a/packages/syft-restrict/tests/test_auditor.py +++ b/packages/syft-restrict/tests/test_auditor.py @@ -174,6 +174,16 @@ def test_catalog_dir_supplies_the_rules(tmp_path): assert einsum.reason == "custom rule" +def test_malformed_catalog_degrades_to_review(tmp_path): + # A broken catalog.json must not crash the advisory audit; its paths fall to review. + version_dir = ".".join(jax.__version__.split(".")[:2]) + ext = tmp_path / "jax" / version_dir + ext.mkdir(parents=True) + (ext / "catalog.json").write_text("{ not valid json ") + report = audit_allow_functions(["jax.numpy.einsum"], catalog_dir=tmp_path) + assert _entry(report, "jax.numpy.einsum").verdict == "review" + + def test_lint_accepts_a_path_and_fixes(tmp_path): cat = tmp_path / "mylib" / "1.0" cat.mkdir(parents=True) diff --git a/packages/syft-restrict/tests/test_run.py b/packages/syft-restrict/tests/test_run.py index 0a2a80c0687..3e148dad338 100644 --- a/packages/syft-restrict/tests/test_run.py +++ b/packages/syft-restrict/tests/test_run.py @@ -31,6 +31,7 @@ def test_run_success_writes_obfuscated_and_certificate(tmp_path): allow_operators=ALLOW_OPERATORS, ) assert result.ok + assert result.obfuscated_path is not None and result.certificate is not None out = Path(result.obfuscated_path) assert out.exists() and out.name == "model.obfuscated.py" assert result.certificate["source_sha256"] @@ -72,6 +73,24 @@ def test_run_audit_attached_on_verification_failure(tmp_path): assert any(e.path == "jax.numpy.einsum" for e in result.audit.safe) +def test_run_survives_a_broken_catalog_dir(tmp_path): + # The advisory audit must never fail the run: a malformed catalog degrades, it does not raise. + src = tmp_path / "model.py" + shutil.copy(FIXTURES / "compliant_model.py", src) + bad = tmp_path / "cat" / "jax" / "0.11" + bad.mkdir(parents=True) + (bad / "catalog.json").write_text("{ broken json") + result = _run( + src, + obfuscate=_private(src.read_text()), + allow_functions=ALLOW_FUNCTIONS, + allow_operators=ALLOW_OPERATORS, + catalog_dir=tmp_path / "cat", + ) + assert result.ok # a clean verify still passes despite the broken catalog + assert result.audit is not None + + def test_run_strict_raises_and_writes_nothing(tmp_path): src = tmp_path / "bad.py" src.write_text("CONFIG = dict(dim=8)\nimport os\nleak = os.getcwd()\n") @@ -111,6 +130,7 @@ def test_run_auto_detects_markers_when_ranges_omitted(tmp_path): allow_operators=ALLOW_OPERATORS, ) assert result.ok + assert result.obfuscated_path is not None and result.certificate is not None out = Path(result.obfuscated_path) assert out.exists() # marked_model.py: line 9 is "# syft-restrict: obfuscate-start", line 40 is "...-end" -- the