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,245 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ from .models import Asset, AssetKind, ScanResult
7
+
8
+ LOW_QUALITY_SEGMENTS = ("/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/", "/test_", "/_test.")
9
+
10
+
11
+ def _is_low_quality(path: str) -> bool:
12
+ normalized = ("/" + path.replace("\\", "/").lower() + "/")
13
+ return any(segment in normalized for segment in LOW_QUALITY_SEGMENTS)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class _AssetRule:
18
+ kind: AssetKind
19
+ name: str
20
+ criticality: str
21
+ secret_patterns: tuple[str, ...] = ()
22
+ route_patterns: tuple[str, ...] = ()
23
+ file_patterns: tuple[str, ...] = ()
24
+
25
+
26
+ _ASSET_RULES: tuple[_AssetRule, ...] = (
27
+ _AssetRule(
28
+ kind="session",
29
+ name="Authentication tokens / session material",
30
+ criticality="critical",
31
+ secret_patterns=("jwt_secret", "session_secret", "cookie_secret", "auth_secret", "refresh_secret", "signing_key"),
32
+ route_patterns=("/login", "/signin", "/token", "/oauth", "/session", "/refresh", "/logout"),
33
+ file_patterns=("auth/", "session/", "/jwt"),
34
+ ),
35
+ _AssetRule(
36
+ kind="credentials",
37
+ name="User credentials at rest",
38
+ criticality="critical",
39
+ secret_patterns=("password_pepper", "hash_secret"),
40
+ route_patterns=("/password", "/signup", "/register", "/reset-password", "/forgot"),
41
+ file_patterns=("models/user", "models/account", "entities/user", "entities/account", "user_repository", "auth/password"),
42
+ ),
43
+ _AssetRule(
44
+ kind="payment",
45
+ name="Payment / billing records",
46
+ criticality="critical",
47
+ secret_patterns=("stripe", "paypal", "braintree", "adyen", "billing_key"),
48
+ route_patterns=("/payment", "/checkout", "/billing", "/invoice", "/subscription", "/charge"),
49
+ file_patterns=("models/payment", "models/invoice", "models/order", "entities/payment", "entities/invoice", "entities/order", "billing/"),
50
+ ),
51
+ _AssetRule(
52
+ kind="user_pii",
53
+ name="User PII / profile data",
54
+ criticality="high",
55
+ route_patterns=("/users", "/user/", "/profile", "/account", "/me", "/contacts", "/address"),
56
+ file_patterns=("models/user", "models/profile", "models/contact", "entities/user", "entities/profile", "user_repository", "user_service"),
57
+ ),
58
+ _AssetRule(
59
+ kind="internal_secret",
60
+ name="Internal service / API secrets",
61
+ criticality="high",
62
+ secret_patterns=(
63
+ "api_key",
64
+ "private_key",
65
+ "webhook_secret",
66
+ "signing_secret",
67
+ "service_token",
68
+ "admin_token",
69
+ "internal_key",
70
+ "encryption_key",
71
+ "master_key",
72
+ "deploy_key",
73
+ ),
74
+ file_patterns=("vault/", "kms/", "/keys/"),
75
+ ),
76
+ _AssetRule(
77
+ kind="audit_log",
78
+ name="Audit / security logs",
79
+ criticality="medium",
80
+ route_patterns=("/audit", "/security/log", "/events"),
81
+ file_patterns=("audit/", "/audit_log", "security/log", "logs/security"),
82
+ ),
83
+ _AssetRule(
84
+ kind="configuration",
85
+ name="Security-relevant configuration",
86
+ criticality="medium",
87
+ secret_patterns=("config_signing", "feature_flag_secret"),
88
+ file_patterns=(".env", "config/secrets", "secrets.yml", "secrets.yaml", "config/security"),
89
+ ),
90
+ )
91
+
92
+
93
+ def _normalize(text: str) -> str:
94
+ return text.replace("\\", "/").lower()
95
+
96
+
97
+ def _matches_any(haystack: str, needles: tuple[str, ...]) -> bool:
98
+ return any(needle in haystack for needle in needles)
99
+
100
+
101
+ def _loc(file_path: str, line: int | None) -> str:
102
+ """`file:line` if line is known, else `file`."""
103
+ return f"{file_path}:{line}" if line is not None else file_path
104
+
105
+
106
+ def _route_path_set(scan: ScanResult) -> set[str]:
107
+ return {_normalize(route.path) for route in scan.routes}
108
+
109
+
110
+ def _route_files_for(scan: ScanResult, route_lower: str) -> list[str]:
111
+ return [route.file for route in scan.routes if _normalize(route.path) == route_lower]
112
+
113
+
114
+ def _gather_asset(rule: _AssetRule, scan: ScanResult) -> Asset | None:
115
+ locations: set[str] = set()
116
+ evidence: set[str] = set()
117
+
118
+ if rule.secret_patterns:
119
+ for hint in scan.secret_hints:
120
+ if _is_low_quality(hint.file):
121
+ continue
122
+ name_lower = hint.name.lower()
123
+ if _matches_any(name_lower, rule.secret_patterns):
124
+ locations.add(hint.file)
125
+ evidence.add(f"secret:{hint.name} ({_loc(hint.file, hint.line)})")
126
+
127
+ if rule.route_patterns:
128
+ for route in scan.routes:
129
+ if _is_low_quality(route.file):
130
+ continue
131
+ path_lower = _normalize(route.path)
132
+ if _matches_any(path_lower, rule.route_patterns):
133
+ locations.add(route.file)
134
+ evidence.add(f"route:{route.method} {route.path} ({_loc(route.file, route.line)})")
135
+
136
+ if rule.file_patterns:
137
+ candidate_files: set[str] = set()
138
+ candidate_files.update(route.file for route in scan.routes)
139
+ candidate_files.update(hint.file for hint in scan.secret_hints)
140
+ candidate_files.update(hint.file for hint in scan.framework_hints)
141
+ candidate_files.update(hint.file for hint in scan.service_hints)
142
+ candidate_files.update(hint.file for hint in scan.auth_hints)
143
+ for file_path in candidate_files:
144
+ if _is_low_quality(file_path):
145
+ continue
146
+ file_lower = _normalize(file_path)
147
+ if _matches_any(file_lower, rule.file_patterns):
148
+ locations.add(file_path)
149
+ evidence.add(f"file:{file_path}")
150
+
151
+ if not evidence:
152
+ return None
153
+
154
+ asset_id = f"asset:{rule.kind}"
155
+ return Asset(
156
+ id=asset_id,
157
+ kind=rule.kind,
158
+ name=rule.name,
159
+ criticality=rule.criticality, # type: ignore[arg-type]
160
+ locations=sorted(locations),
161
+ evidence=sorted(evidence)[:12],
162
+ )
163
+
164
+
165
+ _SECRET_KIND_HEURISTICS: tuple[tuple[re.Pattern[str], AssetKind, str, str], ...] = (
166
+ (re.compile(r"jwt|session|cookie|refresh|auth_secret"), "session", "Auth/session secret", "critical"),
167
+ (re.compile(r"stripe|paypal|braintree|adyen"), "payment", "Payment processor secret", "critical"),
168
+ (re.compile(r"webhook|signing|hmac"), "internal_secret", "Webhook / signing secret", "high"),
169
+ (re.compile(r"api[_-]?key|service[_-]?token|admin[_-]?token|deploy[_-]?key"), "internal_secret", "API / service token", "high"),
170
+ (re.compile(r"private[_-]?key|encryption[_-]?key|master[_-]?key"), "internal_secret", "Cryptographic key material", "critical"),
171
+ )
172
+
173
+
174
+ def _secrets_as_assets(scan: ScanResult) -> list[Asset]:
175
+ assets: list[Asset] = []
176
+ seen_ids: set[str] = set()
177
+ for hint in scan.secret_hints:
178
+ if _is_low_quality(hint.file):
179
+ continue
180
+ name_lower = hint.name.lower()
181
+ for pattern, kind, name, criticality in _SECRET_KIND_HEURISTICS:
182
+ if pattern.search(name_lower):
183
+ asset_id = f"asset:secret:{name_lower}"
184
+ if asset_id in seen_ids:
185
+ break
186
+ seen_ids.add(asset_id)
187
+ assets.append(
188
+ Asset(
189
+ id=asset_id,
190
+ kind=kind,
191
+ name=f"{name}: {hint.name}",
192
+ criticality=criticality, # type: ignore[arg-type]
193
+ locations=[hint.file],
194
+ evidence=[f"secret:{hint.name} ({_loc(hint.file, hint.line)})"],
195
+ )
196
+ )
197
+ break
198
+ return assets
199
+
200
+
201
+ def _business_data_fallback(scan: ScanResult, assets: list[Asset]) -> Asset | None:
202
+ runtime_dbs = [db for db in scan.databases if not _is_low_quality(db.file)]
203
+ if not runtime_dbs:
204
+ return None
205
+ sensitive_kinds = {"user_pii", "payment", "credentials", "session"}
206
+ if any(asset.kind in sensitive_kinds for asset in assets):
207
+ return None
208
+ files = sorted({db.file for db in runtime_dbs})[:8]
209
+ kinds = sorted({db.kind for db in runtime_dbs})
210
+ return Asset(
211
+ id="asset:business_data",
212
+ kind="business_data",
213
+ name="Business / operational data store",
214
+ criticality="medium",
215
+ locations=files,
216
+ evidence=[f"datastore:{kind}" for kind in kinds],
217
+ )
218
+
219
+
220
+ def detect_assets(scan: ScanResult) -> list[Asset]:
221
+ """Return the inferred asset inventory for a scan, deduplicated by id."""
222
+ assets: list[Asset] = []
223
+ seen_ids: set[str] = set()
224
+
225
+ for rule in _ASSET_RULES:
226
+ asset = _gather_asset(rule, scan)
227
+ if asset is None or asset.id in seen_ids:
228
+ continue
229
+ seen_ids.add(asset.id)
230
+ assets.append(asset)
231
+
232
+ for asset in _secrets_as_assets(scan):
233
+ if asset.id in seen_ids:
234
+ continue
235
+ seen_ids.add(asset.id)
236
+ assets.append(asset)
237
+
238
+ fallback = _business_data_fallback(scan, assets)
239
+ if fallback is not None and fallback.id not in seen_ids:
240
+ seen_ids.add(fallback.id)
241
+ assets.append(fallback)
242
+
243
+ _criticality_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
244
+ assets.sort(key=lambda asset: (_criticality_rank.get(asset.criticality, 9), asset.kind, asset.id))
245
+ return assets
@@ -0,0 +1,140 @@
1
+ """MITRE ATT&CK technique mapping for AttackMap insights and findings.
2
+
3
+ Maps internal `InsightKind` values and finding-title keywords to relevant
4
+ ATT&CK techniques (Enterprise matrix). Conservative by design — we only
5
+ emit techniques where the static-analysis evidence directly motivates
6
+ the mapping. Defenders use these to slot AttackMap output into existing
7
+ ATT&CK-aligned detection programs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from .models import AttackTechnique, Finding, Insight, InsightKind
15
+
16
+
17
+ def _technique(technique_id: str, name: str, tactic: str) -> AttackTechnique:
18
+ return AttackTechnique(
19
+ technique_id=technique_id,
20
+ name=name,
21
+ tactic=tactic,
22
+ url=f"https://attack.mitre.org/techniques/{technique_id.replace('.', '/')}/",
23
+ )
24
+
25
+
26
+ # Curated technique catalog — only techniques AttackMap actually maps to.
27
+ T = {
28
+ "T1190": _technique("T1190", "Exploit Public-Facing Application", "Initial Access"),
29
+ "T1078": _technique("T1078", "Valid Accounts", "Defense Evasion / Persistence / Initial Access"),
30
+ "T1199": _technique("T1199", "Trusted Relationship", "Initial Access"),
31
+ "T1212": _technique("T1212", "Exploitation for Credential Access", "Credential Access"),
32
+ "T1552": _technique("T1552", "Unsecured Credentials", "Credential Access"),
33
+ "T1110": _technique("T1110", "Brute Force", "Credential Access"),
34
+ "T1528": _technique("T1528", "Steal Application Access Token", "Credential Access"),
35
+ "T1068": _technique("T1068", "Exploitation for Privilege Escalation", "Privilege Escalation"),
36
+ "T1098": _technique("T1098", "Account Manipulation", "Persistence / Privilege Escalation"),
37
+ "T1562": _technique("T1562", "Impair Defenses", "Defense Evasion"),
38
+ "T1565": _technique("T1565", "Data Manipulation", "Impact"),
39
+ "T1485": _technique("T1485", "Data Destruction", "Impact"),
40
+ "T1041": _technique("T1041", "Exfiltration Over C2 Channel", "Exfiltration"),
41
+ "T1071": _technique("T1071", "Application Layer Protocol", "Command and Control"),
42
+ "T1059": _technique("T1059", "Command and Scripting Interpreter", "Execution"),
43
+ "T1556": _technique("T1556", "Modify Authentication Process", "Defense Evasion / Persistence"),
44
+ }
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class _Mapping:
49
+ technique_ids: tuple[str, ...]
50
+
51
+
52
+ _INSIGHT_KIND_MAP: dict[InsightKind, _Mapping] = {
53
+ "shared_secret_blast_radius": _Mapping(("T1552", "T1528", "T1078")),
54
+ "sensitive_asset_reachability": _Mapping(("T1190", "T1041")),
55
+ "control_bypass": _Mapping(("T1562", "T1190")),
56
+ "defense_gap_in_chain": _Mapping(("T1190", "T1212")),
57
+ "asymmetric_protection": _Mapping(("T1190", "T1078")),
58
+ "trust_boundary_violation": _Mapping(("T1199", "T1190")),
59
+ "audit_gap": _Mapping(("T1562",)),
60
+ "control_strength_mismatch": _Mapping(("T1110", "T1552")),
61
+ "single_point_of_failure": _Mapping(("T1552", "T1528", "T1556")),
62
+ "stale_or_contradictory_signal": _Mapping(()),
63
+ "admin_action_without_auth": _Mapping(("T1078", "T1068", "T1098")),
64
+ }
65
+
66
+
67
+ # Title/evidence keyword fallback for findings (which don't carry a kind enum).
68
+ _FINDING_KEYWORD_MAP: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = (
69
+ (("webhook", "callback"), ("T1190", "T1199")),
70
+ (("admin", "manage", "privileged"), ("T1078", "T1068", "T1098")),
71
+ (("upload",), ("T1190", "T1059")),
72
+ (("auth", "login", "session"), ("T1078", "T1110", "T1556")),
73
+ (("secret", "token", "key"), ("T1552", "T1528")),
74
+ (("public route", "external"), ("T1190",)),
75
+ (("exfil",), ("T1041",)),
76
+ (("command", "rce", "exec"), ("T1059",)),
77
+ (("data store", "datastore", "database"), ("T1565",)),
78
+ )
79
+
80
+
81
+ def techniques_for_insight(insight: Insight) -> list[AttackTechnique]:
82
+ mapping = _INSIGHT_KIND_MAP.get(insight.kind)
83
+ if mapping is None:
84
+ return []
85
+ return [T[tid] for tid in mapping.technique_ids if tid in T]
86
+
87
+
88
+ def techniques_for_finding(finding: Finding) -> list[AttackTechnique]:
89
+ haystack = " ".join([finding.title, *finding.evidence]).lower()
90
+ seen: set[str] = set()
91
+ matched: list[AttackTechnique] = []
92
+ for keywords, technique_ids in _FINDING_KEYWORD_MAP:
93
+ if not any(keyword in haystack for keyword in keywords):
94
+ continue
95
+ for tid in technique_ids:
96
+ if tid in seen or tid not in T:
97
+ continue
98
+ seen.add(tid)
99
+ matched.append(T[tid])
100
+ return matched
101
+
102
+
103
+ def annotate_insights(insights: list[Insight]) -> list[Insight]:
104
+ """Return new Insight objects with `attack_techniques` populated.
105
+
106
+ Insights are immutable pydantic models, so we model_copy with an update.
107
+ """
108
+ annotated: list[Insight] = []
109
+ for insight in insights:
110
+ if insight.attack_techniques:
111
+ annotated.append(insight)
112
+ continue
113
+ techniques = techniques_for_insight(insight)
114
+ if not techniques:
115
+ annotated.append(insight)
116
+ continue
117
+ annotated.append(insight.model_copy(update={"attack_techniques": techniques}))
118
+ return annotated
119
+
120
+
121
+ def annotate_findings(findings: list[Finding]) -> list[Finding]:
122
+ annotated: list[Finding] = []
123
+ for finding in findings:
124
+ if finding.attack_techniques:
125
+ annotated.append(finding)
126
+ continue
127
+ techniques = techniques_for_finding(finding)
128
+ if not techniques:
129
+ annotated.append(finding)
130
+ continue
131
+ annotated.append(finding.model_copy(update={"attack_techniques": techniques}))
132
+ return annotated
133
+
134
+
135
+ __all__ = [
136
+ "techniques_for_insight",
137
+ "techniques_for_finding",
138
+ "annotate_insights",
139
+ "annotate_findings",
140
+ ]
attackmap/cli.py ADDED
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from .analyzer import summarize_architecture, summarize_attack_surface
9
+ from .defensive_review import render_defensive_review
10
+ from .analyzers import (
11
+ analyze_repository,
12
+ get_available_modules,
13
+ get_available_repository_modules,
14
+ get_analyzer_metadata,
15
+ resolve_run_analyzers,
16
+ select_requested_analyzers,
17
+ )
18
+ from .graph import build_graph
19
+ from .llm_review import LlmReviewError, generate_llm_review
20
+ from .recon_to_analysis import translate_recon
21
+ from .report import render_console_summary, write_reports
22
+
23
+ app = typer.Typer(help="AttackMap: understand your system and map your attack surface.")
24
+
25
+
26
+ @app.command()
27
+ def analyze(
28
+ path: str = typer.Argument(".", help="Path to the repository to analyze."),
29
+ output: str = typer.Option("reports", "--output", "-o", help="Directory for generated reports."),
30
+ format: str = typer.Option("all", "--format", help="Output format: all, markdown, or json."),
31
+ module: list[str] | None = typer.Option(
32
+ None,
33
+ "--module",
34
+ "-m",
35
+ help="Analyzer module(s) to run. Repeat to select multiple. Missing external analyzers are auto-installed from the mlaify GitHub organization.",
36
+ ),
37
+ llm: bool = typer.Option(
38
+ False,
39
+ "--llm",
40
+ help="Generate a narrative defensive review by calling Claude with the evidence pack. Auth resolves automatically: ANTHROPIC_API_KEY → ANTHROPIC_AUTH_TOKEN → `claude` CLI (subscription auth). Force a backend with --llm-backend.",
41
+ ),
42
+ llm_model: str | None = typer.Option(
43
+ None,
44
+ "--llm-model",
45
+ help="Claude model ID for --llm (defaults to claude-opus-4-7).",
46
+ ),
47
+ llm_effort: str | None = typer.Option(
48
+ None,
49
+ "--llm-effort",
50
+ help="Effort for --llm: low|medium|high|xhigh|max. Defaults to high.",
51
+ ),
52
+ llm_backend: str = typer.Option(
53
+ "auto",
54
+ "--llm-backend",
55
+ help="Which backend --llm uses: 'auto' (default) tries ANTHROPIC_API_KEY → ANTHROPIC_AUTH_TOKEN → `claude` CLI; 'api' forces the SDK; 'cli' forces the `claude` CLI (uses your `claude login` auth, e.g. Pro/Max subscription).",
56
+ ),
57
+ ) -> None:
58
+ repo_path = Path(path).resolve()
59
+ if not repo_path.exists():
60
+ raise typer.BadParameter(f"Path does not exist: {repo_path}")
61
+
62
+ selected_analyzers = None
63
+ if module:
64
+ try:
65
+ selected_analyzers = select_requested_analyzers(module, auto_install=True)
66
+ except ValueError as exc:
67
+ raise typer.BadParameter(str(exc)) from exc
68
+
69
+ active_analyzers = resolve_run_analyzers(repo_path, analyzers=selected_analyzers)
70
+ scan = analyze_repository(repo_path, analyzers=active_analyzers)
71
+ graph = build_graph(scan)
72
+ analysis = translate_recon(scan)
73
+ attack_surfaces = analysis.attack_surfaces
74
+ architecture_md = summarize_architecture(scan, graph)
75
+ attack_surface_md = summarize_attack_surface(scan, attack_surfaces)
76
+ findings = analysis.findings
77
+ attack_paths = analysis.attack_paths
78
+ defensive_review_md = render_defensive_review(scan, attack_surfaces, findings, attack_paths)
79
+
80
+ write_reports(
81
+ output,
82
+ scan,
83
+ architecture_md,
84
+ attack_surface_md,
85
+ defensive_review_md,
86
+ attack_surfaces,
87
+ findings,
88
+ attack_paths,
89
+ analyzer_metadata=[
90
+ {
91
+ "name": metadata.name,
92
+ "description": metadata.description,
93
+ "scope": metadata.scope,
94
+ "ecosystems": list(metadata.ecosystems),
95
+ }
96
+ for metadata in (get_analyzer_metadata(analyzer) for analyzer in active_analyzers)
97
+ ],
98
+ )
99
+ typer.echo(render_console_summary(scan, findings, attack_paths))
100
+ typer.echo("")
101
+ typer.echo(f"Reports written to: {Path(output).resolve()}")
102
+
103
+ if llm:
104
+ try:
105
+ effort_value = None
106
+ if llm_effort is not None:
107
+ if llm_effort not in {"low", "medium", "high", "xhigh", "max"}:
108
+ raise typer.BadParameter(
109
+ f"Invalid --llm-effort '{llm_effort}'. Use one of: low, medium, high, xhigh, max."
110
+ )
111
+ effort_value = llm_effort # type: ignore[assignment]
112
+
113
+ if llm_backend not in {"auto", "api", "cli"}:
114
+ raise typer.BadParameter(
115
+ f"Invalid --llm-backend '{llm_backend}'. Use one of: auto, api, cli."
116
+ )
117
+
118
+ typer.echo("")
119
+ typer.echo(
120
+ f"Generating narrative review via Claude (backend={llm_backend}, may take a minute)..."
121
+ )
122
+ result = generate_llm_review(
123
+ scan,
124
+ attack_surfaces,
125
+ findings,
126
+ attack_paths,
127
+ model=llm_model,
128
+ effort=effort_value, # type: ignore[arg-type]
129
+ backend=llm_backend, # type: ignore[arg-type]
130
+ )
131
+ except LlmReviewError as exc:
132
+ typer.echo(f"LLM review skipped: {exc}", err=True)
133
+ else:
134
+ output_path = Path(output)
135
+ llm_md_path = output_path / "defensive-review-llm.md"
136
+ llm_md_path.write_text(result.markdown + "\n", encoding="utf-8")
137
+ llm_meta_path = output_path / "defensive-review-llm.meta.json"
138
+ llm_meta_path.write_text(
139
+ json.dumps(
140
+ {
141
+ "backend": result.backend,
142
+ "model": result.model,
143
+ "stop_reason": result.stop_reason,
144
+ "usage": result.usage,
145
+ },
146
+ indent=2,
147
+ )
148
+ + "\n",
149
+ encoding="utf-8",
150
+ )
151
+ typer.echo(f"LLM review written to: {llm_md_path.resolve()} (backend={result.backend})")
152
+
153
+
154
+ @app.command("modules")
155
+ def modules() -> None:
156
+ available_modules = get_available_modules()
157
+ if not available_modules:
158
+ typer.echo("No analyzer modules are currently available.")
159
+ else:
160
+ typer.echo("Available analyzer modules (installed):")
161
+ for module_metadata in available_modules:
162
+ ecosystems = ", ".join(module_metadata.ecosystems) if module_metadata.ecosystems else "none"
163
+ typer.echo(f"- {module_metadata.name}: {module_metadata.description}")
164
+ typer.echo(f" scope: {module_metadata.scope}")
165
+ typer.echo(f" ecosystems: {ecosystems}")
166
+
167
+ typer.echo("")
168
+ typer.echo("Available module repositories (mlaify GitHub org):")
169
+ try:
170
+ repository_modules = get_available_repository_modules()
171
+ except ValueError as exc:
172
+ typer.echo(f"- Unable to fetch remote module repositories: {exc}")
173
+ return
174
+
175
+ if not repository_modules:
176
+ typer.echo("- No module repositories were discovered.")
177
+ return
178
+
179
+ for module in repository_modules:
180
+ typer.echo(f"- {module.analyzer_name} ({module.repo_name})")
181
+ typer.echo(f" repo: {module.web_url}")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ app()
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from .models import ScanResult
4
+
5
+ SCHEMA_VERSION = "1.0.0"
6
+ LOW_QUALITY_SEGMENTS = ["/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/"]
7
+
8
+
9
+ def _infer_domain_hints(scan: ScanResult) -> dict:
10
+ route_values = [route.path.lower() for route in scan.routes]
11
+ hint_values = [hint.hint.lower() for hint in scan.auth_hints]
12
+ file_values = [route.file.lower() for route in scan.routes]
13
+ combined = " ".join([*route_values, *hint_values, *file_values])
14
+
15
+ tags: list[str] = []
16
+ signals: list[str] = []
17
+
18
+ if "/xrpc/" in combined or "atproto_" in combined or "com.atproto." in combined:
19
+ tags.append("atproto")
20
+ signals.append("xrpc/atproto namespace patterns observed")
21
+ if "app.bsky." in combined:
22
+ if "atproto" not in tags:
23
+ tags.append("atproto")
24
+ tags.append("bluesky")
25
+ signals.append("app.bsky namespace patterns observed")
26
+ if "laminas" in combined or "module.config.php" in combined:
27
+ tags.append("laminas")
28
+ signals.append("laminas/module config patterns observed")
29
+
30
+ if not tags:
31
+ tags.append("general")
32
+
33
+ return {
34
+ "tags": sorted(set(tags)),
35
+ "signals": sorted(set(signals)),
36
+ }
37
+
38
+
39
+ def build_review_context_pack(
40
+ review_json: dict,
41
+ scan: ScanResult,
42
+ analyzer_metadata: list[dict[str, object]],
43
+ ) -> dict:
44
+ return {
45
+ "schema_version": SCHEMA_VERSION,
46
+ "artifact_type": "attackmap_review_context_pack",
47
+ "attackmap_review_json": review_json,
48
+ "analyzer_metadata_used": analyzer_metadata,
49
+ "source_quality_rules": {
50
+ "low_quality_path_segments": LOW_QUALITY_SEGMENTS,
51
+ "handling": {
52
+ "default": "down-rank low-quality signals in triage and prioritization",
53
+ "reporting": "retain low-quality signals in raw_structured_signals with explicit evidence-class labeling",
54
+ "operator_note": "do not treat low-quality-only evidence as strong production exposure without corroboration",
55
+ },
56
+ },
57
+ "output_hints": {
58
+ "audience": ["security engineers", "software engineers", "tech leads"],
59
+ "style": {
60
+ "tone": "defensive, concrete, non-hype",
61
+ "reasoning": "evidence-first and uncertainty-aware",
62
+ "disallowed": [
63
+ "inventing findings or architecture elements",
64
+ "offensive exploitation guidance",
65
+ ],
66
+ },
67
+ "prioritization": "focus on highest-impact, highest-confidence chains and trust-boundary issues first",
68
+ },
69
+ "domain_hints": _infer_domain_hints(scan),
70
+ "rag_expansion_hooks": {
71
+ "enabled": False,
72
+ "notes": "local-first context pack; retrieval slots intentionally minimal for future expansion",
73
+ "slots": [],
74
+ },
75
+ "limitations_meta": {
76
+ "analysis_mode": "heuristic",
77
+ "notes": [
78
+ "Domain tags are inferred heuristically from routes, hints, and file patterns.",
79
+ "Analyzer metadata reflects analyzers selected to run after detect-filtering.",
80
+ ],
81
+ },
82
+ }