agentmetry 0.4.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.
- agentmetry/__init__.py +12 -0
- agentmetry/api/__init__.py +0 -0
- agentmetry/api/main.py +232 -0
- agentmetry/api/routes/__init__.py +0 -0
- agentmetry/api/routes/audit.py +396 -0
- agentmetry/api/websocket.py +53 -0
- agentmetry/api/ws_bridge.py +34 -0
- agentmetry/cli/__init__.py +941 -0
- agentmetry/cli/__main__.py +5 -0
- agentmetry/core/__init__.py +0 -0
- agentmetry/core/audit/__init__.py +1 -0
- agentmetry/core/audit/adapters/__init__.py +0 -0
- agentmetry/core/audit/adapters/agt.py +304 -0
- agentmetry/core/audit/adapters/cloudevents.py +159 -0
- agentmetry/core/audit/adapters/ecs.py +103 -0
- agentmetry/core/audit/adapters/splunk.py +40 -0
- agentmetry/core/audit/alerts.py +56 -0
- agentmetry/core/audit/canonical.py +150 -0
- agentmetry/core/audit/compliance_digest.py +299 -0
- agentmetry/core/audit/detection/__init__.py +9 -0
- agentmetry/core/audit/detection/benchmark.py +194 -0
- agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
- agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
- agentmetry/core/audit/detection/disposition.py +651 -0
- agentmetry/core/audit/detection/engine.py +78 -0
- agentmetry/core/audit/detection/live.py +127 -0
- agentmetry/core/audit/detection/live_store.py +355 -0
- agentmetry/core/audit/detection/models.py +53 -0
- agentmetry/core/audit/detection/rules.py +1314 -0
- agentmetry/core/audit/detection/traits.py +648 -0
- agentmetry/core/audit/detection/yaml_config.py +91 -0
- agentmetry/core/audit/detection/yaml_rules.py +83 -0
- agentmetry/core/audit/dlp/__init__.py +4 -0
- agentmetry/core/audit/dlp/loader.py +29 -0
- agentmetry/core/audit/dlp/models.py +29 -0
- agentmetry/core/audit/dlp/scanner.py +96 -0
- agentmetry/core/audit/dogfood.py +398 -0
- agentmetry/core/audit/evidence_pack.py +500 -0
- agentmetry/core/audit/external.py +213 -0
- agentmetry/core/audit/hashing.py +21 -0
- agentmetry/core/audit/hook_bootstrap.py +451 -0
- agentmetry/core/audit/identity.py +39 -0
- agentmetry/core/audit/ingest.py +242 -0
- agentmetry/core/audit/migrate.py +73 -0
- agentmetry/core/audit/mitre.py +244 -0
- agentmetry/core/audit/policy.py +99 -0
- agentmetry/core/audit/redaction.py +50 -0
- agentmetry/core/audit/replay.py +54 -0
- agentmetry/core/audit/run_context.py +129 -0
- agentmetry/core/audit/sinks.py +235 -0
- agentmetry/core/audit/spool.py +394 -0
- agentmetry/core/audit/tool_policy/__init__.py +4 -0
- agentmetry/core/audit/tool_policy/evaluator.py +198 -0
- agentmetry/core/audit/tool_policy/loader.py +44 -0
- agentmetry/core/audit/tool_policy/models.py +25 -0
- agentmetry/core/audit/trail_chain.py +300 -0
- agentmetry/core/audit/trail_db.py +491 -0
- agentmetry/core/audit/trail_merkle.py +332 -0
- agentmetry/core/auth.py +54 -0
- agentmetry/core/bus/__init__.py +5 -0
- agentmetry/core/bus/audit_exporter.py +107 -0
- agentmetry/core/bus/bridges.py +26 -0
- agentmetry/core/bus/bus.py +102 -0
- agentmetry/core/bus/events.py +50 -0
- agentmetry/core/bus/outbox.py +124 -0
- agentmetry/core/config.py +177 -0
- agentmetry/core/diagnostics/__init__.py +0 -0
- agentmetry/core/diagnostics/autostart.py +563 -0
- agentmetry/core/diagnostics/doctor.py +535 -0
- agentmetry/core/diagnostics/driver_paths.py +156 -0
- agentmetry/core/diagnostics/env_file.py +45 -0
- agentmetry/core/drivers/__init__.py +4 -0
- agentmetry/core/drivers/host.py +263 -0
- agentmetry/core/drivers/permissions.py +37 -0
- agentmetry/core/drivers/spec.py +118 -0
- agentmetry/core/extensions.py +107 -0
- agentmetry/core/health.py +26 -0
- agentmetry/core/version.py +13 -0
- agentmetry/policies/detection/manifest.yaml +43 -0
- agentmetry/policies/dlp/manifest.yaml +161 -0
- agentmetry/policies/opa/agent_rules.rego +33 -0
- agentmetry/policies/tool/manifest.yaml +117 -0
- agentmetry-0.4.0.dist-info/METADATA +86 -0
- agentmetry-0.4.0.dist-info/RECORD +131 -0
- agentmetry-0.4.0.dist-info/WHEEL +4 -0
- agentmetry-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Load detection thresholds and YAML count rules from agentmetry/policies/detection/manifest.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
_ORCH_ROOT = Path(__file__).resolve().parents[4]
|
|
11
|
+
_DEFAULT_MANIFEST = _ORCH_ROOT.parent.parent / "policies" / "detection" / "manifest.yaml"
|
|
12
|
+
|
|
13
|
+
_DEFAULT_THRESHOLDS: dict[str, int] = {
|
|
14
|
+
"discovery_burst": 3,
|
|
15
|
+
"delete_burst": 5,
|
|
16
|
+
"subagent_burst": 5,
|
|
17
|
+
"session_tool_burst": 40,
|
|
18
|
+
"host_subagent_burst": 8,
|
|
19
|
+
# Burst rules need a clock as well as a count. Without one, 40 tool calls is
|
|
20
|
+
# an ordinary long coding session and 8 subagent starts is two quiet weeks —
|
|
21
|
+
# both fired as "autonomous campaign". Set a window to 0 to disable the time
|
|
22
|
+
# bound and match on the raw count (the pre-2026-07-24 behaviour).
|
|
23
|
+
"subagent_burst_window_minutes": 15,
|
|
24
|
+
"session_tool_burst_window_minutes": 10,
|
|
25
|
+
"host_subagent_burst_window_minutes": 60,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_cache: dict[str, Any] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _manifest_path() -> Path:
|
|
32
|
+
try:
|
|
33
|
+
from agentmetry.core.config import settings
|
|
34
|
+
|
|
35
|
+
custom = settings.detection_rules_path
|
|
36
|
+
if custom:
|
|
37
|
+
return Path(custom)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
return _DEFAULT_MANIFEST
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_manifest(*, reload: bool = False) -> dict[str, Any]:
|
|
44
|
+
global _cache
|
|
45
|
+
if _cache is not None and not reload:
|
|
46
|
+
return _cache
|
|
47
|
+
|
|
48
|
+
path = _manifest_path()
|
|
49
|
+
if not path.is_file():
|
|
50
|
+
_cache = {"thresholds": dict(_DEFAULT_THRESHOLDS), "count_rules": []}
|
|
51
|
+
return _cache
|
|
52
|
+
|
|
53
|
+
with path.open(encoding="utf-8") as fh:
|
|
54
|
+
data = yaml.safe_load(fh) or {}
|
|
55
|
+
|
|
56
|
+
thresholds = dict(_DEFAULT_THRESHOLDS)
|
|
57
|
+
raw_thresholds = data.get("thresholds")
|
|
58
|
+
if isinstance(raw_thresholds, dict):
|
|
59
|
+
for key, val in raw_thresholds.items():
|
|
60
|
+
try:
|
|
61
|
+
thresholds[str(key)] = int(val)
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
count_rules: list[dict[str, Any]] = []
|
|
66
|
+
for raw in data.get("count_rules") or []:
|
|
67
|
+
if isinstance(raw, dict) and raw.get("id"):
|
|
68
|
+
count_rules.append(raw)
|
|
69
|
+
|
|
70
|
+
_cache = {"thresholds": thresholds, "count_rules": count_rules}
|
|
71
|
+
return _cache
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clear_manifest_cache() -> None:
|
|
75
|
+
"""Test helper."""
|
|
76
|
+
global _cache
|
|
77
|
+
_cache = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def threshold(name: str, default: int | None = None) -> int:
|
|
81
|
+
manifest = load_manifest()
|
|
82
|
+
thresholds = manifest.get("thresholds") or {}
|
|
83
|
+
if name in thresholds:
|
|
84
|
+
return int(thresholds[name])
|
|
85
|
+
if default is not None:
|
|
86
|
+
return default
|
|
87
|
+
return int(_DEFAULT_THRESHOLDS.get(name, 1))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def count_rules() -> list[dict[str, Any]]:
|
|
91
|
+
return list(load_manifest().get("count_rules") or [])
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""YAML-defined session count rules (agentmetry/policies/detection/manifest.yaml)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from .models import Detection
|
|
8
|
+
from .rules import (
|
|
9
|
+
_action,
|
|
10
|
+
_action_type,
|
|
11
|
+
_correlation_id,
|
|
12
|
+
_event_id,
|
|
13
|
+
_outcome,
|
|
14
|
+
_tool_qualified,
|
|
15
|
+
_ts,
|
|
16
|
+
)
|
|
17
|
+
from .yaml_config import count_rules
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _event_matches(event: dict[str, Any], match: dict[str, Any]) -> bool:
|
|
21
|
+
if not match:
|
|
22
|
+
return True
|
|
23
|
+
if (want := match.get("action_type")) and _action_type(event) != str(want):
|
|
24
|
+
return False
|
|
25
|
+
if (want := match.get("outcome")) and _outcome(event) != str(want):
|
|
26
|
+
return False
|
|
27
|
+
if prefix := match.get("tool_qualified_prefix"):
|
|
28
|
+
if not _tool_qualified(event).startswith(str(prefix)):
|
|
29
|
+
return False
|
|
30
|
+
if contains := match.get("tool_qualified_contains"):
|
|
31
|
+
if str(contains) not in _tool_qualified(event):
|
|
32
|
+
return False
|
|
33
|
+
if prefix := match.get("reason_prefix"):
|
|
34
|
+
reason = str(_action(event).get("reason") or "")
|
|
35
|
+
if not reason.startswith(str(prefix)):
|
|
36
|
+
return False
|
|
37
|
+
if trait := match.get("trait"):
|
|
38
|
+
tool = event.get("tool") if isinstance(event.get("tool"), dict) else {}
|
|
39
|
+
traits = tool.get("traits") if isinstance(tool.get("traits"), list) else []
|
|
40
|
+
if str(trait) not in {str(t) for t in traits}:
|
|
41
|
+
return False
|
|
42
|
+
initiator = event.get("initiator") if isinstance(event.get("initiator"), dict) else {}
|
|
43
|
+
if (want := match.get("initiator_actor_type")) and str(initiator.get("actor_type") or "") != str(want):
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _make_count_rule(spec: dict[str, Any]) -> Callable[[list[dict[str, Any]]], list[Detection]]:
|
|
49
|
+
rule_id = str(spec["id"])
|
|
50
|
+
title = str(spec.get("title") or rule_id)
|
|
51
|
+
severity = str(spec.get("severity") or "medium")
|
|
52
|
+
summary_tpl = str(spec.get("summary") or "{count} matching events in one session")
|
|
53
|
+
min_count = int(spec.get("min_count") or 1)
|
|
54
|
+
match = spec.get("match") if isinstance(spec.get("match"), dict) else {}
|
|
55
|
+
tactic_ids = [str(t) for t in (spec.get("tactic_ids") or []) if t]
|
|
56
|
+
technique_ids = [str(t) for t in (spec.get("technique_ids") or []) if t]
|
|
57
|
+
|
|
58
|
+
def rule(events: list[dict[str, Any]]) -> list[Detection]:
|
|
59
|
+
hits = [e for e in events if _event_matches(e, match)]
|
|
60
|
+
if len(hits) < min_count:
|
|
61
|
+
return []
|
|
62
|
+
summary = summary_tpl.replace("{count}", str(len(hits)))
|
|
63
|
+
return [
|
|
64
|
+
Detection(
|
|
65
|
+
rule_id=rule_id,
|
|
66
|
+
title=title,
|
|
67
|
+
severity=severity,
|
|
68
|
+
summary=summary,
|
|
69
|
+
correlation_id=_correlation_id(events),
|
|
70
|
+
tactic_ids=tactic_ids,
|
|
71
|
+
technique_ids=technique_ids,
|
|
72
|
+
event_ids=[_event_id(e) for e in hits[:20]],
|
|
73
|
+
first_seen_utc=_ts(hits[0]),
|
|
74
|
+
last_seen_utc=_ts(hits[-1]),
|
|
75
|
+
)
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
rule.__name__ = f"yaml_{rule_id.replace('-', '_')}"
|
|
79
|
+
return rule
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_yaml_rules() -> list[Callable[[list[dict[str, Any]]], list[Detection]]]:
|
|
83
|
+
return [_make_count_rule(spec) for spec in count_rules() if spec.get("id")]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from .models import RuleMeta
|
|
6
|
+
|
|
7
|
+
def load_dlp_rules(manifest_path: Path | str) -> List[RuleMeta]:
|
|
8
|
+
"""Load DLP rules from a YAML manifest file."""
|
|
9
|
+
path = Path(manifest_path)
|
|
10
|
+
if not path.exists():
|
|
11
|
+
return []
|
|
12
|
+
|
|
13
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
14
|
+
data = yaml.safe_load(f)
|
|
15
|
+
|
|
16
|
+
if not data or "rules" not in data:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
rules = []
|
|
20
|
+
for r in data["rules"]:
|
|
21
|
+
rules.append(RuleMeta(
|
|
22
|
+
id=r.get("id", ""),
|
|
23
|
+
name=r.get("name", ""),
|
|
24
|
+
description=r.get("description", ""),
|
|
25
|
+
pattern=r.get("pattern", ""),
|
|
26
|
+
category=r.get("category", ""),
|
|
27
|
+
severity=r.get("severity", "medium")
|
|
28
|
+
))
|
|
29
|
+
return rules
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class RuleMeta:
|
|
7
|
+
id: str
|
|
8
|
+
name: str
|
|
9
|
+
description: str
|
|
10
|
+
pattern: str
|
|
11
|
+
category: str
|
|
12
|
+
severity: str
|
|
13
|
+
validate: str = "" # optional validator: "luhn" for card numbers
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DlpMatch:
|
|
18
|
+
rule_id: str
|
|
19
|
+
category: str
|
|
20
|
+
severity: str
|
|
21
|
+
pattern_type: str = "regex"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class DlpVerdict:
|
|
26
|
+
matched: bool
|
|
27
|
+
mode: str = "disable" # log, block, disable
|
|
28
|
+
match: Optional[DlpMatch] = None # first match (back-compat)
|
|
29
|
+
matches: list[DlpMatch] = field(default_factory=list) # all matches
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from .models import DlpVerdict, DlpMatch
|
|
7
|
+
from .loader import load_dlp_rules
|
|
8
|
+
from ...config import settings
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Compiled-rule cache. Reset with reset_rules() (tests / live rule reload).
|
|
13
|
+
_COMPILED_RULES: list[tuple[re.Pattern[str], Any]] = []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def reset_rules() -> None:
|
|
17
|
+
"""Clear the compiled-rule cache so the next scan reloads from disk."""
|
|
18
|
+
_COMPILED_RULES.clear()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _init_rules() -> None:
|
|
22
|
+
if _COMPILED_RULES:
|
|
23
|
+
return
|
|
24
|
+
rules = load_dlp_rules(settings.dlp_rules_path)
|
|
25
|
+
for r in rules:
|
|
26
|
+
if not settings.dlp_pii and r.category == "pii":
|
|
27
|
+
continue
|
|
28
|
+
try:
|
|
29
|
+
_COMPILED_RULES.append((re.compile(r.pattern), r))
|
|
30
|
+
except re.error as exc:
|
|
31
|
+
logger.warning("[DLP] invalid regex for rule %s: %s", r.id, exc)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _luhn_ok(digits: str) -> bool:
|
|
35
|
+
"""Luhn checksum — rejects random 16-digit numbers that aren't real cards."""
|
|
36
|
+
nums = [int(c) for c in digits if c.isdigit()]
|
|
37
|
+
if len(nums) < 13:
|
|
38
|
+
return False
|
|
39
|
+
total, parity = 0, len(nums) % 2
|
|
40
|
+
for i, n in enumerate(nums):
|
|
41
|
+
if i % 2 == parity:
|
|
42
|
+
n *= 2
|
|
43
|
+
if n > 9:
|
|
44
|
+
n -= 9
|
|
45
|
+
total += n
|
|
46
|
+
return total % 10 == 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _passes_validator(rule: Any, matched_text: str) -> bool:
|
|
50
|
+
if getattr(rule, "validate", "") == "luhn":
|
|
51
|
+
return _luhn_ok(matched_text)
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def scan(tool_qualified: str, arguments: Dict[str, Any] | str, mode: str | None = None) -> DlpVerdict:
|
|
56
|
+
"""Scan tool arguments against DLP rules. Returns all matches, not just the first.
|
|
57
|
+
|
|
58
|
+
Validators (e.g. Luhn for card numbers) suppress false positives. Only rule
|
|
59
|
+
metadata is returned — never the matched value.
|
|
60
|
+
"""
|
|
61
|
+
if mode is None:
|
|
62
|
+
mode = settings.dlp_mode
|
|
63
|
+
if mode == "disable":
|
|
64
|
+
return DlpVerdict(matched=False, mode=mode)
|
|
65
|
+
|
|
66
|
+
_init_rules()
|
|
67
|
+
if not _COMPILED_RULES:
|
|
68
|
+
return DlpVerdict(matched=False, mode=mode)
|
|
69
|
+
|
|
70
|
+
if isinstance(arguments, dict):
|
|
71
|
+
try:
|
|
72
|
+
text = json.dumps(arguments)
|
|
73
|
+
except Exception:
|
|
74
|
+
text = str(arguments)
|
|
75
|
+
else:
|
|
76
|
+
text = str(arguments)
|
|
77
|
+
|
|
78
|
+
matches: list[DlpMatch] = []
|
|
79
|
+
seen: set[str] = set()
|
|
80
|
+
for pattern, rule in _COMPILED_RULES:
|
|
81
|
+
m = pattern.search(text)
|
|
82
|
+
if not m or not _passes_validator(rule, m.group(0)):
|
|
83
|
+
continue
|
|
84
|
+
if rule.id in seen:
|
|
85
|
+
continue
|
|
86
|
+
seen.add(rule.id)
|
|
87
|
+
matches.append(DlpMatch(
|
|
88
|
+
rule_id=rule.id,
|
|
89
|
+
category=rule.category,
|
|
90
|
+
severity=rule.severity,
|
|
91
|
+
pattern_type="regex",
|
|
92
|
+
))
|
|
93
|
+
|
|
94
|
+
if not matches:
|
|
95
|
+
return DlpVerdict(matched=False, mode=mode)
|
|
96
|
+
return DlpVerdict(matched=True, mode=mode, match=matches[0], matches=matches)
|