privaci 0.1.0b4__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.
Files changed (132) hide show
  1. privaci/__init__.py +5 -0
  2. privaci/__main__.py +8 -0
  3. privaci/autodetect/__init__.py +27 -0
  4. privaci/autodetect/actions.py +70 -0
  5. privaci/autodetect/freeform.py +68 -0
  6. privaci/autodetect/matcher.py +59 -0
  7. privaci/autodetect/models.py +63 -0
  8. privaci/autodetect/patterns.py +119 -0
  9. privaci/autodetect/report.py +71 -0
  10. privaci/autodetect/resolve.py +73 -0
  11. privaci/autodetect/scanner.py +153 -0
  12. privaci/autodetect/table_context.py +53 -0
  13. privaci/catalog/__init__.py +39 -0
  14. privaci/catalog/audit_skipped.py +40 -0
  15. privaci/catalog/detectors.py +217 -0
  16. privaci/catalog/graph.py +203 -0
  17. privaci/catalog/identifiers.py +76 -0
  18. privaci/catalog/introspect.py +371 -0
  19. privaci/catalog/models.py +242 -0
  20. privaci/catalog/partitions.py +129 -0
  21. privaci/catalog/queries.py +251 -0
  22. privaci/catalog/skipped.py +63 -0
  23. privaci/catalog/snapshot.py +292 -0
  24. privaci/cli/__init__.py +3 -0
  25. privaci/cli/_catalog.py +94 -0
  26. privaci/cli/_errors.py +93 -0
  27. privaci/cli/_resume.py +89 -0
  28. privaci/cli/_run.py +187 -0
  29. privaci/cli/app.py +306 -0
  30. privaci/cli/context.py +112 -0
  31. privaci/cli/generate_ci.py +152 -0
  32. privaci/cli/logging_setup.py +16 -0
  33. privaci/cli/options.py +66 -0
  34. privaci/config/__init__.py +50 -0
  35. privaci/config/actions.py +170 -0
  36. privaci/config/loader.py +253 -0
  37. privaci/config/models.py +133 -0
  38. privaci/contracts/__init__.py +50 -0
  39. privaci/contracts/base.py +125 -0
  40. privaci/contracts/fallbacks.py +94 -0
  41. privaci/contracts/plugins.py +86 -0
  42. privaci/errors.py +131 -0
  43. privaci/mask/__init__.py +17 -0
  44. privaci/mask/column_masker.py +201 -0
  45. privaci/mask/engine.py +95 -0
  46. privaci/mask/faker/__init__.py +31 -0
  47. privaci/mask/faker/base.py +32 -0
  48. privaci/mask/faker/context.py +34 -0
  49. privaci/mask/faker/engine.py +41 -0
  50. privaci/mask/faker/hash.py +47 -0
  51. privaci/mask/faker/libraries.py +192 -0
  52. privaci/mask/faker/providers/__init__.py +7 -0
  53. privaci/mask/faker/providers/builtin.py +240 -0
  54. privaci/mask/faker/registry.py +84 -0
  55. privaci/mask/faker/uniqueness.py +74 -0
  56. privaci/mask/ner.py +114 -0
  57. privaci/mask/regex_safe.py +98 -0
  58. privaci/mask/safe_log.py +25 -0
  59. privaci/observability/__init__.py +30 -0
  60. privaci/observability/events.py +93 -0
  61. privaci/observability/jsonlog.py +123 -0
  62. privaci/observability/metrics.py +92 -0
  63. privaci/observability/progress.py +86 -0
  64. privaci/observability/redact.py +104 -0
  65. privaci/packs/__init__.py +7 -0
  66. privaci/packs/install.py +173 -0
  67. privaci/packs/keys.py +40 -0
  68. privaci/packs/verify.py +64 -0
  69. privaci/pipeline/__init__.py +7 -0
  70. privaci/pipeline/lifecycle.py +169 -0
  71. privaci/pipeline/runner.py +277 -0
  72. privaci/pipeline/streaming.py +238 -0
  73. privaci/pipeline/table_plan.py +84 -0
  74. privaci/preflight/__init__.py +8 -0
  75. privaci/preflight/checks.py +214 -0
  76. privaci/preflight/runner.py +116 -0
  77. privaci/preflight/salt.py +67 -0
  78. privaci/preflight/target.py +101 -0
  79. privaci/py.typed +0 -0
  80. privaci/runtime/__init__.py +19 -0
  81. privaci/runtime/signals.py +55 -0
  82. privaci/schema/__init__.py +7 -0
  83. privaci/schema/ddl.py +101 -0
  84. privaci/schema/extensions.py +27 -0
  85. privaci/schema/replicate.py +185 -0
  86. privaci/schema/sequences.py +93 -0
  87. privaci/schema/strategies.py +55 -0
  88. privaci/secrets/__init__.py +26 -0
  89. privaci/secrets/backends/__init__.py +3 -0
  90. privaci/secrets/backends/aws_sm.py +105 -0
  91. privaci/secrets/backends/azure_kv.py +80 -0
  92. privaci/secrets/backends/constants.py +7 -0
  93. privaci/secrets/backends/env.py +30 -0
  94. privaci/secrets/backends/file.py +100 -0
  95. privaci/secrets/backends/hashicorp.py +95 -0
  96. privaci/secrets/parser.py +207 -0
  97. privaci/secrets/resolver.py +222 -0
  98. privaci/secrets/types.py +52 -0
  99. privaci/spikes/__init__.py +19 -0
  100. privaci/spikes/_env.py +11 -0
  101. privaci/spikes/copy_binary.py +106 -0
  102. privaci/spikes/cyclic_fk.py +62 -0
  103. privaci/spikes/spacy_throughput.py +105 -0
  104. privaci/state/__init__.py +65 -0
  105. privaci/state/audit.py +117 -0
  106. privaci/state/checkpoints.py +103 -0
  107. privaci/state/ddl.py +89 -0
  108. privaci/state/fingerprints.py +93 -0
  109. privaci/state/models.py +78 -0
  110. privaci/state/resume.py +251 -0
  111. privaci/state/runs.py +108 -0
  112. privaci/state/schema.py +88 -0
  113. privaci/stream/__init__.py +7 -0
  114. privaci/stream/batch.py +47 -0
  115. privaci/stream/batch_write.py +91 -0
  116. privaci/stream/coerce.py +124 -0
  117. privaci/stream/copy_binary.py +133 -0
  118. privaci/stream/fetch.py +113 -0
  119. privaci/stream/models.py +70 -0
  120. privaci/stream/retry.py +57 -0
  121. privaci/stream/table.py +221 -0
  122. privaci/verify/__init__.py +13 -0
  123. privaci/verify/compare.py +143 -0
  124. privaci/verify/models.py +74 -0
  125. privaci/verify/runner.py +147 -0
  126. privaci/verify/structural.py +141 -0
  127. privaci-0.1.0b4.dist-info/METADATA +108 -0
  128. privaci-0.1.0b4.dist-info/RECORD +132 -0
  129. privaci-0.1.0b4.dist-info/WHEEL +5 -0
  130. privaci-0.1.0b4.dist-info/entry_points.txt +2 -0
  131. privaci-0.1.0b4.dist-info/licenses/LICENSE +99 -0
  132. privaci-0.1.0b4.dist-info/top_level.txt +1 -0
