Skip to content

Commit 793732b

Browse files
committed
Add dual_use category to audit catalog
1 parent d5f106f commit 793732b

5 files changed

Lines changed: 101 additions & 66 deletions

File tree

packages/syft-restrict/src/syft_restrict/audit.py

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,21 @@
1111
1212
- ``"unsafe"`` — matches a catalog entry for known disk/network/host-callback surface, OR is a glob
1313
(``jax.*``) that grants a whole namespace. Remove it or tighten the allow.
14-
- ``"safe"`` — matches a curated entry for a vetted pure-compute path. The explanation also flags any
15-
*residual output-channel risk to review in combination*, kept deliberately vague (not a how-to).
16-
- ``"review"`` — neither unsafe nor in the safe catalog. The audit makes **no** guess about it: it is
17-
reported as uncatalogued and deferred to human review. Unknowns are never assumed safe.
14+
- ``"dual_use"`` — a useful, mostly-safe op that can still be abused in combination (e.g. ``einsum``,
15+
``softmax``, ``where``). Allowed, but flagged: the *category itself* carries the "handle with care"
16+
signal, so entry notes stay terse and vague — never an abuse how-to.
17+
- ``"safe"`` — matches a curated entry for a genuinely inert path (constants, masks, module refs) with
18+
no residual output channel of its own.
19+
- ``"review"`` — none of the above. The audit makes **no** guess about it: it is reported as
20+
uncatalogued and deferred to human review. Unknowns are never assumed safe.
1821
1922
Limits (state them plainly to whoever reads a report):
2023
2124
- Classification is **only** catalog matching — no source inspection, no inference. A path the catalog
2225
does not know is deferred to a human; the tool does not try to guess whether it does I/O.
2326
- The catalog is curated per library version; the report records the versions it saw.
2427
25-
Anything not matched as unsafe or safe defaults to ``"review"``, never silently to ``"safe"``.
28+
Anything not matched as unsafe, dual_use, or safe defaults to ``"review"``, never silently to safe.
2629
"""
2730

2831
from __future__ import annotations
@@ -44,7 +47,11 @@
4447
_COMMON_LIB = "_common"
4548
_COMMON_VERSION = "default"
4649

47-
Verdict = Literal["safe", "unsafe", "review"]
50+
# Catalog buckets, in the order they are matched (first hit wins): the strictest verdict a path
51+
# qualifies for is assigned, so unsafe beats dual_use beats safe.
52+
_BUCKETS: tuple[str, ...] = ("unsafe", "dual_use", "safe")
53+
54+
Verdict = Literal["safe", "dual_use", "unsafe", "review"]
4855

4956

5057
class PathAudit(BaseModel):
@@ -57,33 +64,47 @@ class AuditReport(BaseModel):
5764
entries: list[PathAudit] = Field(default_factory=list)
5865
versions: dict[str, str] = Field(default_factory=dict) # top-level package -> version seen
5966

67+
def _by_verdict(self, verdict: Verdict) -> list[PathAudit]:
68+
return [e for e in self.entries if e.verdict == verdict]
69+
6070
@property
6171
def unsafe(self) -> list[PathAudit]:
62-
return [e for e in self.entries if e.verdict == "unsafe"]
72+
return self._by_verdict("unsafe")
73+
74+
@property
75+
def dual_use(self) -> list[PathAudit]:
76+
return self._by_verdict("dual_use")
6377

6478
@property
6579
def review(self) -> list[PathAudit]:
66-
return [e for e in self.entries if e.verdict == "review"]
80+
return self._by_verdict("review")
6781

6882
@property
6983
def safe(self) -> list[PathAudit]:
70-
return [e for e in self.entries if e.verdict == "safe"]
84+
return self._by_verdict("safe")
7185

7286
@property
7387
def ok(self) -> bool:
74-
"""True if nothing is unsafe. ``review`` entries do not fail it -- they need a human."""
88+
"""True if nothing is unsafe. ``dual_use`` and ``review`` entries do not fail it -- they are
89+
allowed-but-flagged and need a human's eye, not a hard block."""
7590
return not self.unsafe
7691

