traceforge-toolkit 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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Phase 1 of governance enrichment: session-state mutation.
|
|
2
|
+
|
|
3
|
+
Phase 1 is the single mutable step in the pipeline. It advances the per-session
|
|
4
|
+
accumulator in place — phase window, budget counters, information-flow taint,
|
|
5
|
+
and pressure — from an EnrichmentContext. It is isolated here so the durable
|
|
6
|
+
writer (process_event) and the non-persisted preview (preflight_event, which
|
|
7
|
+
runs it against a detached clone) share exactly one definition of what Phase 1
|
|
8
|
+
does; the caller alone decides whether the mutated state is persisted.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from traceforge.governance.budget import BudgetTracker
|
|
17
|
+
from traceforge.governance.labeler import GovernanceLabeler
|
|
18
|
+
from traceforge.governance.state import SessionState
|
|
19
|
+
from traceforge.governance.types import EnrichmentContext
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Phase1:
|
|
23
|
+
"""Apply Phase-1 state mutations (phase window, budget, IFC taint, pressure)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, budget: "BudgetTracker", labeler: "GovernanceLabeler") -> None:
|
|
26
|
+
self._budget = budget
|
|
27
|
+
self._labeler = labeler
|
|
28
|
+
|
|
29
|
+
def infer_phase(self, ctx: "EnrichmentContext") -> str | None:
|
|
30
|
+
"""Infer session phase from classification/event."""
|
|
31
|
+
from traceforge.governance.types import ToolCallEvent
|
|
32
|
+
|
|
33
|
+
cls = ctx.base_classification
|
|
34
|
+
# Network capability takes priority
|
|
35
|
+
if "network_outbound" in cls.capability:
|
|
36
|
+
return "network"
|
|
37
|
+
if cls.effect == "read_only":
|
|
38
|
+
return "exploration"
|
|
39
|
+
if cls.effect == "destructive":
|
|
40
|
+
return "destructive"
|
|
41
|
+
if cls.effect == "mutating":
|
|
42
|
+
tool_name = ""
|
|
43
|
+
if isinstance(ctx.event, ToolCallEvent):
|
|
44
|
+
tool_name = (ctx.event.tool_name or "").lower()
|
|
45
|
+
if "test" in tool_name or "verify" in tool_name or "check" in tool_name:
|
|
46
|
+
return "testing"
|
|
47
|
+
if "deploy" in tool_name or "publish" in tool_name:
|
|
48
|
+
return "deployment"
|
|
49
|
+
return "implementation"
|
|
50
|
+
if cls.effect == "informational":
|
|
51
|
+
return "exploration"
|
|
52
|
+
return "exploration"
|
|
53
|
+
|
|
54
|
+
def apply(self, ctx: "EnrichmentContext", state: "SessionState") -> None:
|
|
55
|
+
"""Run the Phase-1 mutation sequence against ``state`` in place.
|
|
56
|
+
|
|
57
|
+
Persistence is the caller's concern: process_event applies this to the
|
|
58
|
+
registry-resident state and then commits it atomically; preflight_event
|
|
59
|
+
applies it to a detached clone and never persists.
|
|
60
|
+
"""
|
|
61
|
+
phase = self.infer_phase(ctx)
|
|
62
|
+
if phase:
|
|
63
|
+
state.update_phase_window(phase)
|
|
64
|
+
self._budget.increment(ctx, state)
|
|
65
|
+
if self._labeler.has_ifc:
|
|
66
|
+
ifc_src_labels: set[str] = set()
|
|
67
|
+
self._labeler.check_ifc(ctx, ifc_src_labels, state)
|
|
68
|
+
state.record_event(None)
|
|
69
|
+
self._budget.check_pressure(state)
|
|
70
|
+
# Motivation tracking (#10): record the id of the last assistant-authored
|
|
71
|
+
# governed event. Every event that reaches Phase 1 is an assistant-initiated
|
|
72
|
+
# tool call — session lifecycle is handled by process_lifecycle, and user/
|
|
73
|
+
# assistant *messages* are ungoverned at the observe choke point and never
|
|
74
|
+
# reach here — so the tool call's own event id is the assistant motivation
|
|
75
|
+
# pointer. There is no user-authored event in the tool-call observation path,
|
|
76
|
+
# so the user pointer is intentionally left unset (no phantom id).
|
|
77
|
+
state.set_last_assistant(ctx.event.event_id)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""PII and credential scanning for governance enrichment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from traceforge.governance.types import EnrichmentContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PIICategory(StrEnum):
|
|
16
|
+
SSN = "ssn"
|
|
17
|
+
EMAIL = "email"
|
|
18
|
+
PHONE = "phone"
|
|
19
|
+
CREDIT_CARD = "credit_card"
|
|
20
|
+
API_KEY = "api_key"
|
|
21
|
+
PASSWORD = "password"
|
|
22
|
+
PRIVATE_KEY = "private_key"
|
|
23
|
+
AWS_KEY = "aws_key"
|
|
24
|
+
JWT = "jwt"
|
|
25
|
+
CONNECTION_STRING = "connection_string"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── Reserved / documentation ranges ──────────────────────────────────────────
|
|
29
|
+
# Standards bodies reserve these identifiers for documentation and testing, so a
|
|
30
|
+
# match against one is a placeholder, never real PII, and must not be flagged.
|
|
31
|
+
|
|
32
|
+
# RFC 2606 / RFC 6761 reserved TLDs and second-level example domains.
|
|
33
|
+
_RESERVED_EMAIL_TLDS = frozenset({"test", "example", "invalid", "localhost"})
|
|
34
|
+
_RESERVED_EMAIL_SLDS = frozenset({"example.com", "example.net", "example.org"})
|
|
35
|
+
|
|
36
|
+
# Obvious non-secret values that appear in docs, configs, and type annotations.
|
|
37
|
+
_PLACEHOLDER_SECRETS = frozenset(
|
|
38
|
+
{
|
|
39
|
+
"null",
|
|
40
|
+
"none",
|
|
41
|
+
"nil",
|
|
42
|
+
"true",
|
|
43
|
+
"false",
|
|
44
|
+
"redacted",
|
|
45
|
+
"changeme",
|
|
46
|
+
"example",
|
|
47
|
+
"placeholder",
|
|
48
|
+
"password",
|
|
49
|
+
"passphrase",
|
|
50
|
+
"secret",
|
|
51
|
+
"your_password",
|
|
52
|
+
"yourpassword",
|
|
53
|
+
"test",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _digits(text: str) -> str:
|
|
59
|
+
return "".join(ch for ch in text if ch.isdigit())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _luhn_valid(number: str) -> bool:
|
|
63
|
+
"""Return True when a digit string satisfies the Luhn checksum."""
|
|
64
|
+
if len(number) < 13:
|
|
65
|
+
return False
|
|
66
|
+
total = 0
|
|
67
|
+
parity = len(number) % 2
|
|
68
|
+
for index, char in enumerate(number):
|
|
69
|
+
digit = int(char)
|
|
70
|
+
if index % 2 == parity:
|
|
71
|
+
digit *= 2
|
|
72
|
+
if digit > 9:
|
|
73
|
+
digit -= 9
|
|
74
|
+
total += digit
|
|
75
|
+
return total % 10 == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _valid_credit_card(match: re.Match[str]) -> bool:
|
|
79
|
+
"""Card-shaped numbers only count when they pass Luhn (placeholders do not)."""
|
|
80
|
+
return _luhn_valid(_digits(match.group(0)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _valid_ssn(match: re.Match[str]) -> bool:
|
|
84
|
+
"""Reject SSNs the SSA never issues (documentation placeholders)."""
|
|
85
|
+
digits = _digits(match.group(0))
|
|
86
|
+
if len(digits) != 9:
|
|
87
|
+
return False
|
|
88
|
+
area, group, serial = digits[:3], digits[3:5], digits[5:]
|
|
89
|
+
if area in {"000", "666"} or area[0] == "9":
|
|
90
|
+
return False
|
|
91
|
+
return group != "00" and serial != "0000"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _valid_email(match: re.Match[str]) -> bool:
|
|
95
|
+
"""Suppress RFC 2606 / RFC 6761 example and test domains."""
|
|
96
|
+
domain = match.group(0).rsplit("@", 1)[-1].lower().rstrip(".")
|
|
97
|
+
tld = domain.rsplit(".", 1)[-1]
|
|
98
|
+
if tld in _RESERVED_EMAIL_TLDS:
|
|
99
|
+
return False
|
|
100
|
+
return not any(domain == sld or domain.endswith("." + sld) for sld in _RESERVED_EMAIL_SLDS)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _valid_phone(match: re.Match[str]) -> bool:
|
|
104
|
+
"""Suppress the NANP 555-0100..555-0199 range reserved for fictional use."""
|
|
105
|
+
subscriber = _digits(match.group(0))[-10:][-7:]
|
|
106
|
+
return not (subscriber[:3] == "555" and subscriber[3:5] == "01")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _valid_secret_kv(match: re.Match[str]) -> bool:
|
|
110
|
+
"""A key/value pair is a cleartext secret only when the value is a literal.
|
|
111
|
+
|
|
112
|
+
Quoted values are treated as literal secrets. Bare values must carry a
|
|
113
|
+
non-alpha entropy signal, so type annotations (``password: str``) and plain
|
|
114
|
+
identifiers are not mistaken for credentials.
|
|
115
|
+
"""
|
|
116
|
+
raw = match.group("value")
|
|
117
|
+
quoted = len(raw) >= 2 and raw[0] in "'\"" and raw[-1] == raw[0]
|
|
118
|
+
value = (raw[1:-1] if quoted else raw).strip()
|
|
119
|
+
if len(value) < 4 or value.lower() in _PLACEHOLDER_SECRETS:
|
|
120
|
+
return False
|
|
121
|
+
if quoted:
|
|
122
|
+
return True
|
|
123
|
+
return any(not char.isalpha() for char in value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _always_valid(match: re.Match[str]) -> bool:
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Keys, longest/most-specific first, whose value may hold a cleartext secret.
|
|
131
|
+
_SECRET_KEYWORDS = (
|
|
132
|
+
r"passphrase|passwd|password|pwd|"
|
|
133
|
+
r"secret[_-]?key|client[_-]?secret|secret|"
|
|
134
|
+
r"api[_-]?key|apikey|"
|
|
135
|
+
r"access[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|"
|
|
136
|
+
r"private[_-]?key|credentials?|token"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class _Detector:
|
|
142
|
+
"""One PII/credential detector: a regex plus optional per-match validation."""
|
|
143
|
+
|
|
144
|
+
category: PIICategory
|
|
145
|
+
pattern: re.Pattern[str]
|
|
146
|
+
is_credential: bool
|
|
147
|
+
validator: Callable[[re.Match[str]], bool] = _always_valid
|
|
148
|
+
|
|
149
|
+
def found_in(self, content: str) -> bool:
|
|
150
|
+
"""True when content holds at least one *valid* (non-placeholder) match."""
|
|
151
|
+
return any(self.validator(match) for match in self.pattern.finditer(content))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_DETECTORS: tuple[_Detector, ...] = (
|
|
155
|
+
_Detector(
|
|
156
|
+
PIICategory.SSN,
|
|
157
|
+
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
|
|
158
|
+
is_credential=False,
|
|
159
|
+
validator=_valid_ssn,
|
|
160
|
+
),
|
|
161
|
+
_Detector(
|
|
162
|
+
PIICategory.EMAIL,
|
|
163
|
+
re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"),
|
|
164
|
+
is_credential=False,
|
|
165
|
+
validator=_valid_email,
|
|
166
|
+
),
|
|
167
|
+
_Detector(
|
|
168
|
+
PIICategory.PHONE,
|
|
169
|
+
# Two shapes: E.164 (`+` then 6-15 digits) or a grouped NANP-style number
|
|
170
|
+
# with mandatory separators, so plain digit runs, SSNs (3-2-4) and credit
|
|
171
|
+
# cards (4-4-4-4) are never misread as phone numbers.
|
|
172
|
+
re.compile(
|
|
173
|
+
r"(?<![\w.])"
|
|
174
|
+
r"(?:"
|
|
175
|
+
r"\+\d{6,15}"
|
|
176
|
+
r"|"
|
|
177
|
+
r"(?:\+\d{1,3}[ .\-]?)?"
|
|
178
|
+
r"(?:\(\d{3}\)[ .\-]?|\d{3}[ .\-])"
|
|
179
|
+
r"\d{3}[ .\-]\d{4}"
|
|
180
|
+
r")"
|
|
181
|
+
r"(?!\d)"
|
|
182
|
+
),
|
|
183
|
+
is_credential=False,
|
|
184
|
+
validator=_valid_phone,
|
|
185
|
+
),
|
|
186
|
+
_Detector(
|
|
187
|
+
PIICategory.CREDIT_CARD,
|
|
188
|
+
re.compile(
|
|
189
|
+
r"\b(?:"
|
|
190
|
+
r"(?:4\d{3}|5[1-5]\d{2}|6(?:011|5\d{2}))[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}"
|
|
191
|
+
r"|3[47]\d{2}[- ]?\d{6}[- ]?\d{5}"
|
|
192
|
+
r")\b"
|
|
193
|
+
),
|
|
194
|
+
is_credential=False,
|
|
195
|
+
validator=_valid_credit_card,
|
|
196
|
+
),
|
|
197
|
+
_Detector(
|
|
198
|
+
PIICategory.API_KEY,
|
|
199
|
+
re.compile(r"\b(?:sk|pk|api|key|token|secret)[-_][A-Za-z0-9-_]{20,}\b", re.IGNORECASE),
|
|
200
|
+
is_credential=True,
|
|
201
|
+
),
|
|
202
|
+
_Detector(
|
|
203
|
+
PIICategory.PASSWORD,
|
|
204
|
+
# `key = value` / `key: value` secrets, tolerating a JSON key's closing
|
|
205
|
+
# quote (`"password": ...`). A separator is mandatory, so bare paths such
|
|
206
|
+
# as ``/etc/passwd`` never match.
|
|
207
|
+
re.compile(
|
|
208
|
+
r"\b(?:" + _SECRET_KEYWORDS + r")\b['\"]?\s*[:=]\s*"
|
|
209
|
+
r"(?P<value>'[^'\r\n]{4,}'|\"[^\"\r\n]{4,}\"|[^\s'\";,}]{6,})",
|
|
210
|
+
re.IGNORECASE,
|
|
211
|
+
),
|
|
212
|
+
is_credential=True,
|
|
213
|
+
validator=_valid_secret_kv,
|
|
214
|
+
),
|
|
215
|
+
_Detector(
|
|
216
|
+
PIICategory.PRIVATE_KEY,
|
|
217
|
+
re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"),
|
|
218
|
+
is_credential=True,
|
|
219
|
+
),
|
|
220
|
+
_Detector(
|
|
221
|
+
PIICategory.AWS_KEY,
|
|
222
|
+
re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
|
|
223
|
+
is_credential=True,
|
|
224
|
+
),
|
|
225
|
+
_Detector(
|
|
226
|
+
PIICategory.JWT,
|
|
227
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
|
|
228
|
+
is_credential=True,
|
|
229
|
+
),
|
|
230
|
+
_Detector(
|
|
231
|
+
PIICategory.CONNECTION_STRING,
|
|
232
|
+
re.compile(r"(?:mongodb|postgres|mysql|redis|amqp)://[^\s]+", re.IGNORECASE),
|
|
233
|
+
is_credential=True,
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class PIIScanner:
|
|
239
|
+
"""Stateless PII/credential scanner. Adds capability labels."""
|
|
240
|
+
|
|
241
|
+
def scan(self, ctx: "EnrichmentContext", cap: set[str], struct: set[str]) -> None:
|
|
242
|
+
"""Scan event content for PII. Mutates cap/struct sets."""
|
|
243
|
+
from traceforge.governance.types import ToolCallEvent, ToolResultEvent
|
|
244
|
+
|
|
245
|
+
content = ""
|
|
246
|
+
if isinstance(ctx.event, ToolCallEvent):
|
|
247
|
+
content = ctx.event.tool_args_json
|
|
248
|
+
elif isinstance(ctx.event, ToolResultEvent):
|
|
249
|
+
content = ctx.event.result_payload_json or ""
|
|
250
|
+
|
|
251
|
+
if not content:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
found_pii = False
|
|
255
|
+
found_credential = False
|
|
256
|
+
|
|
257
|
+
for detector in _DETECTORS:
|
|
258
|
+
if not detector.found_in(content):
|
|
259
|
+
continue
|
|
260
|
+
if detector.is_credential:
|
|
261
|
+
found_credential = True
|
|
262
|
+
else:
|
|
263
|
+
found_pii = True
|
|
264
|
+
if found_pii and found_credential:
|
|
265
|
+
break
|
|
266
|
+
|
|
267
|
+
if found_credential:
|
|
268
|
+
cap.add("credential_exposure")
|
|
269
|
+
if found_pii:
|
|
270
|
+
cap.add("pii_exposure")
|
|
271
|
+
|
|
272
|
+
# Tainted flow: PII going to network-capable tool
|
|
273
|
+
if (
|
|
274
|
+
found_pii or found_credential
|
|
275
|
+
) and "network_outbound" in ctx.base_classification.capability:
|
|
276
|
+
struct.add("tainted_flow")
|