attackmap 0.6.4__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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- attackmap-0.6.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .fingerprint import assign_fingerprints
|
|
8
|
+
from .analyzer import identify_attack_surfaces
|
|
9
|
+
from .models import AttackPath, AttackSurface, Finding, ScanResult
|
|
10
|
+
from .threat_model import generate_attack_paths, generate_findings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AnalysisOutputs:
|
|
15
|
+
attack_surfaces: list[AttackSurface]
|
|
16
|
+
findings: list[Finding]
|
|
17
|
+
attack_paths: list[AttackPath]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
_WARNED_OVERLOADED: set[str] = set()
|
|
22
|
+
|
|
23
|
+
# Deprecated (#258): prefixes the official plugins used to put in auth_hints.
|
|
24
|
+
NON_AUTH_HINT_PREFIXES: tuple[str, ...] = (
|
|
25
|
+
"service_name:",
|
|
26
|
+
"service_role:",
|
|
27
|
+
"entrypoint:",
|
|
28
|
+
"edge:",
|
|
29
|
+
"handler_type:",
|
|
30
|
+
"handler_visibility:",
|
|
31
|
+
"handler_visibility_basis:",
|
|
32
|
+
"controller:",
|
|
33
|
+
"service:",
|
|
34
|
+
"omeka_",
|
|
35
|
+
"laminas_",
|
|
36
|
+
"atproto_namespace:",
|
|
37
|
+
"atproto_namespace_ref:",
|
|
38
|
+
"atproto_xrpc_ref:",
|
|
39
|
+
"atproto_lexicon:",
|
|
40
|
+
"atproto_event_stream:",
|
|
41
|
+
"atproto_service_note:",
|
|
42
|
+
"atproto_service_edge:",
|
|
43
|
+
"atproto_repo:",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
AUTH_SIGNAL_PATTERN = re.compile(
|
|
47
|
+
r"(auth|jwt|oauth|token|session|password|bearer|authorization|mfa|passport|login_required)",
|
|
48
|
+
re.IGNORECASE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
EXPLICIT_AUTH_HINT_PREFIXES: tuple[str, ...] = ("atproto_auth:",)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_likely_auth_signal(value: str) -> bool:
|
|
55
|
+
lowered = value.lower()
|
|
56
|
+
if lowered.startswith(EXPLICIT_AUTH_HINT_PREFIXES):
|
|
57
|
+
return True
|
|
58
|
+
if lowered.startswith(NON_AUTH_HINT_PREFIXES):
|
|
59
|
+
return False
|
|
60
|
+
return bool(AUTH_SIGNAL_PATTERN.search(lowered))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _auth_filtered_scan(scan: ScanResult) -> ScanResult:
|
|
64
|
+
"""
|
|
65
|
+
Build a conservative auth-focused view of ScanResult.
|
|
66
|
+
|
|
67
|
+
The official plugins emit service/edge/protocol/framework metadata as typed
|
|
68
|
+
hints since #258, so for them this only drops weak auth hints. It remains
|
|
69
|
+
as a compatibility shim for older or third-party plugins that still put
|
|
70
|
+
that metadata in `auth_hints`: those are dropped (with a one-time warning)
|
|
71
|
+
so a file full of `controller:*` hints doesn't look authenticated.
|
|
72
|
+
Kept hints are copied whole, so `line` / `evidence_text` survive for the
|
|
73
|
+
route-to-auth windowing.
|
|
74
|
+
"""
|
|
75
|
+
migrated_non_auth_hints = {
|
|
76
|
+
(hint.hint, hint.file)
|
|
77
|
+
for hint in [
|
|
78
|
+
*scan.service_hints,
|
|
79
|
+
*scan.edge_hints,
|
|
80
|
+
*scan.entrypoint_hints,
|
|
81
|
+
*scan.protocol_hints,
|
|
82
|
+
*scan.framework_hints,
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
overloaded = sorted(
|
|
86
|
+
{
|
|
87
|
+
hint.source_analyzer or "unknown"
|
|
88
|
+
for hint in scan.auth_hints
|
|
89
|
+
if hint.hint.lower().startswith(NON_AUTH_HINT_PREFIXES)
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
for analyzer in overloaded:
|
|
93
|
+
if analyzer not in _WARNED_OVERLOADED:
|
|
94
|
+
_WARNED_OVERLOADED.add(analyzer)
|
|
95
|
+
logger.warning(
|
|
96
|
+
"Analyzer %r puts non-auth metadata in auth_hints; emit ServiceHint/EdgeHint/"
|
|
97
|
+
"EntrypointHint/ProtocolHint/FrameworkHint instead (deprecated, see #258).",
|
|
98
|
+
analyzer,
|
|
99
|
+
)
|
|
100
|
+
filtered_auth_hints = [
|
|
101
|
+
hint.model_copy()
|
|
102
|
+
for hint in scan.auth_hints
|
|
103
|
+
if (hint.hint, hint.file) not in migrated_non_auth_hints and _is_likely_auth_signal(hint.hint)
|
|
104
|
+
]
|
|
105
|
+
return scan.model_copy(update={"auth_hints": filtered_auth_hints})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def to_attack_surface(scan: ScanResult) -> list[AttackSurface]:
|
|
109
|
+
"""Translate recon signals into classified attack-surface entries."""
|
|
110
|
+
return identify_attack_surfaces(_auth_filtered_scan(scan))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def to_findings(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> list[Finding]:
|
|
114
|
+
"""Translate recon signals (and optional surfaces) into conservative findings."""
|
|
115
|
+
auth_filtered_scan = _auth_filtered_scan(scan)
|
|
116
|
+
if attack_surfaces is not None:
|
|
117
|
+
findings = generate_findings(auth_filtered_scan, attack_surfaces)
|
|
118
|
+
else:
|
|
119
|
+
findings = generate_findings(auth_filtered_scan)
|
|
120
|
+
# Per-instance identity for baseline diffs (#222).
|
|
121
|
+
assign_fingerprints(findings, scan.root)
|
|
122
|
+
return findings
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def to_attack_paths(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> list[AttackPath]:
|
|
126
|
+
"""
|
|
127
|
+
Translate recon signals into plausible, heuristic attack paths.
|
|
128
|
+
|
|
129
|
+
Keep full hints for path generation so temporary overloaded hints still
|
|
130
|
+
support chain-linking while migration is in progress.
|
|
131
|
+
"""
|
|
132
|
+
return generate_attack_paths(scan, attack_surfaces=attack_surfaces)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def translate_recon(scan: ScanResult) -> AnalysisOutputs:
|
|
136
|
+
"""
|
|
137
|
+
Formal translation stage from ScanResult to higher-level analysis artifacts.
|
|
138
|
+
|
|
139
|
+
This remains deterministic and explainable by relying only on existing
|
|
140
|
+
signal-driven heuristics from analyzer/threat_model modules.
|
|
141
|
+
"""
|
|
142
|
+
attack_surfaces = to_attack_surface(scan)
|
|
143
|
+
findings = to_findings(scan, attack_surfaces)
|
|
144
|
+
attack_paths = to_attack_paths(scan)
|
|
145
|
+
return AnalysisOutputs(
|
|
146
|
+
attack_surfaces=attack_surfaces,
|
|
147
|
+
findings=findings,
|
|
148
|
+
attack_paths=attack_paths,
|
|
149
|
+
)
|
attackmap/redact.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Credential redaction for every evidence string AttackMap emits (#235).
|
|
2
|
+
|
|
3
|
+
Evidence text is copied into ``attackmap-report.json``, SARIF (often uploaded
|
|
4
|
+
to code scanning, readable by anyone with repo read access), PR comments and
|
|
5
|
+
the LLM evidence pack. A credential that appears in the scanned code must
|
|
6
|
+
never be copied verbatim into any of them, whatever detector produced the
|
|
7
|
+
snippet. :func:`redact_text` is applied to every evidence field when the
|
|
8
|
+
model is constructed (see ``models._RedactedEvidence``), and to source
|
|
9
|
+
excerpts before they reach an LLM prompt.
|
|
10
|
+
|
|
11
|
+
What is masked, anywhere in a string:
|
|
12
|
+
* every provider-shaped token (AWS, GitHub, Slack, Stripe, JWT, …) —
|
|
13
|
+
all matches, not just the one a detector was looking for;
|
|
14
|
+
* URI userinfo passwords (``scheme://user:PASS@`` → ``scheme://user:***@``)
|
|
15
|
+
and token-only userinfo (``https://TOKEN@host``);
|
|
16
|
+
* query / form parameters named like credentials (``?api_key=…``);
|
|
17
|
+
* ``Bearer`` / ``Basic`` authorization values;
|
|
18
|
+
* values assigned to credential-named keys (``password = "…"``,
|
|
19
|
+
``API_KEY=…``, ``secret: …``);
|
|
20
|
+
* other high-entropy quoted literals.
|
|
21
|
+
|
|
22
|
+
Redaction is idempotent and never lengthens a masked value; at most the
|
|
23
|
+
first 4 characters of a secret survive (:func:`mask_secret`).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
MASK = "***"
|
|
32
|
+
|
|
33
|
+
# Provider-shaped tokens: a superset of scanner.HARDCODED_SECRET_PATTERNS
|
|
34
|
+
# (detection keeps its own list so adding a redaction shape here never changes
|
|
35
|
+
# which findings are produced). test_redact checks every detection pattern is
|
|
36
|
+
# also redacted.
|
|
37
|
+
SECRET_TOKEN_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
38
|
+
(re.compile(r"\b(AKIA[0-9A-Z]{16})\b"), "aws_access_key"),
|
|
39
|
+
(re.compile(r"\b(ASIA[0-9A-Z]{16})\b"), "aws_temporary_key"),
|
|
40
|
+
(re.compile(r"\b(ghp_[A-Za-z0-9]{36,})\b"), "github_pat"),
|
|
41
|
+
(re.compile(r"\b(gho_[A-Za-z0-9]{36,})\b"), "github_oauth_token"),
|
|
42
|
+
(re.compile(r"\b(ghu_[A-Za-z0-9]{36,})\b"), "github_user_token"),
|
|
43
|
+
(re.compile(r"\b(ghs_[A-Za-z0-9]{36,})\b"), "github_server_token"),
|
|
44
|
+
(re.compile(r"\b(ghr_[A-Za-z0-9]{36,})\b"), "github_refresh_token"),
|
|
45
|
+
(re.compile(r"\b(github_pat_[A-Za-z0-9_]{60,})\b"), "github_fine_grained_pat"),
|
|
46
|
+
(re.compile(r"\b(xox[bpsare]-[0-9A-Za-z-]{10,})\b"), "slack_token"),
|
|
47
|
+
(re.compile(r"\b((?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{24,})\b"), "stripe_key"),
|
|
48
|
+
(re.compile(r"\b(SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})\b"), "sendgrid_key"),
|
|
49
|
+
(re.compile(r"\b(key-[a-f0-9]{32})\b"), "mailgun_key"),
|
|
50
|
+
(re.compile(r"\b(AC[a-f0-9]{32})\b"), "twilio_account_sid"),
|
|
51
|
+
(re.compile(r"\b(AIza[A-Za-z0-9_-]{35,42})\b"), "google_api_key"),
|
|
52
|
+
(re.compile(r"\b(sk-ant-[A-Za-z0-9_-]{40,})\b"), "anthropic_key"),
|
|
53
|
+
(re.compile(r"\b(sk-proj-[A-Za-z0-9_-]{20,})\b"), "openai_key"),
|
|
54
|
+
(re.compile(r"\b(sk-svcacct-[A-Za-z0-9_-]{20,})\b"), "openai_key"),
|
|
55
|
+
(re.compile(r"\b(sk-[A-Za-z0-9]{48,})\b"), "openai_key"),
|
|
56
|
+
(re.compile(r"\b(glpat-[A-Za-z0-9_-]{20,})\b"), "gitlab_pat"),
|
|
57
|
+
(re.compile(r"\b(npm_[A-Za-z0-9]{36})\b"), "npm_token"),
|
|
58
|
+
(re.compile(r"\b(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b"), "jwt"),
|
|
59
|
+
# --- #252: credential families without a classic prefix -------------
|
|
60
|
+
# AWS secret access key: 40 chars, only trusted next to its key name.
|
|
61
|
+
(re.compile(r"(?i)aws_?secret_?(?:access_?)?key['\"]?\s{0,5}(?::=|=>|[:=])\s{0,5}['\"]?([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])"), "aws_secret_access_key"),
|
|
62
|
+
# Azure storage / Service Bus connection-string keys.
|
|
63
|
+
(re.compile(r"(?i)\bAccountKey=([A-Za-z0-9+/]{40,100}={0,2})"), "azure_storage_account_key"),
|
|
64
|
+
(re.compile(r"(?i)\bSharedAccessKey=([A-Za-z0-9+/]{40,64}={0,2})"), "azure_shared_access_key"),
|
|
65
|
+
# Azure AD (Entra) client secret: 3 chars, a digit, `Q~`, 31-34 chars.
|
|
66
|
+
(re.compile(r"(?<![A-Za-z0-9_~.-])([A-Za-z0-9_~.]{3}\dQ~[A-Za-z0-9_~.-]{31,34})(?![A-Za-z0-9_~.-])"), "azure_ad_client_secret"),
|
|
67
|
+
# Datadog API / application keys (hex, keyed by name).
|
|
68
|
+
(re.compile(r"(?i)\b(?:dd|datadog)_?(?:api|app(?:lication)?)_?key['\"]?\s{0,5}[:=]\s{0,5}['\"]?([a-f0-9]{32}(?:[a-f0-9]{8})?)\b"), "datadog_key"),
|
|
69
|
+
# Heroku platform API key (new format).
|
|
70
|
+
(re.compile(r"\b(HRKU-AA[0-9A-Za-z_-]{58})\b"), "heroku_api_key"),
|
|
71
|
+
# DigitalOcean personal / OAuth / refresh tokens.
|
|
72
|
+
(re.compile(r"\b(do[opr]_v1_[a-f0-9]{64})\b"), "digitalocean_token"),
|
|
73
|
+
# HashiCorp Vault service / batch tokens.
|
|
74
|
+
(re.compile(r"\b(hv[sb]\.[A-Za-z0-9_-]{24,})\b"), "vault_token"),
|
|
75
|
+
# .npmrc registry auth: `//registry.npmjs.org/:_authToken=…`, `_auth=`/`_password=`.
|
|
76
|
+
(re.compile(r":_authToken\s{0,5}=\s{0,5}([A-Za-z0-9._~+/=-]{8,})"), "npm_auth_token"),
|
|
77
|
+
(re.compile(r"(?m)^\s{0,8}(?:\S{0,200}:)?_(?:auth|password)\s{0,5}=\s{0,5}([A-Za-z0-9+/=]{8,})"), "npm_basic_auth"),
|
|
78
|
+
# Docker registry auth blob in `.dockercfg` / `config.json`.
|
|
79
|
+
(re.compile(r"\"auth\"\s{0,5}:\s{0,5}\"([A-Za-z0-9+/]{12,}={0,2})\""), "docker_registry_auth"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
_CRED_WORDS = (
|
|
83
|
+
r"password|passwd|passphrase|pwd|secret|token|api[_-]?key|apikey|access[_-]?key"
|
|
84
|
+
r"|private[_-]?key|priv[_-]?key|client[_-]?secret|auth[_-]?key|credentials?"
|
|
85
|
+
)
|
|
86
|
+
# Query parameters also use short signature names (`?sig=`, `&signature=`).
|
|
87
|
+
_QUERY_WORDS = _CRED_WORDS + r"|sig|signature"
|
|
88
|
+
|
|
89
|
+
# scheme://user:password@host / scheme://token@host
|
|
90
|
+
_URI_USERINFO = re.compile(
|
|
91
|
+
r"(?P<prefix>\b[a-zA-Z][a-zA-Z0-9+.-]{0,31}://)(?P<user>[^\s:/@'\"]{0,256})(?::(?P<password>[^\s/@'\"]{1,256}))?@"
|
|
92
|
+
)
|
|
93
|
+
# ?api_key=… &token=… (query strings and form bodies)
|
|
94
|
+
_QUERY_PARAM = re.compile(
|
|
95
|
+
rf"(?P<prefix>[?&;](?:[\w.-]{{0,64}}?(?:{_QUERY_WORDS})[\w.-]{{0,64}})=)(?P<value>[^&#\s'\"]+)",
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
)
|
|
98
|
+
# Authorization: Bearer … / Basic … (a plain word after it is prose, not a token)
|
|
99
|
+
_AUTH_SCHEME = re.compile(r"(?P<prefix>\b(?:Bearer|Basic)\s+)(?P<value>[A-Za-z0-9._~+/=-]{8,})")
|
|
100
|
+
# key = "value" / key: 'value' / KEY=value — the value of a credential-named key.
|
|
101
|
+
_KEY_ASSIGN = re.compile(
|
|
102
|
+
# Starts only at a token boundary (not every `\b` inside `a.b-c`), and the
|
|
103
|
+
# key prefix/suffix are bounded, so hostile long tokens stay linear (#236).
|
|
104
|
+
rf"(?P<prefix>['\"]?(?<![\w.-])[\w.-]{{0,64}}?(?:{_CRED_WORDS})[\w.-]{{0,64}}['\"]?\s*(?::=|=>|[:=])\s*)"
|
|
105
|
+
# A bare value directly followed by `(`, `[` or more identifier text is
|
|
106
|
+
# code (`request.headers.get(...)`, `os.environ[...]`), not a literal.
|
|
107
|
+
# An unterminated quoted value (a snippet cut at its length cap) is masked
|
|
108
|
+
# too: losing the closing quote must not let the secret through (#252).
|
|
109
|
+
r"(?:(?P<q>['\"])(?P<qvalue>[^'\"\n]{4,}?)(?:(?P=q)|$)|(?P<bare>[^\s'\",;#()\[\]{}]{4,})(?![(\[.\w]))",
|
|
110
|
+
re.IGNORECASE,
|
|
111
|
+
)
|
|
112
|
+
_QUOTED_LITERAL = re.compile(r"(?P<q>['\"])(?P<value>[A-Za-z0-9+/_=-]{24,256})(?P=q)")
|
|
113
|
+
|
|
114
|
+
# Unquoted values that are references to a secret, not the secret itself.
|
|
115
|
+
_REFERENCE_VALUE = re.compile(
|
|
116
|
+
r"""^(?:
|
|
117
|
+
\$\{?[A-Za-z_]\w*\}? # $VAR / ${VAR}
|
|
118
|
+
| \{\{.* # {{ template }}
|
|
119
|
+
| %\(.* # %(name)s
|
|
120
|
+
| <[^>]*>? # <placeholder>
|
|
121
|
+
| [A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+ # settings.SECRET_KEY, process.env.TOKEN
|
|
122
|
+
| [A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+ # JWT_SECRET — the *name* of a secret
|
|
123
|
+
| [A-Za-z_]\w*\(.* # getenv(...), call(...)
|
|
124
|
+
| [A-Za-z_]\w*\[.* # os.environ[...]
|
|
125
|
+
| (?:true|false|null|none|nil)
|
|
126
|
+
)$""",
|
|
127
|
+
re.IGNORECASE | re.VERBOSE,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def mask_secret(value: str) -> str:
|
|
132
|
+
"""Display form of a secret: at most its first 4 characters, then ``…``."""
|
|
133
|
+
if len(value) < 12:
|
|
134
|
+
return "…"
|
|
135
|
+
return f"{value[:4]}…"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def shannon_entropy(value: str) -> float:
|
|
139
|
+
if not value:
|
|
140
|
+
return 0.0
|
|
141
|
+
counts: dict[str, int] = {}
|
|
142
|
+
for ch in value:
|
|
143
|
+
counts[ch] = counts.get(ch, 0) + 1
|
|
144
|
+
total = len(value)
|
|
145
|
+
return -sum((n / total) * math.log2(n / total) for n in counts.values())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_masked(value: str) -> bool:
|
|
149
|
+
return value == MASK or value.endswith("…") or set(value) <= {"*", "…", "x", "X"}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _looks_random(value: str) -> bool:
|
|
153
|
+
if value.isdigit() or (value.isalpha() and (value.islower() or value.isupper())):
|
|
154
|
+
return False
|
|
155
|
+
if "/" in value and value.count("/") >= 2: # paths / mime types
|
|
156
|
+
return False
|
|
157
|
+
return shannon_entropy(value) >= 4.2
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _mask_group(m: re.Match[str]) -> str:
|
|
161
|
+
"""Mask only the secret (group 1), keeping any key-name context the
|
|
162
|
+
pattern matched around it (``AccountKey=``, ``aws_secret_access_key =``)."""
|
|
163
|
+
whole, start = m.group(0), m.start(0)
|
|
164
|
+
return whole[: m.start(1) - start] + mask_secret(m.group(1)) + whole[m.end(1) - start :]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def redact_text(text: str | None) -> str | None:
|
|
168
|
+
"""Mask every credential-looking value in ``text``. Idempotent."""
|
|
169
|
+
if not text or len(text) < 8:
|
|
170
|
+
return text
|
|
171
|
+
out = text
|
|
172
|
+
for pattern, _kind in SECRET_TOKEN_PATTERNS:
|
|
173
|
+
out = pattern.sub(_mask_group, out)
|
|
174
|
+
|
|
175
|
+
def _userinfo(m: re.Match[str]) -> str:
|
|
176
|
+
user, password = m.group("user"), m.group("password")
|
|
177
|
+
if password is not None:
|
|
178
|
+
return f"{m.group('prefix')}{user}:{MASK}@" if not _is_masked(password) else m.group(0)
|
|
179
|
+
# Token-only userinfo (https://TOKEN@github.com) — mask when it isn't
|
|
180
|
+
# an ordinary username (git@, user@).
|
|
181
|
+
if len(user) >= 16 and not _is_masked(user):
|
|
182
|
+
return f"{m.group('prefix')}{MASK}@"
|
|
183
|
+
return m.group(0)
|
|
184
|
+
|
|
185
|
+
out = _URI_USERINFO.sub(_userinfo, out)
|
|
186
|
+
out = _QUERY_PARAM.sub(lambda m: m.group(0) if _is_masked(m.group("value")) else m.group("prefix") + MASK, out)
|
|
187
|
+
out = _AUTH_SCHEME.sub(
|
|
188
|
+
lambda m: m.group(0)
|
|
189
|
+
if _is_masked(m.group("value")) or m.group("value").isalpha()
|
|
190
|
+
else m.group("prefix") + MASK,
|
|
191
|
+
out,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _assign(m: re.Match[str]) -> str:
|
|
195
|
+
if m.group("q"):
|
|
196
|
+
value = m.group("qvalue")
|
|
197
|
+
terminated = m.group(0).endswith(m.group("q")) and len(m.group(0)) > len(m.group("prefix")) + 1 + len(value)
|
|
198
|
+
if not terminated:
|
|
199
|
+
value = value.rstrip("…") # snippet cut mid-value: not "already masked"
|
|
200
|
+
if _is_masked(value) or _REFERENCE_VALUE.match(value):
|
|
201
|
+
return m.group(0)
|
|
202
|
+
return f"{m.group('prefix')}{m.group('q')}{MASK}{m.group('q')}"
|
|
203
|
+
value = m.group("bare")
|
|
204
|
+
if _is_masked(value) or _REFERENCE_VALUE.match(value):
|
|
205
|
+
return m.group(0)
|
|
206
|
+
return f"{m.group('prefix')}{MASK}"
|
|
207
|
+
|
|
208
|
+
out = _KEY_ASSIGN.sub(_assign, out)
|
|
209
|
+
|
|
210
|
+
def _literal(m: re.Match[str]) -> str:
|
|
211
|
+
value = m.group("value")
|
|
212
|
+
if _is_masked(value) or not _looks_random(value):
|
|
213
|
+
return m.group(0)
|
|
214
|
+
return f"{m.group('q')}{mask_secret(value)}{m.group('q')}"
|
|
215
|
+
|
|
216
|
+
return _QUOTED_LITERAL.sub(_literal, out)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def redact_list(values: list[str]) -> list[str]:
|
|
220
|
+
return [redact_text(v) or "" for v in values]
|