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
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 = "​" # 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("&", "&")
|
|
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"]
|