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.
- privaci/__init__.py +5 -0
- privaci/__main__.py +8 -0
- privaci/autodetect/__init__.py +27 -0
- privaci/autodetect/actions.py +70 -0
- privaci/autodetect/freeform.py +68 -0
- privaci/autodetect/matcher.py +59 -0
- privaci/autodetect/models.py +63 -0
- privaci/autodetect/patterns.py +119 -0
- privaci/autodetect/report.py +71 -0
- privaci/autodetect/resolve.py +73 -0
- privaci/autodetect/scanner.py +153 -0
- privaci/autodetect/table_context.py +53 -0
- privaci/catalog/__init__.py +39 -0
- privaci/catalog/audit_skipped.py +40 -0
- privaci/catalog/detectors.py +217 -0
- privaci/catalog/graph.py +203 -0
- privaci/catalog/identifiers.py +76 -0
- privaci/catalog/introspect.py +371 -0
- privaci/catalog/models.py +242 -0
- privaci/catalog/partitions.py +129 -0
- privaci/catalog/queries.py +251 -0
- privaci/catalog/skipped.py +63 -0
- privaci/catalog/snapshot.py +292 -0
- privaci/cli/__init__.py +3 -0
- privaci/cli/_catalog.py +94 -0
- privaci/cli/_errors.py +93 -0
- privaci/cli/_resume.py +89 -0
- privaci/cli/_run.py +187 -0
- privaci/cli/app.py +306 -0
- privaci/cli/context.py +112 -0
- privaci/cli/generate_ci.py +152 -0
- privaci/cli/logging_setup.py +16 -0
- privaci/cli/options.py +66 -0
- privaci/config/__init__.py +50 -0
- privaci/config/actions.py +170 -0
- privaci/config/loader.py +253 -0
- privaci/config/models.py +133 -0
- privaci/contracts/__init__.py +50 -0
- privaci/contracts/base.py +125 -0
- privaci/contracts/fallbacks.py +94 -0
- privaci/contracts/plugins.py +86 -0
- privaci/errors.py +131 -0
- privaci/mask/__init__.py +17 -0
- privaci/mask/column_masker.py +201 -0
- privaci/mask/engine.py +95 -0
- privaci/mask/faker/__init__.py +31 -0
- privaci/mask/faker/base.py +32 -0
- privaci/mask/faker/context.py +34 -0
- privaci/mask/faker/engine.py +41 -0
- privaci/mask/faker/hash.py +47 -0
- privaci/mask/faker/libraries.py +192 -0
- privaci/mask/faker/providers/__init__.py +7 -0
- privaci/mask/faker/providers/builtin.py +240 -0
- privaci/mask/faker/registry.py +84 -0
- privaci/mask/faker/uniqueness.py +74 -0
- privaci/mask/ner.py +114 -0
- privaci/mask/regex_safe.py +98 -0
- privaci/mask/safe_log.py +25 -0
- privaci/observability/__init__.py +30 -0
- privaci/observability/events.py +93 -0
- privaci/observability/jsonlog.py +123 -0
- privaci/observability/metrics.py +92 -0
- privaci/observability/progress.py +86 -0
- privaci/observability/redact.py +104 -0
- privaci/packs/__init__.py +7 -0
- privaci/packs/install.py +173 -0
- privaci/packs/keys.py +40 -0
- privaci/packs/verify.py +64 -0
- privaci/pipeline/__init__.py +7 -0
- privaci/pipeline/lifecycle.py +169 -0
- privaci/pipeline/runner.py +277 -0
- privaci/pipeline/streaming.py +238 -0
- privaci/pipeline/table_plan.py +84 -0
- privaci/preflight/__init__.py +8 -0
- privaci/preflight/checks.py +214 -0
- privaci/preflight/runner.py +116 -0
- privaci/preflight/salt.py +67 -0
- privaci/preflight/target.py +101 -0
- privaci/py.typed +0 -0
- privaci/runtime/__init__.py +19 -0
- privaci/runtime/signals.py +55 -0
- privaci/schema/__init__.py +7 -0
- privaci/schema/ddl.py +101 -0
- privaci/schema/extensions.py +27 -0
- privaci/schema/replicate.py +185 -0
- privaci/schema/sequences.py +93 -0
- privaci/schema/strategies.py +55 -0
- privaci/secrets/__init__.py +26 -0
- privaci/secrets/backends/__init__.py +3 -0
- privaci/secrets/backends/aws_sm.py +105 -0
- privaci/secrets/backends/azure_kv.py +80 -0
- privaci/secrets/backends/constants.py +7 -0
- privaci/secrets/backends/env.py +30 -0
- privaci/secrets/backends/file.py +100 -0
- privaci/secrets/backends/hashicorp.py +95 -0
- privaci/secrets/parser.py +207 -0
- privaci/secrets/resolver.py +222 -0
- privaci/secrets/types.py +52 -0
- privaci/spikes/__init__.py +19 -0
- privaci/spikes/_env.py +11 -0
- privaci/spikes/copy_binary.py +106 -0
- privaci/spikes/cyclic_fk.py +62 -0
- privaci/spikes/spacy_throughput.py +105 -0
- privaci/state/__init__.py +65 -0
- privaci/state/audit.py +117 -0
- privaci/state/checkpoints.py +103 -0
- privaci/state/ddl.py +89 -0
- privaci/state/fingerprints.py +93 -0
- privaci/state/models.py +78 -0
- privaci/state/resume.py +251 -0
- privaci/state/runs.py +108 -0
- privaci/state/schema.py +88 -0
- privaci/stream/__init__.py +7 -0
- privaci/stream/batch.py +47 -0
- privaci/stream/batch_write.py +91 -0
- privaci/stream/coerce.py +124 -0
- privaci/stream/copy_binary.py +133 -0
- privaci/stream/fetch.py +113 -0
- privaci/stream/models.py +70 -0
- privaci/stream/retry.py +57 -0
- privaci/stream/table.py +221 -0
- privaci/verify/__init__.py +13 -0
- privaci/verify/compare.py +143 -0
- privaci/verify/models.py +74 -0
- privaci/verify/runner.py +147 -0
- privaci/verify/structural.py +141 -0
- privaci-0.1.0b4.dist-info/METADATA +108 -0
- privaci-0.1.0b4.dist-info/RECORD +132 -0
- privaci-0.1.0b4.dist-info/WHEEL +5 -0
- privaci-0.1.0b4.dist-info/entry_points.txt +2 -0
- privaci-0.1.0b4.dist-info/licenses/LICENSE +99 -0
- privaci-0.1.0b4.dist-info/top_level.txt +1 -0
privaci/__init__.py
ADDED
privaci/__main__.py
ADDED
|
@@ -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
|