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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Webhook signature rules (#251, extends the #243 opportunity). A genuine
|
|
2
|
+
# provider delivery always carries its signature header; a POST to the
|
|
3
|
+
# webhook route without one is a forging attempt (or a misrouted client).
|
|
4
|
+
# Access logs only carry a request header when you add it as a custom field;
|
|
5
|
+
# the rule uses the W3C/IIS custom-field name `cs(<Header>)`. Map it to
|
|
6
|
+
# whatever your log pipeline calls the field.
|
|
7
|
+
|
|
8
|
+
templates:
|
|
9
|
+
webhook-signature-missing:
|
|
10
|
+
date: "2026-10-12"
|
|
11
|
+
title: "Webhook delivery without a signature header"
|
|
12
|
+
description: >-
|
|
13
|
+
POST requests to ${routes_text} that carry no ${provider_text}
|
|
14
|
+
signature header. AttackMap found ${state_text} on these routes
|
|
15
|
+
(rules ${rule_ids_text}), so a forged event would be processed. Also
|
|
16
|
+
alert on requests whose signature fails verification, on stale
|
|
17
|
+
timestamps and on replayed delivery ids from your application log.
|
|
18
|
+
logsource:
|
|
19
|
+
category: webserver
|
|
20
|
+
fields_required: "${fields}"
|
|
21
|
+
detection: "${detection}"
|
|
22
|
+
falsepositives:
|
|
23
|
+
- Provider connectivity checks that send no signature (some providers ping the endpoint when it is configured)
|
|
24
|
+
- A provider that signs with a header not listed here; add it to the rule
|
|
25
|
+
level: medium
|
|
26
|
+
test_events:
|
|
27
|
+
positive:
|
|
28
|
+
- cs-method: POST
|
|
29
|
+
cs-uri-stem: "${route_example}"
|
|
30
|
+
sc-status: 200
|
|
31
|
+
negative:
|
|
32
|
+
- "${signed_event}"
|
|
33
|
+
- cs-method: GET
|
|
34
|
+
cs-uri-stem: "${route_example}"
|
|
35
|
+
sc-status: 200
|
|
36
|
+
|
|
37
|
+
data:
|
|
38
|
+
# Provider -> the header(s) that carry its signature.
|
|
39
|
+
headers:
|
|
40
|
+
github: [X-Hub-Signature-256, X-Hub-Signature]
|
|
41
|
+
stripe: [Stripe-Signature]
|
|
42
|
+
slack: [X-Slack-Signature]
|
|
43
|
+
shopify: [X-Shopify-Hmac-Sha256]
|
|
44
|
+
svix: [svix-signature, webhook-signature]
|
|
45
|
+
twilio: [X-Twilio-Signature]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# CI workflow rules (#251, part 2). A workflow that runs untrusted code with
|
|
2
|
+
# secrets or a write token can't be watched from inside the job, but the
|
|
3
|
+
# GitHub audit log shows every run and the cloud provider shows what the
|
|
4
|
+
# run's OIDC token was used for.
|
|
5
|
+
#
|
|
6
|
+
# GitHub audit log field names follow the audit-log streaming schema
|
|
7
|
+
# (`action`, `actor`, `repo`, and per-event keys such as `event` on
|
|
8
|
+
# `workflows.created_workflow_run`). They are experimental here: confirm
|
|
9
|
+
# them against a sample event from your stream before alerting.
|
|
10
|
+
|
|
11
|
+
templates:
|
|
12
|
+
github-untrusted-trigger-run:
|
|
13
|
+
date: "2026-10-12"
|
|
14
|
+
title: "GitHub Actions run triggered by ${trigger_text}"
|
|
15
|
+
description: >-
|
|
16
|
+
A workflow run created by a ${trigger_text} event. AttackMap found
|
|
17
|
+
${kind_label} in ${location} (rule ${rule_id}), so a run started from a
|
|
18
|
+
fork or by an outside actor executes attacker-controlled code with the
|
|
19
|
+
workflow's secrets and token. Review runs whose actor isn't a member of
|
|
20
|
+
the organization; scope the rule to the repository with the repo field.
|
|
21
|
+
logsource:
|
|
22
|
+
product: github
|
|
23
|
+
service: audit
|
|
24
|
+
fields_required: [action, event, actor, repo, workflow_run_id, head_branch]
|
|
25
|
+
detection:
|
|
26
|
+
selection:
|
|
27
|
+
action: workflows.created_workflow_run
|
|
28
|
+
event: "${triggers}"
|
|
29
|
+
condition: selection
|
|
30
|
+
falsepositives:
|
|
31
|
+
- Every run of this trigger matches, including maintainers' own; exclude organization members by actor, or use the rule for hunting
|
|
32
|
+
- Bots that comment on or open pull requests (dependabot, renovate)
|
|
33
|
+
level: low
|
|
34
|
+
test_events:
|
|
35
|
+
positive:
|
|
36
|
+
- action: workflows.created_workflow_run
|
|
37
|
+
event: "${trigger_example}"
|
|
38
|
+
actor: outside-contributor
|
|
39
|
+
repo: example-org/example-repo
|
|
40
|
+
negative:
|
|
41
|
+
- action: workflows.created_workflow_run
|
|
42
|
+
event: push
|
|
43
|
+
actor: maintainer
|
|
44
|
+
repo: example-org/example-repo
|
|
45
|
+
- action: workflows.completed_workflow_run
|
|
46
|
+
event: "${trigger_example}"
|
|
47
|
+
actor: outside-contributor
|
|
48
|
+
repo: example-org/example-repo
|
|
49
|
+
|
|
50
|
+
github-oidc-untrusted-trigger-cloudtrail:
|
|
51
|
+
date: "2026-10-12"
|
|
52
|
+
title: "AWS role assumed with a GitHub OIDC token from a pull-request run"
|
|
53
|
+
description: >-
|
|
54
|
+
CloudTrail AssumeRoleWithWebIdentity calls whose GitHub Actions OIDC
|
|
55
|
+
subject ends in ":pull_request", that is, the token was minted by a run
|
|
56
|
+
triggered by a pull request. AttackMap found a workflow that requests
|
|
57
|
+
an OIDC token (id-token: write) on a pull-request trigger (rule
|
|
58
|
+
${rule_id}, ${location}). GitHub gives no OIDC token to pull_request
|
|
59
|
+
runs from forks, so the exposure is pull requests from branches in the
|
|
60
|
+
same repository: anyone who can push a branch (a contributor, a
|
|
61
|
+
compromised account or bot token) can run code holding the role. A
|
|
62
|
+
pull_request_target workflow that runs fork code with id-token: write
|
|
63
|
+
exposes it to forks as well. Restrict the role's trust policy to
|
|
64
|
+
branch or environment subjects. Blind spot: issue_comment and workflow_run runs get the
|
|
65
|
+
default branch's subject (ref:refs/heads/...), so their tokens look like
|
|
66
|
+
trusted ones here; fix those workflows instead.
|
|
67
|
+
logsource:
|
|
68
|
+
product: aws
|
|
69
|
+
service: cloudtrail
|
|
70
|
+
fields_required: [eventName, eventSource, userIdentity.type, userIdentity.identityProvider, userIdentity.userName, requestParameters.roleArn, sourceIPAddress]
|
|
71
|
+
detection:
|
|
72
|
+
selection:
|
|
73
|
+
eventSource: sts.amazonaws.com
|
|
74
|
+
eventName: AssumeRoleWithWebIdentity
|
|
75
|
+
userIdentity.identityProvider|contains: token.actions.githubusercontent.com
|
|
76
|
+
userIdentity.userName|endswith: ":pull_request"
|
|
77
|
+
condition: selection
|
|
78
|
+
falsepositives:
|
|
79
|
+
- Roles deliberately trusted for pull-request runs (read-only plan or test roles); exclude them by requestParameters.roleArn
|
|
80
|
+
level: high
|
|
81
|
+
test_events:
|
|
82
|
+
positive:
|
|
83
|
+
- eventSource: sts.amazonaws.com
|
|
84
|
+
eventName: AssumeRoleWithWebIdentity
|
|
85
|
+
userIdentity:
|
|
86
|
+
type: WebIdentityUser
|
|
87
|
+
identityProvider: arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com
|
|
88
|
+
userName: repo:example-org/example-repo:pull_request
|
|
89
|
+
requestParameters:
|
|
90
|
+
roleArn: arn:aws:iam::123456789012:role/deploy
|
|
91
|
+
negative:
|
|
92
|
+
- eventSource: sts.amazonaws.com
|
|
93
|
+
eventName: AssumeRoleWithWebIdentity
|
|
94
|
+
userIdentity:
|
|
95
|
+
type: WebIdentityUser
|
|
96
|
+
identityProvider: arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com
|
|
97
|
+
userName: repo:example-org/example-repo:ref:refs/heads/main
|
|
98
|
+
requestParameters:
|
|
99
|
+
roleArn: arn:aws:iam::123456789012:role/deploy
|
|
100
|
+
|
|
101
|
+
data:
|
|
102
|
+
# Workflow rule id -> the triggers whose runs carry the risk, and a label.
|
|
103
|
+
# Kinds without an entry (unpinned actions, token permissions, curl | sh,
|
|
104
|
+
# script injection…) have no run-level signal in the audit log: the
|
|
105
|
+
# coverage table reports them as not covered.
|
|
106
|
+
triggers:
|
|
107
|
+
pr-target-checkout:
|
|
108
|
+
label: a pull_request_target workflow that checks out the pull request's code
|
|
109
|
+
events: [pull_request_target]
|
|
110
|
+
cache-poisoning-pr-target:
|
|
111
|
+
label: a pull_request_target workflow that writes a cache keyed on pull-request data
|
|
112
|
+
events: [pull_request_target]
|
|
113
|
+
issue-comment-pr-checkout:
|
|
114
|
+
label: an issue_comment workflow that checks out pull-request code
|
|
115
|
+
events: [issue_comment]
|
|
116
|
+
workflow-run-artifact-poisoning:
|
|
117
|
+
label: a workflow_run workflow that executes the triggering run's artifacts
|
|
118
|
+
events: [workflow_run]
|
|
119
|
+
self-hosted-pr:
|
|
120
|
+
label: a self-hosted runner on a pull-request trigger
|
|
121
|
+
events: [pull_request, pull_request_target]
|
attackmap/diagrams.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Mermaid + Graphviz diagram export (#49).
|
|
2
|
+
|
|
3
|
+
Two shapes to export:
|
|
4
|
+
|
|
5
|
+
- **Attack paths** — each ``AttackPath`` becomes a top-to-bottom
|
|
6
|
+
flowchart with the existing step labels as node text. Steps chain in
|
|
7
|
+
order; the impact line lands in a separate impact node.
|
|
8
|
+
- **Service topology** — the ``ServiceGraph`` produced by
|
|
9
|
+
``build_service_graph`` becomes a directed graph with edge styles
|
|
10
|
+
distinguishing kinds (analyzer-declared / http-call / env-configured
|
|
11
|
+
/ async-queue).
|
|
12
|
+
|
|
13
|
+
Two output formats:
|
|
14
|
+
|
|
15
|
+
- **Mermaid** — renders inline on GitHub, docs sites, and Notion.
|
|
16
|
+
- **Graphviz DOT** — feed into ``dot -Tpng`` / ``dot -Tsvg`` for
|
|
17
|
+
slide-quality graphics.
|
|
18
|
+
|
|
19
|
+
Nothing new is computed here; this is a pure output transform of
|
|
20
|
+
existing model objects.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Iterable
|
|
26
|
+
|
|
27
|
+
from .md import md_text, visible_text
|
|
28
|
+
from .models import AttackPath
|
|
29
|
+
from .topology import ServiceEdge, ServiceGraph
|
|
30
|
+
|
|
31
|
+
# --- Mermaid ---------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render_attack_paths_mermaid(paths: Iterable[AttackPath]) -> str:
|
|
35
|
+
"""Return a Markdown document with one Mermaid flowchart per path."""
|
|
36
|
+
paths = list(paths)
|
|
37
|
+
if not paths:
|
|
38
|
+
return "# AttackMap — Attack paths\n\n_No attack paths surfaced in this scan._\n"
|
|
39
|
+
|
|
40
|
+
parts: list[str] = ["# AttackMap — Attack paths", ""]
|
|
41
|
+
for idx, path in enumerate(paths, start=1):
|
|
42
|
+
parts.append(f"## {idx}. {md_text(path.name)}")
|
|
43
|
+
parts.append("")
|
|
44
|
+
parts.append("```mermaid")
|
|
45
|
+
parts.append("flowchart TD")
|
|
46
|
+
node_ids = [_mermaid_node_id(idx, step_i) for step_i in range(len(path.steps) + 1)]
|
|
47
|
+
for step_i, step in enumerate(path.steps):
|
|
48
|
+
parts.append(f' {node_ids[step_i]}["{_mermaid_text(step)}"]')
|
|
49
|
+
# Impact node — visually distinguished with a rectangular label.
|
|
50
|
+
impact_id = node_ids[-1]
|
|
51
|
+
parts.append(f' {impact_id}["Impact: {_mermaid_text(path.impact)}"]')
|
|
52
|
+
parts.append(f" class {impact_id} impact")
|
|
53
|
+
# Chain: n0 → n1 → n2 → ... → impact
|
|
54
|
+
if node_ids:
|
|
55
|
+
parts.append(" " + " --> ".join(node_ids))
|
|
56
|
+
parts.append(" classDef impact fill:#fee,stroke:#c33,color:#900;")
|
|
57
|
+
parts.append("```")
|
|
58
|
+
parts.append("")
|
|
59
|
+
return "\n".join(parts).rstrip() + "\n"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_topology_mermaid(graph: ServiceGraph) -> str:
|
|
63
|
+
"""Return a Markdown document with one Mermaid graph for the topology."""
|
|
64
|
+
if not graph.nodes:
|
|
65
|
+
return "# AttackMap — Service topology\n\n_No services surfaced in this scan._\n"
|
|
66
|
+
|
|
67
|
+
parts: list[str] = [
|
|
68
|
+
"# AttackMap — Service topology",
|
|
69
|
+
"",
|
|
70
|
+
"```mermaid",
|
|
71
|
+
"flowchart LR",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
node_id_by_name: dict[str, str] = {}
|
|
75
|
+
for idx, node in enumerate(graph.nodes):
|
|
76
|
+
nid = f"S{idx}"
|
|
77
|
+
node_id_by_name[node.name] = nid
|
|
78
|
+
label = _mermaid_text(node.name)
|
|
79
|
+
if node.role and node.role != "service":
|
|
80
|
+
label = f"{label}<br/><i>{_mermaid_text(node.role)}</i>"
|
|
81
|
+
parts.append(f' {nid}["{label}"]')
|
|
82
|
+
|
|
83
|
+
# Edges grouped by kind so the legend line makes sense.
|
|
84
|
+
for edge in graph.edges:
|
|
85
|
+
source = node_id_by_name.get(edge.source)
|
|
86
|
+
target = node_id_by_name.get(edge.target)
|
|
87
|
+
if source is None or target is None:
|
|
88
|
+
continue
|
|
89
|
+
parts.append(f" {source} {_mermaid_edge(edge)} {target}")
|
|
90
|
+
|
|
91
|
+
# Style edges per-kind via linkStyle refs — Mermaid indexes edges in
|
|
92
|
+
# declaration order.
|
|
93
|
+
for idx, edge in enumerate(_valid_edges(graph, node_id_by_name)):
|
|
94
|
+
style = _mermaid_link_style(edge.kind)
|
|
95
|
+
if style:
|
|
96
|
+
parts.append(f" linkStyle {idx} {style}")
|
|
97
|
+
|
|
98
|
+
parts.append("```")
|
|
99
|
+
parts.append("")
|
|
100
|
+
parts.append("_Edge kinds:_ **analyzer-declared** (solid), **http-call** (dashed), **env-configured** (dotted), **async-queue** (thick).")
|
|
101
|
+
return "\n".join(parts).rstrip() + "\n"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _mermaid_edge(edge: ServiceEdge) -> str:
|
|
105
|
+
label = edge.evidence or edge.kind
|
|
106
|
+
label = _mermaid_text(label)
|
|
107
|
+
if edge.kind == "http-call":
|
|
108
|
+
return f'-. "{label}" .->'
|
|
109
|
+
if edge.kind == "env-configured":
|
|
110
|
+
return f'-. "{label}" .-'
|
|
111
|
+
if edge.kind == "async-queue":
|
|
112
|
+
return f'== "{label}" ==>'
|
|
113
|
+
# analyzer-declared / default
|
|
114
|
+
return f'-- "{label}" -->'
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _mermaid_link_style(kind: str) -> str:
|
|
118
|
+
if kind == "http-call":
|
|
119
|
+
return "stroke:#2b7,stroke-dasharray:5 5;"
|
|
120
|
+
if kind == "env-configured":
|
|
121
|
+
return "stroke:#888,stroke-dasharray:2 4;"
|
|
122
|
+
if kind == "async-queue":
|
|
123
|
+
return "stroke:#a52,stroke-width:2px;"
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _mermaid_node_id(path_idx: int, step_idx: int) -> str:
|
|
128
|
+
return f"P{path_idx}_{step_idx}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _mermaid_escape(text: str) -> str:
|
|
132
|
+
"""Escape characters Mermaid's flowchart parser hates inside labels."""
|
|
133
|
+
return (
|
|
134
|
+
text.replace("\\", "\\\\")
|
|
135
|
+
.replace('"', "'")
|
|
136
|
+
.replace("\n", " ")
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _mermaid_text(text: str) -> str:
|
|
141
|
+
"""A label made only of text. Attack-path steps and topology names and
|
|
142
|
+
evidence carry repo-derived strings, and Mermaid renders HTML in labels
|
|
143
|
+
(and Markdown in a label wrapped in backticks), so ``<``, ``>`` and
|
|
144
|
+
backticks become Mermaid entity codes (``#lt;``, ``#gt;``, ``#96;``) and
|
|
145
|
+
show as literal characters; control characters show as ``\\uXXXX``."""
|
|
146
|
+
return _mermaid_escape(visible_text(text)).replace("<", "#lt;").replace(">", "#gt;").replace("`", "#96;")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- Graphviz DOT ----------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def render_attack_paths_dot(paths: Iterable[AttackPath]) -> str:
|
|
153
|
+
"""Return a Graphviz DOT document. One subgraph per attack path."""
|
|
154
|
+
paths = list(paths)
|
|
155
|
+
lines: list[str] = ["digraph AttackPaths {", " rankdir=TB;", " node [shape=box, style=rounded];"]
|
|
156
|
+
if not paths:
|
|
157
|
+
lines.append(' empty [label="No attack paths surfaced.", shape=note];')
|
|
158
|
+
lines.append("}")
|
|
159
|
+
return "\n".join(lines) + "\n"
|
|
160
|
+
|
|
161
|
+
for path_idx, path in enumerate(paths, start=1):
|
|
162
|
+
lines.append(f" subgraph cluster_p{path_idx} {{")
|
|
163
|
+
lines.append(f' label="{_dot_escape(path.name)}";')
|
|
164
|
+
lines.append(' style=rounded;')
|
|
165
|
+
node_ids = [f"p{path_idx}_{step_i}" for step_i in range(len(path.steps) + 1)]
|
|
166
|
+
for step_i, step in enumerate(path.steps):
|
|
167
|
+
lines.append(f' {node_ids[step_i]} [label="{_dot_escape(step)}"];')
|
|
168
|
+
impact_id = node_ids[-1]
|
|
169
|
+
lines.append(
|
|
170
|
+
f' {impact_id} [label="Impact: {_dot_escape(path.impact)}", '
|
|
171
|
+
f'style="rounded,filled", fillcolor="#fee", color="#c33"];'
|
|
172
|
+
)
|
|
173
|
+
for a, b in zip(node_ids, node_ids[1:]):
|
|
174
|
+
lines.append(f" {a} -> {b};")
|
|
175
|
+
lines.append(" }")
|
|
176
|
+
lines.append("}")
|
|
177
|
+
return "\n".join(lines) + "\n"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def render_topology_dot(graph: ServiceGraph) -> str:
|
|
181
|
+
"""Return a Graphviz DOT document for the service graph."""
|
|
182
|
+
lines: list[str] = ["digraph Topology {", " rankdir=LR;", " node [shape=box, style=rounded];"]
|
|
183
|
+
if not graph.nodes:
|
|
184
|
+
lines.append(' empty [label="No services surfaced.", shape=note];')
|
|
185
|
+
lines.append("}")
|
|
186
|
+
return "\n".join(lines) + "\n"
|
|
187
|
+
|
|
188
|
+
node_id_by_name: dict[str, str] = {}
|
|
189
|
+
for idx, node in enumerate(graph.nodes):
|
|
190
|
+
nid = f"s{idx}"
|
|
191
|
+
node_id_by_name[node.name] = nid
|
|
192
|
+
label = node.name if node.role in ("", "service") else f"{node.name}\\n[{node.role}]"
|
|
193
|
+
lines.append(f' {nid} [label="{_dot_escape(label)}"];')
|
|
194
|
+
|
|
195
|
+
for edge in graph.edges:
|
|
196
|
+
source = node_id_by_name.get(edge.source)
|
|
197
|
+
target = node_id_by_name.get(edge.target)
|
|
198
|
+
if source is None or target is None:
|
|
199
|
+
continue
|
|
200
|
+
style = _dot_edge_style(edge.kind)
|
|
201
|
+
label = edge.evidence or edge.kind
|
|
202
|
+
lines.append(f' {source} -> {target} [label="{_dot_escape(label)}"{style}];')
|
|
203
|
+
|
|
204
|
+
lines.append("}")
|
|
205
|
+
return "\n".join(lines) + "\n"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _dot_edge_style(kind: str) -> str:
|
|
209
|
+
if kind == "http-call":
|
|
210
|
+
return ', style=dashed, color="#2b7"'
|
|
211
|
+
if kind == "env-configured":
|
|
212
|
+
return ', style=dotted, color="#888"'
|
|
213
|
+
if kind == "async-queue":
|
|
214
|
+
return ', penwidth=2, color="#a52"'
|
|
215
|
+
return "" # analyzer-declared → default
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _dot_escape(text: str) -> str:
|
|
219
|
+
# Control characters as visible \uXXXX first, so a raw ESC from a scanned
|
|
220
|
+
# repo never reaches the terminal of whoever views the .dot file.
|
|
221
|
+
text = visible_text(text)
|
|
222
|
+
return text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- Shared helper ---------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _valid_edges(graph: ServiceGraph, name_to_id: dict[str, str]) -> list[ServiceEdge]:
|
|
229
|
+
return [e for e in graph.edges if e.source in name_to_id and e.target in name_to_id]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = [
|
|
233
|
+
"render_attack_paths_mermaid",
|
|
234
|
+
"render_attack_paths_dot",
|
|
235
|
+
"render_topology_mermaid",
|
|
236
|
+
"render_topology_dot",
|
|
237
|
+
]
|