cloudmap 1.1.0__tar.gz → 1.2.0__tar.gz
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.
- cloudmap-1.2.0/CONTRIBUTING.md +55 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/PKG-INFO +1 -1
- cloudmap-1.2.0/SECURITY.md +46 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/__init__.py +1 -1
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/adapters/__init__.py +7 -1
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/cli.py +22 -1
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/extract/extractors.py +34 -1
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ingest/azure.py +35 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_arg_rows.py +4 -5
- cloudmap-1.2.0/tests/test_azure.py +45 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_cli_exports.py +29 -0
- cloudmap-1.2.0/tests/test_render_text.py +70 -0
- cloudmap-1.1.0/tests/test_azure.py +0 -20
- {cloudmap-1.1.0 → cloudmap-1.2.0}/.github/workflows/ci.yml +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/.github/workflows/publish.yml +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/.gitignore +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/ARCHITECTURE.md +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/FORMAT.md +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/LICENSE +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/PLAN.md +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/README.md +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/__main__.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ask/__init__.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ask/intent.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ask/narration.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ask/queries.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/extract/__init__.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/extract/llm.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/graph.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ingest/__init__.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ingest/fixture.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/interactive.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/local_model.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/model.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/__init__.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/azure_icons.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/csv_export.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/drawio.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/html.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/json_out.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/mermaid.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/scrub.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/docs/social-preview.html +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/docs/social-preview.png +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/estate-viewer.png +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/fixtures/acme_orders.json +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/fixtures/contoso.json +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/fixtures/estate.json +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/pyproject.toml +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/01_input_complex_random.json +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/04_scrubbed_output.json +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/06_trace_output.json +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/07_trace_output.html +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/08_trace_output.csv +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/09_trace_output.drawio +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/10_input_enterprise_architecture.json +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/12_enterprise_trace.html +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/13_enterprise_trace.json +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/14_enterprise_scrubbed.json +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/README.md +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_adapters.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_ask.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_containerapps.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_drawio_xml.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_enrich.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_estate.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_fixtures_safe.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_golden_orders.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_graph.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_html.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_ingest_paging.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_interactive_wizard.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_llm.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_local_model.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_scrub.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_trust.py +0 -0
- {cloudmap-1.1.0 → cloudmap-1.2.0}/uv.lock +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for looking. The project is small on purpose; contributions that keep it
|
|
4
|
+
small are the easiest to merge.
|
|
5
|
+
|
|
6
|
+
## Setup
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
git clone https://github.com/KatsaounisThanasis/cloudmap && cd cloudmap
|
|
10
|
+
pip install -e ".[dev]"
|
|
11
|
+
pytest && ruff check .
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Python 3.9+ (CI runs 3.9 and 3.13). No test touches a real cloud: `subprocess`
|
|
15
|
+
is faked everywhere, and several test files have an autouse fixture that turns
|
|
16
|
+
any real subprocess call into a failure - keep it that way.
|
|
17
|
+
|
|
18
|
+
## The rules that are not up for debate
|
|
19
|
+
|
|
20
|
+
These are the project's identity; PRs that violate them will be declined even
|
|
21
|
+
if the feature is useful:
|
|
22
|
+
|
|
23
|
+
1. **Read-only.** No `az` command that mutates anything.
|
|
24
|
+
2. **Local-first.** No telemetry, no upload, no outbound call except the
|
|
25
|
+
optional user-configured local model endpoint.
|
|
26
|
+
3. **Every edge carries evidence.** An edge without a provable source
|
|
27
|
+
(property path, config reference, RBAC assignment) does not go on the map.
|
|
28
|
+
4. **A model proposes, code verifies.** LLM output may only suggest candidates
|
|
29
|
+
that a deterministic check confirms against scanned resources. A guess that
|
|
30
|
+
cannot be verified is dropped, not shown.
|
|
31
|
+
5. **Incompleteness is declared.** If a scan was truncated or a read failed,
|
|
32
|
+
the artifact says so. Never let an empty result pass for "nothing depends
|
|
33
|
+
on this".
|
|
34
|
+
6. **Nothing sensitive in the repo.** Fixtures are synthetic or scrubbed;
|
|
35
|
+
`tests/test_fixtures_safe.py` enforces it in CI.
|
|
36
|
+
|
|
37
|
+
## Practical notes
|
|
38
|
+
|
|
39
|
+
- Match the existing style: docstrings explain *why*, tests are named as
|
|
40
|
+
behaviour claims (`test_a_truncated_capture_stays_truncated_when_retraced`).
|
|
41
|
+
- A bug fix needs a regression test that fails without the fix.
|
|
42
|
+
- New resource-type support usually means: a Resolver index entry, an
|
|
43
|
+
extractor rule (or nothing, if the generic ARM-reference pass covers it),
|
|
44
|
+
a FRIENDLY name, and a fixture-based test.
|
|
45
|
+
- Run `ruff check .` before pushing - CI treats lint as a failure.
|
|
46
|
+
|
|
47
|
+
## Reporting bugs
|
|
48
|
+
|
|
49
|
+
Open an issue with the command you ran and the output. If the map itself is
|
|
50
|
+
wrong (missing or bogus edge), the perfect report includes a minimal synthetic
|
|
51
|
+
fixture that reproduces it - see `fixtures/contoso.json` for the shape. Never
|
|
52
|
+
paste a real capture; scrub it first (`cloudmap scrub`) and read it before
|
|
53
|
+
posting.
|
|
54
|
+
|
|
55
|
+
Security issues: see [SECURITY.md](SECURITY.md) - do not open a public issue.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cloudmap
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Trace the blast radius of an Azure resource: one name in, a verified dependency graph out.
|
|
5
5
|
Project-URL: Homepage, https://github.com/KatsaounisThanasis/cloudmap
|
|
6
6
|
Project-URL: Repository, https://github.com/KatsaounisThanasis/cloudmap
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
cloudmap reads cloud infrastructure and handles the output, so security reports
|
|
4
|
+
get priority over everything else.
|
|
5
|
+
|
|
6
|
+
## Reporting a vulnerability
|
|
7
|
+
|
|
8
|
+
Use [GitHub private vulnerability reporting](https://github.com/KatsaounisThanasis/cloudmap/security/advisories/new)
|
|
9
|
+
(Security tab → Report a vulnerability). Please do not open a public issue for
|
|
10
|
+
anything that could expose a user's infrastructure data before a fix exists.
|
|
11
|
+
|
|
12
|
+
You can expect an acknowledgement within a few days. There is no bounty - this
|
|
13
|
+
is a solo open-source project - but reports are credited in the release notes
|
|
14
|
+
unless you ask otherwise.
|
|
15
|
+
|
|
16
|
+
## What counts as a vulnerability here
|
|
17
|
+
|
|
18
|
+
The interesting failure modes for a tool like this:
|
|
19
|
+
|
|
20
|
+
- **Credential leakage into artifacts**: any way a secret, key, token or
|
|
21
|
+
password ends up in a `.drawio` / `.json` / `.html` / `.csv` / capture file.
|
|
22
|
+
The scrubber (`cloudmap/scrub.py`) redacts credentials and pseudonymises
|
|
23
|
+
identifiers; a value that survives it is a bug of the highest priority -
|
|
24
|
+
one shipped before (base64 padding, fixed in `2b1e06d`) and the regression
|
|
25
|
+
test suite grew from it.
|
|
26
|
+
- **Scope escalation**: cloudmap must only ever read what the caller's own
|
|
27
|
+
`az login` token can read, and must never perform a write operation against
|
|
28
|
+
the tenant.
|
|
29
|
+
- **Injection through cloud-controlled data**: resource names, tags and
|
|
30
|
+
properties are attacker-influenceable in shared tenants; anything that lets
|
|
31
|
+
them break out of an `az` argument list, the HTML viewer, or the draw.io XML
|
|
32
|
+
is in scope.
|
|
33
|
+
|
|
34
|
+
## Design promises you can hold us to
|
|
35
|
+
|
|
36
|
+
- Read-only: no `az` mutation commands, ever.
|
|
37
|
+
- Local-first: the only outbound network call in the codebase is to a
|
|
38
|
+
user-configured local model endpoint (`cloudmap/local_model.py`), off by
|
|
39
|
+
default. Resource data never leaves the machine.
|
|
40
|
+
- Secrets stay in memory: `--resolve-secrets` substitutes Key Vault values
|
|
41
|
+
in-memory for edge extraction and never prints or writes them.
|
|
42
|
+
- `tests/test_fixtures_safe.py` fails CI if a committed fixture ever carries
|
|
43
|
+
a credential or a real-looking identifier.
|
|
44
|
+
|
|
45
|
+
If you find code contradicting any of these, that is a valid report even if
|
|
46
|
+
you cannot demonstrate an exploit.
|
|
@@ -88,4 +88,10 @@ def load_graph(path):
|
|
|
88
88
|
data = json.load(f)
|
|
89
89
|
if _looks_neutral(data):
|
|
90
90
|
return graph_from_neutral(data)
|
|
91
|
-
|
|
91
|
+
graph = AzureAdapter.to_graph(data)
|
|
92
|
+
# A capture's own honesty flags (truncated, enriched, scrubbed) ride along:
|
|
93
|
+
# re-tracing a truncated export must not produce a map that claims to be
|
|
94
|
+
# complete just because the file was re-read from disk.
|
|
95
|
+
if isinstance(data, dict) and isinstance(data.get("meta"), dict):
|
|
96
|
+
graph.meta.update(data["meta"])
|
|
97
|
+
return graph
|
|
@@ -15,6 +15,14 @@ from .render.mermaid import to_mermaid
|
|
|
15
15
|
def main(argv=None):
|
|
16
16
|
# Interactive mode if no arguments are provided
|
|
17
17
|
if (argv is None and len(sys.argv) == 1) or (argv is not None and len(argv) == 0):
|
|
18
|
+
# The wizard hands the terminal to questionary; in a pipe / cron / CI
|
|
19
|
+
# there is no terminal to hand over, so fail with a pointer instead of
|
|
20
|
+
# letting the prompt library misbehave against a non-tty stream.
|
|
21
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
22
|
+
print("cloudmap: running with no arguments starts the interactive wizard, "
|
|
23
|
+
"which needs a terminal. Use `cloudmap --help` for the scriptable "
|
|
24
|
+
"commands.", file=sys.stderr)
|
|
25
|
+
return 2
|
|
18
26
|
from .interactive import interactive_main
|
|
19
27
|
return interactive_main()
|
|
20
28
|
|
|
@@ -114,8 +122,14 @@ def _cmd_trace(args):
|
|
|
114
122
|
graph = build_graph(resources)
|
|
115
123
|
else:
|
|
116
124
|
from .adapters import load_graph
|
|
117
|
-
resources
|
|
125
|
+
resources = []
|
|
118
126
|
graph = load_graph(args.fixture) # auto-detects raw export vs neutral cloudmap graph
|
|
127
|
+
# A re-traced capture keeps its own honesty flags: a scan that was
|
|
128
|
+
# truncated at capture time must not become a map that claims
|
|
129
|
+
# completeness just because it was re-read from disk.
|
|
130
|
+
truncated = bool(graph.meta.get("truncated", False))
|
|
131
|
+
read_gaps = list(graph.meta.get("read_gaps") or [])
|
|
132
|
+
blind_spots = list(graph.meta.get("blind_spots") or [])
|
|
119
133
|
|
|
120
134
|
seeds = find_seeds(graph, args.name)
|
|
121
135
|
if not seeds:
|
|
@@ -281,6 +295,13 @@ def _enrich_live(args, graph, seed, resources):
|
|
|
281
295
|
print("Read gaps while enriching (edges below may be INCOMPLETE):", file=sys.stderr)
|
|
282
296
|
for msg in read_gaps:
|
|
283
297
|
print(f" ! could not read {msg}", file=sys.stderr)
|
|
298
|
+
# The artifact is meant to be shared; az stderr carries correlation
|
|
299
|
+
# ids and GUIDs. The terminal (above) keeps the full text, the map
|
|
300
|
+
# records a short classified reason.
|
|
301
|
+
from .ingest.azure import classify_gap
|
|
302
|
+
read_gaps = [f"{m.partition(': ')[0]}: {classify_gap(m.partition(': ')[2])}"
|
|
303
|
+
if ": " in m else classify_gap(m)
|
|
304
|
+
for m in read_gaps]
|
|
284
305
|
|
|
285
306
|
graph = build_graph(resources) # re-extract, now that config is present
|
|
286
307
|
resolver = Resolver(graph.nodes)
|
|
@@ -183,6 +183,7 @@ class Resolver:
|
|
|
183
183
|
self.storage_by_name = {}
|
|
184
184
|
self.law_by_customer_id = {} # Log Analytics workspace GUID -> node_id
|
|
185
185
|
self.by_name = {} # resource name -> node_id (for verifying LLM proposals)
|
|
186
|
+
self.sqldb_by_name = {} # db name -> [(parent server id lower, db node_id)]
|
|
186
187
|
for n in nodes.values():
|
|
187
188
|
self.by_id[n.id.lower()] = n.id
|
|
188
189
|
if n.name:
|
|
@@ -213,6 +214,11 @@ class Resolver:
|
|
|
213
214
|
self.kv_by_name[name] = n.id
|
|
214
215
|
elif t == "microsoft.sql/servers":
|
|
215
216
|
self._add_host(p.get("fullyQualifiedDomainName") or f"{name}.database.windows.net", n.id)
|
|
217
|
+
elif t == "microsoft.sql/servers/databases":
|
|
218
|
+
# a connection string names the SERVER as host and the database by
|
|
219
|
+
# name; keep the pair so config edges can land on the database itself
|
|
220
|
+
self.sqldb_by_name.setdefault(name, []).append(
|
|
221
|
+
(n.id.lower().rsplit("/", 2)[0], n.id))
|
|
216
222
|
elif t.startswith("microsoft.dbforpostgresql/"):
|
|
217
223
|
self._add_host(p.get("fullyQualifiedDomainName") or f"{name}.postgres.database.azure.com", n.id)
|
|
218
224
|
elif t.startswith("microsoft.dbformysql/"):
|
|
@@ -410,6 +416,19 @@ def extract_edges(nodes):
|
|
|
410
416
|
elif t == "microsoft.containerservice/managedclusters":
|
|
411
417
|
_aks_config_edges(n, p, r, add)
|
|
412
418
|
|
|
419
|
+
# Nested-child pass. An id like .../servers/sqlsrv/databases/orders is a
|
|
420
|
+
# child resource: it cannot exist without its parent, so a scanned parent
|
|
421
|
+
# gets a child-of edge. Without this a nested seed (a SQL database, a
|
|
422
|
+
# Service Bus queue) is an island - no property on either side names the
|
|
423
|
+
# other, the relationship exists only in the id's shape.
|
|
424
|
+
for n in nodes.values():
|
|
425
|
+
if n.type == "microsoft.authorization/roleassignments":
|
|
426
|
+
continue
|
|
427
|
+
if len(n.id.split("/")) > 9: # provider path + child segments
|
|
428
|
+
parent = r.by_resource_id(n.id.rsplit("/", 2)[0])
|
|
429
|
+
if parent and parent != n.id:
|
|
430
|
+
add(n.id, parent, "child-of", "nested ARM resource id")
|
|
431
|
+
|
|
413
432
|
# Generic ARM-reference pass. Any resolvable resource id sitting in a
|
|
414
433
|
# resource's properties is a real dependency, whatever the type - this is
|
|
415
434
|
# what lets cloudmap map resource types it has no hand-written rule for,
|
|
@@ -462,13 +481,27 @@ def _config_edges(n, values, r, add, label="app config"):
|
|
|
462
481
|
# O(blob) extraction instead of O(hosts * blob)
|
|
463
482
|
import re
|
|
464
483
|
words = set(re.findall(r"[a-z0-9.-]+", blob))
|
|
465
|
-
|
|
484
|
+
|
|
485
|
+
hit_hosts = {} # resolved node id (lower) -> the host that named it
|
|
466
486
|
for word in words:
|
|
467
487
|
if word in r.by_host:
|
|
488
|
+
hit_hosts[r.by_host[word].lower()] = word
|
|
468
489
|
add(n.id, r.by_host[word], domain_kind(word)[0], f"{label} references host {word}")
|
|
469
490
|
if word in r.by_ik:
|
|
470
491
|
add(n.id, r.by_ik[word], "sends-telemetry", f"{label} contains instrumentation key")
|
|
471
492
|
|
|
493
|
+
# A SQL connection string names the server as host and the database by name.
|
|
494
|
+
# When that database was scanned, land the edge on the database itself (in
|
|
495
|
+
# addition to the server) - it is what a database seed's upward view needs.
|
|
496
|
+
# The parent-server check keeps a same-named database under an unreferenced
|
|
497
|
+
# server from matching.
|
|
498
|
+
for m in re.finditer(r"(?:database|initial catalog)\s*=\s*([a-z0-9._-]+)", blob):
|
|
499
|
+
for srv_id, db_id in r.sqldb_by_name.get(m.group(1), []):
|
|
500
|
+
if srv_id in hit_hosts:
|
|
501
|
+
add(n.id, db_id, "connects-to",
|
|
502
|
+
f"{label} connection string names database {m.group(1)} "
|
|
503
|
+
f"on referenced server host {hit_hosts[srv_id]}")
|
|
504
|
+
|
|
472
505
|
# Storage accounts are often in connection strings: accountname=xyz
|
|
473
506
|
for m in re.finditer(r"accountname=([a-z0-9-]+)", blob):
|
|
474
507
|
acct = m.group(1)
|
|
@@ -44,6 +44,41 @@ def _az(args):
|
|
|
44
44
|
return out.stdout
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
_GUID_RE = re.compile(
|
|
48
|
+
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
49
|
+
|
|
50
|
+
# Matched against the lowercased failure text, first hit wins.
|
|
51
|
+
_GAP_CATEGORIES = (
|
|
52
|
+
("authorizationfailed", "authorization denied (RBAC)"),
|
|
53
|
+
("forbidden", "authorization denied (RBAC)"),
|
|
54
|
+
("denyassignment", "blocked by a deny assignment"),
|
|
55
|
+
("invalidaadclustertoken", "cluster token rejected"),
|
|
56
|
+
("aadsts", "Entra token rejected"),
|
|
57
|
+
("was not found", "not found"),
|
|
58
|
+
("notfound", "not found"),
|
|
59
|
+
("timed out", "timeout"),
|
|
60
|
+
("failed to resolve", "network (DNS)"),
|
|
61
|
+
("connection refused", "network"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def classify_gap(message):
|
|
66
|
+
"""Short, artifact-safe reason for a failed read.
|
|
67
|
+
|
|
68
|
+
Full az stderr carries correlation ids, subscription GUIDs and sometimes
|
|
69
|
+
principal ids - useful on the operator's own terminal, wrong inside a
|
|
70
|
+
JSON/HTML artifact meant to be shared. The terminal keeps the full text;
|
|
71
|
+
the artifact records the category this returns. Unknown failures fall back
|
|
72
|
+
to the first line with every GUID masked, so nothing identifying rides
|
|
73
|
+
along even when the category list has never seen the error."""
|
|
74
|
+
low = str(message).lower()
|
|
75
|
+
for needle, label in _GAP_CATEGORIES:
|
|
76
|
+
if needle in low:
|
|
77
|
+
return label
|
|
78
|
+
first_line = str(message).strip().splitlines()[0] if str(message).strip() else ""
|
|
79
|
+
return _GUID_RE.sub("<id>", first_line)[:120] or "read failed"
|
|
80
|
+
|
|
81
|
+
|
|
47
82
|
def _guard():
|
|
48
83
|
try:
|
|
49
84
|
acct = json.loads(_az(["account", "show", "-o", "json"]))
|
|
@@ -164,12 +164,11 @@ def test_a_neutral_graph_json_is_reloaded_without_re_extracting(tmp_path):
|
|
|
164
164
|
assert graph.meta["seed"] == "/web" # a reloaded map keeps its provenance
|
|
165
165
|
|
|
166
166
|
|
|
167
|
-
@pytest.mark.xfail(strict=True, reason="BUG: a microsoft.sql/servers/databases seed has an "
|
|
168
|
-
"empty blast radius - no child-to-parent edge links a "
|
|
169
|
-
"database to its server, and consumers' connection "
|
|
170
|
-
"strings resolve to the server, so the wizard offers a "
|
|
171
|
-
"seed type that can only ever produce an empty map")
|
|
172
167
|
def test_a_sql_database_seed_reaches_its_server_and_its_consumer():
|
|
168
|
+
# Regression for a real bug: a database seed used to produce an empty map -
|
|
169
|
+
# no child-of edge linked it to its server, and consumers' connection strings
|
|
170
|
+
# resolved only to the server. Fixed by the nested-child pass + landing
|
|
171
|
+
# config edges on the scanned database itself.
|
|
173
172
|
server = f"{S}/microsoft.sql/servers/sqlsrv"
|
|
174
173
|
database = f"{server}/databases/orders"
|
|
175
174
|
graph = build_graph([
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from cloudmap.ingest import azure
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_resolve_secret_handles_failures(monkeypatch):
|
|
7
|
+
def fake_az(*args, **kwargs):
|
|
8
|
+
raise RuntimeError("az command failed")
|
|
9
|
+
monkeypatch.setattr(azure, "_az", fake_az)
|
|
10
|
+
|
|
11
|
+
assert azure._resolve_secret("@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/mysecret/)") == ""
|
|
12
|
+
|
|
13
|
+
def test_guard_raises_system_exit_on_mismatch(monkeypatch):
|
|
14
|
+
monkeypatch.setenv("CLOUDMAP_ALLOW_SUBSCRIPTION", "sub-2")
|
|
15
|
+
def fake_az(*args, **kwargs):
|
|
16
|
+
return '{"id": "sub-1", "tenantId": "t-1"}'
|
|
17
|
+
monkeypatch.setattr(azure, "_az", fake_az)
|
|
18
|
+
|
|
19
|
+
with pytest.raises(SystemExit, match="Refusing the mismatch"):
|
|
20
|
+
azure._guard()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_gap_classification_names_the_category_not_the_incident():
|
|
24
|
+
cases = {
|
|
25
|
+
"az webapp show failed: AuthorizationFailed: client 'x' with object id "
|
|
26
|
+
"'11111111-2222-3333-4444-555555555555' does not have authorization":
|
|
27
|
+
"authorization denied (RBAC)",
|
|
28
|
+
"ERROR: (ResourceNotFound) the resource was not found": "not found",
|
|
29
|
+
"az aks command invoke timed out after 120s": "timeout",
|
|
30
|
+
"AADSTS50076: MFA required": "Entra token rejected",
|
|
31
|
+
"Failed to resolve 'management.azure.com'": "network (DNS)",
|
|
32
|
+
}
|
|
33
|
+
for raw, expected in cases.items():
|
|
34
|
+
assert azure.classify_gap(raw) == expected, raw
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_an_unknown_gap_is_kept_but_every_guid_is_masked():
|
|
38
|
+
short = azure.classify_gap(
|
|
39
|
+
"some novel failure, correlation id 12345678-abcd-ef01-2345-6789abcdef01\n"
|
|
40
|
+
"second line with more detail")
|
|
41
|
+
|
|
42
|
+
assert "12345678-abcd" not in short # nothing identifying rides along
|
|
43
|
+
assert "<id>" in short
|
|
44
|
+
assert "second line" not in short # first line only
|
|
45
|
+
assert len(short) <= 120
|
|
@@ -225,3 +225,32 @@ def test_no_subcommand_is_rejected_rather_than_falling_into_the_wizard():
|
|
|
225
225
|
# interactive session in a non-interactive context (CI, pipes).
|
|
226
226
|
with pytest.raises(SystemExit):
|
|
227
227
|
cli.main(["--nonsense"])
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_a_truncated_capture_stays_truncated_when_retraced(tmp_path):
|
|
231
|
+
# The capture said "I did not see everything". Re-tracing that file offline
|
|
232
|
+
# must not launder the warning into complete:true.
|
|
233
|
+
capture = tmp_path / "capture.json"
|
|
234
|
+
capture.write_text(json.dumps({
|
|
235
|
+
"data": [{"id": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Web/sites/web",
|
|
236
|
+
"name": "web", "type": "microsoft.web/sites", "properties": {}}],
|
|
237
|
+
"meta": {"truncated": True},
|
|
238
|
+
"scrubbed": True,
|
|
239
|
+
}), encoding="utf-8")
|
|
240
|
+
|
|
241
|
+
out = tmp_path / "map.json"
|
|
242
|
+
rc = cli.main(["trace", "web", "--from", str(capture), "--json", str(out)])
|
|
243
|
+
meta = json.loads(out.read_text())["meta"]
|
|
244
|
+
|
|
245
|
+
assert rc == 0
|
|
246
|
+
assert meta["truncated"] is True
|
|
247
|
+
assert meta["complete"] is False
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_bare_cloudmap_without_a_terminal_refuses_instead_of_prompting(monkeypatch, capsys):
|
|
251
|
+
monkeypatch.setattr("sys.stdin", type("S", (), {"isatty": staticmethod(lambda: False)})())
|
|
252
|
+
|
|
253
|
+
rc = cli.main([])
|
|
254
|
+
|
|
255
|
+
assert rc == 2
|
|
256
|
+
assert "terminal" in capsys.readouterr().err
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The two text renderers (Mermaid, CSV).
|
|
2
|
+
|
|
3
|
+
Mermaid has exactly two escape rules - double quotes in labels and pipes in edge
|
|
4
|
+
kinds break its syntax - and the CSV is read by spreadsheets and auditors, so it
|
|
5
|
+
must round-trip through a real csv.reader with hostile evidence text intact.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import io
|
|
10
|
+
|
|
11
|
+
from cloudmap.model import Edge, Graph, Node
|
|
12
|
+
from cloudmap.render.csv_export import to_csv
|
|
13
|
+
from cloudmap.render.mermaid import to_mermaid
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _graph():
|
|
17
|
+
web = Node(id="/web", name='app "prod"', type="microsoft.web/sites")
|
|
18
|
+
kv = Node(id="/kv", name="kv-1", type="microsoft.keyvault/vaults")
|
|
19
|
+
ext = Node(id="/ext", name="unknown.example.com", type="external", external=True)
|
|
20
|
+
return Graph(
|
|
21
|
+
nodes={n.id: n for n in (web, kv, ext)},
|
|
22
|
+
edges=[
|
|
23
|
+
Edge("/web", "/kv", "reads-secret; role: KV | Secrets User",
|
|
24
|
+
evidence='setting "CONN", value contains , and\nnewline'),
|
|
25
|
+
Edge("/web", "/ext", "connects-to", origin="model"),
|
|
26
|
+
],
|
|
27
|
+
distances={"/web": 0, "/kv": 1, "/ext": 1},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- mermaid ----------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def test_mermaid_escapes_the_only_two_things_that_break_it():
|
|
34
|
+
text = to_mermaid(_graph(), "/web")
|
|
35
|
+
|
|
36
|
+
assert '"' not in text.split("\n")[1].split("[")[1].replace('["', "").replace('"]', "") \
|
|
37
|
+
or "app 'prod'" in text # quotes in names become single quotes
|
|
38
|
+
assert "KV / Secrets User" in text # pipes in kinds become slashes
|
|
39
|
+
assert "-->|" in text # verified edge, solid
|
|
40
|
+
assert '-. "connects-to (model)" .->' in text # model edge, dashed and labelled
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_mermaid_marks_seed_and_external_nodes():
|
|
44
|
+
text = to_mermaid(_graph(), "/web")
|
|
45
|
+
|
|
46
|
+
assert "style N0" in text # seed styled
|
|
47
|
+
assert "stroke-dasharray" in text # external node dashed
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- csv --------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def test_csv_round_trips_through_a_real_reader():
|
|
53
|
+
rows = list(csv.reader(io.StringIO(to_csv(_graph(), "/web"))))
|
|
54
|
+
header, body = rows[0], rows[1:]
|
|
55
|
+
|
|
56
|
+
assert header[0] == "Source Name" and "Evidence" in header
|
|
57
|
+
assert len(body) == 2
|
|
58
|
+
by_target = {r[3]: r for r in body}
|
|
59
|
+
assert by_target["kv-1"][5] == "Verified"
|
|
60
|
+
assert by_target["kv-1"][6] == 'setting "CONN", value contains , and\nnewline'
|
|
61
|
+
assert by_target["unknown.example.com"][5] == "GUESS (LLM) (Unverified Target)"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_csv_falls_back_to_the_id_tail_for_unscanned_targets():
|
|
65
|
+
g = _graph()
|
|
66
|
+
g.edges.append(Edge("/web", "/subscriptions/s/x/gone-resource", "references"))
|
|
67
|
+
|
|
68
|
+
rows = list(csv.reader(io.StringIO(to_csv(g, "/web"))))
|
|
69
|
+
|
|
70
|
+
assert any(r[3] == "gone-resource" and "Unverified Target" in r[5] for r in rows[1:])
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from cloudmap.ingest import azure
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_resolve_secret_handles_failures(monkeypatch):
|
|
7
|
-
def fake_az(*args, **kwargs):
|
|
8
|
-
raise RuntimeError("az command failed")
|
|
9
|
-
monkeypatch.setattr(azure, "_az", fake_az)
|
|
10
|
-
|
|
11
|
-
assert azure._resolve_secret("@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/mysecret/)") == ""
|
|
12
|
-
|
|
13
|
-
def test_guard_raises_system_exit_on_mismatch(monkeypatch):
|
|
14
|
-
monkeypatch.setenv("CLOUDMAP_ALLOW_SUBSCRIPTION", "sub-2")
|
|
15
|
-
def fake_az(*args, **kwargs):
|
|
16
|
-
return '{"id": "sub-1", "tenantId": "t-1"}'
|
|
17
|
-
monkeypatch.setattr(azure, "_az", fake_az)
|
|
18
|
-
|
|
19
|
-
with pytest.raises(SystemExit, match="Refusing the mismatch"):
|
|
20
|
-
azure._guard()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/10_input_enterprise_architecture.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|