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
attackmap/md.py ADDED
@@ -0,0 +1,175 @@
1
+ """Markdown escaping for repo-derived text (#233, part B).
2
+
3
+ Route paths, file names, dependency names and evidence snippets come from the
4
+ repository under review and end up in Markdown that a bot posts with
5
+ repository authority (PR comments) or that reviewers open in a rendered
6
+ viewer. Interpolated raw, a route like
7
+ ``/x](https://evil.example/login) <img src=…> @org/security-team`` renders as a
8
+ phishing link, a tracking pixel and a mass ping. Every untrusted value is
9
+ passed through :func:`md_text` (inline prose) or :func:`md_code` (a code span)
10
+ before it is concatenated into Markdown, and LLM-written Markdown through
11
+ :func:`sanitize_llm_markdown`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ # Bidi overrides/isolates, zero-width and other invisible format characters,
19
+ # and C0/C1 controls except tab/newline. Shown as visible \uXXXX escapes.
20
+ _INVISIBLE = re.compile(r"[\u0000-\u0008\u000b-\u001f\u007f-\u009f​-‏‪-‮⁠-⁤⁦-⁩]")
21
+ _MD_SPECIAL = re.compile(r"([\\`*_{}\[\]()#+!|~<>])")
22
+ _MENTION = re.compile(r"@(?=[A-Za-z0-9])")
23
+ _AUTOLINK = re.compile(r"(?i)\b(https?|ftp|file|javascript|data)://")
24
+ _WWW = re.compile(r"(?i)\bwww\.")
25
+ _ZWSP = "&#8203;" # an entity, so no raw invisible character lands in the file
26
+
27
+
28
+ def _visible(text: str) -> str:
29
+ return _INVISIBLE.sub(lambda m: f"\\u{ord(m.group(0)):04x}", text)
30
+
31
+
32
+ def visible_text(value: object) -> str:
33
+ """``value`` with control, invisible and bidi characters shown as visible
34
+ ``\\uXXXX`` escapes (tab and newline kept), for non-Markdown outputs such
35
+ as diagram labels: a raw ESC from a scanned repo would otherwise reach the
36
+ terminal of whoever views the file."""
37
+ return _visible(str(value))
38
+
39
+
40
+ def md_text(value: object) -> str:
41
+ """Escape an untrusted value for inline Markdown prose.
42
+
43
+ Markdown/HTML metacharacters are backslash-escaped, ``@mentions`` and
44
+ URL autolinks are broken with a zero-width space entity, invisible and
45
+ bidi characters are shown escaped, and newlines are flattened.
46
+ """
47
+ text = _visible(str(value)).replace("\r", " ").replace("\n", " ")
48
+ text = text.replace("&", "&amp;")
49
+ text = _MD_SPECIAL.sub(r"\\\1", text)
50
+ text = _MENTION.sub("@" + _ZWSP, text)
51
+ text = _AUTOLINK.sub(lambda m: f"{m.group(1)}:{_ZWSP}//", text)
52
+ return _WWW.sub(lambda m: m.group(0)[:3] + _ZWSP + ".", text)
53
+
54
+
55
+ def md_code(value: object) -> str:
56
+ """Render an untrusted value as a single inline code span.
57
+
58
+ Nothing inside a code span is interpreted (no links, HTML, mentions). The
59
+ backtick fence is made longer than any backtick run in the value, so the
60
+ span can't be closed early.
61
+ """
62
+ text = _visible(str(value)).replace("\r", " ").replace("\n", " ")
63
+ longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
64
+ fence = "`" * (longest + 1)
65
+ pad = " " if text.startswith("`") or text.endswith("`") or not text.strip() else ""
66
+ return f"{fence}{pad}{text}{pad}{fence}"
67
+
68
+
69
+ _CODE_SPAN = re.compile(r"(`+)(.+?)\1")
70
+ _RAW_TAG_START = re.compile(r"(?<!\\)<(?=[A-Za-z!/?])")
71
+ _RAW_LINK = re.compile(r"(?<!\\)\]\(")
72
+ _RAW_IMAGE = re.compile(r"(?<!\\)!\[")
73
+ _LIVE_MENTION = re.compile(r"(?<![\w;\\])@(?=[A-Za-z0-9])")
74
+
75
+
76
+ def _defang_prose(text: str) -> str:
77
+ text = _RAW_TAG_START.sub(r"\\<", text)
78
+ text = _RAW_LINK.sub(r"]\\(", text)
79
+ text = _RAW_IMAGE.sub(r"\\![", text)
80
+ text = _LIVE_MENTION.sub("@" + _ZWSP, text)
81
+ return _AUTOLINK.sub(lambda m: f"{m.group(1)}:{_ZWSP}//", text)
82
+
83
+
84
+ def defang_markdown(markdown: str) -> str:
85
+ """Backstop for a whole AttackMap-generated document that contains no
86
+ intentional links or HTML (e.g. defensive-review.md): outside code spans,
87
+ raw HTML, links, images, mentions and autolinks are neutralized, and
88
+ invisible/bidi characters are shown escaped everywhere. Idempotent."""
89
+ text = _visible(markdown)
90
+ out: list[str] = []
91
+ pos = 0
92
+ for match in _CODE_SPAN.finditer(text):
93
+ out.append(_defang_prose(text[pos : match.start()]))
94
+ out.append(match.group(0))
95
+ pos = match.end()
96
+ out.append(_defang_prose(text[pos:]))
97
+ return "".join(out)
98
+
99
+
100
+ # CommonMark allows line endings inside an HTML tag, so the tag pattern must
101
+ # not stop at a newline; a reference-link definition is `[label]: url` at the
102
+ # start of a line; a protocol-relative `//host/x` destination has no scheme
103
+ # for _AUTOLINK to break.
104
+ _HTML_TAG = re.compile(r"</?[A-Za-z][^<>]*>|<!--.*?-->", re.DOTALL)
105
+ _MD_IMAGE = re.compile(r"!\[([^\]\n]*)\]\(([^)\n]*)\)")
106
+ _MD_LINK = re.compile(r"(?<!!)\[([^\]\n]+)\]\(([^)\n]*)\)")
107
+ _REF_DEFINITION = re.compile(r"^([ \t]{0,3})\[(?=[^\]]*\]:)", re.MULTILINE)
108
+ _PROTOCOL_RELATIVE = re.compile(r"(?<![\w:/\\;])//(?=[A-Za-z0-9\[])")
109
+ _FENCE = re.compile(r"^(?P<indent>[ \t]{0,3})(?P<fence>`{3,}|~{3,})", re.MULTILINE)
110
+
111
+
112
+ def _sanitize_prose(text: str) -> str:
113
+ """One stretch of LLM Markdown outside fenced code blocks and code spans."""
114
+ text = _HTML_TAG.sub("", text)
115
+ text = _MD_IMAGE.sub(lambda m: f"[image removed: {m.group(1)}]", text)
116
+ text = _MD_LINK.sub(lambda m: f"{m.group(1)} ({m.group(2)})", text)
117
+ # Whatever link syntax the two patterns above did not recognise (an
118
+ # escaped `]` or a newline in the text, nested brackets, a reference
119
+ # definition) is neutralised in place rather than parsed.
120
+ text = _RAW_IMAGE.sub(r"\\![", text)
121
+ text = _RAW_LINK.sub(r"]\\(", text)
122
+ text = _REF_DEFINITION.sub(r"\1\\[", text)
123
+ text = _MENTION.sub("@" + _ZWSP, text)
124
+ text = _AUTOLINK.sub(lambda m: f"{m.group(1)}:{_ZWSP}//", text)
125
+ return _PROTOCOL_RELATIVE.sub(f"/{_ZWSP}/", text)
126
+
127
+
128
+ def _sanitize_outside_code_spans(text: str) -> str:
129
+ out: list[str] = []
130
+ pos = 0
131
+ for match in _CODE_SPAN.finditer(text):
132
+ out.append(_sanitize_prose(text[pos : match.start()]))
133
+ out.append(match.group(0))
134
+ pos = match.end()
135
+ out.append(_sanitize_prose(text[pos:]))
136
+ return "".join(out)
137
+
138
+
139
+ def _split_fences(text: str) -> list[tuple[bool, str]]:
140
+ """``(is_code, chunk)`` pairs: fenced code blocks (``` or ~~~, closed by a
141
+ fence of the same character at least as long, or by the end of the text)
142
+ are returned verbatim so their contents are never rewritten."""
143
+ chunks: list[tuple[bool, str]] = []
144
+ pos = 0
145
+ while True:
146
+ opening = _FENCE.search(text, pos)
147
+ if opening is None:
148
+ break
149
+ fence = opening.group("fence")
150
+ closer = re.compile(rf"^[ \t]{{0,3}}{re.escape(fence[0])}{{{len(fence)},}}[ \t]*$", re.MULTILINE)
151
+ closing = closer.search(text, opening.end())
152
+ end = len(text) if closing is None else closing.end()
153
+ chunks.append((False, text[pos : opening.start()]))
154
+ chunks.append((True, text[opening.start() : end]))
155
+ pos = end
156
+ chunks.append((False, text[pos:]))
157
+ return chunks
158
+
159
+
160
+ def sanitize_llm_markdown(markdown: str, *, max_chars: int = 200_000) -> str:
161
+ """Defang Markdown written by an LLM that read untrusted repo content.
162
+
163
+ Outside fenced code blocks and code spans: raw HTML is removed, images
164
+ are dropped, links become ``text (url)`` plain text with the URL
165
+ defanged, every other link, image or reference-definition syntax is
166
+ escaped in place, mentions are broken, and absolute and
167
+ protocol-relative URLs are defanged. Invisible characters are shown
168
+ escaped everywhere. Headings, lists, emphasis and code are kept, since
169
+ that is the report's structure; code is kept verbatim.
170
+ """
171
+ text = _visible(markdown[:max_chars])
172
+ text = "".join(chunk if is_code else _sanitize_outside_code_spans(chunk) for is_code, chunk in _split_fences(text))
173
+ if len(markdown) > max_chars:
174
+ text += "\n\n_[output truncated]_\n"
175
+ return text
attackmap/merge.py ADDED
@@ -0,0 +1,333 @@
1
+ """Centralized merge behavior for analyzer outputs.
2
+
3
+ This module is the single source of truth for **how** analyzer-emitted
4
+ signals are combined into a single ScanResult. It is loaded by both the
5
+ public `merge_analyzer_results` (whole-repo analyzers) and the internal
6
+ `merge_analyzer_signals` (file-by-file built-in micro-analyzers).
7
+
8
+ A new structured signal type is added in one place: append a row to
9
+ :data:`MERGE_SCHEMA` describing the field name and its dedup key. All
10
+ merge / dedup behavior follows automatically.
11
+
12
+ ## Merge rules
13
+
14
+ - **Deterministic ordering**: first-seen wins. The order signals appear
15
+ in the merged result is the order they first appeared across the input
16
+ results, walked in input order. We do not reorder. Analyzers run in
17
+ ``(metadata.priority, name)`` order (#221), so on a duplicate key the
18
+ lower-priority-number analyzer's signal wins.
19
+ - **Deduplication**: each list field has a stable key extracted from the
20
+ signal itself (e.g. `(path, method, file)` for routes). Two signals
21
+ that hash to the same key are treated as duplicates; only the first is
22
+ kept.
23
+ - **Route twins** (#337): a plugin route on the same `(file, line)` as a
24
+ built-in route supersedes it when they are plausibly one registration
25
+ (the plugin joined a group prefix the built-in couldn't see). See
26
+ :func:`superseded_builtin_routes`.
27
+ - **`languages`**: union, then sorted alphabetically for stable display.
28
+ - **`files_scanned`**: summed across results.
29
+ - **`root`**: taken from the explicit `root` argument when supplied,
30
+ otherwise from the first result.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ from collections.abc import Callable, Iterable
37
+ from dataclasses import dataclass
38
+ from typing import Any
39
+
40
+ from .models import NATIVE_WEAKNESS_KINDS
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class MergeRule:
45
+ """How one list field on `ScanResult` is merged across analyzer outputs.
46
+
47
+ - `attr`: the attribute name on `ScanResult` (e.g. `"routes"`).
48
+ - `key`: a callable that returns a hashable dedup key for one signal.
49
+ """
50
+
51
+ attr: str
52
+ key: Callable[[Any], tuple]
53
+ # Optional: called as `upgrade(existing, duplicate)` when a later signal
54
+ # hashes to an existing key, to fold in information the first one lacked.
55
+ upgrade: Callable[[Any, Any], None] | None = None
56
+
57
+ def __post_init__(self) -> None:
58
+ if not self.attr.isidentifier():
59
+ raise ValueError(f"MergeRule.attr must be a valid identifier, got {self.attr!r}")
60
+
61
+
62
+ # The complete schema of list fields on `ScanResult` that get merged
63
+ # across analyzer outputs. Order is preserved in the merged result;
64
+ # duplicates (by key) are suppressed.
65
+
66
+ def _upgrade_route_auth(existing: Any, duplicate: Any) -> None:
67
+ """Known route auth beats unknown (#256): first-seen still wins the route,
68
+ but a later analyzer that resolved the guard fills it in."""
69
+ if getattr(existing, "auth", "unknown") == "unknown" and getattr(duplicate, "auth", "unknown") != "unknown":
70
+ existing.auth = duplicate.auth
71
+ existing.guards = list(duplicate.guards)
72
+ existing.guard_evidence = duplicate.guard_evidence
73
+
74
+ _SEVERITY_RANK = {"low": 0, "medium": 1, "high": 2}
75
+ _CONFIDENCE_RANK = {"low": 0.3, "medium": 0.6, "high": 0.9}
76
+
77
+
78
+ def _rank(item: Any) -> tuple[int, float]:
79
+ # An unset confidence reads as medium, the same as tiered findings do
80
+ # (threat_model._confidence_value): a report that didn't state one isn't
81
+ # weaker than one that says "low".
82
+ confidence = getattr(item, "confidence", None)
83
+ if confidence is None:
84
+ confidence = _CONFIDENCE_RANK["medium"]
85
+ elif isinstance(confidence, str):
86
+ confidence = _CONFIDENCE_RANK.get(confidence, _CONFIDENCE_RANK["medium"])
87
+ return _SEVERITY_RANK.get(getattr(item, "severity", ""), -1), float(confidence)
88
+
89
+
90
+ def keep_more_severe(existing: Any, duplicate: Any) -> None:
91
+ """Upgrade hook for records with ``severity`` (and optionally a numeric or
92
+ low/medium/high ``confidence``): on a duplicate key keep the more severe,
93
+ then the more confident, report: its severity, confidence, descriptive
94
+ fields and provenance, so the merged record never pairs one analyzer's
95
+ severity with another's evidence or `source_analyzer` (#359)."""
96
+ if _rank(duplicate) > _rank(existing):
97
+ existing.severity = duplicate.severity
98
+ if hasattr(existing, "confidence"):
99
+ existing.confidence = duplicate.confidence
100
+ for name in _UPGRADE_FIELDS:
101
+ value = getattr(duplicate, name, None)
102
+ if value is not None and hasattr(existing, name):
103
+ setattr(existing, name, value)
104
+ for name in _UPGRADE_FIELDS_PAIRED:
105
+ if hasattr(existing, name):
106
+ setattr(existing, name, getattr(duplicate, name, None))
107
+
108
+
109
+ # Fields a winning duplicate carries over (when it sets them).
110
+ _UPGRADE_FIELDS = ("evidence_text", "context", "escalation_path", "platform", "source_analyzer")
111
+ # Fields that describe the winner's report as a pair and are carried over even
112
+ # when unset, so a merged record never pairs one analyzer's severity with
113
+ # another analyzer's traced input.
114
+ _UPGRADE_FIELDS_PAIRED = ("source_kind", "source_line")
115
+
116
+
117
+ def _upgrade_native_weakness(existing: Any, duplicate: Any) -> None:
118
+ if existing.kind in NATIVE_WEAKNESS_KINDS:
119
+ keep_more_severe(existing, duplicate)
120
+
121
+
122
+ MERGE_SCHEMA: tuple[MergeRule, ...] = (
123
+ MergeRule("routes", lambda item: (item.path, item.method, item.file), upgrade=_upgrade_route_auth),
124
+ # Method is part of the identity (#146b): two verbs on the same target in one
125
+ # file (GET + POST /items) are distinct calls, not duplicates — collapsing
126
+ # them would drop a method before cross-repo contract linking runs.
127
+ MergeRule("external_calls", lambda item: (item.target, item.method, item.file)),
128
+ MergeRule("databases", lambda item: (item.kind, item.file)),
129
+ MergeRule("auth_hints", lambda item: (item.hint, item.file)),
130
+ MergeRule("service_hints", lambda item: (item.hint, item.file)),
131
+ MergeRule("edge_hints", lambda item: (item.hint, item.file)),
132
+ MergeRule("entrypoint_hints", lambda item: (item.hint, item.file)),
133
+ MergeRule("protocol_hints", lambda item: (item.hint, item.file)),
134
+ MergeRule("framework_hints", lambda item: (item.hint, item.file)),
135
+ # Line is part of the identity: redacted names only keep a 4-char prefix
136
+ # (#235), so two different `AKIA…` keys in one file must stay distinct.
137
+ MergeRule("secret_hints", lambda item: (item.name, item.file, item.line)),
138
+ MergeRule(
139
+ "taint_chains",
140
+ lambda item: (
141
+ item.route_method,
142
+ item.route_path,
143
+ item.route_file,
144
+ item.sink_kind,
145
+ item.sink_file,
146
+ item.sink_line,
147
+ ),
148
+ ),
149
+ MergeRule(
150
+ "dependencies",
151
+ lambda item: (item.ecosystem, item.name, item.version, item.file, item.dev),
152
+ ),
153
+ MergeRule(
154
+ "vulnerabilities",
155
+ lambda item: (item.ecosystem, item.package_name, item.package_version, item.id),
156
+ ),
157
+ MergeRule(
158
+ "authz_candidates",
159
+ lambda item: (item.route_method, item.route_path, item.route_file, item.id_param),
160
+ ),
161
+ MergeRule(
162
+ "crypto_weaknesses",
163
+ lambda item: (item.kind, item.file, item.line),
164
+ ),
165
+ MergeRule(
166
+ "web_hardening_issues",
167
+ lambda item: (item.kind, item.file, item.line),
168
+ ),
169
+ # Native C/C++ kinds (#189) carry evidence in the identity (two sinks of
170
+ # one kind on a line are two instances), and a true duplicate keeps the
171
+ # more severe report with its traced input. Other kinds keep the
172
+ # (kind, file, line) identity and the first report.
173
+ MergeRule(
174
+ "code_weaknesses",
175
+ lambda item: (
176
+ item.kind, item.file, item.line,
177
+ item.evidence_text if item.kind in NATIVE_WEAKNESS_KINDS else None,
178
+ ),
179
+ upgrade=_upgrade_native_weakness,
180
+ ),
181
+ MergeRule(
182
+ "workflow_issues",
183
+ lambda item: (item.kind, item.file, item.line, item.context),
184
+ ),
185
+ MergeRule(
186
+ "supply_chain_issues",
187
+ lambda item: (item.kind, item.file, item.line, item.package),
188
+ ),
189
+ # Evidence is part of the identity: two settings of one kind at one line
190
+ # (a `/var/log` and a `docker.sock` mount) are two instances. On a true
191
+ # duplicate the more severe report wins.
192
+ MergeRule(
193
+ "cloud_misconfigs",
194
+ lambda item: (item.kind, item.file, item.line, item.resource, item.evidence_text),
195
+ upgrade=keep_more_severe,
196
+ ),
197
+ # Evidence is part of the identity: two ATS exceptions in one binary
198
+ # (lineless) Info.plist are two instances. On a true duplicate (the Swift
199
+ # and Objective-C analyzers both reporting a plist) the more severe wins.
200
+ MergeRule(
201
+ "app_surface_issues",
202
+ lambda item: (item.kind, item.file, item.line, item.evidence_text),
203
+ upgrade=keep_more_severe,
204
+ ),
205
+ MergeRule(
206
+ "secret_history",
207
+ lambda item: (item.kind, item.file, item.name, item.introduced_in),
208
+ ),
209
+ MergeRule(
210
+ "anomalies",
211
+ lambda item: (item.kind, item.route_method, item.route_path, item.route_file),
212
+ ),
213
+ )
214
+
215
+
216
+ def merge_into(destination: Any, items: Iterable[Any], rule: MergeRule, seen: set) -> None:
217
+ """Append `items` onto `destination.<rule.attr>` deduping against `seen`.
218
+
219
+ `seen` is mutated to include the keys of items that were appended.
220
+ Pre-populate `seen` with keys from the existing destination if you
221
+ want to merge across multiple sources without rescanning the list.
222
+ """
223
+ target = getattr(destination, rule.attr)
224
+ index: dict | None = None
225
+ for item in items:
226
+ k = rule.key(item)
227
+ if k in seen:
228
+ if rule.upgrade is not None:
229
+ if index is None:
230
+ index = {rule.key(existing): existing for existing in target}
231
+ existing = index.get(k)
232
+ if existing is not None:
233
+ rule.upgrade(existing, item)
234
+ continue
235
+ seen.add(k)
236
+ target.append(item)
237
+ if index is not None:
238
+ index[k] = item
239
+
240
+
241
+ # A path parameter in any framework's syntax: `:id`, `{id}` / `{id...}`,
242
+ # `<int:id>`, `[id]`. Normalized to one placeholder, so `/users/:id` and
243
+ # `/users/{id}` compare equal. Linear: each alternative stops at its closer.
244
+ _PATH_PARAM = re.compile(r":[A-Za-z_]\w*|\{[^{}/]*\}|<[^<>/]*>|\[[^\[\]/]*\]")
245
+ # Segments of a path considered for suffixes; a longer path only offers its
246
+ # last ones (a twin's built-in path is the end of the plugin's).
247
+ _MAX_SUFFIX_SEGMENTS = 32
248
+
249
+
250
+ def _route_segments(path: str) -> tuple[str, ...]:
251
+ """A path's segments for twin matching: parameters normalized, empty
252
+ segments (leading, trailing, repeated `/`) dropped. The root is ``()``."""
253
+ return tuple(_PATH_PARAM.sub("{}", seg) for seg in path.strip().split("/") if seg)
254
+
255
+
256
+ def superseded_builtin_routes(builtin_routes: Iterable[Any], plugin_routes: Iterable[Any]) -> set[int]:
257
+ """``id()`` of each built-in route that a plugin route on the same
258
+ (file, line) supersedes (#337).
259
+
260
+ Core's built-in extractors read one call at a time; a plugin that joins
261
+ group / router prefixes (`api := r.Group("/api")`) emits the same
262
+ registration under a longer path, and the `(path, method, file)` merge
263
+ key keeps both. A built-in route is a twin of a plugin route on its
264
+ (file, line) when:
265
+
266
+ - the plugin path ends with the built-in path at a segment boundary
267
+ (parameters compared as placeholders, so `:id` matches `{id}`), and
268
+ - the methods match, or the built-in said `ANY` (a plugin `ANY` never
269
+ supersedes a specific built-in method: it would lose the method).
270
+
271
+ The root `/` is a suffix of every path, so a built-in `/` only matches
272
+ a plugin `/`, or the plugin's one route when the line has exactly one
273
+ route of each kind. Two calls on one line whose paths end the same way
274
+ (`r.Get("/a", h); v1.Get("/a", h)`) can still be taken for one
275
+ registration; that's the residual risk, and it needs two registrations
276
+ of the same path and method on one line.
277
+
278
+ Only a built-in route is ever dropped, so two plugin routes, two
279
+ built-in routes, or different registrations on one line all survive.
280
+ Linear: plugin routes are indexed per site by (method, suffix).
281
+ """
282
+ # Per site: {(method, suffix)}, {suffix}, the methods of plugin roots, and
283
+ # the number of plugin routes.
284
+ sites: dict[tuple[str, int], tuple[set, set, set, list[int]]] = {}
285
+ for route in plugin_routes:
286
+ if route.line is None:
287
+ continue
288
+ by_method, any_method, roots, count = sites.setdefault((route.file, route.line), (set(), set(), set(), [0]))
289
+ count[0] += 1
290
+ segments = _route_segments(route.path)
291
+ method = route.method.upper()
292
+ if not segments:
293
+ roots.add(method)
294
+ for start in range(max(0, len(segments) - _MAX_SUFFIX_SEGMENTS), len(segments) + 1):
295
+ suffix = segments[start:]
296
+ by_method.add((method, suffix))
297
+ any_method.add(suffix)
298
+ superseded: set[int] = set()
299
+ if not sites:
300
+ return superseded
301
+ builtins = [route for route in builtin_routes if route.line is not None]
302
+ builtin_count: dict[tuple[str, int], int] = {}
303
+ for route in builtins:
304
+ key = (route.file, route.line)
305
+ builtin_count[key] = builtin_count.get(key, 0) + 1
306
+ for route in builtins:
307
+ site = sites.get((route.file, route.line))
308
+ if site is None:
309
+ continue
310
+ by_method, any_method, roots, count = site
311
+ segments = _route_segments(route.path)
312
+ method = route.method.upper()
313
+ if not segments and not (count[0] == 1 and builtin_count[(route.file, route.line)] == 1):
314
+ # A root only matches a plugin root (`()` is every path's suffix).
315
+ twin = bool(roots) if method == "ANY" else method in roots
316
+ elif method == "ANY":
317
+ twin = segments in any_method
318
+ else:
319
+ twin = (method, segments) in by_method
320
+ if twin:
321
+ superseded.add(id(route))
322
+ return superseded
323
+
324
+
325
+ def initial_seen(destination: Any, rule: MergeRule) -> set:
326
+ """Compute the `seen` set for items already on `destination.<rule.attr>`.
327
+
328
+ Used by callers that merge into a non-empty destination.
329
+ """
330
+ return {rule.key(item) for item in getattr(destination, rule.attr)}
331
+
332
+
333
+ __all__ = ["MergeRule", "MERGE_SCHEMA", "keep_more_severe", "merge_into", "initial_seen", "superseded_builtin_routes"]