credgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
credgate/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """CredGate: governance layer for LLM requests with Indian PII detection.
2
+
3
+ Detection rules come from the shared spec (see :func:`spec_path`), so this
4
+ package and the R package find exactly the same matches.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ __version__ = version("credgate")
11
+ except PackageNotFoundError: # running from a source checkout
12
+ __version__ = "0.1.0"
13
+
14
+ from ._spec import spec_path
15
+ from .checksums import (
16
+ gstin_check_char,
17
+ gstin_valid,
18
+ luhn_check_digit,
19
+ luhn_valid,
20
+ verhoeff_check_digit,
21
+ verhoeff_valid,
22
+ )
23
+ from .detect import Span, detect_pii, mask_value
24
+ from .detectors import Detector, bounded, detectors, register_detector, reset_detectors
25
+ from .normalise import Normalised, normalise
26
+ from .synth import synth_indian_pii
27
+
28
+ __all__ = [
29
+ "Detector",
30
+ "Normalised",
31
+ "Span",
32
+ "bounded",
33
+ "detect_pii",
34
+ "detectors",
35
+ "gstin_check_char",
36
+ "gstin_valid",
37
+ "luhn_check_digit",
38
+ "luhn_valid",
39
+ "mask_value",
40
+ "normalise",
41
+ "register_detector",
42
+ "reset_detectors",
43
+ "spec_path",
44
+ "synth_indian_pii",
45
+ "verhoeff_check_digit",
46
+ "verhoeff_valid",
47
+ ]
credgate/_spec.py ADDED
@@ -0,0 +1,64 @@
1
+ """Reading the shared detection spec shipped inside the package.
2
+
3
+ The spec (``spec/`` in the repository, copied here by ``tools/sync-spec.sh``)
4
+ is shared with the R package; see its README.md for the schema.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ from importlib import resources
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import regex
15
+ import yaml
16
+
17
+ SPEC_VERSION_SUPPORTED = 1
18
+
19
+
20
+ def spec_path() -> Path:
21
+ """Path of the ``spec`` directory shipped with the package."""
22
+ return Path(str(resources.files("credgate") / "spec"))
23
+
24
+
25
+ def read_spec_file(name: str, directory: Path | None = None) -> dict[str, Any]:
26
+ path = (directory or spec_path()) / name
27
+ with open(path, encoding="utf-8") as f:
28
+ data = yaml.safe_load(f)
29
+ if data.get("spec_version") != SPEC_VERSION_SUPPORTED:
30
+ raise ValueError(
31
+ f"Unsupported spec_version {data.get('spec_version')} in {name} "
32
+ f"(this credgate supports {SPEC_VERSION_SUPPORTED})."
33
+ )
34
+ return data
35
+
36
+
37
+ def _code_points(values: list[str]) -> frozenset[int]:
38
+ return frozenset(int(v.removeprefix("U+"), 16) for v in values)
39
+
40
+
41
+ @functools.cache
42
+ def engine() -> dict[str, Any]:
43
+ """Engine parameters from engine.yaml, with code points parsed."""
44
+ spec = read_spec_file("engine.yaml")
45
+ norm = spec["normalisation"]
46
+ return {
47
+ "digit_zeros": sorted(_code_points(norm["digit_zeros"])),
48
+ "remove": _code_points(norm["remove"]),
49
+ "dash": _code_points(norm["dash"]),
50
+ "boundary": spec["boundary"],
51
+ "separators": regex.compile(spec["separators"]),
52
+ "scoring": spec["scoring"],
53
+ }
54
+
55
+
56
+ @functools.cache
57
+ def detector_spec() -> dict[str, Any]:
58
+ """The parsed detectors.yaml."""
59
+ spec = read_spec_file("detectors.yaml")
60
+ spec["lists"] = {k: [str(x) for x in v] for k, v in spec.get("lists", {}).items()}
61
+ spec["tables"] = {
62
+ k: {str(key): str(val) for key, val in v.items()} for k, v in spec.get("tables", {}).items()
63
+ }
64
+ return spec
credgate/checksums.py ADDED
@@ -0,0 +1,136 @@
1
+ """Checksum algorithms used to validate identifier candidates.
2
+
3
+ All functions ignore spaces and hyphens and treat anything else that is not
4
+ the expected characters as invalid.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import regex
10
+
11
+ from ._spec import detector_spec, engine
12
+
13
+ _VERHOEFF_D = (
14
+ (0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
15
+ (1, 2, 3, 4, 0, 6, 7, 8, 9, 5),
16
+ (2, 3, 4, 0, 1, 7, 8, 9, 5, 6),
17
+ (3, 4, 0, 1, 2, 8, 9, 5, 6, 7),
18
+ (4, 0, 1, 2, 3, 9, 5, 6, 7, 8),
19
+ (5, 9, 8, 7, 6, 0, 4, 3, 2, 1),
20
+ (6, 5, 9, 8, 7, 1, 0, 4, 3, 2),
21
+ (7, 6, 5, 9, 8, 2, 1, 0, 4, 3),
22
+ (8, 7, 6, 5, 9, 3, 2, 1, 0, 4),
23
+ (9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
24
+ )
25
+ _VERHOEFF_P = (
26
+ (0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
27
+ (1, 5, 7, 6, 2, 8, 3, 0, 9, 4),
28
+ (5, 8, 0, 3, 7, 9, 6, 1, 4, 2),
29
+ (8, 9, 1, 6, 0, 4, 3, 5, 2, 7),
30
+ (9, 4, 5, 3, 1, 2, 6, 8, 7, 0),
31
+ (4, 2, 8, 6, 5, 7, 3, 9, 0, 1),
32
+ (2, 7, 9, 3, 8, 0, 6, 4, 1, 5),
33
+ (7, 0, 4, 6, 9, 1, 3, 2, 5, 8),
34
+ )
35
+ _VERHOEFF_INV = (0, 4, 3, 2, 1, 5, 6, 7, 8, 9)
36
+
37
+ _DIGITS = regex.compile(r"[0-9]+")
38
+ _GSTIN_BODY = regex.compile(r"[0-9A-Z]{14}")
39
+ _GSTIN_FULL = regex.compile(r"[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]")
40
+ _GSTIN_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
41
+
42
+
43
+ def strip_separators(x: str) -> str:
44
+ """Remove the spec's separator characters (spaces and hyphens)."""
45
+ return engine()["separators"].sub("", x)
46
+
47
+
48
+ def _digits(x: str | None) -> str | None:
49
+ if x is None:
50
+ return None
51
+ d = strip_separators(x)
52
+ return d if _DIGITS.fullmatch(d) else None
53
+
54
+
55
+ def _verhoeff_accumulate(digits: str, offset: int) -> int:
56
+ c = 0
57
+ for i, ch in enumerate(reversed(digits)):
58
+ c = _VERHOEFF_D[c][_VERHOEFF_P[(i + offset) % 8][int(ch)]]
59
+ return c
60
+
61
+
62
+ def verhoeff_valid(x: str | None) -> bool:
63
+ """Whether a number (with its final check digit) passes Verhoeff.
64
+
65
+ Used by Aadhaar and Aadhaar Virtual ID.
66
+ """
67
+ d = _digits(x)
68
+ return d is not None and _verhoeff_accumulate(d, 0) == 0
69
+
70
+
71
+ def verhoeff_check_digit(x: str | None) -> str | None:
72
+ """The Verhoeff check digit to append to ``x``, or ``None`` if invalid."""
73
+ d = _digits(x)
74
+ if d is None:
75
+ return None
76
+ return str(_VERHOEFF_INV[_verhoeff_accumulate(d, 1)])
77
+
78
+
79
+ def _luhn_sum(digits: str, with_check: bool) -> int:
80
+ # With the check digit present, every second digit from the right is
81
+ # doubled; when computing a check digit it is the first, third, ...
82
+ doubled = 1 if with_check else 0
83
+ total = 0
84
+ for i, ch in enumerate(reversed(digits)):
85
+ d = int(ch)
86
+ if i % 2 == doubled:
87
+ d *= 2
88
+ if d > 9:
89
+ d -= 9
90
+ total += d
91
+ return total
92
+
93
+
94
+ def luhn_valid(x: str | None) -> bool:
95
+ """Whether a number passes the Luhn checksum (payment cards)."""
96
+ d = _digits(x)
97
+ return d is not None and _luhn_sum(d, with_check=True) % 10 == 0
98
+
99
+
100
+ def luhn_check_digit(x: str | None) -> str | None:
101
+ """The Luhn check digit to append to ``x``, or ``None`` if invalid."""
102
+ d = _digits(x)
103
+ if d is None:
104
+ return None
105
+ return str((10 - _luhn_sum(d, with_check=False) % 10) % 10)
106
+
107
+
108
+ def gstin_check_char(x: str | None) -> str | None:
109
+ """The mod-36 check character for the first 14 characters of a GSTIN."""
110
+ if x is None:
111
+ return None
112
+ x = x.upper()
113
+ if not _GSTIN_BODY.fullmatch(x):
114
+ return None
115
+ total = 0
116
+ for i, ch in enumerate(x, start=1):
117
+ # Counting from the right, the last character has factor 2, then 1, 2...
118
+ addend = _GSTIN_ALPHABET.index(ch) * (2 if i % 2 == 0 else 1)
119
+ total += addend // 36 + addend % 36
120
+ return _GSTIN_ALPHABET[(36 - total % 36) % 36]
121
+
122
+
123
+ def gstin_valid(x: str | None) -> bool:
124
+ """Whether ``x`` is a structurally valid GSTIN with a correct check character.
125
+
126
+ A GSTIN is a 2-digit state code, the holder's PAN, an entity number, the
127
+ letter ``Z`` and the check character.
128
+ """
129
+ if x is None:
130
+ return False
131
+ x = x.upper()
132
+ return (
133
+ _GSTIN_FULL.fullmatch(x) is not None
134
+ and x[:2] in detector_spec()["lists"]["gst_state_codes"]
135
+ and x[14] == gstin_check_char(x[:14])
136
+ )
credgate/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ """Command-line interface: ``credgate detect``, ``credgate detectors``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ from . import __version__
11
+ from ._spec import spec_path
12
+ from .detect import detect_pii, mask_value
13
+ from .detectors import detectors
14
+
15
+
16
+ def main(argv: Sequence[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(
18
+ prog="credgate",
19
+ description="Detect Indian PII before it leaves the machine.",
20
+ )
21
+ parser.add_argument("--version", action="version", version=f"credgate {__version__}")
22
+ sub = parser.add_subparsers(dest="command", required=True)
23
+
24
+ p = sub.add_parser("detect", help="detect PII in files or standard input")
25
+ p.add_argument("files", nargs="*", help="files to scan (default: standard input)")
26
+ p.add_argument("--lines", action="store_true", help="treat each line as a separate document")
27
+ p.add_argument("--json", action="store_true", help="write one JSON object per match")
28
+ p.add_argument(
29
+ "--reveal",
30
+ action="store_true",
31
+ help="show detected values and canonical forms (masked by default)",
32
+ )
33
+ p.add_argument("--min-confidence", type=float, default=0.0, metavar="X")
34
+ p.add_argument("--context", default=None, help="extra context applied to every match")
35
+ p.add_argument(
36
+ "--fail",
37
+ action="store_true",
38
+ help="exit with status 1 if anything is detected (for CI and pre-commit hooks)",
39
+ )
40
+
41
+ sub.add_parser("detectors", help="list the registered detectors")
42
+ sub.add_parser("spec-path", help="print the path of the shipped detection spec")
43
+
44
+ args = parser.parse_args(argv)
45
+ if args.command == "detectors":
46
+ for d in detectors():
47
+ print(f"{d['id']:<16} {d['class']:<16} {d['label']}")
48
+ return 0
49
+ if args.command == "spec-path":
50
+ print(spec_path())
51
+ return 0
52
+ return _detect(args)
53
+
54
+
55
+ def _detect(args: argparse.Namespace) -> int:
56
+ sources = args.files or ["-"]
57
+ found = 0
58
+ for source in sources:
59
+ if source == "-":
60
+ content = sys.stdin.read()
61
+ else:
62
+ with open(source, encoding="utf-8") as f:
63
+ content = f.read()
64
+ docs = content.splitlines() if args.lines else [content]
65
+ spans = detect_pii(docs, context=args.context, min_confidence=args.min_confidence)
66
+ found += len(spans)
67
+ for s in spans:
68
+ location = s.doc + 1 if args.lines else None
69
+ if args.json:
70
+ record = {"source": source, "line": location, **s.to_dict(reveal=args.reveal)}
71
+ print(json.dumps(record, ensure_ascii=False))
72
+ else:
73
+ where = f"{source}:{location}" if location else source
74
+ value = s.value if args.reveal else mask_value(s.value)
75
+ print(f"{where}\t{s.start}-{s.end}\t{s.type}\t{s.confidence:.2f}\t{value}")
76
+ return 1 if args.fail and found else 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main())
credgate/detect.py ADDED
@@ -0,0 +1,203 @@
1
+ """PII detection over text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import asdict, dataclass
7
+ from typing import Any
8
+
9
+ import regex
10
+
11
+ from ._spec import engine
12
+ from .detectors import Detector, get_detectors
13
+ from .normalise import Normalised, normalise
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Span:
18
+ """One detected PII match.
19
+
20
+ ``start`` and ``end`` are 0-based, end-exclusive code-point offsets into
21
+ the original text of document ``doc``. The representation masks
22
+ ``value``; read the attribute to see it.
23
+ """
24
+
25
+ doc: int
26
+ start: int
27
+ end: int
28
+ type: str
29
+ data_class: str
30
+ value: str
31
+ canonical: str
32
+ confidence: float
33
+ reasons: str
34
+
35
+ def __repr__(self) -> str:
36
+ return (
37
+ f"Span(doc={self.doc}, start={self.start}, end={self.end}, type={self.type!r}, "
38
+ f"value={mask_value(self.value)!r}, confidence={self.confidence})"
39
+ )
40
+
41
+ def to_dict(self, reveal: bool = False) -> dict[str, Any]:
42
+ """A JSON-ready dict; ``value`` is masked and ``canonical`` omitted unless ``reveal``."""
43
+ d = asdict(self)
44
+ d["class"] = d.pop("data_class")
45
+ if not reveal:
46
+ d["value"] = mask_value(self.value)
47
+ del d["canonical"]
48
+ return d
49
+
50
+
51
+ def detect_pii(
52
+ text: str | Sequence[str | None],
53
+ detectors: Sequence[str] | None = None,
54
+ context: str | Sequence[str | None] | None = None,
55
+ min_confidence: float = 0.0,
56
+ window: int | None = None,
57
+ ) -> list[Span]:
58
+ """Detect PII in one or more documents.
59
+
60
+ Runs every registered detector over normalised text, validates
61
+ candidates (checksums, code tables), scores them from nearby context and
62
+ resolves overlapping matches (the more confident, then the longer, span
63
+ wins - so a GSTIN is reported once, not also as the PAN inside it).
64
+
65
+ Args:
66
+ text: A string, or a sequence of strings (``None`` counts as empty).
67
+ detectors: Detector ids to run; ``None`` runs all registered ones.
68
+ context: Extra context per document (one string for all), treated as
69
+ if it appeared next to every match - e.g. a column name, so a
70
+ column called ``voter_id`` supports Voter ID matches.
71
+ min_confidence: Drop matches scoring below this value.
72
+ window: Characters either side of a match searched for context
73
+ keywords; ``None`` uses the spec's ``context_window``.
74
+
75
+ Returns:
76
+ Spans ordered by document and position.
77
+ """
78
+ docs = [text] if isinstance(text, str) else ["" if t is None else t for t in text]
79
+ if context is None or isinstance(context, str):
80
+ contexts = [context or ""] * len(docs)
81
+ else:
82
+ contexts = ["" if c is None else c for c in context]
83
+ if len(contexts) != len(docs):
84
+ raise ValueError("`context` must be one string or one per document.")
85
+ if window is None:
86
+ window = engine()["scoring"]["context_window"]
87
+
88
+ norms = [normalise(t) for t in docs]
89
+ spans: list[Span] = []
90
+ for det in get_detectors(detectors):
91
+ spans.extend(_run_detector(det, docs, norms, contexts, window))
92
+ spans = _resolve_overlaps(spans)
93
+ spans = [s for s in spans if s.confidence >= min_confidence]
94
+ spans.sort(key=lambda s: (s.doc, s.start))
95
+ return spans
96
+
97
+
98
+ def _run_detector(
99
+ det: Detector,
100
+ docs: list[str],
101
+ norms: list[Normalised],
102
+ contexts: list[str],
103
+ window: int,
104
+ ) -> list[Span]:
105
+ patterns, context_re, negative_re = det._compiled
106
+ scoring = engine()["scoring"]
107
+ out: list[Span] = []
108
+ for doc, (original, norm) in enumerate(zip(docs, norms, strict=True)):
109
+ ntext = norm.text
110
+ for pattern in patterns:
111
+ for m in pattern.finditer(ntext):
112
+ s, e = m.span(det.capture) if det.capture else m.span()
113
+ if s < 0:
114
+ continue
115
+ matched = ntext[s:e]
116
+ if det.validate is not None and not det.validate(matched):
117
+ continue
118
+
119
+ around = f"{ntext[max(0, s - window) : s]} {ntext[e : e + window]} {contexts[doc]}"
120
+ ctx_hit = _keyword(context_re, around)
121
+ neg_hit = _keyword(negative_re, around)
122
+ if det.context_required and ctx_hit is None:
123
+ continue
124
+
125
+ score = det.base(matched) if callable(det.base) else det.base
126
+ if ctx_hit is not None:
127
+ score += (1 - score) * scoring["context_boost"]
128
+ if neg_hit is not None:
129
+ score *= scoring["negative_factor"]
130
+
131
+ reasons = f"matched {det.label} pattern"
132
+ if det.checks:
133
+ reasons += f"; {det.checks}"
134
+ if ctx_hit is not None:
135
+ reasons += f"; context '{ctx_hit}'"
136
+ if neg_hit is not None:
137
+ reasons += f"; negative context '{neg_hit}'"
138
+ if det.describe is not None:
139
+ reasons += f"; {det.describe(matched)}"
140
+
141
+ if norm.map is None:
142
+ start, end = s, e
143
+ else:
144
+ start, end = norm.map[s], norm.map[e - 1] + 1
145
+ out.append(
146
+ Span(
147
+ doc=doc,
148
+ start=start,
149
+ end=end,
150
+ type=det.id,
151
+ data_class=det.data_class,
152
+ value=original[start:end],
153
+ canonical=det.canonical(matched),
154
+ confidence=round(score, scoring["digits"]),
155
+ reasons=reasons,
156
+ )
157
+ )
158
+ return out
159
+
160
+
161
+ def _keyword(pattern: regex.Pattern[str] | None, text: str) -> str | None:
162
+ if pattern is None:
163
+ return None
164
+ m = pattern.search(text)
165
+ return m.group(0) if m else None
166
+
167
+
168
+ def _resolve_overlaps(spans: list[Span]) -> list[Span]:
169
+ """Within each cluster of overlapping spans, keep the most confident, then
170
+ the longest, span; repeat with what remains."""
171
+ spans = sorted(spans, key=lambda s: (s.doc, s.start))
172
+ kept: list[Span] = []
173
+ cluster: list[Span] = []
174
+ running_end = -1
175
+ for s in [*spans, None]:
176
+ if s is not None and cluster and s.doc == cluster[0].doc and s.start < running_end:
177
+ cluster.append(s)
178
+ running_end = max(running_end, s.end)
179
+ continue
180
+ if len(cluster) == 1:
181
+ kept.extend(cluster)
182
+ elif cluster:
183
+ taken: list[Span] = []
184
+ for c in sorted(cluster, key=lambda c: (-c.confidence, -(c.end - c.start))):
185
+ if not any(c.start < t.end and c.end > t.start for t in taken):
186
+ taken.append(c)
187
+ kept.extend(taken)
188
+ if s is not None:
189
+ cluster = [s]
190
+ running_end = s.end
191
+ return kept
192
+
193
+
194
+ _ALNUM = regex.compile(r"[\p{L}\p{N}]")
195
+
196
+
197
+ def mask_value(x: str) -> str:
198
+ """Replace letters and digits with ``X``, keeping the last 4 characters
199
+ (2 for values shorter than 8) - the same convention as UIDAI's masked
200
+ Aadhaar."""
201
+ keep = 4 if len(x) >= 8 else 2
202
+ cut = max(0, len(x) - keep)
203
+ return _ALNUM.sub("X", x[:cut]) + x[cut:]