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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. 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]