maskflow-evidence 0.1.0__tar.gz
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.
- maskflow_evidence-0.1.0/.gitignore +16 -0
- maskflow_evidence-0.1.0/PKG-INFO +73 -0
- maskflow_evidence-0.1.0/README.md +55 -0
- maskflow_evidence-0.1.0/contrib/grafana-dashboard.json +117 -0
- maskflow_evidence-0.1.0/pyproject.toml +51 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/__init__.py +50 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/config.py +125 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/derive.py +136 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/__init__.py +98 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/file.py +44 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/otlp.py +49 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/py.typed +0 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/stdout.py +20 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/syslog.py +31 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/emitters/webhook.py +36 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/guard.py +117 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/py.typed +0 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/schema.py +182 -0
- maskflow_evidence-0.1.0/src/maskflow_evidence/versions.py +54 -0
- maskflow_evidence-0.1.0/tests/conftest.py +5 -0
- maskflow_evidence-0.1.0/tests/test_contrib.py +16 -0
- maskflow_evidence-0.1.0/tests/test_derive.py +99 -0
- maskflow_evidence-0.1.0/tests/test_derive_no_value_access.py +23 -0
- maskflow_evidence-0.1.0/tests/test_emitters.py +85 -0
- maskflow_evidence-0.1.0/tests/test_evidence_config.py +68 -0
- maskflow_evidence-0.1.0/tests/test_schema_metadata_only.py +158 -0
- maskflow_evidence-0.1.0/tests/test_versions.py +24 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: maskflow-evidence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MaskFlow evidence layer: a metadata-only, verifiable record of what was masked -- never the values. Self-hosted emitters (stdout, file, syslog, webhook, OTLP), off by default.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: maskflow-core<0.8,>=0.7.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
10
|
+
Requires-Dist: hypothesis>=6.100; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
12
|
+
Provides-Extra: otlp
|
|
13
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.25; extra == 'otlp'
|
|
14
|
+
Requires-Dist: opentelemetry-sdk>=1.25; extra == 'otlp'
|
|
15
|
+
Provides-Extra: webhook
|
|
16
|
+
Requires-Dist: httpx>=0.27; extra == 'webhook'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# maskflow-evidence
|
|
20
|
+
|
|
21
|
+
A **metadata-only, verifiable record of what MaskFlow masked** — never the
|
|
22
|
+
values themselves. Publishing the schema and shipping the emitters in the
|
|
23
|
+
open lets any user confirm, by reading the code, exactly what leaves their
|
|
24
|
+
environment.
|
|
25
|
+
|
|
26
|
+
> Part of [MaskFlow](https://github.com/maskflow/maskflow). MIT, free
|
|
27
|
+
> forever, no telemetry.
|
|
28
|
+
|
|
29
|
+
## What an event looks like
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{"event_id":"…","ts":"2026-09-07T12:00:00Z","session_id":"…","service":"support-bot",
|
|
33
|
+
"environment":"prod","entity_type":"AADHAAR","count":1,"score":0.98,
|
|
34
|
+
"recognizer":"pattern:AADHAAR","action":"masked","provider":"openai","model":"gpt-4o",
|
|
35
|
+
"pack_version":"0.5.0","engine_version":"0.6.0"}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
There is **no field that can carry free text.** Every string is a bounded
|
|
39
|
+
slug; there is no place for a detected value, a placeholder, or the mapping
|
|
40
|
+
between them. This is enforced structurally in `schema.py` and in CI by
|
|
41
|
+
`tests/test_schema_metadata_only.py`.
|
|
42
|
+
|
|
43
|
+
## Off by default
|
|
44
|
+
|
|
45
|
+
Nothing is emitted unless you turn it on. In `.maskflowrc`:
|
|
46
|
+
|
|
47
|
+
```toml
|
|
48
|
+
[evidence]
|
|
49
|
+
enabled = true
|
|
50
|
+
sink = "file" # stdout | file | syslog | webhook | otlp
|
|
51
|
+
path = "evidence.log"
|
|
52
|
+
service = "support-bot"
|
|
53
|
+
environment = "prod"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The gateway reads `MASKFLOW_GATEWAY_EVIDENCE_*` environment variables with
|
|
57
|
+
the same names.
|
|
58
|
+
|
|
59
|
+
## Sinks
|
|
60
|
+
|
|
61
|
+
| sink | transport | extra |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `stdout` | one JSON line per event | — |
|
|
64
|
+
| `file` | size-rotated JSON lines | — |
|
|
65
|
+
| `syslog` | `SysLogHandler` | — |
|
|
66
|
+
| `webhook` | `POST` JSON per event | `maskflow-evidence[webhook]` |
|
|
67
|
+
| `otlp` | OpenTelemetry log records | `maskflow-evidence[otlp]` |
|
|
68
|
+
|
|
69
|
+
## What is deliberately **not** collected
|
|
70
|
+
|
|
71
|
+
Raw values, masked values, the mapping, request/response bodies, prompt
|
|
72
|
+
text, and any user identifier beyond a caller-supplied opaque `session_id`.
|
|
73
|
+
See `docs/evidence.md` for the full statement.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# maskflow-evidence
|
|
2
|
+
|
|
3
|
+
A **metadata-only, verifiable record of what MaskFlow masked** — never the
|
|
4
|
+
values themselves. Publishing the schema and shipping the emitters in the
|
|
5
|
+
open lets any user confirm, by reading the code, exactly what leaves their
|
|
6
|
+
environment.
|
|
7
|
+
|
|
8
|
+
> Part of [MaskFlow](https://github.com/maskflow/maskflow). MIT, free
|
|
9
|
+
> forever, no telemetry.
|
|
10
|
+
|
|
11
|
+
## What an event looks like
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{"event_id":"…","ts":"2026-09-07T12:00:00Z","session_id":"…","service":"support-bot",
|
|
15
|
+
"environment":"prod","entity_type":"AADHAAR","count":1,"score":0.98,
|
|
16
|
+
"recognizer":"pattern:AADHAAR","action":"masked","provider":"openai","model":"gpt-4o",
|
|
17
|
+
"pack_version":"0.5.0","engine_version":"0.6.0"}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
There is **no field that can carry free text.** Every string is a bounded
|
|
21
|
+
slug; there is no place for a detected value, a placeholder, or the mapping
|
|
22
|
+
between them. This is enforced structurally in `schema.py` and in CI by
|
|
23
|
+
`tests/test_schema_metadata_only.py`.
|
|
24
|
+
|
|
25
|
+
## Off by default
|
|
26
|
+
|
|
27
|
+
Nothing is emitted unless you turn it on. In `.maskflowrc`:
|
|
28
|
+
|
|
29
|
+
```toml
|
|
30
|
+
[evidence]
|
|
31
|
+
enabled = true
|
|
32
|
+
sink = "file" # stdout | file | syslog | webhook | otlp
|
|
33
|
+
path = "evidence.log"
|
|
34
|
+
service = "support-bot"
|
|
35
|
+
environment = "prod"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The gateway reads `MASKFLOW_GATEWAY_EVIDENCE_*` environment variables with
|
|
39
|
+
the same names.
|
|
40
|
+
|
|
41
|
+
## Sinks
|
|
42
|
+
|
|
43
|
+
| sink | transport | extra |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `stdout` | one JSON line per event | — |
|
|
46
|
+
| `file` | size-rotated JSON lines | — |
|
|
47
|
+
| `syslog` | `SysLogHandler` | — |
|
|
48
|
+
| `webhook` | `POST` JSON per event | `maskflow-evidence[webhook]` |
|
|
49
|
+
| `otlp` | OpenTelemetry log records | `maskflow-evidence[otlp]` |
|
|
50
|
+
|
|
51
|
+
## What is deliberately **not** collected
|
|
52
|
+
|
|
53
|
+
Raw values, masked values, the mapping, request/response bodies, prompt
|
|
54
|
+
text, and any user identifier beyond a caller-supplied opaque `session_id`.
|
|
55
|
+
See `docs/evidence.md` for the full statement.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
{
|
|
2
|
+
"__inputs": [
|
|
3
|
+
{
|
|
4
|
+
"name": "DS_PROMETHEUS",
|
|
5
|
+
"label": "Prometheus",
|
|
6
|
+
"description": "Prometheus datasource scraping the MaskFlow Gateway /metrics endpoint",
|
|
7
|
+
"type": "datasource",
|
|
8
|
+
"pluginId": "prometheus",
|
|
9
|
+
"pluginName": "Prometheus"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"annotations": { "list": [] },
|
|
13
|
+
"description": "MaskFlow evidence: what was masked, from gateway Prometheus metrics. Panels only -- no compliance-control framing (pending issue #42).",
|
|
14
|
+
"editable": true,
|
|
15
|
+
"graphTooltip": 0,
|
|
16
|
+
"panels": [
|
|
17
|
+
{
|
|
18
|
+
"type": "row",
|
|
19
|
+
"title": "Evidence emission",
|
|
20
|
+
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
|
21
|
+
"id": 10
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "stat",
|
|
25
|
+
"title": "Evidence events emitted (5m rate)",
|
|
26
|
+
"description": "maskflow_evidence_emitted_total -- zero unless [evidence] is enabled on the gateway.",
|
|
27
|
+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
28
|
+
"gridPos": { "h": 6, "w": 8, "x": 0, "y": 1 },
|
|
29
|
+
"id": 11,
|
|
30
|
+
"targets": [
|
|
31
|
+
{ "expr": "sum(rate(maskflow_evidence_emitted_total[5m]))", "refId": "A" }
|
|
32
|
+
],
|
|
33
|
+
"fieldConfig": { "defaults": { "unit": "ops" }, "overrides": [] }
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"type": "timeseries",
|
|
37
|
+
"title": "Evidence events by sink",
|
|
38
|
+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
39
|
+
"gridPos": { "h": 6, "w": 16, "x": 8, "y": 1 },
|
|
40
|
+
"id": 12,
|
|
41
|
+
"targets": [
|
|
42
|
+
{
|
|
43
|
+
"expr": "sum by (sink) (rate(maskflow_evidence_emitted_total[5m]))",
|
|
44
|
+
"legendFormat": "{{sink}}",
|
|
45
|
+
"refId": "A"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"type": "row",
|
|
51
|
+
"title": "What was masked",
|
|
52
|
+
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 7 },
|
|
53
|
+
"id": 20
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"type": "timeseries",
|
|
57
|
+
"title": "Detections by entity type (request side, 5m rate)",
|
|
58
|
+
"description": "maskflow_detections_total{direction=\"request\"} -- PII masked before it reached the provider.",
|
|
59
|
+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
60
|
+
"gridPos": { "h": 9, "w": 12, "x": 0, "y": 8 },
|
|
61
|
+
"id": 21,
|
|
62
|
+
"targets": [
|
|
63
|
+
{
|
|
64
|
+
"expr": "sum by (entity_type) (rate(maskflow_detections_total{direction=\"request\"}[5m]))",
|
|
65
|
+
"legendFormat": "{{entity_type}}",
|
|
66
|
+
"refId": "A"
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"type": "piechart",
|
|
72
|
+
"title": "Detection mix by entity type (last 24h)",
|
|
73
|
+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
74
|
+
"gridPos": { "h": 9, "w": 12, "x": 12, "y": 8 },
|
|
75
|
+
"id": 22,
|
|
76
|
+
"targets": [
|
|
77
|
+
{
|
|
78
|
+
"expr": "sum by (entity_type) (increase(maskflow_detections_total[24h]))",
|
|
79
|
+
"legendFormat": "{{entity_type}}",
|
|
80
|
+
"refId": "A"
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"type": "timeseries",
|
|
86
|
+
"title": "Mask stage latency (p50 / p95 / p99)",
|
|
87
|
+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
88
|
+
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 17 },
|
|
89
|
+
"id": 30,
|
|
90
|
+
"targets": [
|
|
91
|
+
{
|
|
92
|
+
"expr": "histogram_quantile(0.50, sum by (le) (rate(maskflow_stage_latency_seconds_bucket{stage=\"mask\"}[5m])))",
|
|
93
|
+
"legendFormat": "p50",
|
|
94
|
+
"refId": "A"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"expr": "histogram_quantile(0.95, sum by (le) (rate(maskflow_stage_latency_seconds_bucket{stage=\"mask\"}[5m])))",
|
|
98
|
+
"legendFormat": "p95",
|
|
99
|
+
"refId": "B"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"expr": "histogram_quantile(0.99, sum by (le) (rate(maskflow_stage_latency_seconds_bucket{stage=\"mask\"}[5m])))",
|
|
103
|
+
"legendFormat": "p99",
|
|
104
|
+
"refId": "C"
|
|
105
|
+
}
|
|
106
|
+
],
|
|
107
|
+
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"schemaVersion": 39,
|
|
111
|
+
"tags": ["maskflow", "evidence", "pii"],
|
|
112
|
+
"templating": { "list": [] },
|
|
113
|
+
"time": { "from": "now-6h", "to": "now" },
|
|
114
|
+
"title": "MaskFlow Evidence",
|
|
115
|
+
"uid": "maskflow-evidence",
|
|
116
|
+
"version": 1
|
|
117
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maskflow-evidence"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MaskFlow evidence layer: a metadata-only, verifiable record of what was masked -- never the values. Self-hosted emitters (stdout, file, syslog, webhook, OTLP), off by default."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dependencies = [
|
|
9
|
+
# Span / Mapping / Strategy / PIIType come from the engine, and the
|
|
10
|
+
# metadata-only leak assertion reuses core's detect_patterns_only(). No
|
|
11
|
+
# SDK dependency -- the evidence layer never masks, it only records what
|
|
12
|
+
# a mask() call already decided.
|
|
13
|
+
"maskflow-core>=0.7.0,<0.8",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
# The webhook emitter POSTs one JSON object per event. httpx only; kept out
|
|
18
|
+
# of the base install so `import maskflow_evidence` stays dependency-light
|
|
19
|
+
# for the stdout/file/syslog sinks (all stdlib).
|
|
20
|
+
webhook = [
|
|
21
|
+
"httpx>=0.27",
|
|
22
|
+
]
|
|
23
|
+
# The OTLP emitter ships events as OpenTelemetry log records. The OTel SDK
|
|
24
|
+
# tree is large; opt in only if you already run an OTel collector.
|
|
25
|
+
otlp = [
|
|
26
|
+
"opentelemetry-sdk>=1.25",
|
|
27
|
+
"opentelemetry-exporter-otlp-proto-http>=1.25",
|
|
28
|
+
]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
"hypothesis>=6.100",
|
|
32
|
+
"httpx>=0.27",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.uv.sources]
|
|
36
|
+
maskflow-core = { workspace = true }
|
|
37
|
+
|
|
38
|
+
[tool.uv]
|
|
39
|
+
package = true
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/maskflow_evidence"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
markers = [
|
|
50
|
+
"leak: guards against a raw or masked value, or the mapping, reaching an evidence event",
|
|
51
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""MaskFlow evidence layer.
|
|
2
|
+
|
|
3
|
+
A metadata-only, verifiable record of *what was masked* -- never the values.
|
|
4
|
+
See ``docs/evidence.md`` for the schema, what is deliberately not collected,
|
|
5
|
+
and the self-hosted setup guide.
|
|
6
|
+
|
|
7
|
+
Off by default: nothing is emitted unless ``[evidence] enabled = true`` (or
|
|
8
|
+
the equivalent env var) selects a sink.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .config import EvidenceConfig
|
|
14
|
+
from .derive import (
|
|
15
|
+
EventContext,
|
|
16
|
+
action_for_strategy,
|
|
17
|
+
events_from_mapping,
|
|
18
|
+
events_from_spans,
|
|
19
|
+
)
|
|
20
|
+
from .emitters import Emitter, NullEmitter, SafeEmitter, build_emitter
|
|
21
|
+
from .guard import (
|
|
22
|
+
MetadataOnlyViolation,
|
|
23
|
+
assert_no_pii,
|
|
24
|
+
assert_schema_is_metadata_only,
|
|
25
|
+
scan_for_pii,
|
|
26
|
+
)
|
|
27
|
+
from .schema import ACTIONS, EvidenceEvent, EvidenceSchemaError
|
|
28
|
+
from .versions import engine_version, pack_version, pack_versions
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ACTIONS",
|
|
32
|
+
"EvidenceConfig",
|
|
33
|
+
"EvidenceEvent",
|
|
34
|
+
"EvidenceSchemaError",
|
|
35
|
+
"EventContext",
|
|
36
|
+
"Emitter",
|
|
37
|
+
"NullEmitter",
|
|
38
|
+
"SafeEmitter",
|
|
39
|
+
"MetadataOnlyViolation",
|
|
40
|
+
"action_for_strategy",
|
|
41
|
+
"assert_no_pii",
|
|
42
|
+
"assert_schema_is_metadata_only",
|
|
43
|
+
"build_emitter",
|
|
44
|
+
"events_from_mapping",
|
|
45
|
+
"events_from_spans",
|
|
46
|
+
"engine_version",
|
|
47
|
+
"pack_version",
|
|
48
|
+
"pack_versions",
|
|
49
|
+
"scan_for_pii",
|
|
50
|
+
]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""``EvidenceConfig`` -- the resolved settings :func:`build_emitter` needs,
|
|
2
|
+
plus the two ways to obtain it:
|
|
3
|
+
|
|
4
|
+
* :meth:`EvidenceConfig.from_rootconfig` -- from a ``maskflow_core`` resolved
|
|
5
|
+
config's ``[evidence]`` section (the CLI path);
|
|
6
|
+
* :meth:`EvidenceConfig.from_env` -- from ``MASKFLOW_..._EVIDENCE_*``
|
|
7
|
+
environment variables (the gateway path, which is env-only by design).
|
|
8
|
+
|
|
9
|
+
Evidence is **off by default** in both.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_SINKS = ("stdout", "file", "syslog", "webhook", "otlp")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _as_bool(value: Any, default: bool) -> bool:
|
|
23
|
+
if isinstance(value, bool):
|
|
24
|
+
return value
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
27
|
+
return default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _as_int(value: Any, default: int) -> int:
|
|
31
|
+
try:
|
|
32
|
+
return int(value)
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
return default
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _as_float(value: Any, default: float) -> float:
|
|
38
|
+
try:
|
|
39
|
+
return float(value)
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class EvidenceConfig:
|
|
46
|
+
enabled: bool = False
|
|
47
|
+
sink: str = "file"
|
|
48
|
+
# file sink
|
|
49
|
+
path: str = "evidence.log"
|
|
50
|
+
max_bytes: int = 10_000_000
|
|
51
|
+
backups: int = 5
|
|
52
|
+
# webhook / otlp sink
|
|
53
|
+
url: str = ""
|
|
54
|
+
timeout: float = 2.0
|
|
55
|
+
# syslog sink -- empty host means the local socket (/dev/log, or the
|
|
56
|
+
# macOS equivalent), which is what most deployments want.
|
|
57
|
+
syslog_host: str = ""
|
|
58
|
+
syslog_port: int = 514
|
|
59
|
+
# context stamped onto every event
|
|
60
|
+
service: str = "maskflow"
|
|
61
|
+
environment: str = "production"
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
if self.sink not in _SINKS:
|
|
65
|
+
raise ValueError(f"[evidence] sink must be one of {_SINKS}, got {self.sink!r}")
|
|
66
|
+
|
|
67
|
+
def syslog_address(self) -> str | tuple[str, int]:
|
|
68
|
+
if self.syslog_host:
|
|
69
|
+
return (self.syslog_host, self.syslog_port)
|
|
70
|
+
return "/dev/log"
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, data: Mapping[str, Any]) -> EvidenceConfig:
|
|
74
|
+
base = cls()
|
|
75
|
+
return cls(
|
|
76
|
+
enabled=_as_bool(data.get("enabled"), base.enabled),
|
|
77
|
+
sink=str(data.get("sink", base.sink)),
|
|
78
|
+
path=str(data.get("path", base.path)),
|
|
79
|
+
max_bytes=_as_int(data.get("max_bytes"), base.max_bytes),
|
|
80
|
+
backups=_as_int(data.get("backups"), base.backups),
|
|
81
|
+
url=str(data.get("url", base.url)),
|
|
82
|
+
timeout=_as_float(data.get("timeout"), base.timeout),
|
|
83
|
+
syslog_host=str(data.get("syslog_host", base.syslog_host)),
|
|
84
|
+
syslog_port=_as_int(data.get("syslog_port"), base.syslog_port),
|
|
85
|
+
service=str(data.get("service", base.service)),
|
|
86
|
+
environment=str(data.get("environment", base.environment)),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_rootconfig(cls, root_config: Any) -> EvidenceConfig:
|
|
91
|
+
"""Read the ``evidence`` section off a ``maskflow_core`` ``RootConfig``
|
|
92
|
+
(or anything with a compatible ``.evidence`` attribute). A config with
|
|
93
|
+
no ``[evidence]`` section yields the disabled default."""
|
|
94
|
+
section = getattr(root_config, "evidence", None)
|
|
95
|
+
if section is None:
|
|
96
|
+
return cls()
|
|
97
|
+
if isinstance(section, Mapping):
|
|
98
|
+
return cls.from_dict(section)
|
|
99
|
+
# a dataclass section -> pull known attributes
|
|
100
|
+
keys = (
|
|
101
|
+
"enabled",
|
|
102
|
+
"sink",
|
|
103
|
+
"path",
|
|
104
|
+
"max_bytes",
|
|
105
|
+
"backups",
|
|
106
|
+
"url",
|
|
107
|
+
"timeout",
|
|
108
|
+
"syslog_host",
|
|
109
|
+
"syslog_port",
|
|
110
|
+
"service",
|
|
111
|
+
"environment",
|
|
112
|
+
)
|
|
113
|
+
return cls.from_dict({k: getattr(section, k) for k in keys if hasattr(section, k)})
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_env(cls, prefix: str, env: Mapping[str, str] | None = None) -> EvidenceConfig:
|
|
117
|
+
"""Build from ``{prefix}ENABLED``, ``{prefix}SINK``, ... (e.g.
|
|
118
|
+
``prefix="MASKFLOW_GATEWAY_EVIDENCE_"``)."""
|
|
119
|
+
source = env if env is not None else os.environ
|
|
120
|
+
picked = {
|
|
121
|
+
key[len(prefix) :].lower(): value
|
|
122
|
+
for key, value in source.items()
|
|
123
|
+
if key.startswith(prefix)
|
|
124
|
+
}
|
|
125
|
+
return cls.from_dict(picked)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Turn a masking decision into evidence events -- without ever reading a
|
|
2
|
+
detected value.
|
|
3
|
+
|
|
4
|
+
``derive.py`` may touch ``Span.start``/``end``/``entity_type``/``score``/
|
|
5
|
+
``recognizer`` and ``MappingEntry.entity_type``/``strategy``/``score``/
|
|
6
|
+
``recognizer``. It must never read ``Span.text`` or ``MappingEntry.original``
|
|
7
|
+
-- ``tests/test_derive_no_value_access.py`` enforces this with an AST check.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from maskflow_core import Mapping, Span
|
|
17
|
+
from maskflow_core.strategies import Strategy
|
|
18
|
+
|
|
19
|
+
from .schema import EvidenceEvent
|
|
20
|
+
from .versions import engine_version, pack_version
|
|
21
|
+
|
|
22
|
+
# maskflow_core.Strategy -> evidence `action`. HASH collapses to "masked":
|
|
23
|
+
# from an evidence standpoint the value was replaced by something
|
|
24
|
+
# non-recoverable, same category as MASK/REDACT is "redacted".
|
|
25
|
+
_STRATEGY_ACTION: dict[Strategy, str] = {
|
|
26
|
+
Strategy.REPLACE: "masked",
|
|
27
|
+
Strategy.MASK: "masked",
|
|
28
|
+
Strategy.HASH: "masked",
|
|
29
|
+
Strategy.REDACT: "redacted",
|
|
30
|
+
Strategy.SURROGATE: "surrogate",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class EventContext:
|
|
36
|
+
"""The invariant part of every event from one masking call. All fields
|
|
37
|
+
are validated as slugs by ``EvidenceEvent`` -- a caller that passes an
|
|
38
|
+
email as ``session_id`` gets an ``EvidenceSchemaError``."""
|
|
39
|
+
|
|
40
|
+
session_id: str
|
|
41
|
+
service: str
|
|
42
|
+
environment: str
|
|
43
|
+
provider: str | None = None
|
|
44
|
+
model: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _emit_group(
|
|
48
|
+
ctx: EventContext,
|
|
49
|
+
entity_type: str,
|
|
50
|
+
recognizer: str,
|
|
51
|
+
action: str,
|
|
52
|
+
scores: list[float],
|
|
53
|
+
) -> EvidenceEvent:
|
|
54
|
+
return EvidenceEvent(
|
|
55
|
+
entity_type=entity_type,
|
|
56
|
+
count=len(scores),
|
|
57
|
+
# The most conservative claim: the weakest detection in the group.
|
|
58
|
+
score=min(scores),
|
|
59
|
+
recognizer=recognizer,
|
|
60
|
+
action=action,
|
|
61
|
+
service=ctx.service,
|
|
62
|
+
environment=ctx.environment,
|
|
63
|
+
session_id=ctx.session_id,
|
|
64
|
+
provider=ctx.provider,
|
|
65
|
+
model=ctx.model,
|
|
66
|
+
pack_version=pack_version(),
|
|
67
|
+
engine_version=engine_version(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def events_from_spans(
|
|
72
|
+
spans: Sequence[Span],
|
|
73
|
+
ctx: EventContext,
|
|
74
|
+
*,
|
|
75
|
+
strategy_for: Callable[[str], str] | None = None,
|
|
76
|
+
dropped: Iterable[Span] = (),
|
|
77
|
+
) -> list[EvidenceEvent]:
|
|
78
|
+
"""One event per ``(entity_type, recognizer, action)`` across the
|
|
79
|
+
resolved ``spans`` (plus any ``dropped`` spans, which become
|
|
80
|
+
``action="passed"``).
|
|
81
|
+
|
|
82
|
+
``strategy_for`` maps an entity-type name to an evidence action
|
|
83
|
+
(``"masked"``/``"redacted"``/``"surrogate"``); when omitted every
|
|
84
|
+
resolved span is treated as ``"masked"``.
|
|
85
|
+
"""
|
|
86
|
+
groups: dict[tuple[str, str, str], list[float]] = defaultdict(list)
|
|
87
|
+
|
|
88
|
+
for span in spans:
|
|
89
|
+
etype = span.entity_type.value
|
|
90
|
+
action = strategy_for(etype) if strategy_for is not None else "masked"
|
|
91
|
+
groups[(etype, span.recognizer, action)].append(span.score)
|
|
92
|
+
|
|
93
|
+
for span in dropped:
|
|
94
|
+
groups[(span.entity_type.value, span.recognizer, "passed")].append(span.score)
|
|
95
|
+
|
|
96
|
+
return [
|
|
97
|
+
_emit_group(ctx, etype, recognizer, action, scores)
|
|
98
|
+
for (etype, recognizer, action), scores in sorted(groups.items())
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def action_for_strategy(strategy: Strategy) -> str:
|
|
103
|
+
return _STRATEGY_ACTION.get(strategy, "masked")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def events_from_mapping(
|
|
107
|
+
mapping: Mapping,
|
|
108
|
+
ctx: EventContext,
|
|
109
|
+
*,
|
|
110
|
+
only_tokens: Iterable[str] | None = None,
|
|
111
|
+
) -> list[EvidenceEvent]:
|
|
112
|
+
"""Events from a ``mask_with_policy`` result -- the gateway path, which
|
|
113
|
+
holds the session ``Mapping`` rather than the span list.
|
|
114
|
+
|
|
115
|
+
``only_tokens`` restricts to tokens added by the current request (the
|
|
116
|
+
gateway diffs the mapping before/after each mask call). ``score`` and
|
|
117
|
+
``recognizer`` come from the ``MappingEntry`` when present (core >=
|
|
118
|
+
0.6.x populates them); otherwise ``recognizer`` is ``"unknown"`` and the
|
|
119
|
+
group score defaults to ``1.0``.
|
|
120
|
+
"""
|
|
121
|
+
wanted = set(only_tokens) if only_tokens is not None else None
|
|
122
|
+
groups: dict[tuple[str, str, str], list[float]] = defaultdict(list)
|
|
123
|
+
|
|
124
|
+
for token, entry in mapping.items():
|
|
125
|
+
if wanted is not None and token not in wanted:
|
|
126
|
+
continue
|
|
127
|
+
etype = entry.entity_type.value
|
|
128
|
+
action = action_for_strategy(entry.strategy)
|
|
129
|
+
recognizer = getattr(entry, "recognizer", None) or "unknown"
|
|
130
|
+
score = getattr(entry, "score", None)
|
|
131
|
+
groups[(etype, recognizer, action)].append(1.0 if score is None else float(score))
|
|
132
|
+
|
|
133
|
+
return [
|
|
134
|
+
_emit_group(ctx, etype, recognizer, action, scores)
|
|
135
|
+
for (etype, recognizer, action), scores in sorted(groups.items())
|
|
136
|
+
]
|