7792
def format(self) -> str:
7893
vers = ", ".join(f"{k} {v}" for k, v in sorted(self.versions.items())) or "no versions detected"
7994
lines = [f"allow-list audit ({vers})"]
80-
for label, group in (("UNSAFE", self.unsafe), ("REVIEW", self.review), ("SAFE", self.safe)):
95+
groups = (
96+
("UNSAFE", self.unsafe),
97+
("DUAL-USE", self.dual_use),
98+
("REVIEW", self.review),
99+
("SAFE", self.safe),
100+
)
101+
for label, group in groups:
81102
if not group:
82103
continue
83104
lines.append(f" {label} ({len(group)}):")
84105
for e in group:
85106
lines.append(f" - {e.path}{' — ' + e.reason if e.reason else ''}")
86-
lines.append(f" => ok={self.ok} (unsafe entries fail; review entries need a human)")
107+
lines.append(f" => ok={self.ok} (unsafe entries fail; dual-use and review entries need a human)")
87108
return "\n".join(lines)
88109

89110
def __str__(self) -> str:
@@ -111,31 +132,29 @@ def _classify(path: str, versions: dict[str, str]) -> PathAudit:
111132
"list exact leaves instead",
112133
)
113134
library = path.split(".", 1)[0]
114-
unsafe_rules, safe_rules = _rules_for(library, versions.get(library, ""))
115-
for pattern, reason in unsafe_rules.items():
116-
if fnmatch.fnmatchcase(path, pattern):
117-
return PathAudit(path=path, verdict="unsafe", reason=reason)
118-
for pattern, reason in safe_rules.items():
119-
if fnmatch.fnmatchcase(path, pattern):
120-
return PathAudit(path=path, verdict="safe", reason=reason)
135+
rules = _rules_for(library, versions.get(library, ""))
136+
for verdict in _BUCKETS: # unsafe -> dual_use -> safe: strictest match wins
137+
for pattern, reason in rules[verdict].items():
138+
if fnmatch.fnmatchcase(path, pattern):
139+
return PathAudit(path=path, verdict=verdict, reason=reason)
121140
return PathAudit(
122141
path=path,
123142
verdict="review",
124143
reason="not in the curated catalog; defer to human review (unknowns are not assumed safe)",
125144
)
126145

127146

128-
def _rules_for(library: str, version: str) -> tuple[dict[str, str], dict[str, str]]:
129-
"""Merge the library-agnostic ``_common`` rules with the version-matched library rules."""
130-
unsafe: dict[str, str] = {}
131-
safe: dict[str, str] = {}
147+
def _rules_for(library: str, version: str) -> dict[str, dict[str, str]]:
148+
"""Merge the library-agnostic ``_common`` rules with the version-matched library rules, per
149+
bucket (``unsafe`` / ``dual_use`` / ``safe``)."""
150+
merged: dict[str, dict[str, str]] = {bucket: {} for bucket in _BUCKETS}
132151
common = _load_ruleset(_COMMON_LIB, _COMMON_VERSION)
133152
version_dir = _match_version_dir(library, version)
134153
lib_rules = _load_ruleset(library, version_dir) if version_dir is not None else {}
135154
for ruleset in (common, lib_rules):
136-
unsafe.update(ruleset.get("unsafe", {}))
137-
safe.update(ruleset.get("safe", {}))
138-
return unsafe, safe
155+
for bucket in _BUCKETS:
156+
merged[bucket].update(ruleset.get(bucket, {}))
157+
return merged
139158

140159

141160
@lru_cache(maxsize=None)

packages/syft-restrict/src/syft_restrict/catalog/README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,17 @@ Each `catalog.json` is:
3232
```json
3333
{
3434
"_about": "free-text note",
35-
"unsafe": { "<dotted-path glob>": "why it is unsafe" },
36-
"safe": { "<dotted-path glob>": "why it is safe, plus a vague flag of any residual risk" }
35+
"unsafe": { "<dotted-path glob>": "why it is unsafe" },
36+
"dual_use": { "<dotted-path glob>": "what the op is (terse; the category carries the caution)" },
37+
"safe": { "<dotted-path glob>": "why it is genuinely inert" }
3738
}
3839
```
3940

