attackmap 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
attackmap/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "0.1.0"
attackmap/analyzer.py ADDED
@@ -0,0 +1,278 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+
5
+ import networkx as nx
6
+
7
+ from .models import AttackSurface, ScanResult
8
+
9
+
10
+ def identify_attack_surfaces(scan: ScanResult) -> list[AttackSurface]:
11
+ surfaces: list[AttackSurface] = []
12
+ auth_hints_by_file: dict[str, set[str]] = {}
13
+ for hint in scan.auth_hints:
14
+ auth_hints_by_file.setdefault(hint.file, set()).add(hint.hint)
15
+ global_auth_hints = sorted({hint.hint for hint in scan.auth_hints})
16
+
17
+ supporting_hints_by_file: dict[str, set[str]] = {}
18
+ all_supporting_hints = [
19
+ *scan.auth_hints,
20
+ *scan.service_hints,
21
+ *scan.edge_hints,
22
+ *scan.entrypoint_hints,
23
+ *scan.protocol_hints,
24
+ *scan.framework_hints,
25
+ ]
26
+
27
+ for hint in all_supporting_hints:
28
+ supporting_hints_by_file.setdefault(hint.file, set()).add(hint.hint)
29
+
30
+ for route in scan.routes:
31
+ path_lower = route.path.lower()
32
+ file_auth_hints = sorted(auth_hints_by_file.get(route.file, set()))
33
+ auth_signals = file_auth_hints or global_auth_hints
34
+ supporting_signals = sorted(supporting_hints_by_file.get(route.file, set())) or sorted(
35
+ {hint.hint for hint in all_supporting_hints}
36
+ )
37
+ rationale: list[str] = []
38
+ exposure: AttackSurface["exposure"] = "public"
39
+ internal_handler_markers = {
40
+ "handler_visibility:internal",
41
+ "handler_type:internal_handler",
42
+ "service_role:worker",
43
+ "service_role:event_consumer",
44
+ "service_role:internal_handler",
45
+ }
46
+ has_explicit_public_visibility = "handler_visibility:public" in supporting_signals
47
+ has_internal_handler_hint = any(marker in supporting_signals for marker in internal_handler_markers) and not has_explicit_public_visibility
48
+
49
+ if "webhook" in path_lower:
50
+ category = "webhook"
51
+ risk = "high"
52
+ rationale.append("Webhook endpoints commonly accept attacker-controlled input from the internet.")
53
+ elif any(segment in path_lower for segment in ("/admin", "/manage", "/root")):
54
+ category = "admin"
55
+ risk = "high"
56
+ rationale.append("Administrative routes are high-value targets because they expose privileged actions.")
57
+ elif any(segment in path_lower for segment in ("/login", "/signin", "/signup", "/auth", "/session", "/token", "/oauth")):
58
+ category = "auth"
59
+ risk = "high" if not auth_signals else "medium"
60
+ rationale.append("Authentication endpoints are common targets for brute force, token abuse, and session attacks.")
61
+ elif any(segment in path_lower for segment in ("/upload", "/import", "/file")):
62
+ category = "upload"
63
+ risk = "high"
64
+ rationale.append("Upload and import flows often lead to parser abuse or untrusted file handling.")
65
+ elif any(segment in path_lower for segment in ("/internal", "/debug")):
66
+ category = "internal"
67
+ exposure = "internal"
68
+ risk = "medium"
69
+ rationale.append("Internal or debug routes are risky if they become reachable from untrusted networks.")
70
+ elif any(segment in path_lower for segment in ("/health", "/ready", "/live", "/metrics")):
71
+ category = "health"
72
+ risk = "low"
73
+ rationale.append("Operational endpoints reveal limited attack surface but may disclose useful reconnaissance details.")
74
+ else:
75
+ category = "public_api"
76
+ risk = "medium" if scan.databases or scan.external_calls else "low"
77
+ rationale.append("Public application routes are initial footholds for probing input handling and authorization gaps.")
78
+
79
+ if has_internal_handler_hint and category not in {"admin", "auth", "webhook"}:
80
+ exposure = "internal"
81
+ if category == "public_api":
82
+ category = "internal"
83
+ risk = "medium" if category != "health" else "low"
84
+ rationale.append("Service-level handler metadata suggests this route is internal-facing (worker/event/internal handler path).")
85
+
86
+ if auth_signals:
87
+ rationale.append(f"Auth indicators observed: {', '.join(auth_signals)}.")
88
+ else:
89
+ rationale.append("No auth indicators were observed near this route.")
90
+
91
+ if scan.databases:
92
+ rationale.append("Datastore usage was detected elsewhere in the repository, so route-to-data flows are plausible.")
93
+ if scan.external_calls:
94
+ rationale.append("Outbound HTTP calls were detected, creating trust boundaries to third-party systems.")
95
+
96
+ surfaces.append(
97
+ AttackSurface(
98
+ route=route.path,
99
+ method=route.method,
100
+ file=route.file,
101
+ line=route.line,
102
+ category=category,
103
+ exposure=exposure,
104
+ risk=risk,
105
+ auth_signals=auth_signals,
106
+ data_store_interaction=bool(scan.databases),
107
+ outbound_integration=bool(scan.external_calls),
108
+ rationale=rationale,
109
+ )
110
+ )
111
+
112
+ return surfaces
113
+
114
+
115
+ def summarize_architecture(scan: ScanResult, graph: nx.DiGraph) -> str:
116
+ route_count = len(scan.routes)
117
+ dbs = sorted({db.kind for db in scan.databases})
118
+ external_targets = sorted({call.target for call in scan.external_calls})
119
+ auth_summary = sorted({hint.hint for hint in scan.auth_hints})
120
+ secret_count = len(scan.secret_hints)
121
+ graph_edges = sorted(
122
+ f"{source} -> {target} ({data['relation']})"
123
+ for source, target, data in graph.edges(data=True)
124
+ if "relation" in data
125
+ )
126
+
127
+ lines = [
128
+ "# Architecture Summary",
129
+ "",
130
+ "## Overview",
131
+ f"- AttackMap inferred a {'web-facing' if route_count else 'non-web'} repository with {route_count} entry point{'s' if route_count != 1 else ''}.",
132
+ f"- Files scanned: {scan.files_scanned}",
133
+ f"- Languages detected: {', '.join(scan.languages) if scan.languages else 'none'}",
134
+ f"- Inferred entry points: {route_count}",
135
+ f"- Datastores detected: {', '.join(dbs) if dbs else 'none'}",
136
+ f"- External targets detected: {', '.join(external_targets) if external_targets else 'none'}",
137
+ f"- Auth hints observed: {', '.join(auth_summary) if auth_summary else 'none'}",
138
+ f"- Secret-like env references: {secret_count}",
139
+ ]
140
+
141
+ if scan.routes:
142
+ common = Counter(route.file for route in scan.routes).most_common(3)
143
+ lines.extend(
144
+ [
145
+ "",
146
+ "## Entry Point Concentration",
147
+ *[f"- {file}: {count} routes" for file, count in common],
148
+ ]
149
+ )
150
+ busiest_file, busiest_count = common[0]
151
+ lines.extend(
152
+ [
153
+ "",
154
+ "## Likely Review Starting Point",
155
+ f"- Start with `{busiest_file}`. It contains {busiest_count} of the detected routes and is the fastest place to validate exposure and trust boundaries.",
156
+ ]
157
+ )
158
+
159
+ if graph_edges:
160
+ lines.extend(
161
+ [
162
+ "",
163
+ "## Inferred Trust Boundaries",
164
+ *[f"- {edge}" for edge in graph_edges[:10]],
165
+ ]
166
+ )
167
+
168
+ lines.extend(
169
+ [
170
+ "",
171
+ "## Analyst Notes",
172
+ "- AttackMap inferred this system shape heuristically from framework patterns, dependencies, and code hints.",
173
+ "- Treat this as an initial map for security review, then validate the highest-risk routes and boundaries manually.",
174
+ ]
175
+ )
176
+
177
+ return "\n".join(lines)
178
+
179
+
180
+ def _risk_rank(value: str) -> int:
181
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
182
+
183
+
184
+ def _surface_summary(surface: AttackSurface) -> str:
185
+ traits: list[str] = []
186
+ if surface.auth_signals:
187
+ traits.append(f"auth signals: {', '.join(surface.auth_signals)}")
188
+ else:
189
+ traits.append("no auth signals observed")
190
+
191
+ if surface.data_store_interaction:
192
+ traits.append("data store reachable")
193
+ if surface.outbound_integration:
194
+ traits.append("external trust boundary")
195
+
196
+ return "; ".join(traits)
197
+
198
+
199
+ def summarize_attack_surface(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> str:
200
+ surfaces = attack_surfaces if attack_surfaces is not None else identify_attack_surfaces(scan)
201
+ ordered_surfaces = sorted(surfaces, key=lambda surface: (_risk_rank(surface.risk), surface.route, surface.method))
202
+ external_targets = sorted({call.target for call in scan.external_calls})
203
+ auth_summary = sorted({a.hint for a in scan.auth_hints})
204
+ high_risk = [surface for surface in ordered_surfaces if surface.risk == "high"]
205
+ auth_and_admin = [surface for surface in ordered_surfaces if surface.category in {"auth", "admin"}]
206
+ public_entry_points = [surface for surface in ordered_surfaces if surface.exposure == "public" and surface.category not in {"auth", "admin", "health"}]
207
+ operational_routes = [surface for surface in ordered_surfaces if surface.category in {"health", "internal"}]
208
+
209
+ lines = [
210
+ "# Attack Surface",
211
+ "",
212
+ "## Priority View",
213
+ f"- High-risk entry points: {len(high_risk)}",
214
+ f"- Public routes detected: {sum(1 for surface in ordered_surfaces if surface.exposure == 'public')}",
215
+ f"- Internal-only routes detected: {sum(1 for surface in ordered_surfaces if surface.exposure == 'internal')}",
216
+ f"- Routes with likely data access: {sum(1 for surface in ordered_surfaces if surface.data_store_interaction)}",
217
+ f"- Routes with outbound trust boundaries: {sum(1 for surface in ordered_surfaces if surface.outbound_integration)}",
218
+ ]
219
+
220
+ lines.extend(["", "## Highest-Risk Entry Points"])
221
+ if high_risk:
222
+ for surface in high_risk[:10]:
223
+ lines.append(
224
+ f"- {surface.method} {surface.route} ({surface.file}) -> {surface.category}; {_surface_summary(surface)}"
225
+ )
226
+ else:
227
+ lines.append("- No high-risk entry points were classified")
228
+
229
+ lines.extend(["", "## Auth And Privileged Routes"])
230
+ if auth_and_admin:
231
+ for surface in auth_and_admin[:10]:
232
+ lines.append(
233
+ f"- [{surface.risk.upper()}] {surface.method} {surface.route} "
234
+ f"({surface.file}) -> {surface.category}, {surface.exposure}; {_surface_summary(surface)}"
235
+ )
236
+ else:
237
+ lines.append("- No dedicated auth or admin routes were classified")
238
+
239
+ lines.extend(["", "## Public Application Routes"])
240
+ if public_entry_points:
241
+ for surface in public_entry_points[:10]:
242
+ lines.append(
243
+ f"- [{surface.risk.upper()}] {surface.method} {surface.route} "
244
+ f"({surface.file}) -> {surface.category}, {surface.exposure}; {_surface_summary(surface)}"
245
+ )
246
+ else:
247
+ lines.append("- No additional public application routes were classified")
248
+
249
+ lines.extend(["", "## Operational And Internal Routes"])
250
+ if operational_routes:
251
+ for surface in operational_routes[:10]:
252
+ lines.append(
253
+ f"- [{surface.risk.upper()}] {surface.method} {surface.route} "
254
+ f"({surface.file}) -> {surface.category}, {surface.exposure}; {_surface_summary(surface)}"
255
+ )
256
+ else:
257
+ lines.append("- No health, metrics, debug, or internal routes were classified")
258
+
259
+ lines.extend(["", "## External Dependencies"])
260
+ if external_targets:
261
+ for target in external_targets[:25]:
262
+ lines.append(f"- {target}")
263
+ else:
264
+ lines.append("- No explicit outbound HTTP calls detected")
265
+
266
+ lines.extend(["", "## Secrets And Auth Hints"])
267
+ if scan.secret_hints:
268
+ for hint in scan.secret_hints[:25]:
269
+ lines.append(f"- Secret-related env usage: {hint.name} ({hint.file})")
270
+ else:
271
+ lines.append("- No secret-like environment variable usage detected")
272
+
273
+ if auth_summary:
274
+ lines.append(f"- Auth-related keywords observed: {', '.join(auth_summary)}")
275
+ else:
276
+ lines.append("- No auth-related keywords observed")
277
+
278
+ return "\n".join(lines)
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol
5
+
6
+ from pydantic import BaseModel, Field, model_validator
7
+
8
+ from .recon_models import ScanResult
9
+
10
+ # Phase-1 shared repository analyzer contract.
11
+ # Keep AnalyzerResult mapped to ScanResult for backward compatibility.
12
+ AnalyzerResult = ScanResult
13
+
14
+
15
+ class AnalyzerMetadata(BaseModel):
16
+ name: str
17
+ display_name: str = ""
18
+ version: str = "0.1.0"
19
+ description: str = ""
20
+ scope: str = ""
21
+ targets: list[str] = Field(default_factory=list)
22
+ languages: list[str] = Field(default_factory=list)
23
+ priority: int = 100
24
+ experimental: bool = True
25
+ enabled_by_default: bool = False
26
+
27
+ @model_validator(mode="before")
28
+ @classmethod
29
+ def _coerce_legacy_ecosystems(cls, value: object) -> object:
30
+ if not isinstance(value, dict):
31
+ return value
32
+ payload = dict(value)
33
+ # Backward-compat: older call sites may pass ecosystems directly.
34
+ # Use it as a fallback source for languages when newer fields are omitted.
35
+ ecosystems = payload.pop("ecosystems", None)
36
+ if ecosystems and not payload.get("languages") and not payload.get("targets"):
37
+ payload["languages"] = list(ecosystems)
38
+ if not payload.get("display_name") and payload.get("name"):
39
+ payload["display_name"] = str(payload["name"])
40
+ return payload
41
+
42
+ @property
43
+ def ecosystems(self) -> tuple[str, ...]:
44
+ values = [*self.languages, *self.targets]
45
+ seen: set[str] = set()
46
+ ordered: list[str] = []
47
+ for value in values:
48
+ lowered = value.lower()
49
+ if lowered in seen:
50
+ continue
51
+ seen.add(lowered)
52
+ ordered.append(lowered)
53
+ return tuple(ordered)
54
+
55
+
56
+ class AnalyzerRepositoryModule(BaseModel):
57
+ analyzer_name: str
58
+ repo_name: str
59
+ web_url: str
60
+
61
+
62
+ class AnalyzerProtocol(Protocol):
63
+ metadata: AnalyzerMetadata
64
+
65
+ @property
66
+ def name(self) -> str: ...
67
+
68
+ def detect(self, root: str | Path) -> bool: ...
69
+
70
+ def analyze(self, root: str | Path) -> AnalyzerResult: ...
71
+
72
+
73
+ __all__ = [
74
+ "AnalyzerResult",
75
+ "AnalyzerMetadata",
76
+ "AnalyzerRepositoryModule",
77
+ "AnalyzerProtocol",
78
+ "normalize_analyzer_metadata",
79
+ ]
80
+
81
+
82
+ def normalize_analyzer_metadata(value: object) -> AnalyzerMetadata:
83
+ if isinstance(value, AnalyzerMetadata):
84
+ return value
85
+ if isinstance(value, dict):
86
+ return AnalyzerMetadata.model_validate(value)
87
+
88
+ payload: dict[str, object] = {}
89
+ for key in (
90
+ "name",
91
+ "display_name",
92
+ "version",
93
+ "description",
94
+ "scope",
95
+ "targets",
96
+ "languages",
97
+ "priority",
98
+ "experimental",
99
+ "enabled_by_default",
100
+ "ecosystems",
101
+ ):
102
+ if hasattr(value, key):
103
+ payload[key] = getattr(value, key)
104
+ return AnalyzerMetadata.model_validate(payload)