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.
Files changed (77) hide show
  1. cloudmap-1.2.0/CONTRIBUTING.md +55 -0
  2. {cloudmap-1.1.0 → cloudmap-1.2.0}/PKG-INFO +1 -1
  3. cloudmap-1.2.0/SECURITY.md +46 -0
  4. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/__init__.py +1 -1
  5. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/adapters/__init__.py +7 -1
  6. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/cli.py +22 -1
  7. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/extract/extractors.py +34 -1
  8. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ingest/azure.py +35 -0
  9. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_arg_rows.py +4 -5
  10. cloudmap-1.2.0/tests/test_azure.py +45 -0
  11. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_cli_exports.py +29 -0
  12. cloudmap-1.2.0/tests/test_render_text.py +70 -0
  13. cloudmap-1.1.0/tests/test_azure.py +0 -20
  14. {cloudmap-1.1.0 → cloudmap-1.2.0}/.github/workflows/ci.yml +0 -0
  15. {cloudmap-1.1.0 → cloudmap-1.2.0}/.github/workflows/publish.yml +0 -0
  16. {cloudmap-1.1.0 → cloudmap-1.2.0}/.gitignore +0 -0
  17. {cloudmap-1.1.0 → cloudmap-1.2.0}/ARCHITECTURE.md +0 -0
  18. {cloudmap-1.1.0 → cloudmap-1.2.0}/FORMAT.md +0 -0
  19. {cloudmap-1.1.0 → cloudmap-1.2.0}/LICENSE +0 -0
  20. {cloudmap-1.1.0 → cloudmap-1.2.0}/PLAN.md +0 -0
  21. {cloudmap-1.1.0 → cloudmap-1.2.0}/README.md +0 -0
  22. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/__main__.py +0 -0
  23. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ask/__init__.py +0 -0
  24. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ask/intent.py +0 -0
  25. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ask/narration.py +0 -0
  26. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ask/queries.py +0 -0
  27. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/extract/__init__.py +0 -0
  28. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/extract/llm.py +0 -0
  29. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/graph.py +0 -0
  30. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ingest/__init__.py +0 -0
  31. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/ingest/fixture.py +0 -0
  32. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/interactive.py +0 -0
  33. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/local_model.py +0 -0
  34. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/model.py +0 -0
  35. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/__init__.py +0 -0
  36. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/azure_icons.py +0 -0
  37. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/csv_export.py +0 -0
  38. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/drawio.py +0 -0
  39. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/html.py +0 -0
  40. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/json_out.py +0 -0
  41. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/render/mermaid.py +0 -0
  42. {cloudmap-1.1.0 → cloudmap-1.2.0}/cloudmap/scrub.py +0 -0
  43. {cloudmap-1.1.0 → cloudmap-1.2.0}/docs/social-preview.html +0 -0
  44. {cloudmap-1.1.0 → cloudmap-1.2.0}/docs/social-preview.png +0 -0
  45. {cloudmap-1.1.0 → cloudmap-1.2.0}/estate-viewer.png +0 -0
  46. {cloudmap-1.1.0 → cloudmap-1.2.0}/fixtures/acme_orders.json +0 -0
  47. {cloudmap-1.1.0 → cloudmap-1.2.0}/fixtures/contoso.json +0 -0
  48. {cloudmap-1.1.0 → cloudmap-1.2.0}/fixtures/estate.json +0 -0
  49. {cloudmap-1.1.0 → cloudmap-1.2.0}/pyproject.toml +0 -0
  50. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/01_input_complex_random.json +0 -0
  51. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/04_scrubbed_output.json +0 -0
  52. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/06_trace_output.json +0 -0
  53. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/07_trace_output.html +0 -0
  54. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/08_trace_output.csv +0 -0
  55. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/09_trace_output.drawio +0 -0
  56. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/10_input_enterprise_architecture.json +0 -0
  57. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/12_enterprise_trace.html +0 -0
  58. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/13_enterprise_trace.json +0 -0
  59. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/14_enterprise_scrubbed.json +0 -0
  60. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/complex_mock_demo/README.md +0 -0
  61. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_adapters.py +0 -0
  62. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_ask.py +0 -0
  63. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_containerapps.py +0 -0
  64. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_drawio_xml.py +0 -0
  65. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_enrich.py +0 -0
  66. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_estate.py +0 -0
  67. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_fixtures_safe.py +0 -0
  68. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_golden_orders.py +0 -0
  69. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_graph.py +0 -0
  70. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_html.py +0 -0
  71. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_ingest_paging.py +0 -0
  72. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_interactive_wizard.py +0 -0
  73. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_llm.py +0 -0
  74. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_local_model.py +0 -0
  75. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_scrub.py +0 -0
  76. {cloudmap-1.1.0 → cloudmap-1.2.0}/tests/test_trust.py +0 -0
  77. {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.1.0
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.
@@ -1,3 +1,3 @@
1
1
  """cloudmap - trace an Azure resource's full dependency graph and export it."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.2.0"
@@ -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
- return AzureAdapter.to_graph(data)
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, truncated = [], False
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