40-
`unsafe` = known disk/network/host-callback surface. `safe` = vetted pure-compute; its note also
41-
flags any *residual output-channel risk to review in combination* — kept deliberately vague, not a
42-
how-to (don't spell out abuse mechanics). Anything matched by neither defaults to `review` — never
43-
silently to `safe`.
41+
- `unsafe` = known disk/network/host-callback surface.
42+
- `dual_use` = a useful op that is mostly safe but can be **abused in combination** (`einsum`,
43+
`softmax`, `where`, …). Allowed but flagged. The *category* is the caution, so keep each note a
44+
terse description of what the op is — do **not** spell out abuse mechanics (no how-to).
45+
- `safe` = genuinely inert (constants, masks, module refs) with no residual channel of its own.
46+
47+
A path is matched strictest-first (`unsafe``dual_use``safe`). Anything matched by none defaults
48+
to `review` — never silently to `safe`.

packages/syft-restrict/src/syft_restrict/catalog/flax/0.12/catalog.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"_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.",
3-
"safe": {
4-
"flax.linen.Module": "Flax module base classrequired 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."
2+
"_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.",
3+
"dual_use": {
4+
"flax.linen.Module": "Flax module base class; required to define a model. Keep allow_base_class_attributes=False to require explicit assignment."
55
},
66
"unsafe": {
77
"flax.serialization.*": "Serializes model state to bytes / disk.",

packages/syft-restrict/src/syft_restrict/catalog/jax/0.11/catalog.json

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,30 @@
11
{
2-
"_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.",
2+
"_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.",
3+
"dual_use": {
4+
"jax.lax.rsqrt": "Reciprocal square root.",
5+
"jax.nn.gelu": "GELU activation.",
6+
"jax.nn.softmax": "Softmax activation; required for attention.",
7+
"jax.numpy.array": "Materializes input into an array.",
8+
"jax.numpy.bool_": "Cast to boolean.",
9+
"jax.numpy.concatenate": "Joins arrays along an axis.",
10+
"jax.numpy.cos": "Element-wise cosine.",
11+
"jax.numpy.einsum": "Einstein-summation contraction; required for attention and projections.",
12+
"jax.numpy.float32": "Cast to float32.",
13+
"jax.numpy.mean": "Reduction to a mean.",
14+
"jax.numpy.repeat": "Repeats elements along an axis.",
15+
"jax.numpy.sin": "Element-wise sine.",
16+
"jax.numpy.sqrt": "Element-wise square root.",
17+
"jax.numpy.transpose": "Reorders axes.",
18+
"jax.numpy.where": "Element-wise conditional select."
19+
},
320
"safe": {
421
"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).",
5-
"jax.lax.rsqrt": "Reciprocal square root. Pure; some residual output-channel risk — review in combination.",
622
"jax.nn": "Module reference, present so deep-path calls (jax.nn.softmax / gelu) resolve. Does NOT permit calling other jax.nn members.",
7-
"jax.nn.gelu": "GELU activation. Pure; residual output-channel risk only.",
8-
"jax.nn.softmax": "Softmax activation. Pure, required for attention; residual output-channel risk — review in combination.",
9-
"jax.numpy.arange": "Integer range generator. Pure; produces constants, no secret enters.",
10-
"jax.numpy.array": "Materializes input into an array. Pure; low residual risk — review in context.",
11-
"jax.numpy.bool_": "Cast to boolean. Pure; residual output-channel risk only.",
12-
"jax.numpy.concatenate": "Joins arrays along an axis. Pure; residual output-shaping risk — review in combination.",
13-
"jax.numpy.cos": "Element-wise cosine. Pure; residual output-channel risk only.",
14-
"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.",
15-
"jax.numpy.float32": "Cast to float32. Pure; residual output-channel risk only.",
16-
"jax.numpy.mean": "Reduction to a mean. Pure; residual risk via the returned value only.",
17-
"jax.numpy.ones": "Constant-ones tensor. Pure; scaffolding only.",
18-
"jax.numpy.repeat": "Repeats elements. Pure; residual output-shaping risk — review in combination.",
19-
"jax.numpy.sin": "Element-wise sine. Pure; residual output-channel risk only.",
20-
"jax.numpy.sqrt": "Element-wise square root. Pure; some residual output-channel risk — review in combination.",
21-
"jax.numpy.square": "Element-wise square. Pure; no channel of its own (a statistic amplifier, e.g. in RMSNorm).",
22-
"jax.numpy.transpose": "Reorders axes. Pure; residual output-shaping risk only.",
23-
"jax.numpy.tril": "Lower-triangular mask. Pure; builds attention masks (compositional, low bandwidth).",
24-
"jax.numpy.triu": "Upper-triangular mask. Pure; as tril.",
25-
"jax.numpy.where": "Element-wise conditional select. Pure; residual output-channel risk — review in combination."
23+
"jax.numpy.arange": "Integer range generator; produces constants, no secret enters.",
24+
"jax.numpy.ones": "Constant-ones tensor; scaffolding only.",
25+
"jax.numpy.square": "Element-wise square; no channel of its own (a statistic amplifier, e.g. in RMSNorm).",
26+
"jax.numpy.tril": "Lower-triangular mask; builds attention masks.",
27+
"jax.numpy.triu": "Upper-triangular mask; as tril."
2628
},
2729
"unsafe": {
2830
"jax.debug.*": "Debug host callbacks / stdout (jax.debug.print, jax.debug.callback) — runs host Python and writes to the process stdout.",

packages/syft-restrict/tests/test_audit.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,27 @@ def test_glob_allow_is_flagged_unsafe():
2929
assert "glob" in _entry(report, "jax.*").reason
3030

3131

32-
def test_vetted_pure_compute_paths_are_safe_with_explanations():
33-
paths = ["jax.numpy.einsum", "jax.nn.softmax", "flax.linen.Module", "jax.lax"]
32+
def test_genuinely_inert_paths_are_safe():
33+
# constants, masks, and module refs have no residual channel of their own.
34+
paths = ["jax.numpy.arange", "jax.numpy.ones", "jax.numpy.tril", "jax.lax"]
3435
report = audit_allow_functions(paths)
3536
assert all(_entry(report, p).verdict == "safe" for p in paths)
36-
assert all(_entry(report, p).reason for p in paths) # safe entries also explained
37-
# the explanation must convey residual risk to review, not just "it's fine" -- but stay vague
38-
# (no abuse how-to): flag the risk with words like "residual" / "review", never a recipe.
39-
einsum_reason = _entry(report, "jax.numpy.einsum").reason
40-
assert "residual" in einsum_reason and "review" in einsum_reason
37+
assert all(_entry(report, p).reason for p in paths) # safe entries still explained
4138
assert report.ok # only-safe list passes
4239

4340

41+
def test_dual_use_paths_are_flagged_between_safe_and_unsafe():
42+
# useful ops that are mostly safe but abusable in combination land in dual_use, not safe.
43+
paths = ["jax.numpy.einsum", "jax.nn.softmax", "jax.numpy.where", "flax.linen.Module"]
44+
report = audit_allow_functions(paths)
45+
assert all(_entry(report, p).verdict == "dual_use" for p in paths)
46+
assert all(_entry(report, p).reason for p in paths) # dual_use entries carry a terse note
47+
# the category carries the caution, so the note stays vague -- no abuse how-to / recipe wording.
48+
einsum_reason = _entry(report, "jax.numpy.einsum").reason
49+
assert "encode" not in einsum_reason and "secret" not in einsum_reason
50+
assert report.ok # dual_use does not fail the report (allowed-but-flagged)
51+
52+
4453
def test_uncatalogued_path_is_deferred_to_review_without_assumptions():
4554
# An unknown path is neither safe nor unsafe: the audit makes no guess, it defers to a human.
4655
# 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():
6776

6877
def test_report_format_has_sections_and_ok_flag():
6978
report = audit_allow_functions(
70-
["jax.numpy.einsum", "jax.profiler.start_server"]
79+
["jax.profiler.start_server", "jax.numpy.einsum", "jax.numpy.ones"]
7180
)
7281
text = report.format()
73-
assert "UNSAFE" in text and "SAFE" in text
74-
assert "ok=False" in text
82+
assert "UNSAFE" in text and "DUAL-USE" in text and "SAFE" in text
83+
assert "ok=False" in text # the unsafe profiler entry fails the report
7584

7685

7786
def test_best_version_key_matches_on_dot_boundaries_only():

0 commit comments

Comments
 (0)