diff --git a/README.md b/README.md index 2c5c5a9..1402b86 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ hubble-audit2policy --from loki --loki-url http://loki:3100 -n monitoring -n def # Custom time window -- last 24 hours: hubble-audit2policy --from loki --loki-url http://loki:3100 --since 24h -o policies/ +# Absolute time window -- Friday night to Sunday morning: +hubble-audit2policy --from loki --loki-url http://loki:3100 \ + --since 2026-03-27T20:00:00 --until 2026-03-29T08:00:00 -o policies/ + # Print a flow frequency report (works with any source): hubble-audit2policy --from loki --loki-url http://loki:3100 --report hubble-audit2policy flows.json --report-only @@ -90,9 +94,17 @@ Query a Grafana Loki instance directly -- ideal when Hubble flows are already be # All flows from the last hour: hubble-audit2policy --from loki --loki-url http://loki:3100 --dry-run -# Scoped to a namespace with a custom time window: +# Scoped to a namespace with a relative time window: hubble-audit2policy --from loki --loki-url http://loki:3100 --since 2h --until 30m -n kube-system -o policies/ +# Absolute time range (ISO 8601 timestamps): +hubble-audit2policy --from loki --loki-url http://loki:3100 \ + --since 2026-03-27T20:00:00Z --until 2026-03-29T08:00:00Z -o policies/ + +# Date-only shorthand (midnight UTC): +hubble-audit2policy --from loki --loki-url http://loki:3100 \ + --since 2026-03-27 --until 2026-03-29 -o policies/ + # Custom LogQL selector (adjust to match your labels): hubble-audit2policy --from loki --loki-url http://loki:3100 --loki-query '{namespace="hubble"}' ``` @@ -174,8 +186,8 @@ hubble-audit2policy [-h] [-o OUTPUT_DIR] [-n NAMESPACE] [--hubble-cmd CMD] [--capture-file FILE] [--no-enrich] [--from {file,loki}] [--loki-url URL] - [--loki-query LOGQL] [--since DURATION] - [--until DURATION] [--loki-limit N] + [--loki-query LOGQL] [--since TIME] + [--until TIME] [--loki-limit N] [--loki-user USER] [--loki-password PASSWORD] [--loki-token TOKEN] [--loki-tls-ca PATH] [-v] [-V] @@ -201,8 +213,8 @@ hubble-audit2policy [-h] [-o OUTPUT_DIR] [-n NAMESPACE] | `--from {file,loki}` | Flow source backend (default: `file`) | | `--loki-url URL` | Loki base URL, e.g. `http://loki:3100` | | `--loki-query LOGQL` | LogQL stream selector (default: `{app="hubble"}`) | -| `--since DURATION` | How far back to query, e.g. `30m`, `2h`, `1d` (default: `1h`) | -| `--until DURATION` | End of query window as duration before now (default: `0s` = now) | +| `--since TIME` | Start of query window: relative duration (`30m`, `2h`, `1d`) or ISO 8601 timestamp (`2026-03-27T20:00:00`, `2026-03-27`) (default: `1h`) | +| `--until TIME` | End of query window: relative duration or ISO 8601 timestamp (default: `0s` = now) | | `--loki-limit N` | Max entries per Loki request batch (default: `5000`) | | `--loki-user USER` | Username for Loki HTTP Basic authentication | | `--loki-password PASSWORD` | Password for Loki HTTP Basic authentication (used with `--loki-user`) | diff --git a/hubble_audit2policy.py b/hubble_audit2policy.py index 2aa5599..15fbee9 100755 --- a/hubble_audit2policy.py +++ b/hubble_audit2policy.py @@ -8,7 +8,7 @@ from __future__ import annotations -__version__ = "0.7.5" +__version__ = "0.8.0" __author__ = "noexecstack" __license__ = "Apache-2.0" @@ -16,6 +16,7 @@ import base64 import curses import dataclasses +import datetime import io import json import logging @@ -408,6 +409,53 @@ def _parse_duration(value: str) -> float: return num * multipliers[unit] +def _parse_timestamp(value: str) -> datetime.datetime: + """Parse an ISO 8601 datetime string into a datetime object. + + Accepted formats:: + + 2026-03-27T20:00:00 (naive, treated as local time) + 2026-03-27T20:00:00Z (UTC) + 2026-03-27T20:00:00+02:00 (explicit offset) + 2026-03-27 (date only, midnight local time) + """ + text = value.strip() + # Date-only shorthand: treat as midnight local time. + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text): + text += "T00:00:00" + # Python's fromisoformat doesn't accept trailing 'Z' before 3.11. + if text.endswith("Z") or text.endswith("z"): + text = text[:-1] + "+00:00" + try: + dt = datetime.datetime.fromisoformat(text) + except ValueError as exc: + raise argparse.ArgumentTypeError( + f"Invalid timestamp {value!r} " + "-- expected ISO 8601, e.g. 2026-03-27T20:00:00 or 2026-03-27" + ) from exc + return dt + + +def _parse_time_arg(value: str) -> float: + """Parse a --since/--until argument into a Unix timestamp (seconds). + + Accepts either a relative duration (``2h``, ``30m``) interpreted as + seconds before *now*, or an absolute ISO 8601 datetime. + """ + text = value.strip() + # Try relative duration first (cheap regex check). + if re.fullmatch(r"(\d+(?:\.\d+)?)\s*([smhd])?", text): + offset = _parse_duration(text) + return time.time() - offset + # Fall through to absolute timestamp. + dt = _parse_timestamp(text) + # Naive datetimes are treated as local time. + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + dt = dt.astimezone(datetime.timezone.utc) + return dt.timestamp() + + def _build_loki_ssl_context( ca_cert: str | None = None, ) -> ssl.SSLContext | None: @@ -428,8 +476,8 @@ def _build_loki_ssl_context( def _read_flows_loki( loki_url: str, query: str, - since_seconds: float, - until_seconds: float, + start_epoch: float, + end_epoch: float, limit: int = 5000, *, loki_user: str | None = None, @@ -445,10 +493,10 @@ def _read_flows_loki( Base URL of the Loki instance, e.g. ``http://loki:3100``. query: LogQL stream selector, e.g. ``{app="hubble"}``. - since_seconds: - Start of the query window as seconds before *now*. - until_seconds: - End of the query window as seconds before *now* (0 = now). + start_epoch: + Start of the query window as a Unix timestamp in seconds. + end_epoch: + End of the query window as a Unix timestamp in seconds. limit: Maximum number of log entries per request batch. loki_user: @@ -460,9 +508,8 @@ def _read_flows_loki( loki_tls_ca: Path to a PEM CA certificate for TLS verification. """ - now = time.time() - start_ns = int((now - since_seconds) * 1_000_000_000) - end_ns = int((now - until_seconds) * 1_000_000_000) + start_ns = int(start_epoch * 1_000_000_000) + end_ns = int(end_epoch * 1_000_000_000) base = loki_url.rstrip("/") fetched = 0 @@ -1952,14 +1999,21 @@ def _build_parser() -> argparse.ArgumentParser: loki_group.add_argument( "--since", default="1h", - metavar="DURATION", - help="How far back to query, e.g. 30m, 2h, 1d (default: 1h)", + metavar="TIME", + help=( + "Start of query window: relative duration (30m, 2h, 1d) " + "or ISO 8601 timestamp (2026-03-27T20:00:00, 2026-03-27) " + "(default: 1h)" + ), ) loki_group.add_argument( "--until", default="0s", - metavar="DURATION", - help="End of query window as duration before now (default: 0s = now)", + metavar="TIME", + help=( + "End of query window: relative duration or ISO 8601 timestamp " + "(default: 0s = now)" + ), ) loki_group.add_argument( "--loki-limit", @@ -2030,13 +2084,13 @@ def main() -> None: parser.error("--loki-token and --loki-user are mutually exclusive") if args.loki_password and not args.loki_user: parser.error("--loki-password requires --loki-user") - since_sec = _parse_duration(args.since) - until_sec = _parse_duration(args.until) + start_epoch = _parse_time_arg(args.since) + end_epoch = _parse_time_arg(args.until) loki_iter = _read_flows_loki( args.loki_url, args.loki_query, - since_sec, - until_sec, + start_epoch, + end_epoch, args.loki_limit, loki_user=args.loki_user, loki_password=args.loki_password, diff --git a/pyproject.toml b/pyproject.toml index 1e01e51..0cb9330 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hubble-audit2policy" -version = "0.7.6" +version = "0.8.0" description = "Generate least-privilege CiliumNetworkPolicy YAML from Hubble flow logs." readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_flow_parsing.py b/tests/test_flow_parsing.py index c5d60fa..4d99dc0 100644 --- a/tests/test_flow_parsing.py +++ b/tests/test_flow_parsing.py @@ -241,6 +241,74 @@ def test_fractional(self) -> None: assert h._parse_duration("1.5h") == 5400.0 +class TestParseTimestamp: + def test_full_iso(self) -> None: + import datetime + + dt = h._parse_timestamp("2026-03-27T20:00:00") + assert dt == datetime.datetime(2026, 3, 27, 20, 0, 0) + + def test_utc_z_suffix(self) -> None: + import datetime + + dt = h._parse_timestamp("2026-03-27T20:00:00Z") + assert dt == datetime.datetime( + 2026, 3, 27, 20, 0, 0, tzinfo=datetime.timezone.utc + ) + + def test_date_only(self) -> None: + import datetime + + dt = h._parse_timestamp("2026-03-27") + assert dt == datetime.datetime(2026, 3, 27, 0, 0, 0) + + def test_with_offset(self) -> None: + import datetime + + dt = h._parse_timestamp("2026-03-27T20:00:00+02:00") + assert dt.utcoffset() == datetime.timedelta(hours=2) + + def test_invalid_raises(self) -> None: + import pytest + + with pytest.raises(argparse.ArgumentTypeError): + h._parse_timestamp("not-a-date") + + def test_whitespace_tolerance(self) -> None: + import datetime + + dt = h._parse_timestamp(" 2026-03-27T10:00:00 ") + assert dt == datetime.datetime(2026, 3, 27, 10, 0, 0) + + +class TestParseTimeArg: + def test_relative_duration(self) -> None: + import time + + before = time.time() - 3600 + result = h._parse_time_arg("1h") + after = time.time() - 3600 + assert before <= result <= after + + def test_absolute_timestamp(self) -> None: + result = h._parse_time_arg("2026-03-27T00:00:00Z") + # 2026-03-27 00:00:00 UTC + assert abs(result - 1774569600.0) < 1.0 + + def test_date_only(self) -> None: + result = h._parse_time_arg("2026-03-27") + # Should resolve to midnight UTC + assert abs(result - 1774569600.0) < 1.0 + + def test_zero_duration_means_now(self) -> None: + import time + + before = time.time() + result = h._parse_time_arg("0s") + after = time.time() + assert before <= result <= after + + class TestParseFlowsWithIterator: """parse_flows accepts a flow_iter to decouple from file I/O."""