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.
@@ -0,0 +1,300 @@
1
+ from __future__ import annotations
2
+
3
+ from .models import AttackPath, AttackSurface, Finding, ScanResult
4
+ from .security_overlay import build_security_overlay
5
+
6
+ SCHEMA_VERSION = "1.2.0"
7
+ LOW_QUALITY_SEGMENTS = ("/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/")
8
+
9
+
10
+ def _severity_rank(value: str) -> int:
11
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
12
+
13
+
14
+ def _risk_rank(value: str) -> int:
15
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
16
+
17
+
18
+ def _is_low_quality_source(path_or_text: str) -> bool:
19
+ normalized = path_or_text.replace("\\", "/").lower()
20
+ return any(segment in f"/{normalized}/" for segment in LOW_QUALITY_SEGMENTS)
21
+
22
+
23
+ def _is_protocol_derived_surface(surface: AttackSurface) -> bool:
24
+ file_lower = surface.file.lower()
25
+ route_lower = surface.route.lower()
26
+ return "lexicon" in file_lower or "/xrpc/" in route_lower or "atproto_" in " ".join(surface.auth_signals).lower()
27
+
28
+
29
+ def _surface_evidence_class(surface: AttackSurface) -> str:
30
+ if _is_low_quality_source(surface.file):
31
+ return "low_quality"
32
+ if _is_protocol_derived_surface(surface):
33
+ return "inferred_protocol"
34
+ if surface.exposure == "public":
35
+ return "observed_runtime_public"
36
+ if surface.exposure == "internal":
37
+ return "observed_runtime_internal"
38
+ return "inferred"
39
+
40
+
41
+ def _evidence_class_counts(surfaces: list[AttackSurface]) -> dict[str, int]:
42
+ counts = {
43
+ "observed_runtime_public": 0,
44
+ "observed_runtime_internal": 0,
45
+ "inferred_protocol": 0,
46
+ "inferred": 0,
47
+ "low_quality": 0,
48
+ }
49
+ for surface in surfaces:
50
+ counts[_surface_evidence_class(surface)] += 1
51
+ return counts
52
+
53
+
54
+ def _strengths(scan: ScanResult, attack_surfaces: list[AttackSurface]) -> list[dict]:
55
+ strengths: list[dict] = []
56
+ auth_hints = sorted({hint.hint for hint in scan.auth_hints})
57
+ if auth_hints:
58
+ strengths.append(
59
+ {
60
+ "statement": "Authentication/identity indicators were observed in code signals.",
61
+ "evidence_basis": "observed",
62
+ "evidence": auth_hints[:8],
63
+ }
64
+ )
65
+ internal_surfaces = [surface for surface in attack_surfaces if surface.exposure == "internal"]
66
+ if internal_surfaces:
67
+ strengths.append(
68
+ {
69
+ "statement": "Some inferred entry points appear internal-only, reducing direct internet exposure when network boundaries are correctly enforced.",
70
+ "evidence_basis": "inferred",
71
+ "evidence": [f"{surface.method} {surface.route} ({surface.location()})" for surface in internal_surfaces[:6]],
72
+ }
73
+ )
74
+ if scan.secret_hints:
75
+ strengths.append(
76
+ {
77
+ "statement": "Secret references appear environment-driven rather than hardcoded literals.",
78
+ "evidence_basis": "observed",
79
+ "evidence": [f"{hint.name} ({hint.file})" for hint in scan.secret_hints[:8]],
80
+ }
81
+ )
82
+ if not strengths:
83
+ strengths.append(
84
+ {
85
+ "statement": "No strong defensive indicators were detected heuristically.",
86
+ "evidence_basis": "inferred",
87
+ "evidence": [],
88
+ }
89
+ )
90
+ return strengths[:4]
91
+
92
+
93
+ def _related_surfaces_for_finding(finding: Finding, attack_surfaces: list[AttackSurface]) -> list[AttackSurface]:
94
+ combined = f"{finding.title} {' '.join(finding.evidence)}".lower()
95
+ related = [
96
+ surface
97
+ for surface in attack_surfaces
98
+ if surface.route.lower() in combined
99
+ or surface.file.lower() in combined
100
+ or surface.category.lower() in combined
101
+ ]
102
+ return related or attack_surfaces
103
+
104
+
105
+ def _basis_label_for_surfaces(surfaces: list[AttackSurface]) -> str:
106
+ counts = _evidence_class_counts(surfaces)
107
+ total = max(sum(counts.values()), 1)
108
+ observed_total = counts["observed_runtime_public"] + counts["observed_runtime_internal"]
109
+ if (observed_total / total) >= 0.5:
110
+ return "observed"
111
+ if (counts["inferred_protocol"] / total) >= 0.5:
112
+ return "inferred_protocol"
113
+ if (counts["low_quality"] / total) >= 0.5:
114
+ return "low_quality"
115
+ return "mixed"
116
+
117
+
118
+ def _weaknesses_and_hotspots(
119
+ attack_surfaces: list[AttackSurface],
120
+ findings: list[Finding],
121
+ ) -> tuple[list[dict], list[dict]]:
122
+ ordered_findings = sorted(findings, key=lambda finding: (_severity_rank(finding.severity), finding.title))
123
+ weakness_items = []
124
+ for finding in ordered_findings[:8]:
125
+ related = _related_surfaces_for_finding(finding, attack_surfaces)
126
+ weakness_items.append(
127
+ {
128
+ "title": finding.title,
129
+ "severity": finding.severity,
130
+ "confidence": finding.confidence,
131
+ "evidence_basis": _basis_label_for_surfaces(related),
132
+ "evidence": finding.evidence[:10],
133
+ "mitigation": finding.mitigation,
134
+ "attack_techniques": [t.model_dump() for t in finding.attack_techniques],
135
+ }
136
+ )
137
+
138
+ runtime_surfaces = [surface for surface in attack_surfaces if not _is_low_quality_source(surface.file)]
139
+ hotspot_pool = runtime_surfaces if runtime_surfaces else attack_surfaces
140
+ hotspot_pool = sorted(
141
+ hotspot_pool,
142
+ key=lambda surface: (_risk_rank(surface.risk), surface.exposure != "public", surface.route),
143
+ )
144
+ hotspot_items = [
145
+ {
146
+ "surface": {
147
+ "method": surface.method,
148
+ "route": surface.route,
149
+ "file": surface.file,
150
+ "category": surface.category,
151
+ "risk": surface.risk,
152
+ "exposure": surface.exposure,
153
+ },
154
+ "evidence_class": _surface_evidence_class(surface),
155
+ "notes": surface.rationale[:3],
156
+ }
157
+ for surface in hotspot_pool[:6]
158
+ ]
159
+ return weakness_items, hotspot_items
160
+
161
+
162
+ def _evidence_chains(attack_paths: list[AttackPath]) -> list[dict]:
163
+ chains = []
164
+ for path in attack_paths[:6]:
165
+ joined = " ".join(path.steps).lower()
166
+ basis = "inferred"
167
+ if "entry:" in joined and "confidence=" in joined:
168
+ basis = "observed_plus_inferred"
169
+ elif "entry:" in joined:
170
+ basis = "observed"
171
+ chains.append(
172
+ {
173
+ "name": path.name,
174
+ "impact": path.impact,
175
+ "evidence_basis": basis,
176
+ "steps": path.steps[:8],
177
+ }
178
+ )
179
+ return chains
180
+
181
+
182
+ def _recommendations(findings: list[Finding], attack_surfaces: list[AttackSurface]) -> list[dict]:
183
+ ordered_findings = sorted(findings, key=lambda finding: (_severity_rank(finding.severity), finding.title))
184
+ recommendations: list[dict] = []
185
+ seen: set[str] = set()
186
+ for finding in ordered_findings:
187
+ mitigation = finding.mitigation.strip()
188
+ if not mitigation or mitigation in seen:
189
+ continue
190
+ seen.add(mitigation)
191
+ related = _related_surfaces_for_finding(finding, attack_surfaces)
192
+ recommendations.append(
193
+ {
194
+ "priority": finding.severity,
195
+ "evidence_basis": _basis_label_for_surfaces(related),
196
+ "action": mitigation,
197
+ "linked_finding": finding.title,
198
+ }
199
+ )
200
+ if len(recommendations) >= 8:
201
+ break
202
+ return recommendations
203
+
204
+
205
+ def build_defensive_review_json(
206
+ scan: ScanResult,
207
+ attack_surfaces: list[AttackSurface],
208
+ findings: list[Finding],
209
+ attack_paths: list[AttackPath],
210
+ ) -> dict:
211
+ evidence_counts = _evidence_class_counts(attack_surfaces)
212
+ overlay = build_security_overlay(scan, attack_surfaces, findings, attack_paths)
213
+ weaknesses, hotspots = _weaknesses_and_hotspots(attack_surfaces, overlay.findings)
214
+ return {
215
+ "schema_version": SCHEMA_VERSION,
216
+ "target_metadata": {
217
+ "root": scan.root,
218
+ "files_scanned": scan.files_scanned,
219
+ "languages": scan.languages,
220
+ },
221
+ "system_overview": {
222
+ "repository_type": "web/service-facing" if scan.routes else "non-web",
223
+ "raw_inferred_entry_point_count": len(scan.routes),
224
+ "external_call_count": len(scan.external_calls),
225
+ "database_count": len(scan.databases),
226
+ "auth_hint_count": len(scan.auth_hints),
227
+ "secret_hint_count": len(scan.secret_hints),
228
+ "asset_count": len(overlay.assets),
229
+ "control_count_present": sum(1 for c in overlay.controls if c.strength != "absent"),
230
+ "control_count_absent": sum(1 for c in overlay.controls if c.strength == "absent"),
231
+ "notable_observation_count": len(overlay.insights),
232
+ "detection_opportunity_count": len(overlay.detection_opportunities),
233
+ "attack_techniques_observed_count": len(_flatten_attack_techniques(overlay)),
234
+ },
235
+ "attack_surface": {
236
+ "total_surfaces": len(attack_surfaces),
237
+ "evidence_class_counts": evidence_counts,
238
+ "surfaces": [
239
+ {
240
+ "method": surface.method,
241
+ "route": surface.route,
242
+ "file": surface.file,
243
+ "line": surface.line,
244
+ "category": surface.category,
245
+ "exposure": surface.exposure,
246
+ "risk": surface.risk,
247
+ "evidence_class": _surface_evidence_class(surface),
248
+ "auth_signals": surface.auth_signals,
249
+ "data_store_interaction": surface.data_store_interaction,
250
+ "outbound_integration": surface.outbound_integration,
251
+ }
252
+ for surface in attack_surfaces[:60]
253
+ ],
254
+ },
255
+ "assets": [asset.model_dump() for asset in overlay.assets],
256
+ "controls": [control.model_dump() for control in overlay.controls],
257
+ "notable_observations": [insight.model_dump() for insight in overlay.insights],
258
+ "detection_opportunities": [opp.model_dump() for opp in overlay.detection_opportunities],
259
+ "attack_techniques_observed": _flatten_attack_techniques(overlay),
260
+ "strengths": _strengths(scan, attack_surfaces),
261
+ "weaknesses_risk_hotspots": {
262
+ "weaknesses": weaknesses,
263
+ "risk_hotspots": hotspots,
264
+ },
265
+ "evidence_chains": _evidence_chains(attack_paths),
266
+ "recommendations": _recommendations(overlay.findings, attack_surfaces),
267
+ "raw_structured_signals": {
268
+ "scan": scan.model_dump(),
269
+ "attack_surfaces": [surface.model_dump() for surface in attack_surfaces],
270
+ "findings": [finding.model_dump() for finding in overlay.findings],
271
+ "attack_paths": [path.model_dump() for path in attack_paths],
272
+ "assets": [asset.model_dump() for asset in overlay.assets],
273
+ "controls": [control.model_dump() for control in overlay.controls],
274
+ "notable_observations": [insight.model_dump() for insight in overlay.insights],
275
+ "detection_opportunities": [opp.model_dump() for opp in overlay.detection_opportunities],
276
+ },
277
+ "limitations_meta": {
278
+ "analysis_mode": "heuristic",
279
+ "defensive_only": True,
280
+ "notes": [
281
+ "Observed vs inferred classifications are heuristic and based on route, file, and signal patterns.",
282
+ "Low-quality paths (tests/fixtures/mocks/examples) are retained in raw signals but separated in evidence class counts.",
283
+ "Use this artifact as a triage source-of-truth; validate high-priority items with repository context and runtime controls.",
284
+ "Assets, controls, and notable observations are heuristic overlays; absent-control entries indicate scanner did not find evidence, not necessarily that the control is missing in production.",
285
+ "ATT&CK technique mappings and detection opportunities are derived from insight kinds and finding-title keywords; treat them as triage hooks for SIEM/detection-engineering work, not authoritative MITRE mappings.",
286
+ ],
287
+ },
288
+ }
289
+
290
+
291
+ def _flatten_attack_techniques(overlay) -> list[dict]:
292
+ """Deduplicated list of ATT&CK techniques observed across insights and findings."""
293
+ by_id: dict[str, dict] = {}
294
+ for insight in overlay.insights:
295
+ for technique in insight.attack_techniques:
296
+ by_id.setdefault(technique.technique_id, technique.model_dump())
297
+ for finding in overlay.findings:
298
+ for technique in finding.attack_techniques:
299
+ by_id.setdefault(technique.technique_id, technique.model_dump())
300
+ return sorted(by_id.values(), key=lambda t: t["technique_id"])
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+
6
+ from .models import AttackPath, AttackSurface, Finding, ScanResult
7
+ from .security_overlay import build_security_overlay
8
+
9
+ LOW_QUALITY_SEGMENTS = ("/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/")
10
+
11
+
12
+ SYSTEM_PROMPT_TEMPLATE = """You are AttackMap Review Analyst, a defensive security reviewer.
13
+
14
+ Operating rules:
15
+ - Be evidence-first. Every claim must map to provided evidence.
16
+ - Do not invent findings, routes, services, data stores, trust boundaries, or mitigations.
17
+ - Distinguish observed vs inferred signals clearly.
18
+ - Keep output defensive and remediation-oriented. Do not provide offensive exploitation instructions.
19
+ - If evidence is weak or partial, say so directly.
20
+
21
+ The evidence pack now includes five layers you must reason over:
22
+ - `assets` — value-at-risk inventory (with criticality)
23
+ - `controls` — defensive controls observed AND expected-but-absent
24
+ - `notable_observations` — pre-computed cross-cutting insights connecting assets, controls, surfaces, and chains
25
+ - `attack_techniques_observed` — MITRE ATT&CK technique mappings for findings and insights
26
+ - `detection_opportunities` — defender-facing detection-engineering hints (Sigma/KQL-style rule sketches)
27
+
28
+ When writing the review, lead with the highest-severity notable_observations and connect them to specific assets, controls, and ATT&CK techniques. Tell the story — do not just enumerate findings. Explicitly call out where a defense gap meets a critical asset, and reference the relevant ATT&CK technique(s) and any detection opportunity that would catch the same condition at runtime.
29
+
30
+ Output sections (in order):
31
+ 1. System Overview
32
+ 2. Notable Observations (top 3, each as a 2–4 sentence story citing surface/finding/asset/control IDs and the ATT&CK technique it maps to)
33
+ 3. Asset and Control Map (which crown jewels exist, what protects them, what is missing)
34
+ 4. Detection Opportunities (top 3 — for each, name the runtime signal that would catch the static finding)
35
+ 5. Strengths
36
+ 6. Weaknesses / Risk Hotspots
37
+ 7. Key Evidence Chains
38
+ 8. Prioritized Recommendations
39
+ 9. Analyst Confidence and Limitations
40
+
41
+ Formatting constraints:
42
+ - Use concise, human-readable language for engineers and defenders.
43
+ - For each weakness and recommendation, include why it is prioritized.
44
+ - Cite evidence IDs from the provided evidence pack where practical (surface:N, finding:N, path:N, asset:*, control:*, insight:*).
45
+ """
46
+
47
+
48
+ USER_PROMPT_TEMPLATE = """Generate a grounded defensive review for this repository.
49
+
50
+ Requirements:
51
+ - Use only the evidence pack below.
52
+ - Mark each major statement as OBSERVED or INFERRED.
53
+ - Prioritize by practical defensive risk reduction.
54
+ - Include explicit trust-boundary commentary where evidence supports it.
55
+ - Call out source-quality caveats (tests/fixtures/examples) when relevant.
56
+
57
+ Repository context:
58
+ {repo_context}
59
+
60
+ Evidence pack (JSON):
61
+ {evidence_json}
62
+ """
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class RenderedReviewPrompt:
67
+ system: str
68
+ user: str
69
+ evidence_json: str
70
+
71
+
72
+ def _is_low_quality_source(path_or_text: str) -> bool:
73
+ normalized = path_or_text.replace("\\", "/").lower()
74
+ return any(segment in f"/{normalized}/" for segment in LOW_QUALITY_SEGMENTS)
75
+
76
+
77
+ def _is_protocol_derived_surface(surface: AttackSurface) -> bool:
78
+ file_lower = surface.file.lower()
79
+ route_lower = surface.route.lower()
80
+ return "lexicon" in file_lower or "/xrpc/" in route_lower or "atproto_" in " ".join(surface.auth_signals).lower()
81
+
82
+
83
+ def _surface_evidence_class(surface: AttackSurface) -> str:
84
+ if _is_low_quality_source(surface.file):
85
+ return "low_quality"
86
+ if _is_protocol_derived_surface(surface):
87
+ return "inferred_protocol"
88
+ if surface.exposure == "public":
89
+ return "observed_runtime_public"
90
+ if surface.exposure == "internal":
91
+ return "observed_runtime_internal"
92
+ return "inferred"
93
+
94
+
95
+ def _repo_context(scan: ScanResult) -> str:
96
+ language_text = ", ".join(scan.languages) if scan.languages else "unknown"
97
+ datastore_text = ", ".join(sorted({db.kind for db in scan.databases})) if scan.databases else "none"
98
+ return (
99
+ f"root={scan.root}; files_scanned={scan.files_scanned}; "
100
+ f"languages={language_text}; routes={len(scan.routes)}; "
101
+ f"external_calls={len(scan.external_calls)}; datastores={datastore_text}; "
102
+ f"auth_hints={len(scan.auth_hints)}; secret_hints={len(scan.secret_hints)}"
103
+ )
104
+
105
+
106
+ def _evidence_pack(
107
+ scan: ScanResult,
108
+ attack_surfaces: list[AttackSurface],
109
+ findings: list[Finding],
110
+ attack_paths: list[AttackPath],
111
+ ) -> dict:
112
+ surfaces_payload = [
113
+ {
114
+ "id": f"surface:{idx + 1}",
115
+ "method": surface.method,
116
+ "route": surface.route,
117
+ "file": surface.file,
118
+ "category": surface.category,
119
+ "exposure": surface.exposure,
120
+ "risk": surface.risk,
121
+ "evidence_class": _surface_evidence_class(surface),
122
+ "auth_signals": surface.auth_signals,
123
+ "data_store_interaction": surface.data_store_interaction,
124
+ "outbound_integration": surface.outbound_integration,
125
+ "rationale": surface.rationale,
126
+ }
127
+ for idx, surface in enumerate(attack_surfaces[:50])
128
+ ]
129
+
130
+ findings_payload = [
131
+ {
132
+ "id": f"finding:{idx + 1}",
133
+ "title": finding.title,
134
+ "severity": finding.severity,
135
+ "confidence": finding.confidence,
136
+ "evidence": finding.evidence[:10],
137
+ "mitigation": finding.mitigation,
138
+ }
139
+ for idx, finding in enumerate(findings[:30])
140
+ ]
141
+
142
+ attack_paths_payload = [
143
+ {
144
+ "id": f"path:{idx + 1}",
145
+ "name": path.name,
146
+ "steps": path.steps[:8],
147
+ "impact": path.impact,
148
+ }
149
+ for idx, path in enumerate(attack_paths[:10])
150
+ ]
151
+
152
+ evidence_counts = {
153
+ "observed_runtime_public": sum(1 for item in surfaces_payload if item["evidence_class"] == "observed_runtime_public"),
154
+ "observed_runtime_internal": sum(1 for item in surfaces_payload if item["evidence_class"] == "observed_runtime_internal"),
155
+ "inferred_protocol": sum(1 for item in surfaces_payload if item["evidence_class"] == "inferred_protocol"),
156
+ "low_quality": sum(1 for item in surfaces_payload if item["evidence_class"] == "low_quality"),
157
+ }
158
+
159
+ overlay = build_security_overlay(scan, attack_surfaces, findings, attack_paths)
160
+ assets_payload = [asset.model_dump() for asset in overlay.assets]
161
+ controls_payload = [control.model_dump() for control in overlay.controls]
162
+ insights_payload = [insight.model_dump() for insight in overlay.insights]
163
+ detection_payload = [opp.model_dump() for opp in overlay.detection_opportunities]
164
+
165
+ techniques_observed: dict[str, dict] = {}
166
+ for insight in overlay.insights:
167
+ for tech in insight.attack_techniques:
168
+ techniques_observed.setdefault(tech.technique_id, tech.model_dump())
169
+ for finding in overlay.findings:
170
+ for tech in finding.attack_techniques:
171
+ techniques_observed.setdefault(tech.technique_id, tech.model_dump())
172
+ techniques_payload = sorted(techniques_observed.values(), key=lambda t: t["technique_id"])
173
+
174
+ return {
175
+ "scan_summary": {
176
+ "root": scan.root,
177
+ "files_scanned": scan.files_scanned,
178
+ "languages": scan.languages,
179
+ "route_count": len(scan.routes),
180
+ "external_call_count": len(scan.external_calls),
181
+ "database_count": len(scan.databases),
182
+ "auth_hint_count": len(scan.auth_hints),
183
+ "secret_hint_count": len(scan.secret_hints),
184
+ "asset_count": len(assets_payload),
185
+ "control_count_present": sum(1 for c in controls_payload if c["strength"] != "absent"),
186
+ "control_count_absent": sum(1 for c in controls_payload if c["strength"] == "absent"),
187
+ "notable_observation_count": len(insights_payload),
188
+ "detection_opportunity_count": len(detection_payload),
189
+ "attack_techniques_observed_count": len(techniques_payload),
190
+ },
191
+ "evidence_counts": evidence_counts,
192
+ "attack_surfaces": surfaces_payload,
193
+ "findings": findings_payload,
194
+ "attack_paths": attack_paths_payload,
195
+ "assets": assets_payload,
196
+ "controls": controls_payload,
197
+ "notable_observations": insights_payload,
198
+ "detection_opportunities": detection_payload,
199
+ "attack_techniques_observed": techniques_payload,
200
+ }
201
+
202
+
203
+ def render_system_prompt() -> str:
204
+ return SYSTEM_PROMPT_TEMPLATE.strip()
205
+
206
+
207
+ def render_user_prompt(
208
+ scan: ScanResult,
209
+ attack_surfaces: list[AttackSurface],
210
+ findings: list[Finding],
211
+ attack_paths: list[AttackPath],
212
+ ) -> str:
213
+ evidence_payload = _evidence_pack(scan, attack_surfaces, findings, attack_paths)
214
+ evidence_json = json.dumps(evidence_payload, indent=2, sort_keys=True)
215
+ return USER_PROMPT_TEMPLATE.format(repo_context=_repo_context(scan), evidence_json=evidence_json).strip()
216
+
217
+
218
+ def render_review_prompts(
219
+ scan: ScanResult,
220
+ attack_surfaces: list[AttackSurface],
221
+ findings: list[Finding],
222
+ attack_paths: list[AttackPath],
223
+ ) -> RenderedReviewPrompt:
224
+ evidence_payload = _evidence_pack(scan, attack_surfaces, findings, attack_paths)
225
+ evidence_json = json.dumps(evidence_payload, indent=2, sort_keys=True)
226
+ return RenderedReviewPrompt(
227
+ system=render_system_prompt(),
228
+ user=USER_PROMPT_TEMPLATE.format(repo_context=_repo_context(scan), evidence_json=evidence_json).strip(),
229
+ evidence_json=evidence_json,
230
+ )