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,31 @@
1
+ from __future__ import annotations
2
+
3
+ # Phase-1 shared recon/result model exports.
4
+ # Keep ScanResult and recon signal models as the single source of truth via models.py.
5
+ from .models import (
6
+ AuthHint,
7
+ DatabaseHint,
8
+ EdgeHint,
9
+ EntrypointHint,
10
+ ExternalCall,
11
+ FrameworkHint,
12
+ ProtocolHint,
13
+ Route,
14
+ ScanResult,
15
+ SecretHint,
16
+ ServiceHint,
17
+ )
18
+
19
+ __all__ = [
20
+ "Route",
21
+ "ExternalCall",
22
+ "DatabaseHint",
23
+ "AuthHint",
24
+ "ServiceHint",
25
+ "EdgeHint",
26
+ "EntrypointHint",
27
+ "ProtocolHint",
28
+ "FrameworkHint",
29
+ "SecretHint",
30
+ "ScanResult",
31
+ ]
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import re
5
+
6
+ from .analyzer import identify_attack_surfaces
7
+ from .models import AttackPath, AttackSurface, AuthHint, Finding, ScanResult
8
+ from .threat_model import generate_attack_paths, generate_findings
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class AnalysisOutputs:
13
+ attack_surfaces: list[AttackSurface]
14
+ findings: list[Finding]
15
+ attack_paths: list[AttackPath]
16
+
17
+
18
+ NON_AUTH_HINT_PREFIXES: tuple[str, ...] = (
19
+ "service_name:",
20
+ "service_role:",
21
+ "entrypoint:",
22
+ "edge:",
23
+ "handler_type:",
24
+ "handler_visibility:",
25
+ "handler_visibility_basis:",
26
+ "controller:",
27
+ "service:",
28
+ "omeka_",
29
+ "laminas_",
30
+ "atproto_namespace:",
31
+ "atproto_namespace_ref:",
32
+ "atproto_xrpc_ref:",
33
+ "atproto_lexicon:",
34
+ "atproto_event_stream:",
35
+ "atproto_service_note:",
36
+ "atproto_service_edge:",
37
+ "atproto_repo:",
38
+ )
39
+
40
+ AUTH_SIGNAL_PATTERN = re.compile(
41
+ r"(auth|jwt|oauth|token|session|password|bearer|authorization|mfa|passport|login_required)",
42
+ re.IGNORECASE,
43
+ )
44
+
45
+ EXPLICIT_AUTH_HINT_PREFIXES: tuple[str, ...] = ("atproto_auth:",)
46
+
47
+
48
+ def _is_likely_auth_signal(value: str) -> bool:
49
+ lowered = value.lower()
50
+ if lowered.startswith(EXPLICIT_AUTH_HINT_PREFIXES):
51
+ return True
52
+ if lowered.startswith(NON_AUTH_HINT_PREFIXES):
53
+ return False
54
+ return bool(AUTH_SIGNAL_PATTERN.search(lowered))
55
+
56
+
57
+ def _auth_filtered_scan(scan: ScanResult) -> ScanResult:
58
+ """
59
+ Build a conservative auth-focused view of ScanResult.
60
+
61
+ Why:
62
+ `auth_hints` is temporarily overloaded with non-auth analyzer metadata
63
+ (service names, edges, protocol notes). For attack-surface and finding
64
+ generation, treat only likely auth signals as auth to avoid overconfidence.
65
+ """
66
+ migrated_non_auth_hints = {
67
+ (hint.hint, hint.file)
68
+ for hint in [
69
+ *scan.service_hints,
70
+ *scan.edge_hints,
71
+ *scan.entrypoint_hints,
72
+ *scan.protocol_hints,
73
+ *scan.framework_hints,
74
+ ]
75
+ }
76
+ filtered_auth_hints = [
77
+ hint
78
+ for hint in scan.auth_hints
79
+ if (hint.hint, hint.file) not in migrated_non_auth_hints and _is_likely_auth_signal(hint.hint)
80
+ ]
81
+ return scan.model_copy(update={"auth_hints": [AuthHint(hint=hint.hint, file=hint.file) for hint in filtered_auth_hints]})
82
+
83
+
84
+ def to_attack_surface(scan: ScanResult) -> list[AttackSurface]:
85
+ """Translate recon signals into classified attack-surface entries."""
86
+ return identify_attack_surfaces(_auth_filtered_scan(scan))
87
+
88
+
89
+ def to_findings(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> list[Finding]:
90
+ """Translate recon signals (and optional surfaces) into conservative findings."""
91
+ auth_filtered_scan = _auth_filtered_scan(scan)
92
+ if attack_surfaces is not None:
93
+ return generate_findings(auth_filtered_scan, attack_surfaces)
94
+ return generate_findings(auth_filtered_scan)
95
+
96
+
97
+ def to_attack_paths(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> list[AttackPath]:
98
+ """
99
+ Translate recon signals into plausible, heuristic attack paths.
100
+
101
+ Keep full hints for path generation so temporary overloaded hints still
102
+ support chain-linking while migration is in progress.
103
+ """
104
+ return generate_attack_paths(scan, attack_surfaces=attack_surfaces)
105
+
106
+
107
+ def translate_recon(scan: ScanResult) -> AnalysisOutputs:
108
+ """
109
+ Formal translation stage from ScanResult to higher-level analysis artifacts.
110
+
111
+ This remains deterministic and explainable by relying only on existing
112
+ signal-driven heuristics from analyzer/threat_model modules.
113
+ """
114
+ attack_surfaces = to_attack_surface(scan)
115
+ findings = to_findings(scan, attack_surfaces)
116
+ attack_paths = to_attack_paths(scan)
117
+ return AnalysisOutputs(
118
+ attack_surfaces=attack_surfaces,
119
+ findings=findings,
120
+ attack_paths=attack_paths,
121
+ )
attackmap/report.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .context_pack import build_review_context_pack
7
+ from .models import AttackPath, AttackSurface, Finding, ScanResult
8
+ from .review_json import build_defensive_review_json
9
+
10
+
11
+ def _severity_rank(value: str) -> int:
12
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
13
+
14
+
15
+ def write_reports(
16
+ output_dir: str | Path,
17
+ scan: ScanResult,
18
+ architecture_md: str,
19
+ attack_surface_md: str,
20
+ defensive_review_md: str,
21
+ attack_surfaces: list[AttackSurface],
22
+ findings: list[Finding],
23
+ attack_paths: list[AttackPath],
24
+ analyzer_metadata: list[dict[str, object]] | None = None,
25
+ ) -> None:
26
+ out = Path(output_dir)
27
+ out.mkdir(parents=True, exist_ok=True)
28
+
29
+ (out / "architecture.md").write_text(architecture_md + "\n", encoding="utf-8")
30
+ (out / "attack-surface.md").write_text(attack_surface_md + "\n", encoding="utf-8")
31
+ (out / "defensive-review.md").write_text(defensive_review_md + "\n", encoding="utf-8")
32
+ defensive_review_json = build_defensive_review_json(scan, attack_surfaces, findings, attack_paths)
33
+ (out / "defensive-review.json").write_text(json.dumps(defensive_review_json, indent=2) + "\n", encoding="utf-8")
34
+ review_context_pack = build_review_context_pack(
35
+ defensive_review_json,
36
+ scan,
37
+ analyzer_metadata if analyzer_metadata is not None else [],
38
+ )
39
+ (out / "review-context-pack.json").write_text(json.dumps(review_context_pack, indent=2) + "\n", encoding="utf-8")
40
+
41
+ json_report = {
42
+ "scan": scan.model_dump(),
43
+ "architecture_summary": architecture_md,
44
+ "attack_surface_summary": attack_surface_md,
45
+ "defensive_review": defensive_review_md,
46
+ "defensive_review_json": defensive_review_json,
47
+ "review_context_pack": review_context_pack,
48
+ "attack_surfaces": [surface.model_dump() for surface in attack_surfaces],
49
+ "findings": [finding.model_dump() for finding in findings],
50
+ "attack_paths": [path.model_dump() for path in attack_paths],
51
+ }
52
+ (out / "attackmap-report.json").write_text(json.dumps(json_report, indent=2) + "\n", encoding="utf-8")
53
+
54
+
55
+ def render_console_summary(scan: ScanResult, findings: list[Finding], attack_paths: list[AttackPath]) -> str:
56
+ ordered_findings = sorted(findings, key=lambda finding: (_severity_rank(finding.severity), finding.title))
57
+ lines = [
58
+ f"Scanned {scan.files_scanned} files",
59
+ f"Detected languages: {', '.join(scan.languages) if scan.languages else 'none'}",
60
+ f"Routes: {len(scan.routes)}",
61
+ f"External calls: {len(scan.external_calls)}",
62
+ f"Datastores: {len(scan.databases)}",
63
+ "",
64
+ "Findings:",
65
+ ]
66
+ for finding in ordered_findings:
67
+ lines.append(f"- [{finding.severity.upper()}] {finding.title}")
68
+
69
+ lines.append("")
70
+ lines.append("Attack paths:")
71
+ for path in attack_paths:
72
+ lines.append(f"- {path.name}: {path.impact}")
73
+
74
+ return "\n".join(lines)
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def load_eval_fixture(path: str | Path) -> dict[str, Any]:
11
+ return json.loads(Path(path).read_text(encoding="utf-8"))
12
+
13
+
14
+ def load_review_text(path: str | Path) -> str:
15
+ return Path(path).read_text(encoding="utf-8")
16
+
17
+
18
+ def _extract_section(text: str, heading: str) -> str:
19
+ pattern = re.compile(rf"^\s*##\s+{re.escape(heading)}\s*$", re.MULTILINE)
20
+ match = pattern.search(text)
21
+ if match is None:
22
+ return ""
23
+ start = match.end()
24
+ next_heading = re.search(r"^\s*##\s+.+$", text[start:], re.MULTILINE)
25
+ end = start + next_heading.start() if next_heading else len(text)
26
+ return text[start:end].strip()
27
+
28
+
29
+ def _extract_bullets(section_text: str) -> list[str]:
30
+ return [line.strip()[2:].strip() for line in section_text.splitlines() if line.strip().startswith("- ")]
31
+
32
+
33
+ def _count_matches(text: str, pattern: str) -> int:
34
+ return len(re.findall(pattern, text, flags=re.IGNORECASE))
35
+
36
+
37
+ def evaluate_review_text(review_text: str, fixture: dict[str, Any]) -> dict[str, Any]:
38
+ expectations = fixture.get("expectations", {})
39
+ checks: list[dict[str, Any]] = []
40
+
41
+ grounding = expectations.get("grounding", {})
42
+ citation_pattern = grounding.get("citation_pattern", r"(?:surface|finding|path):\d+")
43
+ citations = sorted(set(re.findall(citation_pattern, review_text, flags=re.IGNORECASE)))
44
+ allowed_ids = set(grounding.get("allowed_ids", []))
45
+ unknown_citations = sorted(citation for citation in citations if citation not in allowed_ids) if allowed_ids else []
46
+ min_citations = int(grounding.get("min_citations", 0))
47
+ grounding_pass = len(citations) >= min_citations and not unknown_citations
48
+ checks.append(
49
+ {
50
+ "name": "grounding",
51
+ "passed": grounding_pass,
52
+ "details": {
53
+ "citations_found": citations,
54
+ "unknown_citations": unknown_citations,
55
+ "min_citations_required": min_citations,
56
+ },
57
+ }
58
+ )
59
+
60
+ observed_inferred = expectations.get("observed_inferred", {})
61
+ observed_token = observed_inferred.get("observed_token", r"\bOBSERVED\b")
62
+ inferred_token = observed_inferred.get("inferred_token", r"\bINFERRED\b")
63
+ observed_count = _count_matches(review_text, observed_token)
64
+ inferred_count = _count_matches(review_text, inferred_token)
65
+ observed_required = int(observed_inferred.get("min_observed", 0))
66
+ inferred_required = int(observed_inferred.get("min_inferred", 0))
67
+ oi_pass = observed_count >= observed_required and inferred_count >= inferred_required
68
+ checks.append(
69
+ {
70
+ "name": "observed_vs_inferred_discipline",
71
+ "passed": oi_pass,
72
+ "details": {
73
+ "observed_count": observed_count,
74
+ "inferred_count": inferred_count,
75
+ "min_observed_required": observed_required,
76
+ "min_inferred_required": inferred_required,
77
+ },
78
+ }
79
+ )
80
+
81
+ strengths_expectation = expectations.get("strengths", {})
82
+ strengths_section = _extract_section(review_text, "Strengths")
83
+ strengths_items = _extract_bullets(strengths_section)
84
+ strengths_keywords = [keyword.lower() for keyword in strengths_expectation.get("required_keywords", [])]
85
+ strengths_text = strengths_section.lower()
86
+ missing_strengths_keywords = [keyword for keyword in strengths_keywords if keyword not in strengths_text]
87
+ min_strengths_items = int(strengths_expectation.get("min_items", 0))
88
+ strengths_pass = len(strengths_items) >= min_strengths_items and not missing_strengths_keywords
89
+ checks.append(
90
+ {
91
+ "name": "strengths_coverage",
92
+ "passed": strengths_pass,
93
+ "details": {
94
+ "item_count": len(strengths_items),
95
+ "min_items_required": min_strengths_items,
96
+ "missing_keywords": missing_strengths_keywords,
97
+ },
98
+ }
99
+ )
100
+
101
+ weakness_expectation = expectations.get("weaknesses_hotspots", {})
102
+ weakness_section = _extract_section(review_text, "Weaknesses / Risk Hotspots")
103
+ weakness_items = _extract_bullets(weakness_section)
104
+ min_weakness_items = int(weakness_expectation.get("min_items", 0))
105
+ weakness_terms = [term.lower() for term in weakness_expectation.get("required_terms", [])]
106
+ weakness_text = weakness_section.lower()
107
+ missing_weakness_terms = [term for term in weakness_terms if term not in weakness_text]
108
+ weakness_pass = len(weakness_items) >= min_weakness_items and not missing_weakness_terms
109
+ checks.append(
110
+ {
111
+ "name": "weakness_hotspot_quality",
112
+ "passed": weakness_pass,
113
+ "details": {
114
+ "item_count": len(weakness_items),
115
+ "min_items_required": min_weakness_items,
116
+ "missing_terms": missing_weakness_terms,
117
+ },
118
+ }
119
+ )
120
+
121
+ recommendation_expectation = expectations.get("recommendations", {})
122
+ recommendation_section = _extract_section(review_text, "Prioritized Recommendations")
123
+ recommendation_items = _extract_bullets(recommendation_section)
124
+ min_recommendation_items = int(recommendation_expectation.get("min_items", 0))
125
+ action_verbs = [verb.lower() for verb in recommendation_expectation.get("action_verbs", [])]
126
+ recommendation_text = recommendation_section.lower()
127
+ recommendation_verbs_present = [verb for verb in action_verbs if verb in recommendation_text]
128
+ recommendation_pass = len(recommendation_items) >= min_recommendation_items and bool(recommendation_verbs_present)
129
+ checks.append(
130
+ {
131
+ "name": "recommendation_usefulness",
132
+ "passed": recommendation_pass,
133
+ "details": {
134
+ "item_count": len(recommendation_items),
135
+ "min_items_required": min_recommendation_items,
136
+ "action_verbs_present": recommendation_verbs_present,
137
+ },
138
+ }
139
+ )
140
+
141
+ false_positive_expectation = expectations.get("false_positive_control", {})
142
+ banned_phrases = [phrase.lower() for phrase in false_positive_expectation.get("banned_phrases", [])]
143
+ lowered_review = review_text.lower()
144
+ found_banned = [phrase for phrase in banned_phrases if phrase in lowered_review]
145
+ required_cautions = [phrase.lower() for phrase in false_positive_expectation.get("required_cautions", [])]
146
+ missing_cautions = [phrase for phrase in required_cautions if phrase not in lowered_review]
147
+ false_positive_pass = not found_banned and not missing_cautions
148
+ checks.append(
149
+ {
150
+ "name": "false_positive_control",
151
+ "passed": false_positive_pass,
152
+ "details": {
153
+ "found_banned_phrases": found_banned,
154
+ "missing_required_cautions": missing_cautions,
155
+ },
156
+ }
157
+ )
158
+
159
+ passed_count = sum(1 for check in checks if check["passed"])
160
+ total_count = len(checks)
161
+ score = round((passed_count / total_count) * 100.0, 2) if total_count else 0.0
162
+ return {
163
+ "fixture_id": fixture.get("fixture_id", "unknown"),
164
+ "target": fixture.get("target", {}),
165
+ "summary": {
166
+ "score": score,
167
+ "passed_checks": passed_count,
168
+ "total_checks": total_count,
169
+ "status": "pass" if passed_count == total_count else "fail",
170
+ },
171
+ "checks": checks,
172
+ }
173
+
174
+
175
+ def run_evaluation(fixture_path: str | Path, review_path: str | Path) -> dict[str, Any]:
176
+ fixture = load_eval_fixture(fixture_path)
177
+ review_text = load_review_text(review_path)
178
+ return evaluate_review_text(review_text, fixture)
179
+
180
+
181
+ def _resolve_review_path_for_fixture(fixture_path: Path, fixture: dict[str, Any], reviews_dir: Path) -> Path:
182
+ configured = fixture.get("review_file")
183
+ if isinstance(configured, str) and configured.strip():
184
+ return (reviews_dir / configured).resolve()
185
+ return (reviews_dir / f"{fixture_path.stem}.md").resolve()
186
+
187
+
188
+ def run_evaluation_suite(fixtures_dir: str | Path, reviews_dir: str | Path) -> dict[str, Any]:
189
+ fixture_dir_path = Path(fixtures_dir).resolve()
190
+ review_dir_path = Path(reviews_dir).resolve()
191
+ fixture_paths = sorted(fixture_dir_path.glob("*.json"))
192
+ cases: list[dict[str, Any]] = []
193
+
194
+ for fixture_path in fixture_paths:
195
+ fixture = load_eval_fixture(fixture_path)
196
+ review_path = _resolve_review_path_for_fixture(fixture_path, fixture, review_dir_path)
197
+ if not review_path.exists():
198
+ cases.append(
199
+ {
200
+ "fixture_id": fixture.get("fixture_id", fixture_path.stem),
201
+ "summary": {
202
+ "score": 0.0,
203
+ "passed_checks": 0,
204
+ "total_checks": 1,
205
+ "status": "fail",
206
+ },
207
+ "checks": [
208
+ {
209
+ "name": "review_file_presence",
210
+ "passed": False,
211
+ "details": {"missing_review_path": str(review_path)},
212
+ }
213
+ ],
214
+ }
215
+ )
216
+ continue
217
+ cases.append(run_evaluation(fixture_path, review_path))
218
+
219
+ passed = sum(1 for case in cases if case["summary"]["status"] == "pass")
220
+ total = len(cases)
221
+ avg_score = round(sum(case["summary"]["score"] for case in cases) / total, 2) if total else 0.0
222
+ return {
223
+ "suite": {
224
+ "fixtures_dir": str(fixture_dir_path),
225
+ "reviews_dir": str(review_dir_path),
226
+ "fixture_count": total,
227
+ "passed": passed,
228
+ "failed": total - passed,
229
+ "average_score": avg_score,
230
+ "status": "pass" if total > 0 and passed == total else "fail",
231
+ },
232
+ "cases": cases,
233
+ }
234
+
235
+
236
+ def _main() -> int:
237
+ parser = argparse.ArgumentParser(description="Evaluate AttackMap defensive review output against a fixture spec.")
238
+ parser.add_argument("--fixture", help="Path to evaluation fixture JSON.")
239
+ parser.add_argument("--review", help="Path to review markdown output to evaluate.")
240
+ parser.add_argument("--fixtures-dir", help="Directory containing evaluation fixture JSON files.")
241
+ parser.add_argument("--reviews-dir", help="Directory containing review markdown outputs for suite mode.")
242
+ parser.add_argument("--json", action="store_true", help="Print full JSON result.")
243
+ args = parser.parse_args()
244
+
245
+ if args.fixtures_dir or args.reviews_dir:
246
+ if not (args.fixtures_dir and args.reviews_dir):
247
+ parser.error("--fixtures-dir and --reviews-dir must be used together.")
248
+ if args.fixture or args.review:
249
+ parser.error("Use either single mode (--fixture/--review) or suite mode (--fixtures-dir/--reviews-dir), not both.")
250
+ suite_result = run_evaluation_suite(args.fixtures_dir, args.reviews_dir)
251
+ if args.json:
252
+ print(json.dumps(suite_result, indent=2))
253
+ else:
254
+ summary = suite_result["suite"]
255
+ print(f"Suite fixtures: {summary['fixture_count']}")
256
+ print(f"Pass: {summary['passed']}, Fail: {summary['failed']}, Average score: {summary['average_score']}")
257
+ print(f"Status: {summary['status']}")
258
+ for case in suite_result["cases"]:
259
+ case_summary = case["summary"]
260
+ print(f"- {case['fixture_id']}: {case_summary['status']} ({case_summary['score']})")
261
+ return 0 if suite_result["suite"]["status"] == "pass" else 1
262
+
263
+ if not (args.fixture and args.review):
264
+ parser.error("Single mode requires --fixture and --review.")
265
+
266
+ result = run_evaluation(args.fixture, args.review)
267
+ if args.json:
268
+ print(json.dumps(result, indent=2))
269
+ else:
270
+ summary = result["summary"]
271
+ print(f"Fixture: {result['fixture_id']}")
272
+ print(f"Score: {summary['score']} ({summary['passed_checks']}/{summary['total_checks']} checks)")
273
+ print(f"Status: {summary['status']}")
274
+ for check in result["checks"]:
275
+ mark = "PASS" if check["passed"] else "FAIL"
276
+ print(f"- {mark}: {check['name']}")
277
+ return 0 if result["summary"]["status"] == "pass" else 1
278
+
279
+
280
+ if __name__ == "__main__":
281
+ raise SystemExit(_main())