qualys-cli 0.1.1__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,896 @@
1
+ """
2
+ Output formatting for qualys-cli.
3
+
4
+ Default (interactive): rich table, first --limit rows + count footer.
5
+ Default (non-interactive / piped): JSON — auto-detected via stdout TTY check.
6
+
7
+ Format flags:
8
+ --format json full JSON to stdout, no decoration
9
+ --format jsonl one flattened JSON object per line (NDJSON)
10
+ --format yaml full YAML
11
+ --format table rich table (forced even when piped)
12
+ --format csv RFC-4180 CSV with header row
13
+ --format tsv tab-separated values with header
14
+ --format markdown GitHub-flavoured Markdown table
15
+
16
+ Composable post-processing flags (work with any format, on every command —
17
+ injected by cli.main() as click options with expose_value=False, so command
18
+ signatures don't need to thread them):
19
+ --where <predicate> Filter rows. Supported predicates:
20
+ field=value case-insensitive equality
21
+ field!=value inequality
22
+ field~regex case-insensitive substring
23
+ field>=N | >N numeric / lexicographic
24
+ field<=N | <N
25
+ Field names match row keys case-insensitively
26
+ (so ``severity`` matches Qualys ``SEVERITY``).
27
+ --columns name,id,status Override the default table columns
28
+ --sort field[:asc|:desc] Sort rows in memory before rendering
29
+ --jq '.path.to.thing' jq-lite expression evaluated on the parsed data
30
+ --limit 0 Remove the default 25-row cap
31
+
32
+ The jq expression accepts:
33
+ ``.foo``, ``.foo.bar`` — dotted key access
34
+ ``.foo[]`` — flatten a list of dicts
35
+ ``.foo[3]`` — list index
36
+ No expression chaining beyond a single ``[]`` segment is supported by design;
37
+ for anything more complex, pipe through ``jq``.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import csv
43
+ import io
44
+ import json
45
+ import re
46
+ import sys
47
+ from collections.abc import Generator, Iterable
48
+ from contextlib import contextmanager
49
+ from contextvars import ContextVar
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ import yaml
54
+ from rich import box
55
+ from rich.console import Console
56
+ from rich.panel import Panel
57
+ from rich.table import Table as _Table
58
+ from rich.text import Text
59
+
60
+ _console = Console()
61
+ _err = Console(stderr=True)
62
+
63
+ DEFAULT_LIMIT = 25
64
+
65
+
66
+ def apply_output_mode(mode: str) -> None:
67
+ global _console, _err
68
+ if mode == "agentic":
69
+ _console = Console(force_terminal=False, no_color=True)
70
+ _err = Console(stderr=True, force_terminal=False, no_color=True)
71
+ else:
72
+ _console = Console(force_terminal=True)
73
+ _err = Console(stderr=True, force_terminal=True)
74
+
75
+
76
+ _STATUS_MAP: dict[str, str] = {
77
+ "active": "green", "running": "bright_green", "success": "green",
78
+ "complete": "green", "completed": "green", "enabled": "green",
79
+ "finished": "green", "passed": "green", "valid": "green",
80
+ "error": "red", "failed": "red", "inactive": "red",
81
+ "disabled": "red", "canceled": "red", "cancelled": "red",
82
+ "deleted": "red", "invalid": "red",
83
+ "pending": "yellow", "queued": "yellow", "processing": "yellow",
84
+ "warning": "yellow", "submitted": "yellow", "scheduled": "yellow",
85
+ "paused": "yellow", "timeout": "yellow",
86
+ "new": "cyan", "draft": "cyan", "open": "cyan", "unknown": "dim",
87
+ }
88
+
89
+ _STATUS_COLS = {"status", "state", "phase", "result", "condition"}
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Module-level switches set by the CLI on startup
94
+ # ---------------------------------------------------------------------------
95
+
96
+ REDACT_FIELDS: list[str] = []
97
+ """Per-profile output redaction list. Walks every dict in the rendered payload
98
+ and replaces matching field values with ``***REDACTED***``."""
99
+
100
+ LOCKED_MODE: bool = False
101
+ """Set by the CLI root callback when the active profile has ``locked_mode =
102
+ true``. Disables free-form ``--output-file`` writes so a scope-restricted
103
+ profile can't be used to exfiltrate data to an arbitrary filesystem path."""
104
+
105
+
106
+ # Global post-processing flags (--where / --columns / --sort / --jq) are
107
+ # injected into every Typer command as click Options with expose_value=False.
108
+ # Their callbacks stash values here so output() can pick them up without
109
+ # requiring every command signature to thread the kwargs explicitly.
110
+ _POST_OPTS: ContextVar[dict[str, Any] | None] = ContextVar("post_opts", default=None)
111
+
112
+
113
+ def set_post_process_opt(name: str, value: Any) -> None:
114
+ if value is None:
115
+ return
116
+ current = dict(_POST_OPTS.get() or {})
117
+ current[name] = value
118
+ _POST_OPTS.set(current)
119
+
120
+
121
+ def _post_opt(name: str) -> Any:
122
+ opts = _POST_OPTS.get()
123
+ return opts.get(name) if opts else None
124
+
125
+
126
+ def reset_post_process_opts() -> None:
127
+ """Used by tests to reset the per-invocation context."""
128
+ _POST_OPTS.set(None)
129
+
130
+
131
+ def _redact_payload(data: Any) -> Any:
132
+ if not REDACT_FIELDS:
133
+ return data
134
+ keys = {k.strip().lower() for k in REDACT_FIELDS if k.strip()}
135
+ if not keys:
136
+ return data
137
+ return _walk_redact(data, keys)
138
+
139
+
140
+ def _record_to_history(data: Any) -> None:
141
+ """Persist *data* against the active correlation ID so ``last`` / ``replay``
142
+ can re-render it later. Best-effort: any error is swallowed since the
143
+ user's command has already completed successfully by the time we get here.
144
+ """
145
+ try:
146
+ from . import audit as _audit
147
+ from . import history as _history
148
+ if not _audit.CORRELATION_ID or not _audit.PROFILE_NAME:
149
+ return
150
+ _history.record(
151
+ _audit.PROFILE_NAME,
152
+ _audit.CORRELATION_ID,
153
+ list(sys.argv),
154
+ data,
155
+ )
156
+ except Exception:
157
+ pass
158
+
159
+
160
+ def _walk_redact(obj: Any, keys: set[str]) -> Any:
161
+ if isinstance(obj, dict):
162
+ return {
163
+ k: ("***REDACTED***" if isinstance(k, str) and k.lower() in keys
164
+ else _walk_redact(v, keys))
165
+ for k, v in obj.items()
166
+ }
167
+ if isinstance(obj, list):
168
+ return [_walk_redact(x, keys) for x in obj]
169
+ return obj
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # jq-lite query
174
+ # ---------------------------------------------------------------------------
175
+
176
+ _QUERY_SEGMENT_RE = re.compile(r"^([A-Za-z0-9_-]+)(\[\d*\])?$")
177
+
178
+
179
+ def apply_query(data: Any, expr: str) -> Any:
180
+ """Evaluate a *very* limited jq-style expression against *data*.
181
+
182
+ Supported tokens (separated by ``.``):
183
+ - ``foo`` — dict key
184
+ - ``foo[]`` — dict key, then iterate the list
185
+ - ``foo[3]`` — dict key, then index 3
186
+
187
+ Wildcards (``*``), pipes, and any other jq syntax are **not** supported.
188
+ Earlier builds accepted ``*`` in the segment regex but treated it as a
189
+ literal dict key, silently returning ``None``. The regex now rejects
190
+ such input outright; callers who need richer expressions should pipe
191
+ through real ``jq``.
192
+ """
193
+ if not expr:
194
+ return data
195
+ cur: Any = data
196
+ body = expr.lstrip(".")
197
+ if not body:
198
+ return data
199
+ for raw in body.split("."):
200
+ if not raw:
201
+ continue
202
+ m = _QUERY_SEGMENT_RE.match(raw)
203
+ if not m:
204
+ return None
205
+ key, idx = m.group(1), m.group(2)
206
+ if isinstance(cur, dict):
207
+ cur = cur.get(key)
208
+ elif isinstance(cur, list):
209
+ cur = [x.get(key) if isinstance(x, dict) else None for x in cur]
210
+ else:
211
+ return None
212
+ if idx is not None:
213
+ inner = idx[1:-1]
214
+ if inner == "":
215
+ # `[]` flattens one level
216
+ if isinstance(cur, list):
217
+ flat: list[Any] = []
218
+ for x in cur:
219
+ if isinstance(x, list):
220
+ flat.extend(x)
221
+ else:
222
+ flat.append(x)
223
+ cur = flat
224
+ else:
225
+ try:
226
+ n = int(inner)
227
+ except ValueError:
228
+ return None
229
+ if isinstance(cur, list) and -len(cur) <= n < len(cur):
230
+ cur = cur[n]
231
+ else:
232
+ return None
233
+ return cur
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Public entry point
238
+ # ---------------------------------------------------------------------------
239
+
240
+ def output(
241
+ data: Any,
242
+ *,
243
+ format: str = "table",
244
+ limit: int | None = DEFAULT_LIMIT,
245
+ output_file: str | None = None,
246
+ raw: bool = False,
247
+ fields: list[str] | None = None,
248
+ where: str | None = None,
249
+ columns: str | None = None,
250
+ sort: str | None = None,
251
+ query: str | None = None,
252
+ ) -> None:
253
+ """Render API response data according to user-selected format.
254
+
255
+ Composable post-processing happens in this order:
256
+ 1. ``--query`` reshapes the payload (jq-lite path).
257
+ 2. PII / customer-field redaction via the profile's ``redact_fields``.
258
+ 3. ``--where`` filters rows after extraction.
259
+ 4. ``--sort`` sorts rows.
260
+ 5. ``--columns`` overrides the default table column set.
261
+
262
+ The fully-processed payload is also persisted to the per-profile history
263
+ cache (``~/.config/qualys-cli/history/<profile>/<correlation>.json``) so
264
+ ``qualys-cli last`` and ``qualys-cli replay <id>`` can re-render it
265
+ without hitting the API again.
266
+ """
267
+ # Pull post-processing flags from the per-invocation contextvar when
268
+ # caller didn't pass them explicitly. The CLI's main() injects --where,
269
+ # --columns, --sort, --jq on every command so users don't need each
270
+ # command signature to thread them.
271
+ if where is None:
272
+ where = _post_opt("where")
273
+ if columns is None:
274
+ columns = _post_opt("columns")
275
+ if sort is None:
276
+ sort = _post_opt("sort")
277
+ if sort is None:
278
+ sort_by = _post_opt("sort_by")
279
+ if sort_by:
280
+ sort = f"{sort_by}:{'desc' if _post_opt('sort_desc') else 'asc'}"
281
+ if query is None:
282
+ query = _post_opt("jq")
283
+
284
+ if query:
285
+ data = apply_query(data, query)
286
+
287
+ data = _redact_payload(data)
288
+ _record_to_history(data)
289
+
290
+ # Export formats default to "all rows" — the 25-row table cap is for
291
+ # interactive previewing. CSV / TSV / Markdown are typically piped to
292
+ # spreadsheets or tickets; truncating silently is a footgun. A user who
293
+ # *did* explicitly pass --limit gets their value through unchanged
294
+ # (DEFAULT_LIMIT means "no override given"; any other value is honoured).
295
+ if format in ("csv", "tsv", "markdown") and limit == DEFAULT_LIMIT:
296
+ limit = 0
297
+
298
+ if raw:
299
+ print(json.dumps(data, default=str) if not isinstance(data, str) else data)
300
+ return
301
+
302
+ # Auto-switch table → json on non-TTY
303
+ if format == "table" and not _console.is_terminal:
304
+ format = "json"
305
+
306
+ if output_file:
307
+ guard_output_file()
308
+ _dump_file(data, output_file)
309
+ if _err.is_terminal:
310
+ _err.print(f"[dim]Full data written to[/dim] [bold]{output_file}[/bold]")
311
+
312
+ # Resolve the user's column override into the fields list
313
+ cols_override = (
314
+ [c.strip() for c in columns.split(",") if c.strip()] if columns else None
315
+ )
316
+ fields = fields or cols_override
317
+
318
+ if format == "json":
319
+ print(json.dumps(data, indent=2, default=str))
320
+ elif format == "jsonl":
321
+ _render_jsonl(data, where=where, sort=sort)
322
+ elif format == "yaml":
323
+ _console.print(yaml.dump(data, default_flow_style=False, allow_unicode=True))
324
+ elif format in ("csv", "tsv"):
325
+ _render_dsv(data, sep="\t" if format == "tsv" else ",",
326
+ where=where, sort=sort, fields=fields)
327
+ elif format == "markdown":
328
+ _render_markdown(data, where=where, sort=sort, fields=fields, limit=limit)
329
+ else:
330
+ _render_table(data, limit=limit, fields=fields, where=where, sort=sort)
331
+
332
+
333
+ def guard_output_file() -> None:
334
+ """Raise a clean CLI error if the active profile is locked and the
335
+ caller is about to write to a free-form ``--output-file`` path.
336
+
337
+ Used by commands that stream raw response bytes directly to disk
338
+ (bypassing :func:`output`'s own ``--output-file`` handling), e.g.
339
+ ``sub export`` and ``sub user-prefs export``.
340
+ """
341
+ if not LOCKED_MODE:
342
+ return
343
+ import typer
344
+ output_error(
345
+ "This profile is in locked mode — free-form --output-file writes "
346
+ "are disabled. Redirect stdout instead.",
347
+ title="Locked mode",
348
+ )
349
+ raise typer.Exit(3)
350
+
351
+
352
+ def output_error(message: str, title: str = "Error") -> None:
353
+ if not _err.is_terminal:
354
+ print(json.dumps({"error": message, "type": title.lower().replace(" ", "_")}),
355
+ file=sys.stderr)
356
+ return
357
+ _err.print(
358
+ Panel(
359
+ f"[white]{message}[/white]",
360
+ title=f"[bold red] {title} [/bold red]",
361
+ border_style="red",
362
+ padding=(0, 1),
363
+ expand=False,
364
+ )
365
+ )
366
+
367
+
368
+ def output_success(message: str) -> None:
369
+ if _err.is_terminal:
370
+ _err.print(f" [bold green]✓[/bold green] {message}")
371
+
372
+
373
+ def output_warn(message: str) -> None:
374
+ if not _err.is_terminal:
375
+ print(json.dumps({"warning": message}), file=sys.stderr)
376
+ return
377
+ _err.print(f" [bold yellow]⚠[/bold yellow] [yellow]{message}[/yellow]")
378
+
379
+
380
+ def output_info(message: str) -> None:
381
+ if _err.is_terminal:
382
+ _err.print(f" [bold cyan]ℹ[/bold cyan] [dim]{message}[/dim]")
383
+
384
+
385
+ @contextmanager
386
+ def spinner(label: str = "Fetching…") -> Generator[None, None, None]:
387
+ if _err.is_terminal:
388
+ with _err.status(f"[dim]{label}[/dim]", spinner="dots"):
389
+ yield
390
+ else:
391
+ yield
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # Filter / sort
396
+ # ---------------------------------------------------------------------------
397
+
398
+ _WHERE_OP_RE = re.compile(r"\s*(>=|<=|!=|>|<|=|~)\s*")
399
+
400
+
401
+ def _row_get_ci(row: dict[str, Any], field: str) -> Any:
402
+ """Lookup *field* in *row* with an exact match first, then case-insensitive
403
+ fallback so users can write ``severity`` and match a column named
404
+ ``SEVERITY`` (common in Qualys VM/PC XML responses)."""
405
+ if field in row:
406
+ return row[field]
407
+ lf = field.lower()
408
+ for k, v in row.items():
409
+ if isinstance(k, str) and k.lower() == lf:
410
+ return v
411
+ return None
412
+
413
+
414
+ _CMP_OPS = {
415
+ ">": lambda a, b: a > b,
416
+ ">=": lambda a, b: a >= b,
417
+ "<": lambda a, b: a < b,
418
+ "<=": lambda a, b: a <= b,
419
+ }
420
+
421
+
422
+ def _compare(left: Any, right: str, op: str) -> bool:
423
+ lstr = "" if left is None else str(left)
424
+ if op == "=":
425
+ return lstr.lower() == right.lower()
426
+ if op == "!=":
427
+ return lstr.lower() != right.lower()
428
+ cmp = _CMP_OPS.get(op)
429
+ if cmp is None:
430
+ return False
431
+ try:
432
+ return bool(cmp(float(left), float(right)))
433
+ except (TypeError, ValueError):
434
+ return bool(cmp(lstr, right))
435
+
436
+
437
+ def _apply_where(rows: list[dict[str, Any]], where: str | None) -> list[dict[str, Any]]:
438
+ """Filter *rows* by a single predicate.
439
+
440
+ Supports:
441
+ ``field = value`` (case-insensitive string equality)
442
+ ``field != value``
443
+ ``field ~ regex`` (case-insensitive substring match)
444
+ ``field >= value`` (numeric when both sides parse as numbers,
445
+ else lexicographic)
446
+ ``field > value`` (and the obvious ``<``, ``<=`` variants)
447
+
448
+ Field names are matched case-insensitively against row keys so callers
449
+ can write ``severity`` for Qualys XML columns like ``SEVERITY``.
450
+ """
451
+ if not where:
452
+ return rows
453
+ expr = where.strip()
454
+ m = _WHERE_OP_RE.search(expr)
455
+ if not m or m.start() == 0:
456
+ return rows
457
+ field = expr[:m.start()].strip()
458
+ op = m.group(1)
459
+ value = expr[m.end():].strip()
460
+ if op == "~":
461
+ try:
462
+ rx = re.compile(value, re.IGNORECASE)
463
+ except re.error:
464
+ return rows
465
+ return [r for r in rows if rx.search(str(_row_get_ci(r, field) or ""))]
466
+ return [r for r in rows if _compare(_row_get_ci(r, field), value, op)]
467
+
468
+
469
+ def _apply_sort(rows: list[dict[str, Any]], sort: str | None) -> list[dict[str, Any]]:
470
+ if not sort:
471
+ return rows
472
+ spec = sort.strip()
473
+ desc = False
474
+ if ":" in spec:
475
+ spec, _, direction = spec.partition(":")
476
+ desc = direction.strip().lower().startswith("desc")
477
+ field = spec.strip()
478
+
479
+ def key(r: dict[str, Any]) -> tuple[int, Any]:
480
+ v = _row_get_ci(r, field)
481
+ if v is None or v == "":
482
+ return (1, "")
483
+ try:
484
+ return (0, float(v))
485
+ except (TypeError, ValueError):
486
+ return (0, str(v).lower())
487
+
488
+ return sorted(rows, key=key, reverse=desc)
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # Renderers
493
+ # ---------------------------------------------------------------------------
494
+
495
+ def _render_table(
496
+ data: Any, limit: int | None, fields: list[str] | None,
497
+ where: str | None = None, sort: str | None = None,
498
+ ) -> None:
499
+ rows, total = _extract_rows(data)
500
+ rows = _apply_where(rows, where)
501
+ rows = _apply_sort(rows, sort)
502
+
503
+ if not rows:
504
+ if _err.is_terminal:
505
+ _err.print(" [dim]No results.[/dim]")
506
+ return
507
+
508
+ no_cap = limit is None or limit == 0
509
+ display = rows if no_cap else rows[:limit]
510
+ cols = fields or _infer_columns(display)
511
+
512
+ table = _Table(
513
+ box=box.SIMPLE_HEAD,
514
+ header_style="bold",
515
+ border_style="dim",
516
+ show_edge=False,
517
+ padding=(0, 1),
518
+ row_styles=["", "dim"],
519
+ )
520
+ for col in cols:
521
+ table.add_column(col, overflow="fold", max_width=64, no_wrap=False)
522
+
523
+ for row in display:
524
+ table.add_row(*[_colorize(c, str(_get(row, c))) for c in cols])
525
+
526
+ _console.print()
527
+ _console.print(table)
528
+
529
+ if _err.is_terminal:
530
+ shown = len(display)
531
+ if total is not None and total > shown:
532
+ _err.print(
533
+ f" [dim]Showing {shown:,} of {total:,}"
534
+ " · use [bold]--output-file[/bold] to export all[/dim]"
535
+ )
536
+ elif not no_cap and len(rows) > shown:
537
+ _err.print(
538
+ f" [dim]Showing {shown:,} of {len(rows):,}"
539
+ " · use [bold]--limit 0[/bold] or"
540
+ " [bold]--output-file[/bold] to see all[/dim]"
541
+ )
542
+ _console.print()
543
+
544
+
545
+ def _render_jsonl(data: Any, where: str | None = None, sort: str | None = None) -> None:
546
+ """Emit one flattened JSON object per line (NDJSON).
547
+
548
+ Streaming-friendly: we flush after each row so consumers (jq, awk,
549
+ Splunk's json sourcetype, …) can start working before the whole list
550
+ has been written.
551
+ """
552
+ rows, _ = _extract_rows(data)
553
+ rows = _apply_where(rows, where)
554
+ rows = _apply_sort(rows, sort)
555
+ out = sys.stdout
556
+ for row in rows:
557
+ out.write(json.dumps(row, default=str))
558
+ out.write("\n")
559
+ out.flush()
560
+
561
+
562
+ def _render_dsv(
563
+ data: Any, *, sep: str,
564
+ where: str | None, sort: str | None, fields: list[str] | None,
565
+ ) -> None:
566
+ rows, _ = _extract_rows(data)
567
+ rows = _apply_where(rows, where)
568
+ rows = _apply_sort(rows, sort)
569
+ if not rows:
570
+ return
571
+ cols = fields or _infer_columns(rows)
572
+ buf = io.StringIO()
573
+ w = csv.writer(buf, delimiter=sep, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
574
+ w.writerow(cols)
575
+ for r in rows:
576
+ w.writerow([str(r.get(c, "")) for c in cols])
577
+ sys.stdout.write(buf.getvalue())
578
+ sys.stdout.flush()
579
+
580
+
581
+ def _render_markdown(
582
+ data: Any, *, where: str | None, sort: str | None,
583
+ fields: list[str] | None, limit: int | None,
584
+ ) -> None:
585
+ rows, _ = _extract_rows(data)
586
+ rows = _apply_where(rows, where)
587
+ rows = _apply_sort(rows, sort)
588
+ if not rows:
589
+ return
590
+ no_cap = limit is None or limit == 0
591
+ display = rows if no_cap else rows[:limit]
592
+ cols = fields or _infer_columns(display)
593
+ out = sys.stdout
594
+
595
+ def cell(v: Any) -> str:
596
+ s = str(v).replace("|", "\\|").replace("\n", " ")
597
+ return s
598
+
599
+ out.write("| " + " | ".join(cols) + " |\n")
600
+ out.write("|" + "|".join("---" for _ in cols) + "|\n")
601
+ for r in display:
602
+ out.write("| " + " | ".join(cell(r.get(c, "")) for c in cols) + " |\n")
603
+ out.flush()
604
+
605
+
606
+ def _colorize(col: str, val: str) -> Text:
607
+ text = Text(val)
608
+ if any(kw in col.lower() for kw in _STATUS_COLS):
609
+ color = _STATUS_MAP.get(val.lower().strip())
610
+ if color:
611
+ text.stylize(f"bold {color}" if color != "dim" else color)
612
+ return text
613
+
614
+
615
+ # ---------------------------------------------------------------------------
616
+ # Streaming JSONL helper (for `qualys-cli batch` and bulk pipelines)
617
+ # ---------------------------------------------------------------------------
618
+
619
+ def stream_jsonl(rows: Iterable[dict[str, Any]]) -> None:
620
+ """Stream an iterator of dicts as NDJSON to stdout."""
621
+ out = sys.stdout
622
+ for row in rows:
623
+ out.write(json.dumps(row, default=str))
624
+ out.write("\n")
625
+ out.flush()
626
+
627
+
628
+ # ---------------------------------------------------------------------------
629
+ # Data extraction
630
+ # ---------------------------------------------------------------------------
631
+
632
+ def _parse_int(v: Any) -> int | None:
633
+ if v is None:
634
+ return None
635
+ try:
636
+ return int(v)
637
+ except (TypeError, ValueError):
638
+ return None
639
+
640
+
641
+ def _extract_rows(data: Any) -> tuple[list[dict[str, Any]], int | None]:
642
+ if isinstance(data, list):
643
+ return [_flatten(r) for r in data], None
644
+
645
+ if not isinstance(data, dict):
646
+ return [], None
647
+
648
+ for list_key in ("data", "items", "content", "list", "results"):
649
+ if isinstance(data.get(list_key), list):
650
+ total = _parse_int(
651
+ data.get("total") or data.get("totalCount") or data.get("count")
652
+ )
653
+ return [_flatten(r) for r in data[list_key]], total
654
+
655
+ _SR_META = frozenset({
656
+ "responseCode", "count", "hasMoreRecords", "lastId",
657
+ "@xmlns:xsi", "@xsi:noNamespaceSchemaLocation",
658
+ })
659
+ sr = data.get("ServiceResponse")
660
+ if isinstance(sr, dict):
661
+ total = _parse_int(sr.get("count"))
662
+ sr_data = sr.get("data")
663
+ if isinstance(sr_data, dict):
664
+ for v in sr_data.values():
665
+ if isinstance(v, list):
666
+ return [_flatten(r) for r in v], total
667
+ if isinstance(v, dict):
668
+ return [_flatten(v)], total
669
+ elif isinstance(sr_data, list):
670
+ return [_flatten(r) for r in sr_data], total
671
+ for k, v in sr.items():
672
+ if k in _SR_META or k == "data":
673
+ continue
674
+ if isinstance(v, list):
675
+ return [_flatten(r) for r in v], total
676
+ if isinstance(v, dict):
677
+ for v2 in v.values():
678
+ if isinstance(v2, list):
679
+ return [_flatten(r) for r in v2], total
680
+ return [_flatten(v)], total
681
+ # Fall-through: SR has no rows container and no non-meta fields. This
682
+ # is the shape of the */count/* endpoints (e.g. `ca agent count` or
683
+ # `was webapp count`), whose entire payload is metadata. Surface the
684
+ # `count` as a synthetic single-row so the table renderer shows the
685
+ # value instead of an empty grid. Without this, `output()` returns
686
+ # `[{}]` — truthy enough to skip the "No results" branch but with no
687
+ # columns to render, leaving the user with a blank screen after the
688
+ # spinner clears.
689
+ remaining = {k: v for k, v in sr.items() if k not in _SR_META and k != "data"}
690
+ if not remaining and total is not None:
691
+ return [{"count": total}], total
692
+ return [_flatten(remaining)], None
693
+
694
+ peeled = _peel_vm_xml_envelope(data)
695
+ if peeled is not None:
696
+ return peeled
697
+
698
+ found = _find_list_key(data)
699
+ if found is not None:
700
+ return [_flatten(r) for r in found], None
701
+
702
+ deep = _find_dict_list(data)
703
+ if deep is not None:
704
+ total = _parse_int(
705
+ data.get("count") or data.get("total") or data.get("totalCount")
706
+ )
707
+ return [_flatten(r) for r in deep], total
708
+
709
+ return [_flatten(data)], None
710
+
711
+
712
+ # Metadata fields on a Qualys <RESPONSE> body that never carry row data and
713
+ # should be skipped when locating the rows container.
714
+ _VM_RESPONSE_META = frozenset({"DATETIME", "STATUS", "COUNT", "USER_LOGIN"})
715
+
716
+
717
+ def _peel_vm_xml_envelope(
718
+ data: dict[str, Any],
719
+ ) -> tuple[list[dict[str, Any]], int | None] | None:
720
+ """Extract rows from a Qualys VM/PC XML envelope.
721
+
722
+ Shape: ``{<X>_OUTPUT: {RESPONSE: {DATETIME, STATUS, COUNT, <rows>}}}``
723
+ (or a bare top-level ``{RESPONSE: {...}}``).
724
+
725
+ The rows container is not always ``*_LIST`` — many endpoints wrap rows
726
+ under a plural noun (e.g. ``AUTH_VAULTS > AUTH_VAULT``). xmltodict also
727
+ collapses an empty list into ``None``, which previously fell through to
728
+ the catch-all and rendered the entire envelope as one stringified cell.
729
+ Returns ``None`` when *data* is not a recognisable envelope.
730
+ """
731
+ response: dict[str, Any] | None = None
732
+ if len(data) == 1:
733
+ only_key = next(iter(data))
734
+ only_val = data[only_key]
735
+ if isinstance(only_val, dict):
736
+ inner = only_val.get("RESPONSE")
737
+ if isinstance(inner, dict) and (
738
+ only_key.endswith("_OUTPUT") or only_key == "SIMPLE_RETURN"
739
+ or only_key == "BATCH_RETURN"
740
+ ):
741
+ response = inner
742
+ if response is None and isinstance(data.get("RESPONSE"), dict) and len(data) == 1:
743
+ response = data["RESPONSE"]
744
+ if response is None:
745
+ return None
746
+
747
+ count = _parse_int(response.get("COUNT"))
748
+
749
+ for k, v in response.items():
750
+ if k in _VM_RESPONSE_META:
751
+ continue
752
+ if isinstance(v, list):
753
+ return [_flatten(r) for r in v], count
754
+ if isinstance(v, dict):
755
+ for inner_v in v.values():
756
+ if isinstance(inner_v, list):
757
+ return [_flatten(r) for r in inner_v], count
758
+ if isinstance(inner_v, dict):
759
+ return [_flatten(inner_v)], count
760
+ # Empty wrapper dict — treat as no rows.
761
+
762
+ # No rows container located. Either an empty list (COUNT=0 / all-None
763
+ # children) or an action confirmation like SIMPLE_RETURN with just
764
+ # CODE/TEXT — surface those as a single flat row, omitting the
765
+ # DATETIME/STATUS/COUNT/USER_LOGIN metadata so the table doesn't get
766
+ # cluttered with envelope bookkeeping.
767
+ non_meta = {k: v for k, v in response.items() if k not in _VM_RESPONSE_META}
768
+ if count == 0 or not non_meta or all(v is None for v in non_meta.values()):
769
+ return [], count if count is not None else 0
770
+ return [_flatten(non_meta)], count
771
+
772
+
773
+ def _find_dict_list(data: dict[str, Any], depth: int = 0) -> list[Any] | None:
774
+ if depth > 4:
775
+ return None
776
+ for v in data.values():
777
+ if isinstance(v, list) and v and isinstance(v[0], dict):
778
+ return v
779
+ if isinstance(v, dict):
780
+ found = _find_dict_list(v, depth + 1)
781
+ if found is not None:
782
+ return found
783
+ return None
784
+
785
+
786
+ def _find_list_key(data: dict[str, Any], depth: int = 0) -> list[Any] | None:
787
+ if depth > 5:
788
+ return None
789
+ for k, v in data.items():
790
+ if k.endswith("_LIST"):
791
+ if isinstance(v, list):
792
+ return v
793
+ if isinstance(v, dict):
794
+ for child_v in v.values():
795
+ if isinstance(child_v, list):
796
+ return child_v
797
+ if isinstance(child_v, dict):
798
+ return [child_v]
799
+ return [v]
800
+ # xmltodict collapses ``<X_LIST/>`` to None — that's an empty
801
+ # list, not "no _LIST wrapper found". Returning [] here prevents
802
+ # the catch-all from stringifying the surrounding envelope.
803
+ if v is None:
804
+ return []
805
+ if isinstance(v, dict):
806
+ result = _find_list_key(v, depth + 1)
807
+ if result is not None:
808
+ return result
809
+ return None
810
+
811
+
812
+ def _flatten(obj: Any, prefix: str = "", depth: int = 0) -> dict[str, str]:
813
+ if not isinstance(obj, dict):
814
+ return {"value": str(obj)}
815
+ result: dict[str, str] = {}
816
+ for k, v in obj.items():
817
+ key = f"{prefix}{k}" if prefix else k
818
+ if isinstance(v, dict) and depth < 1:
819
+ result.update(_flatten(v, prefix=f"{key}.", depth=depth + 1))
820
+ elif isinstance(v, list):
821
+ if v and isinstance(v[0], dict):
822
+ result[key] = f"[{len(v)} item{'s' if len(v) != 1 else ''}]"
823
+ else:
824
+ result[key] = ", ".join(str(i) for i in v[:5])
825
+ else:
826
+ result[key] = str(v) if v is not None else ""
827
+ return result
828
+
829
+
830
+ def _infer_columns(rows: list[dict[str, Any]]) -> list[str]:
831
+ if not rows:
832
+ return []
833
+ sample = rows[0]
834
+ all_keys = list(sample.keys())
835
+ priority = [
836
+ "id", "ID", "name", "NAME", "title", "TITLE", "ref", "REF",
837
+ "status", "STATUS", "type", "TYPE", "ip", "IP",
838
+ "action", "ACTION", "datetime", "DATETIME",
839
+ ]
840
+
841
+ def looks_complex(key: str) -> bool:
842
+ for r in rows[:5]:
843
+ v = r.get(key)
844
+ if v is None or v == "":
845
+ continue
846
+ s = str(v).strip()
847
+ if s.startswith(("{", "[")) or s.startswith("[0 item") or s.endswith("items]"):
848
+ return True
849
+ return False
850
+
851
+ cols: list[str] = []
852
+ for p in priority:
853
+ if p in all_keys and p not in cols:
854
+ cols.append(p)
855
+ for k in all_keys:
856
+ if k not in cols and not looks_complex(k):
857
+ cols.append(k)
858
+ for k in all_keys:
859
+ if k not in cols:
860
+ cols.append(k)
861
+ return cols[:8]
862
+
863
+
864
+ def _get(row: dict[str, Any], col: str) -> Any:
865
+ return row.get(col, "")
866
+
867
+
868
+ # ---------------------------------------------------------------------------
869
+ # File dump
870
+ # ---------------------------------------------------------------------------
871
+
872
+ def _dump_file(data: Any, path: str) -> None:
873
+ p = Path(path)
874
+ suffix = p.suffix.lower()
875
+ if suffix in (".yaml", ".yml"):
876
+ p.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
877
+ elif suffix == ".csv":
878
+ rows, _ = _extract_rows(data)
879
+ if rows:
880
+ cols = _infer_columns(rows)
881
+ buf = io.StringIO()
882
+ w = csv.writer(buf, lineterminator="\n")
883
+ w.writerow(cols)
884
+ for r in rows:
885
+ w.writerow([str(r.get(c, "")) for c in cols])
886
+ p.write_text(buf.getvalue())
887
+ else:
888
+ p.write_text("")
889
+ elif suffix in (".jsonl", ".ndjson"):
890
+ rows, _ = _extract_rows(data)
891
+ with p.open("w") as fh:
892
+ for r in rows:
893
+ fh.write(json.dumps(r, default=str))
894
+ fh.write("\n")
895
+ else:
896
+ p.write_text(json.dumps(data, indent=2, default=str))