privaci/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """PrivaCI — in-VPC PostgreSQL masking and anonymization engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
privaci/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow `python -m privaci` to invoke the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from privaci.cli.app import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,27 @@
1
+ """PII column auto-detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from privaci.autodetect.models import (
6
+ DetectionConfidence,
7
+ DetectionFinding,
8
+ DetectionResult,
9
+ )
10
+ from privaci.autodetect.report import write_detection_report
11
+ from privaci.autodetect.resolve import (
12
+ build_detection,
13
+ resolve_effective_table_config,
14
+ uncovered_strict_columns,
15
+ )
16
+ from privaci.autodetect.scanner import scan_catalog
17
+
18
+ __all__ = [
19
+ "DetectionConfidence",
20
+ "DetectionFinding",
21
+ "DetectionResult",
22
+ "build_detection",
23
+ "resolve_effective_table_config",
24
+ "scan_catalog",
25
+ "uncovered_strict_columns",
26
+ "write_detection_report",
27
+ ]
@@ -0,0 +1,70 @@
1
+ """Map pattern rules to config column actions, respecting column types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from privaci.autodetect.patterns import PatternRule
6
+ from privaci.catalog.models import ColumnInfo
7
+ from privaci.config.actions import (
8
+ ColumnAction,
9
+ FakeAction,
10
+ HashAction,
11
+ NerMaskAction,
12
+ StaticAction,
13
+ )
14
+
15
+ _TEXTLIKE = frozenset(
16
+ {"text", "character varying", "character", "varchar", "char", "citext", "name"}
17
+ )
18
+
19
+ # Column base types each fake provider can produce a valid value for. Providers
20
+ # absent from this map are treated as text-only.
21
+ _PROVIDER_TYPES: dict[str, frozenset[str]] = {
22
+ "dob": frozenset({"date"}) | _TEXTLIKE,
23
+ "ip_address": frozenset({"inet", "cidr"}) | _TEXTLIKE,
24
+ "uuid": frozenset({"uuid"}) | _TEXTLIKE,
25
+ }
26
+
27
+
28
+ def _base_type(data_type: str) -> str:
29
+ head, _, _ = data_type.strip().lower().partition("(")
30
+ return head.strip()
31
+
32
+
33
+ def action_for_column(rule: PatternRule, column: ColumnInfo) -> ColumnAction | None:
34
+ """Build a type-compatible action for ``column``, or ``None`` to skip.
35
+
36
+ Returns ``None`` when the matched rule's action cannot produce a value
37
+ valid for the column's type (e.g. ``hash`` on a ``uuid`` column), so the
38
+ scanner can leave the column as passthrough rather than emit a broken mask.
39
+ """
40
+ base = _base_type(column.data_type)
41
+ if rule.action == "fake":
42
+ return _fake_action(rule, base)
43
+ if rule.action == "hash":
44
+ return _hash_action(base)
45
+ if rule.action == "static":
46
+ if base not in _TEXTLIKE:
47
+ return None
48
+ value = rule.static_value or "privaci-test-pw"
49
+ return StaticAction(action="static", value=value)
50
+ if base not in _TEXTLIKE:
51
+ return None
52
+ return NerMaskAction(action="ner_mask")
53
+
54
+
55
+ def _fake_action(rule: PatternRule, base: str) -> ColumnAction | None:
56
+ if rule.provider is None:
57
+ msg = f"fake rule {rule.rule_id} missing provider"
58
+ raise ValueError(msg)
59
+ allowed = _PROVIDER_TYPES.get(rule.provider, _TEXTLIKE)
60
+ if base not in allowed:
61
+ return None
62
+ return FakeAction(action="fake", provider=rule.provider)
63
+
64
+
65
+ def _hash_action(base: str) -> ColumnAction | None:
66
+ if base == "uuid":
67
+ return FakeAction(action="fake", provider="uuid")
68
+ if base in _TEXTLIKE:
69
+ return HashAction(action="hash")
70
+ return None
@@ -0,0 +1,68 @@
1
+ """Freeform-text shape and confidence scoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from privaci.autodetect.models import DetectionConfidence
8
+ from privaci.autodetect.table_context import table_sensitivity
9
+ from privaci.catalog.models import ColumnInfo
10
+
11
+ _FREEFORM_MIN_AVG_WIDTH = 200
12
+ _FREEFORM_UNCERTAIN_MIN_AVG_WIDTH = 100
13
+ _TEXT_TYPES = frozenset({"text"})
14
+ _VARCHAR_RE = re.compile(r"^character varying\((\d+)\)$", re.IGNORECASE)
15
+
16
+
17
+ def is_freeform_eligible_type(column: ColumnInfo) -> bool:
18
+ """Return whether the column type can hold narrative freeform text."""
19
+ base = _base_type(column.data_type)
20
+ if base in _TEXT_TYPES:
21
+ return True
22
+ match = _VARCHAR_RE.match(column.data_type.strip())
23
+ if match is None:
24
+ return False
25
+ return int(match.group(1)) >= 500
26
+
27
+
28
+ def score_freeform_confidence(
29
+ *,
30
+ table_name: str,
31
+ column: ColumnInfo,
32
+ ) -> tuple[DetectionConfidence, tuple[str, ...]]:
33
+ """Score a freeform name-match using type, stats, and table context."""
34
+ reasons: list[str] = []
35
+ if not is_freeform_eligible_type(column):
36
+ reasons.append(f"type {column.data_type!r} is not freeform-eligible")
37
+ return "low", tuple(reasons)
38
+
39
+ reasons.append("column type is freeform-eligible")
40
+ sensitivity = table_sensitivity(table_name)
41
+ avg_width = column.avg_width
42
+
43
+ if avg_width is None:
44
+ reasons.append("pg_stats.avg_width unavailable")
45
+ if sensitivity == "sensitive":
46
+ reasons.append("sensitive table context")
47
+ return "medium", tuple(reasons)
48
+ return "low", tuple(reasons)
49
+
50
+ reasons.append(f"pg_stats.avg_width={avg_width:.0f}")
51
+ if avg_width >= _FREEFORM_MIN_AVG_WIDTH:
52
+ if sensitivity == "low":
53
+ reasons.append("low-sensitivity table context downgrades confidence")
54
+ return "medium", tuple(reasons)
55
+ if sensitivity == "sensitive":
56
+ reasons.append("sensitive table context")
57
+ return "high", tuple(reasons)
58
+
59
+ if avg_width >= _FREEFORM_UNCERTAIN_MIN_AVG_WIDTH and sensitivity == "sensitive":
60
+ reasons.append("borderline avg_width on sensitive table")
61
+ return "medium", tuple(reasons)
62
+
63
+ reasons.append("avg_width below freeform threshold")
64
+ return "low", tuple(reasons)
65
+
66
+
67
+ def _base_type(data_type: str) -> str:
68
+ return re.sub(r"\(.*\)$", "", data_type.strip().lower())
@@ -0,0 +1,59 @@
1
+ """Column-name pattern matching for auto-detect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import get_args
7
+
8
+ from privaci.autodetect.patterns import (
9
+ BUILTIN_PATTERNS,
10
+ PatternKind,
11
+ PatternRule,
12
+ compile_patterns,
13
+ )
14
+
15
+
16
+ def match_column_name(column_name: str) -> PatternRule | None:
17
+ """Return the first pattern rule matching ``column_name``, if any.
18
+
19
+ Rules are evaluated in library order; earlier rules win.
20
+ """
21
+ lowered = column_name.lower()
22
+ compiled = compile_patterns()
23
+ for rule in BUILTIN_PATTERNS:
24
+ if _rule_matches(lowered, rule, compiled):
25
+ return rule
26
+ return None
27
+
28
+
29
+ def _delimiter_bounded_substring(lowered: str, needle: str) -> bool:
30
+ """Match ``needle`` only at underscore boundaries or as the whole name."""
31
+ if lowered == needle:
32
+ return True
33
+ padded = f"_{lowered}_"
34
+ return f"_{needle}_" in padded
35
+
36
+
37
+ def _rule_matches(
38
+ lowered: str,
39
+ rule: PatternRule,
40
+ compiled: dict[str, re.Pattern[str]],
41
+ ) -> bool:
42
+ kind = rule.kind
43
+ if kind == "substring":
44
+ return rule.pattern in lowered
45
+ elif kind == "bounded_substring":
46
+ return _delimiter_bounded_substring(lowered, rule.pattern)
47
+ elif kind == "suffix":
48
+ return lowered.endswith(rule.pattern)
49
+ elif kind in ("prefix", "wildcard_prefix"):
50
+ return lowered.startswith(rule.pattern)
51
+ elif kind == "regex":
52
+ pattern = compiled.get(rule.rule_id)
53
+ return pattern is not None and pattern.fullmatch(lowered) is not None
54
+ # CodeQL's mixed-return rule wants an explicit non-returning terminal rather
55
+ # than an implicit fall-through after the exhaustive ``kind`` chain above.
56
+ # Derive the valid kinds from PatternKind so the message can never drift.
57
+ expected = ", ".join(get_args(PatternKind))
58
+ msg = f"Unknown auto-detect pattern kind: {kind!r}. Expected one of: {expected}."
59
+ raise ValueError(msg)
@@ -0,0 +1,63 @@
1
+ """Detection finding models for PII auto-detect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from privaci.config.actions import ColumnAction
9
+
10
+ DetectionConfidence = Literal["high", "medium", "low"]
11
+ DetectionSource = Literal["autodetect", "config"]
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class DetectionFinding:
16
+ """Outcome of inspecting one column for PII.
17
+
18
+ Attributes:
19
+ table_id: Schema-qualified table identifier.
20
+ column_name: Column under inspection.
21
+ confidence: ``high`` auto-masks; ``medium`` flags review; ``low`` passthrough.
22
+ reasons: Human-readable scoring explanation (never contains cell values).
23
+ action: Proposed masking action, or ``None`` for passthrough.
24
+ provider: Inferred fake provider name when applicable.
25
+ matched_pattern: Pattern rule id that fired, if any.
26
+ source: Whether YAML or auto-detect produced the effective action.
27
+ """
28
+
29
+ table_id: str
30
+ column_name: str
31
+ confidence: DetectionConfidence
32
+ reasons: tuple[str, ...]
33
+ action: ColumnAction | None = None
34
+ provider: str | None = None
35
+ matched_pattern: str | None = None
36
+ source: DetectionSource = "autodetect"
37
+
38
+ def __repr__(self) -> str:
39
+ return (
40
+ f"DetectionFinding({self.table_id}.{self.column_name!r}, "
41
+ f"confidence={self.confidence!r})"
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class DetectionResult:
47
+ """All findings from one catalog scan."""
48
+
49
+ findings: tuple[DetectionFinding, ...]
50
+
51
+ def by_table(self, table_id: str) -> tuple[DetectionFinding, ...]:
52
+ """Return findings for ``table_id`` in column order."""
53
+ return tuple(f for f in self.findings if f.table_id == table_id)
54
+
55
+ def finding_for(self, table_id: str, column_name: str) -> DetectionFinding | None:
56
+ """Return the finding for one column, if present."""
57
+ for finding in self.findings:
58
+ if finding.table_id == table_id and finding.column_name == column_name:
59
+ return finding
60
+ return None
61
+
62
+ def __repr__(self) -> str:
63
+ return f"DetectionResult(findings={len(self.findings)})"
@@ -0,0 +1,119 @@
1
+ """Built-in PII column-name pattern library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ PatternKind = Literal[
10
+ "substring",
11
+ "bounded_substring",
12
+ "suffix",
13
+ "prefix",
14
+ "regex",
15
+ "wildcard_prefix",
16
+ ]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class PatternRule:
21
+ """One auto-detect pattern entry."""
22
+
23
+ rule_id: str
24
+ kind: PatternKind
25
+ pattern: str
26
+ provider: str | None
27
+ action: Literal["fake", "hash", "static", "ner_mask"]
28
+ static_value: str | None = None
29
+
30
+
31
+ _STATIC_PASSWORD = (
32
+ "privaci-test-pw" # noqa: S105 — documented test placeholder, not a secret
33
+ )
34
+
35
+ # SECURITY: fixed test placeholder for password columns — never a real hash.
36
+ BUILTIN_PATTERNS: tuple[PatternRule, ...] = (
37
+ PatternRule("email_suffix", "suffix", "_email", "email", "fake"),
38
+ PatternRule("email_prefix", "prefix", "email_", "email", "fake"),
39
+ PatternRule("email", "substring", "email", "email", "fake"),
40
+ PatternRule("e_mail", "substring", "e_mail", "email", "fake"),
41
+ PatternRule("phone", "substring", "phone", "phone", "fake"),
42
+ PatternRule("mobile", "substring", "mobile", "phone", "fake"),
43
+ PatternRule("tel", "bounded_substring", "tel", "phone", "fake"),
44
+ PatternRule("cell", "bounded_substring", "cell", "phone", "fake"),
45
+ PatternRule("phone_suffix", "suffix", "_phone", "phone", "fake"),
46
+ PatternRule("ssn", "substring", "ssn", "ssn", "fake"),
47
+ PatternRule("social_security", "substring", "social_security", "ssn", "fake"),
48
+ PatternRule("tax_id", "substring", "tax_id", "ssn", "fake"),
49
+ PatternRule("national_id", "substring", "national_id", "ssn", "fake"),
50
+ PatternRule("first_name", "substring", "first_name", "first_name", "fake"),
51
+ PatternRule("fname", "substring", "fname", "first_name", "fake"),
52
+ PatternRule("given_name", "substring", "given_name", "first_name", "fake"),
53
+ PatternRule("last_name", "substring", "last_name", "last_name", "fake"),
54
+ PatternRule("lname", "substring", "lname", "last_name", "fake"),
55
+ PatternRule("surname", "substring", "surname", "last_name", "fake"),
56
+ PatternRule("family_name", "substring", "family_name", "last_name", "fake"),
57
+ PatternRule("full_name", "substring", "full_name", "full_name", "fake"),
58
+ PatternRule("display_name", "substring", "display_name", "full_name", "fake"),
59
+ PatternRule("name_exact", "regex", r"^name$", "full_name", "fake"),
60
+ # IP rules precede the generic `address` substring so `ip_address` is not
61
+ # mis-matched as a street address. `ip` is word-anchored to avoid matching
62
+ # substrings like `descr(ip)tion` or `sh(ip)ping`.
63
+ PatternRule("ip_address", "substring", "ip_address", "ip_address", "fake"),
64
+ PatternRule("ip_suffix", "suffix", "_ip", "ip_address", "fake"),
65
+ PatternRule("ip_prefix", "prefix", "ip_", "ip_address", "fake"),
66
+ PatternRule("ip_exact", "regex", r"^ip$", "ip_address", "fake"),
67
+ PatternRule("address", "substring", "address", "address", "fake"),
68
+ PatternRule("street", "substring", "street", "street", "fake"),
69
+ PatternRule("city", "substring", "city", "city", "fake"),
70
+ PatternRule("postcode", "substring", "postcode", "postcode", "fake"),
71
+ PatternRule("zip", "substring", "zip", "postcode", "fake"),
72
+ PatternRule("country", "substring", "country", "country", "fake"),
73
+ PatternRule("dob", "substring", "dob", "dob", "fake"),
74
+ PatternRule("date_of_birth", "substring", "date_of_birth", "dob", "fake"),
75
+ PatternRule("birth_date", "substring", "birth_date", "dob", "fake"),
76
+ PatternRule("birthday", "substring", "birthday", "dob", "fake"),
77
+ PatternRule("credit_card", "substring", "credit_card", "credit_card", "fake"),
78
+ PatternRule("card_number", "substring", "card_number", "credit_card", "fake"),
79
+ PatternRule("cc_number", "substring", "cc_number", "credit_card", "fake"),
80
+ PatternRule("pan", "bounded_substring", "pan", "credit_card", "fake"),
81
+ PatternRule(
82
+ "password_hash",
83
+ "substring",
84
+ "password_hash",
85
+ "password",
86
+ "static",
87
+ _STATIC_PASSWORD,
88
+ ),
89
+ PatternRule(
90
+ "password", "substring", "password", "password", "static", _STATIC_PASSWORD
91
+ ),
92
+ PatternRule(
93
+ "passwd", "substring", "passwd", "password", "static", _STATIC_PASSWORD
94
+ ),
95
+ PatternRule("pwd", "substring", "pwd", "password", "static", _STATIC_PASSWORD),
96
+ PatternRule("token", "substring", "token", None, "hash"),
97
+ PatternRule("api_key", "substring", "api_key", None, "hash"),
98
+ PatternRule("secret", "substring", "secret", None, "hash"),
99
+ PatternRule("token_suffix", "suffix", "_token", None, "hash"),
100
+ PatternRule("auth_prefix", "prefix", "auth_", None, "hash"),
101
+ PatternRule("notes_suffix", "suffix", "_notes", None, "ner_mask"),
102
+ PatternRule("notes_substring", "substring", "notes", None, "ner_mask"),
103
+ PatternRule("notes", "wildcard_prefix", "note", None, "ner_mask"),
104
+ PatternRule("comments", "wildcard_prefix", "comment", None, "ner_mask"),
105
+ PatternRule("description", "substring", "description", None, "ner_mask"),
106
+ PatternRule("bio", "substring", "bio", None, "ner_mask"),
107
+ PatternRule("about", "substring", "about", None, "ner_mask"),
108
+ )
109
+
110
+ _COMPILED_REGEX: dict[str, re.Pattern[str]] = {}
111
+
112
+
113
+ def compile_patterns() -> dict[str, re.Pattern[str]]:
114
+ """Return cached compiled regex rules keyed by rule id."""
115
+ if not _COMPILED_REGEX:
116
+ for rule in BUILTIN_PATTERNS:
117
+ if rule.kind == "regex":
118
+ _COMPILED_REGEX[rule.rule_id] = re.compile(rule.pattern, re.IGNORECASE)
119
+ return _COMPILED_REGEX
@@ -0,0 +1,71 @@
1
+ """Markdown report writer for ``privaci dry-run --report``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from privaci.autodetect.models import DetectionFinding, DetectionResult
8
+ from privaci.catalog.models import CatalogResult
9
+ from privaci.config.models import Config
10
+
11
+
12
+ def write_detection_report(
13
+ path: Path,
14
+ *,
15
+ catalog: CatalogResult,
16
+ detection: DetectionResult,
17
+ config: Config,
18
+ ) -> None:
19
+ """Write a human-readable markdown summary of detection outcomes."""
20
+ lines = [
21
+ "# PrivaCI auto-detect report",
22
+ "",
23
+ f"Tables inspected: {len(catalog.tables)}",
24
+ f"Auto-detect: {'on' if config.auto_detect else 'off'}",
25
+ f"Strict mode: {'on' if config.strict_autodetect else 'off'}",
26
+ "",
27
+ ]
28
+ table_ids = sorted({f.table_id for f in detection.findings})
29
+ for table_id in table_ids:
30
+ lines.extend(_table_section(table_id, detection))
31
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
32
+
33
+
34
+ def _table_section(table_id: str, detection: DetectionResult) -> list[str]:
35
+ findings = detection.by_table(table_id)
36
+ masked = [f for f in findings if f.action is not None and f.source == "autodetect"]
37
+ passthrough = [f for f in findings if f.action is None and f.confidence != "medium"]
38
+ uncertain = [f for f in findings if f.confidence == "medium"]
39
+ explicit = [f for f in findings if f.source == "config"]
40
+
41
+ lines = [f"## {table_id}", ""]
42
+ lines.append("### Masked (auto-detect)")
43
+ lines.extend(_finding_lines(masked))
44
+ lines.append("")
45
+ lines.append("### Explicit config")
46
+ lines.extend(_finding_lines(explicit))
47
+ lines.append("")
48
+ lines.append("### Uncertain (manual review)")
49
+ lines.extend(_finding_lines(uncertain))
50
+ lines.append("")
51
+ lines.append("### Passthrough")
52
+ lines.extend(_finding_lines(passthrough))
53
+ lines.append("")
54
+ return lines
55
+
56
+
57
+ def _finding_lines(findings: list[DetectionFinding]) -> list[str]:
58
+ if not findings:
59
+ return ["- _(none)_"]
60
+ lines: list[str] = []
61
+ for finding in findings:
62
+ action = finding.action.action if finding.action else "passthrough"
63
+ provider = f", provider={finding.provider}" if finding.provider else ""
64
+ pattern = (
65
+ f", pattern={finding.matched_pattern}" if finding.matched_pattern else ""
66
+ )
67
+ lines.append(
68
+ f"- `{finding.column_name}`: {action}{provider}{pattern} "
69
+ f"({finding.confidence})"
70
+ )
71
+ return lines
@@ -0,0 +1,73 @@
1
+ """Merge YAML config with auto-detect findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from privaci.autodetect.models import DetectionResult
6
+ from privaci.autodetect.scanner import scan_catalog
7
+ from privaci.catalog.models import CatalogResult, TableInfo
8
+ from privaci.config.actions import ColumnAction
9
+ from privaci.config.models import Config, TableConfig
10
+
11
+
12
+ def resolve_effective_table_config(
13
+ table: TableInfo,
14
+ config: Config,
15
+ detection: DetectionResult,
16
+ ) -> TableConfig:
17
+ """Return the masking config for one table after auto-detect merge."""
18
+ yaml_cfg = config.tables.get(table.identifier)
19
+ if yaml_cfg is None:
20
+ yaml_cfg = TableConfig()
21
+ if not config.auto_detect:
22
+ return yaml_cfg
23
+
24
+ merged_columns = dict(yaml_cfg.columns)
25
+ for column in table.columns:
26
+ if column.name in merged_columns:
27
+ continue
28
+ finding = detection.finding_for(table.identifier, column.name)
29
+ if finding is None or finding.action is None:
30
+ continue
31
+ merged_columns[column.name] = finding.action
32
+ return TableConfig(
33
+ strategy=yaml_cfg.strategy,
34
+ columns=merged_columns,
35
+ batch_size=yaml_cfg.batch_size,
36
+ null_orphan_fks=yaml_cfg.null_orphan_fks,
37
+ )
38
+
39
+
40
+ def uncovered_strict_columns(
41
+ config: Config,
42
+ detection: DetectionResult,
43
+ ) -> tuple[str, ...]:
44
+ """Return schema-qualified columns that fail strict auto-detect."""
45
+ if not config.strict_autodetect:
46
+ return ()
47
+ uncovered: list[str] = []
48
+ for finding in detection.findings:
49
+ if finding.confidence not in {"high", "medium"}:
50
+ continue
51
+ if finding.matched_pattern is None:
52
+ continue
53
+ table_cfg = config.tables.get(finding.table_id)
54
+ if table_cfg is not None and table_cfg.strategy == "exclude":
55
+ continue
56
+ if table_cfg is not None and finding.column_name in table_cfg.columns:
57
+ continue
58
+ uncovered.append(f"{finding.table_id}.{finding.column_name}")
59
+ return tuple(sorted(uncovered))
60
+
61
+
62
+ def build_detection(config: Config, catalog: CatalogResult) -> DetectionResult:
63
+ """Scan the catalog unless auto-detect is disabled."""
64
+ return scan_catalog(catalog, config)
65
+
66
+
67
+ def effective_columns_for_table(
68
+ table: TableInfo,
69
+ config: Config,
70
+ detection: DetectionResult,
71
+ ) -> dict[str, ColumnAction]:
72
+ """Return the merged per-column action map for one table."""
73
+ return resolve_effective_table_config(table, config, detection).columns